Parapho Docs

Integrate Parapho e-signatures

Everything you need to send documents for signature from your own product: what to set up in the dashboard, every API call with copy-paste examples, webhooks, embedded signing, and fixes for the problems teams usually hit.

An integration needs four things. Most teams have all four working on test keys within an afternoon:

  1. A Parapho workspace, created at /signup. Two-factor authentication is required for every account.
  2. A template: a PDF with signature and data fields placed on it and assigned to roles such as Client and Company. Most teams build templates in the dashboard; you can also create them through the API.
  3. An API key, from Settings & API → API keys. Start with a sk_test_ key: nothing is emailed or billed.
  4. A webhook endpoint (recommended), so your server hears when a document is signed instead of polling.

Embedded signing (showing the document inside your own site) needs one extra setting: your site's origin under Settings & API → Embedded signing.

Base URLAll API requests go to https://app.parapho.com/api/v1. If you're on a self-hosted or staging deployment, use its URL instead. The examples on this page already use the address you're reading it from.

How an integration works

Every integration follows the same five steps, whether you email links or embed signing:

Build a templateUpload the PDF once, place fields, name the roles.You, in the dashboard or through the API (once)
Create an envelopePOST /api/v1/envelopes with the template ID and a person for each role.Your backend
Signer signsFrom the email we send, or inside your app using the embed_url.Your customer
You're notifiedA signed webhook: recipient.signed, then envelope.completed.Parapho → your server
Fetch the PDFGET /api/v1/envelopes/:id/download returns the sealed PDF.Your backend

An envelope is one signing transaction: a copy of a template's document and fields, plus the people who must sign it. When the last person signs, Parapho flattens every value into the PDF, appends a certificate of completion (signers, timestamps, IP addresses, full audit trail), digitally seals the file and records its SHA-256 fingerprint.

Choose your integration

You can mix them: for example, embed signing for your customer, then let Parapho email your own countersigner.

Quickstart (15 minutes)

By the end you'll have created a test envelope from your backend, signed it, and downloaded the sealed PDF.

  1. Create a workspace

    Sign up at /signup and turn on two-factor authentication with any authenticator app. The free plan is enough to build and test an integration; test-mode envelopes are always free.

  2. Build a template

    Go to Templates → New template and upload your PDF. In the builder:

    • Rename the signer roles in the left panel to something meaningful and stable, such as Client and Company. Your code will refer to these names exactly, including capitals and spaces. The default is Signer 1.
    • Drag fields onto the page and assign each one to a role. Every role that has at least one field must be given a person when you create an envelope.
    • Click Save.

    The template ID (tpl_…) is in the browser's address bar while you edit (/app#/templates/tpl_…), and it's returned by List templates.

  3. Create a test API key

    Go to Settings & API → API keys → Create key and choose Test. Copy the key now: it's shown once and stored only as a hash. Only workspace owners and admins can create keys.

    export PARAPHO_API_KEY="sk_test_…"
  4. Find your template and its roles

    curl https://app.parapho.com/api/v1/templates \
      -H "Authorization: Bearer $PARAPHO_API_KEY"
    import { Parapho } from '@parapho/node';
    
    const parapho = new Parapho(); // reads PARAPHO_API_KEY
    const templates = await parapho.templates.list();
    console.log(templates.map((t) => [t.id, t.name, t.roles]));
    from parapho import Parapho
    
    parapho = Parapho()  # reads PARAPHO_API_KEY
    for t in parapho.templates.list():
        print(t["id"], t["name"], t["roles"])
    [
      {
        "id": "tpl_8Kq2vX0bR1aZ9mWc",
        "name": "Mutual NDA",
        "roles": ["Client", "Company"],
        "role_settings": {},
        "field_count": 6,
        "updated_at": "2026-10-28T14:03:11.402Z"
      }
    ]
  5. Create an envelope

    Pass the template ID and one recipient per role. With a test key nothing is emailed, so ask for the links back with send_email: false.

    curl https://app.parapho.com/api/v1/envelopes \
      -H "Authorization: Bearer $PARAPHO_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "template_id": "tpl_8Kq2vX0bR1aZ9mWc",
        "title": "NDA with Analytical Engines Ltd",
        "send_email": false,
        "recipients": [
          { "role": "Client",  "name": "Ada Lovelace",    "email": "ada@example.com",     "order": 1 },
          { "role": "Company", "name": "Charles Babbage", "email": "charles@example.com", "order": 2 }
        ]
      }'
    const envelope = await parapho.envelopes.create({
      template_id: 'tpl_8Kq2vX0bR1aZ9mWc',
      title: 'NDA with Analytical Engines Ltd',
      send_email: false,
      recipients: [
        { role: 'Client',  name: 'Ada Lovelace',    email: 'ada@example.com',     order: 1 },
        { role: 'Company', name: 'Charles Babbage', email: 'charles@example.com', order: 2 },
      ],
    });
    console.log(envelope.id, envelope.recipients[0].signing_url);
    envelope = parapho.envelopes.create(
        "tpl_8Kq2vX0bR1aZ9mWc",
        [
            {"role": "Client", "name": "Ada Lovelace", "email": "ada@example.com", "order": 1},
            {"role": "Company", "name": "Charles Babbage", "email": "charles@example.com", "order": 2},
        ],
        title="NDA with Analytical Engines Ltd",
        send_email=False,
    )
    print(envelope["id"], envelope["recipients"][0]["signing_url"])
    {
      "id": "env_Yp3tN6sQe0LwHk2u",
      "test": true,
      "recipients": [
        {
          "id": "rcp_c9VdA1mE7rTq4ZxB",
          "role": "Client",
          "name": "Ada Lovelace",
          "email": "ada@example.com",
          "order": 1,
          "signing_url": "https://app.parapho.com/s/q8Jm…",
          "embed_url": "https://app.parapho.com/s/q8Jm…?embed=1"
        },
        {
          "id": "rcp_Fh2LwQ8nB5sYk0Pe",
          "role": "Company",
          "name": "Charles Babbage",
          "email": "charles@example.com",
          "order": 2,
          "signing_url": "https://app.parapho.com/s/Zr4c…",
          "embed_url": "https://app.parapho.com/s/Zr4c…?embed=1"
        }
      ]
    }

    Save id and each recipient's id against your own record (for example your deal or customer). Webhooks refer to them.

  6. Sign it

    Open Ada's signing_url in a browser, fill in the fields and sign. Then open Charles's link and sign. (If you open Charles's link first, the page says it's waiting for Ada: the signing order is enforced.) Test-mode documents are watermarked TEST - NOT LEGALLY BINDING.

  7. Check status and download the sealed PDF

    curl https://app.parapho.com/api/v1/envelopes/env_Yp3tN6sQe0LwHk2u \
      -H "Authorization: Bearer $PARAPHO_API_KEY"
    
    # once "status" is "completed":
    curl https://app.parapho.com/api/v1/envelopes/env_Yp3tN6sQe0LwHk2u/download \
      -H "Authorization: Bearer $PARAPHO_API_KEY" \
      -o signed.pdf
    import { writeFile } from 'node:fs/promises';
    
    const status = await parapho.envelopes.get(envelope.id);
    if (status.status === 'completed') {
      await writeFile('signed.pdf', await parapho.envelopes.download(envelope.id));
    }
    status = parapho.envelopes.get(envelope["id"])
    if status["status"] == "completed":
        with open("signed.pdf", "wb") as f:
            f.write(parapho.envelopes.download(envelope["id"]))
That's a working integration.Next: add a webhook so you don't have to poll, then either let us email signers or embed signing in your app.

Templates, roles and envelopes

ObjectID prefixWhat it is
Workspaceorg_Your company's account. API keys, templates, envelopes, webhooks, branding and billing all belong to one workspace. A person can be a member of several.
Templatetpl_A PDF plus field positions, the list of roles, and optional witness settings. Reusable: one template can send any number of envelopes.
Role(a name)A placeholder for a person, such as Client, Tenant or Signer 1. Each field belongs to one role. When you create an envelope you map each role to a real person.
Envelopeenv_One signing transaction made from a template. It keeps its own copy of the fields, so later edits to the template don't change envelopes already sent.
Recipientrcp_One person on an envelope, filling one role. Each has a private signing link, a status and an audit trail.
API keysk_live_ / sk_test_A secret that authenticates your backend as the workspace. Test keys create sandboxed envelopes.

Envelope lifecycle

An envelope is created in sent and ends in exactly one final status.

Envelope statusMeaningFinal?
sentOut for signature. At least one recipient still has to sign.No
completedEveryone signed. The sealed PDF is ready to download and final_sha256 is set.Yes
declinedA recipient declined and gave a reason (recipients[].decline_reason). Nobody else can sign.Yes
voidedSomeone in your workspace cancelled it from the dashboard. All links stop working.Yes
expiredThe deadline (expires_at, 30 days by default) passed before everyone signed.Yes
Recipient statusMeaning
waitingNot their turn yet (an earlier signer, or the person they witness, hasn't signed).
sentIt's their turn and their link is active.
viewedThey opened the document.
signedThey finished. signed_at is set.
declinedThey declined; the envelope is now declined.

Signing order

  • "routing": "sequential" (the default): recipients sign in ascending order. Recipients with the same order sign in parallel within that step. Someone with order: 2 can open their link early but can't sign until everyone with order: 1 has.
  • "routing": "parallel": everyone can sign at once, in any order. order is ignored.
  • With send_email: true (the default), we email each step's recipients when it becomes their turn. Recipients after the first step come back with signing_url: null, because their link is created and emailed when it's their turn.
  • With send_email: false, you get every recipient's link in the create response so you can deliver them yourself or embed them. Someone in a later step can open their link early, but the page won't let them sign until it's their turn.

Test mode

Requests made with a sk_test_ key create test envelopes:

  • No email is ever delivered (it's logged under Email log in the dashboard instead), so you can use any address.
  • PDFs are watermarked TEST - NOT LEGALLY BINDING, and envelopes show "test": true.
  • They're never billed and don't count toward plan limits.
  • Test and live data are separate: a test key can't read a live envelope and a live key can't read a test envelope (you'll get 404).
  • Templates are shared between test and live, so the same template_id works with both keys.
  • Webhooks fire for test envelopes too, with data.test: true. Every endpoint receives both modes, so ignore test events in your production handler.

To go live, swap the key for a sk_live_ one. No other code change is needed.

Preparing templates

Build templates in the dashboard (Templates → New template), or upload a PDF and place fields through the API when your documents are generated by code. A few minutes spent on the template avoids most integration errors:

  • Use stable role names and treat them like an API contract. If you rename Client to Customer in the builder, API calls sending "role": "Client" fail with Unknown role Client.
  • Give each role at least one field. Roles without fields don't need a recipient. Roles with fields must have one.
  • Mark fields required unless they're genuinely optional. Checkboxes default to optional; other fields default to required.
  • Keep one template per document version. Envelopes copy the fields when they're created, so editing a template is safe for envelopes already sent, but your code will start sending the new version immediately.
  • Private templates (Team plan) are hidden from other members in the dashboard. They can still be used by the API.
  • Label fields you'll prefill (for example Company or Amount). You can then fill them in from the API by label, read-only or as an editable default, without hard-coding field IDs.

Field types

TypeWhat the signer doesValue stored
signatureTypes (4 fonts), draws, or uploads a signaturePNG/JPEG image
initialsSame as signature, smallerPNG/JPEG image
dateFilled in automatically with the signing dateText
name / emailPrefilled with the recipient's name or emailText (up to 200 characters)
textFree textText (up to 1,000 characters)
numberA number, up to 6 decimal placesText
phoneA phone numberText
checkboxTicks a boxtrue / false
selectPicks one option from a dropdownOne of the options
radioPicks one option from a listOne of the options
imageUploads an image or stampPNG/JPEG image
fileAttaches a file (PDF, image, Word, Excel, CSV, text; 10 MB max)Listed and fingerprinted on the certificate; downloadable from the dashboard

Conditional fields are shown only when another field matches a rule (equals, not equals, checked, unchecked, filled, empty). Hidden fields are never required.

API reference

Base URLhttps://app.parapho.com/api/v1
FormatJSON request and response bodies. Send Content-Type: application/json on POST.
AuthAuthorization: Bearer sk_live_… or sk_test_…
TimestampsISO 8601 in UTC, for example 2026-10-28T14:03:11.402Z
Calls fromYour server only. The API doesn't send CORS headers, and your API key must never reach a browser or mobile app.
EndpointWhat it does
GET /templatesList templates and their roles
POST /documentsUpload a PDF to build a template from
POST /templates, GET / PUT / DELETE /templates/:idCreate a template with fields, read it (with field IDs), change it, delete it
POST /envelopesCreate and send an envelope, optionally prefilled; returns a signing link per recipient
GET /envelopesList and search envelopes by status, template, title, recipient, metadata or date
GET /envelopes/:idStatus, recipients and the full audit trail
GET /envelopes/:id/fieldsEvery field's value as JSON; attachments via /attachments/:id
POST /envelopes/:id/void, /remind, /recipients/:rid/linkCancel an envelope, send reminders, issue a new signing link
GET /envelopes/:id/downloadThe sealed, signed PDF
GET /events, GET /events/:id, POST /events/:id/resendWebhook events with their delivery attempts; send one again

Authentication

Every request needs an API key in the Authorization header:

Authorization: Bearer sk_live_9Zs0…
  • Keys are created by owners and admins under Settings & API → API keys. The full key is shown once; we store only a hash and can't recover it. Lost keys must be replaced.
  • A key acts as the whole workspace. Store it in a secret manager or environment variable, never in source control or front-end code.
  • Revoking a key (same page) takes effect immediately. To rotate without downtime: create a new key, deploy it, then revoke the old one. Last used on that page shows whether the old key is still in use.
  • A missing or malformed header returns 401 Missing or malformed API key.; a revoked or unknown key returns 401 Invalid API key.

Idempotency keys

Networks fail. If a POST times out you can't tell whether it worked, and retrying a create could send the same contract twice. Send an Idempotency-Key header with any POST, PUT or DELETE, and reuse it when you retry:

curl https://app.parapho.com/api/v1/envelopes \
  -H "Authorization: Bearer $PARAPHO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: nda-deal-1042" \
  -d '{ "template_id": "tpl_8Kq2vX0bR1aZ9mWc", "recipients": [ … ] }'
const envelope = await parapho.envelopes.create(params, { idempotencyKey: `nda-${deal.id}` });
envelope = parapho.envelopes.create(template_id, recipients, idempotency_key=f"nda-{deal.id}")
  • A retry with the same key and the same body gets the first response back, including the same envelope ID and signing links, with the header Idempotent-Replayed: true. Nothing is created or sent again.
  • Two requests with the same key at the same moment are safe: the second waits for the first and then gets its response.
  • Reusing a key with a different body or endpoint returns 422 with code: "idempotency_key_reused".
  • Only successful responses are kept. If the first attempt returned an error (for example a 400), fix the request and retry with the same key.
  • Keys are 1–255 visible ASCII characters, scoped to your workspace and mode (test or live), and kept for 24 hours. Use something tied to the action, such as your own record's ID, or a UUID you store before the first attempt.
GET/api/v1/templates

Lists the workspace's templates, most recently updated first. Use it to look up IDs and the exact role names a template expects.

curl https://app.parapho.com/api/v1/templates \
  -H "Authorization: Bearer $PARAPHO_API_KEY"
const templates = await parapho.templates.list();
templates = parapho.templates.list()
FieldDescription
id stringTemplate ID to pass as template_id.
name stringName shown in the dashboard; the default envelope title.
roles string[]Role names, in builder order. Use these exactly in recipients[].role.
role_settings objectWitness settings per role, for example {"Witness": {"witnessFor": "Tenant", "chosenBySigner": true}}. See Witnesses.
field_count integerNumber of fields. A template with 0 fields can't be sent.
document_id stringThe PDF the template is built on.
updated_at timestampLast edit.
POST/api/v1/documents

Uploads a PDF (up to 25 MB and 500 pages, not password-protected) so you can build a template on it. Send the raw file as the body with Content-Type: application/pdf, and optionally its name, URL-encoded, in X-Filename. Returns 201 Created.

curl https://app.parapho.com/api/v1/documents \
  -H "Authorization: Bearer $PARAPHO_API_KEY" \
  -H "Content-Type: application/pdf" \
  -H "X-Filename: Order%20form.pdf" \
  --data-binary @order-form.pdf
const doc = await parapho.documents.upload(fs.readFileSync('order-form.pdf'), { name: 'Order form.pdf' });
doc = parapho.documents.upload(open("order-form.pdf", "rb").read(), name="Order form.pdf")
{
  "id": "doc_3mR8wQ1zXc5vLp0a",
  "name": "Order form.pdf",
  "pages": [{ "w": 612, "h": 792 }, { "w": 612, "h": 792 }],
  "page_count": 2,
  "sha256": "a1b2…",
  "size": 48213
}

pages gives each page's size in PDF points (1/72 inch), which you need if you're converting your own coordinates into field positions.

POST/api/v1/templates

Creates a template on an uploaded document. Returns 201 Created with the full template, including the ID of every field.

Body parameters

ParameterDescription
document_idrequired stringFrom POST /documents. One document can back several templates.
rolesrequired string[]1–20 unique role names, such as ["Client", "Company"].
fieldsrequired object[]Up to 500 fields. See the field object below.
nameoptional stringDefaults to the document's name.
role_settingsoptional objectWitnesses, for example {"Witness": {"witnessFor": "Client", "chosenBySigner": true}}. See Witnesses.
hiddenoptional booleanDefault false. Hidden templates don't appear in the dashboard or GET /templates and don't count toward your plan's template limit: use them for one-off documents generated per envelope.

Field object

ParameterDescription
typerequired stringOne of the field types.
rolerequired stringThe role that fills it in; must be in roles.
pagerequired integer0-based page number.
x, y, w, hrequired numberPosition and size as fractions of the page (0–1), measured from the top-left corner. A field 1 inch from the left on a US Letter page has x = 72 / 612 ≈ 0.118.
idoptional string4–40 letters, digits, _ or -, unique within the template. Generated if missing; an invalid or repeated ID is rejected. Set your own to prefill and read values by stable IDs.
labeloptional stringShown to the signer; also usable as the prefill key.
requiredoptional booleanDefault true, except checkboxes (false).
optionsoptional string[]Required for select and radio.
conditionoptional objectShow the field only when another one matches: {"field": "f_pets", "op": "checked"}. op is equals, not_equals (with value), checked, unchecked, filled or empty.
curl https://app.parapho.com/api/v1/templates \
  -H "Authorization: Bearer $PARAPHO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Order form",
    "document_id": "doc_3mR8wQ1zXc5vLp0a",
    "roles": ["Client"],
    "fields": [
      { "id": "company", "type": "text",      "role": "Client", "page": 0, "x": 0.12, "y": 0.20, "w": 0.40, "h": 0.04, "label": "Company" },
      { "id": "amount",  "type": "number",    "role": "Client", "page": 0, "x": 0.12, "y": 0.26, "w": 0.20, "h": 0.04, "label": "Amount" },
      { "id": "sig",     "type": "signature", "role": "Client", "page": 1, "x": 0.12, "y": 0.80, "w": 0.30, "h": 0.06 }
    ]
  }'
const template = await parapho.templates.create({
  name: 'Order form',
  document_id: doc.id,
  roles: ['Client'],
  fields: [
    { id: 'company', type: 'text', role: 'Client', page: 0, x: 0.12, y: 0.2, w: 0.4, h: 0.04, label: 'Company' },
    { id: 'amount', type: 'number', role: 'Client', page: 0, x: 0.12, y: 0.26, w: 0.2, h: 0.04, label: 'Amount' },
    { id: 'sig', type: 'signature', role: 'Client', page: 1, x: 0.12, y: 0.8, w: 0.3, h: 0.06 },
  ],
});
template = parapho.templates.create(
    doc["id"],
    ["Client"],
    [
        {"id": "company", "type": "text", "role": "Client", "page": 0, "x": 0.12, "y": 0.2, "w": 0.4, "h": 0.04, "label": "Company"},
        {"id": "amount", "type": "number", "role": "Client", "page": 0, "x": 0.12, "y": 0.26, "w": 0.2, "h": 0.04, "label": "Amount"},
        {"id": "sig", "type": "signature", "role": "Client", "page": 1, "x": 0.12, "y": 0.8, "w": 0.3, "h": 0.06},
    ],
    name="Order form",
)

The response (and GET /templates/:id) contains id, name, document_id, roles, role_settings, fields (as stored, every one with an id), hidden, pages, created_at and updated_at. Check the result in the dashboard's template builder the first time: it's the quickest way to see a misplaced field.

  • GET /templates/:id: the template with its fields and page sizes.
  • PUT /templates/:id: change name, roles, fields, role_settings or hidden. Anything you leave out is kept; fields replaces the whole list. Envelopes already sent keep their copy of the old fields.
  • DELETE /templates/:id: deletes the template. Envelopes sent from it are unaffected.

Templates belong to the workspace, not to a mode: test and live keys see the same ones, and templates created with either count toward your plan's template limit unless they're hidden.

POST/api/v1/envelopes

Creates an envelope from a template and sends it. Returns 201 Created with a signing link for every recipient.

Body parameters

ParameterDescription
template_idrequired stringThe template to send.
recipientsrequired object[]One entry per role. See the recipient table below.
titleoptional stringShown to signers, in emails and on the certificate. Defaults to the template name. Max 200 characters.
messageoptional stringA note included in the invitation email. Max 2,000 characters.
routingoptional string"sequential" (default) or "parallel". See Signing order.
send_emailoptional booleanDefault true: we email each recipient when it's their turn, and later steps' signing_url is null in the response. Set false to get every link straight away and deliver them yourself or embed signing. Either way, every signer gets an email with the signed copy at the end (test mode never sends email).
expires_daysoptional integerDays until the envelope expires, 1–365. Default 30.
reminder_daysoptional integerEmail unsigned recipients every N days (1–30), up to 10 times. Needs send_email: true and the Solo plan or above on live keys. Note: each reminder replaces the signer's link.
prefilloptional objectValues to fill in for the signers, keyed by field ID or label. See Prefill fields.
metadataoptional objectUp to 20 of your own key/value pairs, such as {"deal_id": "D-1042"}. Keys are 1–40 letters, digits, _, - or .; values are strings up to 500 characters (numbers and booleans are converted). Returned by every endpoint and in webhooks, and you can search by it.

Recipient object

ParameterDescription
rolerequired stringA role name from the template, matched exactly (case- and space-sensitive). Each role can appear once.
namerequired stringFull name, max 120 characters. Prefills name fields and appears on the certificate.
emailrequired stringEmail address. Required even with send_email: false: it identifies the signer on the certificate and receives the signed copy.
orderoptional integerSigning step for sequential routing, starting at 1. Default 1. Ignored for parallel routing and for witnesses.
accessCodeoptional stringA 4–64 character code the signer must enter before seeing the document. Share it separately (by phone or SMS). Note the camelCase spelling. See Access codes.
curl https://app.parapho.com/api/v1/envelopes \
  -H "Authorization: Bearer $PARAPHO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "template_id": "tpl_8Kq2vX0bR1aZ9mWc",
    "title": "Service agreement – Acme Corp",
    "message": "Hi Ada, here is the agreement we discussed.",
    "routing": "sequential",
    "expires_days": 14,
    "send_email": true,
    "reminder_days": 3,
    "metadata": { "deal_id": "D-1042" },
    "recipients": [
      { "role": "Client",  "name": "Ada Lovelace",    "email": "ada@acme.example",   "order": 1 },
      { "role": "Company", "name": "Charles Babbage", "email": "charles@you.example", "order": 2 }
    ]
  }'
const envelope = await parapho.envelopes.create({
  template_id: 'tpl_8Kq2vX0bR1aZ9mWc',
  title: 'Service agreement – Acme Corp',
  message: 'Hi Ada, here is the agreement we discussed.',
  routing: 'sequential',
  expires_days: 14,
  send_email: true,
  reminder_days: 3,
  metadata: { deal_id: 'D-1042' },
  recipients: [
    { role: 'Client',  name: 'Ada Lovelace',    email: 'ada@acme.example',   order: 1 },
    { role: 'Company', name: 'Charles Babbage', email: 'charles@you.example', order: 2 },
  ],
}, { idempotencyKey: 'nda-D-1042' });
envelope = parapho.envelopes.create(
    "tpl_8Kq2vX0bR1aZ9mWc",
    [
        {"role": "Client", "name": "Ada Lovelace", "email": "ada@acme.example", "order": 1},
        {"role": "Company", "name": "Charles Babbage", "email": "charles@you.example", "order": 2},
    ],
    title="Service agreement – Acme Corp",
    message="Hi Ada, here is the agreement we discussed.",
    routing="sequential",
    expires_days=14,
    send_email=True,
    reminder_days=3,
    metadata={"deal_id": "D-1042"},
    idempotency_key="nda-D-1042",
)

Response

FieldDescription
id stringThe envelope ID. Store it.
test booleantrue when created with a test key.
metadata objectYour metadata, as stored.
recipients[].id stringRecipient ID.
recipients[].role, name, email, orderAs stored.
recipients[].signing_url string | nullThe recipient's private link. Anyone holding it can sign as that person, so treat it like a password. null when we'll email the link later: for recipients after the first step when send_email is true, and for a witness the signer hasn't chosen yet.
recipients[].embed_url string | nullsigning_url with ?embed=1: the version to put in an iframe.
We can't show a link again.We store links only as hashes. Save them if you'll need them later (encrypted, like any credential), or call POST /envelopes/:id/recipients/:rid/link when you need one: it issues a new link, and the previous one stops working.

Validation errors (400)

MessageFix
Template not found. (404)Wrong ID, a template from another workspace, or it was deleted. Check GET /templates with the same key.
Add at least one recipient.recipients is missing or empty.
Unknown role X.The role name doesn't match the template exactly. Compare with roles from GET /templates.
Assign someone to X.Role X has fields but no recipient.
X is assigned twice.Each role takes one person. For two people, add two roles to the template.
Enter a name for X. / Enter a valid email for X.Name or email missing or invalid.
The document has no fields to fill in.Add fields to the template and save it.
Routing must be sequential or parallel.Typo in routing.
Access codes must be 4–64 characters.Lengthen or shorten accessCode.
prefill: there's no field with the ID or label "X".Check the IDs and labels in GET /templates/:id. Labels match case-insensitively.
prefill: "X" is a signature field, which only the signer can fill in.Signatures, initials, images and file uploads can't be prefilled.

Prefill fields

Fill in values you already know, such as a company name, an amount or an address, so signers only check them. Pass prefill when you create the envelope. Keys are field IDs or labels (labels match case-insensitively, and a label shared by several fields fills them all). Each value is either the value itself, or an object with readonly:

"prefill": {
  "Company": { "value": "Acme Ltd", "readonly": true },   // shown as fixed text; the signer can't change it
  "amount": 1200,                                          // an editable default (by field ID)
  "Plan": "Pro",                                           // must be one of a select/radio field's options
  "Accept terms": true                                     // checkboxes take true or false
}
  • Editable values (the default) appear already filled in; the signer can change them. Read-only values are shown as part of the document, to every signer, and are enforced when their signer submits.
  • Works on text, name, email, number, phone, date, checkbox, select and radio fields. Values are checked the same way as a signer's (numbers, phone numbers, options, length). Signatures, initials, images and files can't be prefilled.
  • Prefilled values become the signer's values when they sign, so they're in the sealed PDF and GET /envelopes/:id/fields like any other value.
GET/api/v1/envelopes

Lists envelopes, newest first, in the key's mode (a test key sees only test envelopes). Combine any of these query parameters:

ParameterDescription
statusOne status, or several separated by commas: status=sent,expired.
template_idEnvelopes sent from one template.
qText anywhere in the title, case-insensitive.
recipient_emailEnvelopes with this recipient.
metadata[key]Envelopes whose metadata has this value, for example metadata[deal_id]=D-1042. Repeat for several keys; all must match.
created_after, created_beforeISO 8601 timestamps.
limit1–100, default 25.
starting_afterFor the next page: the next_cursor from the previous response.
curl -G https://app.parapho.com/api/v1/envelopes \
  -H "Authorization: Bearer $PARAPHO_API_KEY" \
  --data-urlencode "status=sent" \
  --data-urlencode "metadata[deal_id]=D-1042"
// Every open envelope, page by page
let page = await parapho.envelopes.list({ status: 'sent', limit: 100 });
for (;;) {
  for (const env of page.data) await reconcile(env);
  if (!page.has_more) break;
  page = await parapho.envelopes.list({ status: 'sent', limit: 100, starting_after: page.next_cursor });
}
page = parapho.envelopes.list(status="sent", limit=100)
while True:
    for env in page["data"]:
        reconcile(env)
    if not page["has_more"]:
        break
    page = parapho.envelopes.list(status="sent", limit=100, starting_after=page["next_cursor"])
{
  "data": [
    {
      "id": "env_Yp3tN6sQe0LwHk2u",
      "title": "Service agreement – Acme Corp",
      "status": "sent",
      "routing": "sequential",
      "source": "api",
      "test": false,
      "template_id": "tpl_8Kq2vX0bR1aZ9mWc",
      "metadata": { "deal_id": "D-1042" },
      "created_at": "2026-10-28T14:10:02.118Z",
      "expires_at": "2026-11-11T14:10:02.118Z",
      "completed_at": null,
      "final_sha256": null,
      "recipients": [
        { "id": "rcp_c9VdA1mE7rTq4ZxB", "role": "Client", "name": "Ada Lovelace", "email": "ada@acme.example", "status": "viewed", "order": 1, "signed_at": null, "witness_for": null }
      ]
    }
  ],
  "has_more": false,
  "next_cursor": null
}

Each item has the same shape as a webhook's data, plus source, template_id and expires_at. For the audit trail, call GET /envelopes/:id.

GET/api/v1/envelopes/:id

Returns an envelope with its recipients, attachments and full audit trail. Use it to show status in your product, or to reconcile if you missed a webhook.

curl https://app.parapho.com/api/v1/envelopes/env_Yp3tN6sQe0LwHk2u \
  -H "Authorization: Bearer $PARAPHO_API_KEY"
const env = await parapho.envelopes.get('env_Yp3tN6sQe0LwHk2u');
env = parapho.envelopes.get("env_Yp3tN6sQe0LwHk2u")
{
  "id": "env_Yp3tN6sQe0LwHk2u",
  "org_id": "org_T1kZ0p8sWq3nVb6c",
  "title": "NDA with Analytical Engines Ltd",
  "message": "",
  "status": "completed",
  "routing": "sequential",
  "source": "api",
  "test": true,
  "template_id": "tpl_8Kq2vX0bR1aZ9mWc",
  "document_id": "doc_3mR8wQ1zXc5vLp0a",
  "created_at": "2026-10-28T14:10:02.118Z",
  "expires_at": "2026-11-27T14:10:02.118Z",
  "completed_at": "2026-10-28T14:22:47.930Z",
  "final_sha256": "5f0c1d7a9b…e41",
  "reminder_days": null,
  "role_settings": {},
  "metadata": { "deal_id": "D-1042" },
  "created_by": null,
  "field_count": 6,
  "recipients": [
    {
      "id": "rcp_c9VdA1mE7rTq4ZxB",
      "role": "Client",
      "name": "Ada Lovelace",
      "email": "ada@example.com",
      "order_idx": 1,
      "status": "signed",
      "sent_at": "2026-10-28T14:10:02.200Z",
      "viewed_at": "2026-10-28T14:15:40.011Z",
      "consent_at": "2026-10-28T14:16:31.502Z",
      "signed_at": "2026-10-28T14:16:31.502Z",
      "decline_reason": null,
      "ip": "203.0.113.24",
      "witness_for": null,
      "has_access_code": false,
      "reminder_count": 0,
      "last_reminded_at": null
    }
  ],
  "events": [
    { "id": 101, "type": "created",   "detail": "Created from \"Mutual NDA\" via api in test mode", "ip": "198.51.100.7", "created_at": "…", "recipient_id": null, "recipient_name": null, "actor_name": null },
    { "id": 102, "type": "sent",      "detail": "Link generated for embedded or in-person signing", "…": "…" },
    { "id": 104, "type": "viewed",    "…": "…" },
    { "id": 105, "type": "consented", "detail": "ESIGN/UETA disclosure v1", "…": "…" },
    { "id": 106, "type": "signed",    "detail": "4 fields completed", "…": "…" },
    { "id": 109, "type": "completed", "detail": "All recipients signed; document sealed", "…": "…" }
  ],
  "document": { "id": "doc_3mR8wQ1zXc5vLp0a", "name": "nda.pdf", "sha256": "a1b2…", "pages": [{ "w": 612, "h": 792 }] },
  "attachments": []
}
  • recipients[].order_idx is the signing step (it's called order on create and in webhooks).
  • source is api, app (sent from the dashboard), bulk or form (a public link).
  • Audit event types: created, sent, viewed, consented, signed, witness_invited, reminded, declined, voided, expired, completed.
  • Field values aren't included here: use GET /envelopes/:id/fields.
  • If an envelope's deadline has passed, reading it marks it expired right away.
GET/api/v1/envelopes/:id/fields

Every field on the envelope with the value its signer entered, so you can save the answers into your own system without reading the PDF. A field's value appears as soon as its signer has signed, before the envelope completes.

curl https://app.parapho.com/api/v1/envelopes/env_Yp3tN6sQe0LwHk2u/fields \
  -H "Authorization: Bearer $PARAPHO_API_KEY"
const { fields } = await parapho.envelopes.fields('env_Yp3tN6sQe0LwHk2u');
const answers = Object.fromEntries(fields.map((f) => [f.label || f.id, f.value]));
fields = parapho.envelopes.fields("env_Yp3tN6sQe0LwHk2u")["fields"]
answers = {f["label"] or f["id"]: f["value"] for f in fields}
{
  "envelope_id": "env_Yp3tN6sQe0LwHk2u",
  "status": "completed",
  "fields": [
    { "id": "company", "type": "text",      "label": "Company", "role": "Client", "recipient_id": "rcp_c9VdA1mE7rTq4ZxB", "page": 0, "required": true, "filled": true, "visible": true, "value": "Acme Ltd" },
    { "id": "terms",   "type": "checkbox",  "label": "I accept the terms", "role": "Client", "…": "…", "value": true },
    { "id": "po",      "type": "file",      "label": "Purchase order", "role": "Client", "…": "…",
      "value": { "attachment_id": "att_Qw8…", "name": "po-1042.pdf", "content_type": "application/pdf", "size": 48213, "sha256": "9f2c…" } },
    { "id": "sig",     "type": "signature", "label": "", "role": "Client", "…": "…", "value": "data:image/png;base64,iVBORw0KGgo…" }
  ]
}
  • value is text for most fields, true/false for checkboxes, a data: URL (PNG or JPEG) for signatures, initials and images, and file details for attachments. It's null until the field's signer signs (filled: false), for optional fields left empty, and for fields hidden by a condition (visible: false).
  • Download an attachment with GET /envelopes/:id/attachments/:attachment_id (Node: envelopes.attachment(id, attachmentId)).

Void, remind and new signing links

These work on envelopes that are still open (status: "sent"); otherwise they return 400 This envelope is completed. (or voided, declined, expired).

POST/api/v1/envelopes/:id/void

Cancels the envelope. Body: {"reason": "Customer cancelled the order"} (required, up to 500 characters). Every signing link stops working, anyone who's already been sent the document is emailed the reason, and envelope.voided fires. Returns {"id": "env_…", "status": "voided"}.

POST/api/v1/envelopes/:id/remind

Emails a reminder to everyone whose turn it is and hasn't signed. Each reminder contains a fresh link and the old one stops working, so don't remind recipients whose links you embed. Returns {"id": "env_…", "reminded": [{"id", "role", "name", "email"}]}. Limited to 5 calls per envelope per hour.

POST/api/v1/envelopes/:id/recipients/:recipient_id/link

Issues a new signing link for one recipient and returns it; nothing is emailed. Use it when someone lost their email, or when you embed signing and need a link for a returning user. The previous link stops working. Recipients who have signed or declined can't get a new link.

curl -X POST https://app.parapho.com/api/v1/envelopes/env_Yp3tN6sQe0LwHk2u/recipients/rcp_c9VdA1mE7rTq4ZxB/link \
  -H "Authorization: Bearer $PARAPHO_API_KEY"
const { embed_url } = await parapho.envelopes.signingLink(envelopeId, recipientId);
await parapho.envelopes.remind(envelopeId);
await parapho.envelopes.void(envelopeId, 'Customer cancelled the order');
link = parapho.envelopes.signing_link(envelope_id, recipient_id)["embed_url"]
parapho.envelopes.remind(envelope_id)
parapho.envelopes.void(envelope_id, "Customer cancelled the order")
{
  "envelope_id": "env_Yp3tN6sQe0LwHk2u",
  "recipient_id": "rcp_c9VdA1mE7rTq4ZxB",
  "signing_url": "https://app.parapho.com/s/q1W…",
  "embed_url": "https://app.parapho.com/s/q1W…?embed=1"
}

A link handed out before it's someone's turn still works only when their turn comes, and we won't email them a link then: you've taken over delivering it.

GET/api/v1/envelopes/:id/download

Returns the sealed PDF (Content-Type: application/pdf) once the envelope is completed. The file contains the signed document followed by the certificate of completion, and is digitally signed so PDF readers flag any later change. Before completion it returns 404 No sealed document for this envelope yet.

curl https://app.parapho.com/api/v1/envelopes/env_Yp3tN6sQe0LwHk2u/download \
  -H "Authorization: Bearer $PARAPHO_API_KEY" \
  -o "signed.pdf"
const pdf = await parapho.envelopes.download('env_Yp3tN6sQe0LwHk2u'); // Buffer
await s3.putObject({ Bucket: 'contracts', Key: `${dealId}.pdf`, Body: pdf });
pdf = parapho.envelopes.download("env_Yp3tN6sQe0LwHk2u")  # bytes
open(f"{deal_id}.pdf", "wb").write(pdf)
GET/api/v1/events

Webhook events for the key's mode, newest first, each with its delivery attempts. Events are kept for 30 days, and are recorded only while at least one enabled webhook endpoint is subscribed to them. Query parameters: envelope_id, type (for example envelope.completed), limit (1–100, default 25) and starting_after.

curl -G https://app.parapho.com/api/v1/events \
  -H "Authorization: Bearer $PARAPHO_API_KEY" \
  --data-urlencode "envelope_id=env_Yp3tN6sQe0LwHk2u"
{
  "data": [
    {
      "id": "evt_Lk2…",
      "event": "envelope.completed",
      "envelope_id": "env_Yp3tN6sQe0LwHk2u",
      "test": false,
      "created_at": "2026-10-28T14:22:47.951Z",
      "deliveries": [
        { "id": "whd_9a…", "endpoint_id": "whe_3c…", "endpoint_url": "https://api.you.example/webhooks/parapho",
          "status": "succeeded", "attempts": 2, "manual": false, "response_status": 200, "error": null, "duration_ms": 143,
          "created_at": "…", "last_attempt_at": "…", "next_attempt_at": null }
      ]
    }
  ],
  "has_more": false,
  "next_cursor": null
}
  • GET /events/:id: the same, plus payload: the exact body we send.
  • POST /events/:id/resend: sends the event again, with the same event ID and body, to every enabled endpoint, or to one with {"endpoint_id": "whe_…"}. Returns {"event_id", "deliveries": ["whd_…"]}. Handy after fixing a broken handler.

Errors

Errors return a non-2xx status and a JSON body with a human-readable error. Some include a machine-readable code:

{ "error": "You've used the 100 free API envelopes this month. Turn on pay-as-you-go API billing (2¢ per completed envelope) in Billing to keep going.", "code": "plan_limit", "needs": "api" }
StatusMeaningWhat to do
400Invalid request. error says what's wrong.Fix the request; don't retry as-is.
401Missing, malformed, revoked or unknown API key.Check the header is exactly Bearer sk_…, with no quotes or line breaks.
402code: "plan_limit": a plan limit or a paid feature (for example the monthly free API envelopes, or reminders on the Free plan). feature and needs say which.Upgrade or enable API billing in Billing. Test keys are never limited.
403code: "suspended": the workspace has been suspended.Contact support.
404Not found, or it belongs to the other mode (test vs live).Check the ID and that you're using the matching key.
409code: "idempotency_key_in_use": the first request with this key hasn't finished.Retry in a moment with the same key.
413Request body too large.JSON bodies are limited to 8 MB, PDF uploads to 25 MB.
422code: "idempotency_key_reused": the idempotency key was used for a different request.Use a new key for a new request.
429Rate limit exceeded.Back off and retry (see below).
5xxOur side.Retry with exponential back-off, sending the same Idempotency-Key so a retry can't act twice.

The Node SDK throws ParaphoError (err.status, err.body); the Python SDK raises ParaphoError (e.status, e.body).

Rate limits and quotas

  • 600 requests per minute per API key. Over that you get 429 Rate limit exceeded. Retry after a short wait, with exponential back-off and jitter.
  • API envelopes: 100 free live envelopes a month per workspace. After that, turn on pay-as-you-go API billing in Billing (2¢ per completed envelope; 1¢ above 10,000 a month). Without it, creates return 402 until the next calendar month (UTC). Test envelopes are unlimited and free.
  • Retries: if a request times out it may still have succeeded. Send an Idempotency-Key on every POST and reuse it when you retry, so a retry can't send a second envelope.

Webhooks

Webhooks tell your server when something happens to an envelope, so you don't have to poll. A workspace can have up to 10 endpoints (for example production and staging, or one per internal service), each with its own signing secret and its own choice of events. Every endpoint receives events for every envelope in the workspace: sent by API, from the dashboard, by bulk send or through a public link, in test and live mode.

Set up

  1. Add an endpoint

    Go to Settings & API → Webhooks → Add endpoint, enter your URL (for example https://api.yourcompany.com/webhooks/parapho), and choose All events or just the ones you handle. In production the URL must use https and resolve to a public IP address.

  2. Save the signing secret

    When you add the endpoint we show its whsec_… signing secret once. Copy it into your server's configuration (for example PARAPHO_WEBHOOK_SECRET). Each endpoint has its own secret.

    To rotate it, click Rotate secret next to the endpoint. The old secret stops working at once, so deploy the new one straight away; deliveries in between fail verification (and are retried, or you can resend them).

  3. Verify and respond

    In your handler, verify the Parapho-Signature header against the raw body (examples below), then return any 2xx status within 8 seconds. Do slow work (downloading the PDF, sending emails) after responding, or in a background job.

  4. Test it

    Create a test envelope with your sk_test_ key. You'll receive envelope.sent immediately, and it shows up under Webhook deliveries with your server's response. To receive webhooks on your laptop, use a tunnel such as ngrok or Cloudflare Tunnel and register its https URL as a second endpoint.

Events

EventWhen it's sent
envelope.sentThe envelope was created. Sent once per envelope.
recipient.viewedA recipient opened the document while it was their turn (once per recipient).
recipient.signedA recipient signed and others still have to. Not sent for the final signature: that sends envelope.completed instead.
envelope.completedEveryone signed and the sealed PDF is ready to download. data.final_sha256 is set.
envelope.declinedA recipient declined. The reason is in GET /envelopes/:id (recipients[].decline_reason).
envelope.voidedSomeone in your workspace voided the envelope.
envelope.expiredThe deadline passed before everyone signed.

To find out who viewed or signed, look at data.recipients[]: compare each recipient's status and signed_at with what you stored before.

Payload

Each delivery is a POST with a JSON body:

POST /webhooks/parapho HTTP/1.1
Content-Type: application/json
User-Agent: Parapho-Webhooks/1.0
Parapho-Signature: t=1761661367,v1=6f1c0b5e2a…9d
Parapho-Event-Id: evt_Lk2pX9vQ0aTn4RbW
Parapho-Delivery-Id: whd_9aM3sW7kE1qZ5yUc

{
  "id": "evt_Lk2pX9vQ0aTn4RbW",
  "event": "envelope.completed",
  "created_at": "2026-10-28T14:22:47.951Z",
  "data": {
    "id": "env_Yp3tN6sQe0LwHk2u",
    "title": "NDA with Analytical Engines Ltd",
    "status": "completed",
    "routing": "sequential",
    "test": false,
    "created_at": "2026-10-28T14:10:02.118Z",
    "completed_at": "2026-10-28T14:22:47.930Z",
    "final_sha256": "5f0c1d7a9b…e41",
    "metadata": { "deal_id": "D-1042" },
    "recipients": [
      { "id": "rcp_c9VdA1mE7rTq4ZxB", "role": "Client",  "name": "Ada Lovelace",    "email": "ada@example.com",     "status": "signed", "order": 1, "signed_at": "2026-10-28T14:16:31.502Z", "witness_for": null },
      { "id": "rcp_Fh2LwQ8nB5sYk0Pe", "role": "Company", "name": "Charles Babbage", "email": "charles@example.com", "status": "signed", "order": 2, "signed_at": "2026-10-28T14:22:47.902Z", "witness_for": null }
    ]
  }
}
FieldDescription
idThe event ID (evt_…), also in the Parapho-Event-Id header. It's the same on every retry and resend of this event, so use it to skip events you've already processed.
eventOne of the event names above.
created_atWhen the event happened (unchanged on retries).
dataA snapshot of the envelope at that moment: id, title, status, routing, test, metadata, created_at, completed_at, final_sha256 and recipients.
data.recipients[]id, role, name, email, status, order, signed_at and witness_for (the recipient ID this person witnesses, or null).

Verify signatures

Always verify a webhook before trusting it: anyone can POST to your URL. The Parapho-Signature header looks like t=1761661367,v1=6f1c…, where:

  • t is the Unix time (seconds) the request was signed.
  • v1 is the hex HMAC-SHA256 of the string {t}.{raw body}, keyed with your whsec_… secret.

To verify: rebuild that string from the exact bytes you received, compute the HMAC, compare in constant time, and reject timestamps more than 5 minutes from now (to stop replays).

Use the raw body.If your framework parses JSON first and you re-serialize it, spacing and key order change and the signature won't match. Every example below reads the raw body.
import express from 'express';
import { verifyWebhook } from '@parapho/node';

const app = express();

// Register this route BEFORE app.use(express.json()), or the raw body is gone.
app.post('/webhooks/parapho', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;
  try {
    event = verifyWebhook(req.body, req.get('parapho-signature'), process.env.PARAPHO_WEBHOOK_SECRET);
  } catch {
    return res.sendStatus(400);
  }
  res.sendStatus(200); // respond first, then do the work

  if (event.data.test && process.env.NODE_ENV === 'production') return;
  if (!(await processedEvents.add(event.id))) return; // already handled (a retry or resend)
  switch (event.event) {
    case 'envelope.completed':
      await jobs.enqueue('store-signed-pdf', { envelopeId: event.data.id });
      break;
    case 'envelope.declined':
    case 'envelope.expired':
    case 'envelope.voided':
      await deals.markSignatureFailed(event.data.id, event.event);
      break;
  }
});

app.use(express.json()); // the rest of your app
// app/api/webhooks/parapho/route.ts
import { verifyWebhook } from '@parapho/node';

export async function POST(request: Request) {
  const raw = await request.text(); // raw body, not request.json()
  try {
    const event = verifyWebhook(raw, request.headers.get('parapho-signature') ?? '', process.env.PARAPHO_WEBHOOK_SECRET!);
    if (event.event === 'envelope.completed') {
      await markSigned(event.data.id);
    }
    return new Response('ok');
  } catch {
    return new Response('invalid signature', { status: 400 });
  }
}
import os
from flask import Flask, request, abort
from parapho import verify_webhook, WebhookVerificationError

app = Flask(__name__)

@app.post("/webhooks/parapho")
def parapho_webhook():
    try:
        event = verify_webhook(
            request.get_data(),                      # raw bytes
            request.headers.get("Parapho-Signature"),
            os.environ["PARAPHO_WEBHOOK_SECRET"],
        )
    except WebhookVerificationError:
        abort(400)

    if event["event"] == "envelope.completed":
        store_signed_pdf.delay(event["data"]["id"])  # background job
    return "", 200
# Django
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from parapho import verify_webhook, WebhookVerificationError

@csrf_exempt
@require_POST
def parapho_webhook(request):
    try:
        event = verify_webhook(request.body, request.headers.get("Parapho-Signature"), settings.PARAPHO_WEBHOOK_SECRET)
    except WebhookVerificationError:
        return HttpResponseBadRequest()
    handle(event)
    return HttpResponse()

# FastAPI
@app.post("/webhooks/parapho")
async def parapho_webhook(request: Request):
    try:
        event = verify_webhook(await request.body(), request.headers.get("parapho-signature"), SECRET)
    except WebhookVerificationError:
        raise HTTPException(status_code=400)
    handle(event)
    return {"ok": True}
<?php
$secret = getenv('PARAPHO_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_PARAPHO_SIGNATURE'] ?? '';

$parts = [];
foreach (explode(',', $header) as $pair) {
    [$k, $v] = array_pad(explode('=', trim($pair), 2), 2, '');
    $parts[$k] = $v;
}
$t = $parts['t'] ?? '';
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);

if ($t === '' || !hash_equals($expected, $parts['v1'] ?? '') || abs(time() - (int) $t) > 300) {
    http_response_code(400);
    exit;
}

$event = json_decode($raw, true);
if ($event['event'] === 'envelope.completed') {
    // queue a job to download and store the PDF
}
http_response_code(200);
class ParaphoWebhooksController < ActionController::API
  def create
    raw = request.raw_post
    parts = request.headers['Parapho-Signature'].to_s.split(',').to_h { |p| p.strip.split('=', 2) }
    expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('PARAPHO_WEBHOOK_SECRET'), "#{parts['t']}.#{raw}")

    unless parts['t'] && ActiveSupport::SecurityUtils.secure_compare(expected, parts['v1'].to_s) &&
           (Time.now.to_i - parts['t'].to_i).abs <= 300
      return head :bad_request
    end

    event = JSON.parse(raw)
    StoreSignedPdfJob.perform_later(event.dig('data', 'id')) if event['event'] == 'envelope.completed'
    head :ok
  end
end
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

func verify(raw []byte, header, secret string) bool {
	var ts, sig string
	for _, p := range strings.Split(header, ",") {
		kv := strings.SplitN(strings.TrimSpace(p), "=", 2)
		if len(kv) != 2 {
			continue
		}
		switch kv[0] {
		case "t":
			ts = kv[1]
		case "v1":
			sig = kv[1]
		}
	}
	t, err := strconv.ParseInt(ts, 10, 64)
	if err != nil || sig == "" {
		return false
	}
	if d := time.Now().Unix() - t; d > 300 || d < -300 {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(ts + "." + string(raw)))
	return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(sig))
}

func Handler(w http.ResponseWriter, r *http.Request) {
	raw, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if !verify(raw, r.Header.Get("Parapho-Signature"), os.Getenv("PARAPHO_WEBHOOK_SECRET")) {
		http.Error(w, "invalid signature", http.StatusBadRequest)
		return
	}
	var event struct {
		Event string `json:"event"`
		Data  struct {
			ID     string `json:"id"`
			Status string `json:"status"`
		} `json:"data"`
	}
	json.Unmarshal(raw, &event)
	w.WriteHeader(http.StatusOK)
}
// ASP.NET Core minimal API
app.MapPost("/webhooks/parapho", async (HttpRequest request) =>
{
    using var reader = new StreamReader(request.Body);
    var raw = await reader.ReadToEndAsync();
    var parts = request.Headers["Parapho-Signature"].ToString()
        .Split(',', StringSplitOptions.RemoveEmptyEntries)
        .Select(p => p.Trim().Split('=', 2))
        .Where(kv => kv.Length == 2)
        .ToDictionary(kv => kv[0], kv => kv[1]);

    if (!parts.TryGetValue("t", out var t) || !parts.TryGetValue("v1", out var v1)
        || !long.TryParse(t, out var ts) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300)
        return Results.BadRequest();

    var secret = Environment.GetEnvironmentVariable("PARAPHO_WEBHOOK_SECRET")!;
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var expected = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{raw}"))).ToLowerInvariant();
    if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(v1)))
        return Results.BadRequest();

    var evt = JsonDocument.Parse(raw).RootElement;
    // evt.GetProperty("event").GetString() == "envelope.completed" …
    return Results.Ok();
});

Delivery and retries

  • A delivery succeeds when your endpoint returns any 2xx within 8 seconds. Redirects aren't followed and count as failures, so register the final URL (including https:// and any trailing slash your server expects).
  • Failed deliveries are retried 6 times: after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours (about a day and a half in all). Retries survive restarts on our side. After the last one the delivery is marked Failed, and you can still resend it for 30 days.
  • Each endpoint is delivered to independently: one slow or failing endpoint doesn't hold up the others.
  • Events can arrive more than once (a retry after a slow success, or a resend) and occasionally out of order. Skip event ids you've already processed, and treat data.status as the source of truth. A final status (completed, declined, voided, expired) never changes again.
  • For anything important, also run a reconciliation job (for example every hour) that lists open envelopes with GET /envelopes?status=sent and processes any your records consider stale. It covers the rare case where your endpoint was down for longer than the retry window.
  • Webhook requests come from our servers' IP addresses, which can change. Authenticate with the signature, not an IP allowlist.

Delivery log and resend

Settings & API → Webhook deliveries lists every delivery for the last 30 days: the event and its ID, the endpoint, the status (Delivered, Retrying with the next attempt time, or Failed), the number of attempts, and your server's HTTP status or the connection error. Details shows the exact payload and the first 500 characters of your response; Resend sends the same event (same ID, same body) to that endpoint again straight away. The endpoint list shows how many deliveries failed in the last 7 days.

From code, GET /events returns events with their deliveries and POST /events/:id/resend sends one again, for example from a script after an outage.

Embed signing in your app

Embedded signing shows the document inside your own page in an iframe, so your users never leave your product. The signing page handles consent, field navigation, signature drawing and mobile layout, and tells your page when the user has finished.

  1. Allow your site's origin (once).
  2. Create the envelope on your server with send_email: false and pass the recipient's embed_url to your front end.
  3. Render it with the script tag, the React component, or a plain iframe.
  4. React to the completed / declined browser events in the UI, and confirm on your server with the webhook.

1. Allow your origin

Browsers only show signing pages in a frame on sites you've approved. Go to Settings & API → Embedded signing → Allowed origins and add each origin that will host the frame, separated by spaces:

https://app.yourcompany.com https://staging.yourcompany.com http://localhost:5173
  • An origin is scheme + host + port only: https://app.yourcompany.com. No path, no trailing slash, no wildcards.
  • https://yourcompany.com and https://www.yourcompany.com are different origins. Add both if you use both.
  • Production deployments of Parapho only accept https origins. http://localhost:… works against local and development instances.
  • Until an origin is added, framing is blocked and the browser shows an error such as “refused to connect” or “Refused to frame … because an ancestor violates the Content Security Policy”.

2. Create the envelope on your server

app.post('/api/contracts/:dealId/signing-session', requireUser, async (req, res) => {
  const deal = await deals.find(req.params.dealId, { ownerId: req.user.id });

  const envelope = await parapho.envelopes.create({
    template_id: process.env.PARAPHO_TEMPLATE_ID,
    title: `Agreement – ${deal.companyName}`,
    send_email: false,
    recipients: [{ role: 'Client', name: req.user.name, email: req.user.email }],
  });

  await deals.update(deal.id, { envelopeId: envelope.id, signatureStatus: 'sent' });
  res.json({ embedUrl: envelope.recipients[0].embed_url });
});
@app.post("/api/contracts/<deal_id>/signing-session")
@login_required
def signing_session(deal_id):
    deal = Deal.get_for_user(deal_id, current_user)
    envelope = parapho.envelopes.create(
        os.environ["PARAPHO_TEMPLATE_ID"],
        [{"role": "Client", "name": current_user.name, "email": current_user.email}],
        title=f"Agreement – {deal.company_name}",
        send_email=False,
    )
    deal.update(envelope_id=envelope["id"], signature_status="sent")
    return {"embedUrl": envelope["recipients"][0]["embed_url"]}

3a. Script tag and web component

Works with any front end (plain HTML, Vue, Angular, Svelte, server-rendered pages). Load the script from your Parapho instance:

<script src="https://app.parapho.com/sdk/embed.js"></script>

<parapho-signing id="signing" src="EMBED_URL"></parapho-signing>

<script>
  const el = document.getElementById('signing');
  el.addEventListener('parapho:loaded',    (e) => console.log('ready', e.detail));
  el.addEventListener('parapho:completed', (e) => { location.href = '/thanks'; });
  el.addEventListener('parapho:declined',  (e) => { showMessage('You declined to sign.'); });
</script>
<div id="sign-here"></div>
<script src="https://app.parapho.com/sdk/embed.js"></script>
<script>
  const { embedUrl } = await fetch('/api/contracts/42/signing-session', { method: 'POST' }).then((r) => r.json());

  const signing = Parapho.mount('#sign-here', {
    url: embedUrl,
    height: 'auto',        // grow with the document (default); or a number of pixels
    minHeight: 640,
    onLoad:     ({ envelopeId, status }) => {},
    onComplete: ({ envelopeId, envelopeCompleted }) => { location.href = '/thanks'; },
    onDecline:  ({ envelopeId }) => {},
  });

  // later, e.g. when closing a modal:
  // signing.destroy();
</script>
Option / attributeDescription
url / srcThe embed_url (or signing_url; ?embed=1 is added for you).
height'auto' (default) resizes the frame to fit the document, so your page scrolls instead of the frame. Or a fixed pixel height.
minHeightMinimum height in pixels with 'auto'. Default 640.
titleAccessible title for the iframe. Default “Review and sign document”.
onLoad, onComplete, onDeclineCallbacks; see Browser events.
onEvent(type, detail)Called for every event, with type = loaded, completed or declined.

Parapho.mount() returns { iframe, destroy() }. The web component cleans up when it's removed from the page, and re-renders when you change its src.

Content Security Policy on your siteIf your site sends a CSP header, allow Parapho: script-src https://app.parapho.com; frame-src https://app.parapho.com. (Only frame-src is needed with React or a plain iframe.)

3b. React

npm install @parapho/react
import { useEffect, useState } from 'react';
import { ParaphoSigning } from '@parapho/react';

export function SignContract({ dealId }) {
  const [embedUrl, setEmbedUrl] = useState(null);
  const [done, setDone] = useState(false);

  useEffect(() => {
    fetch(`/api/contracts/${dealId}/signing-session`, { method: 'POST' })
      .then((r) => r.json())
      .then((d) => setEmbedUrl(d.embedUrl));
  }, [dealId]);

  if (done) return <p>Thanks, you're all signed.</p>;
  if (!embedUrl) return <p>Loading…</p>;

  return (
    <ParaphoSigning
      url={embedUrl}
      onComplete={() => setDone(true)}
      onDecline={() => alert('You declined to sign.')}
    />
  );
}

Props: url (required), onLoad, onComplete, onDecline, onEvent, height ('auto' or pixels), minHeight, title, className, style. It renders a plain <iframe>, so it works with Next.js, Remix and Vite. In Next.js, render it from a client component ('use client').

3c. Plain iframe (any framework, or a mobile WebView)

<iframe id="sign" src="EMBED_URL" title="Review and sign document"
        style="width:100%;height:90vh;border:0" allow="clipboard-write" referrerpolicy="no-referrer"></iframe>

<script>
  const PARAPHO_ORIGIN = 'https://app.parapho.com';
  const frame = document.getElementById('sign');

  window.addEventListener('message', (e) => {
    if (e.origin !== PARAPHO_ORIGIN || e.source !== frame.contentWindow) return; // ignore anything else
    switch (e.data?.type) {
      case 'parapho:resize':    frame.style.height = Math.max(640, e.data.height) + 'px'; break;
      case 'parapho:completed': location.href = '/thanks'; break;
      case 'parapho:declined':  showMessage('You declined to sign.'); break;
    }
  });
</script>

Mobile apps: in iOS (WKWebView) or Android (WebView) you can load the signing_url directly, full screen, without an iframe. Detect completion by polling GET /envelopes/:id from your backend, or by listening for the webhook and notifying the app. JavaScript must be enabled in the WebView.

Browser events

The signing page sends these messages to the parent window:

postMessage typeWeb component event / callbackdetailWhen
parapho:loadedparapho:loaded / onLoad{ envelopeId, status }The document has loaded.
parapho:completedparapho:completed / onComplete{ envelopeId, envelopeCompleted }This signer finished. envelopeCompleted is true if they were the last one.
parapho:declinedparapho:declined / onDecline{ envelopeId }This signer declined.
parapho:resize(handled for you){ height }The content height changed.

Browser events are for updating your UI. They can be faked by the user, so always confirm on your server with the webhook or GET /envelopes/:id before you act on a signature (for example, before activating an account or releasing goods).

Security

  • The link is the credential. Anyone with a recipient's embed_url can sign as them. Only return it to the logged-in user it belongs to, over HTTPS, and don't put it in URLs that get logged or shared.
  • Create the session after authenticating the user. The pattern above (an authenticated endpoint that creates the envelope and returns embed_url) ties the signature to your own login.
  • For a second factor, add an accessCode and send it to the signer by SMS or show it in your app.
  • The embed scripts only accept messages from the Parapho origin and from their own frame. If you write your own listener, check event.origin and event.source the same way.
  • The signing page records the signer's IP address, user agent, ESIGN/UETA consent and every step in the audit trail, exactly as it does for emailed links.

Guide: send for signature by email

The simplest integration: one API call when a deal reaches “ready to sign”, one webhook handler for the result.

// 1. When the contract is ready
const envelope = await parapho.envelopes.create({
  template_id: 'tpl_8Kq2vX0bR1aZ9mWc',
  title: `Service agreement – ${customer.company}`,
  message: 'Please review and sign. Reply to this email with any questions.',
  reminder_days: 3,          // nudge every 3 days (Solo plan and above)
  expires_days: 21,
  recipients: [
    { role: 'Client',  name: customer.name, email: customer.email, order: 1 },
    { role: 'Company', name: 'Grace Hopper', email: 'grace@yourcompany.com', order: 2 }, // emailed once the client signs
  ],
});
await db.contracts.update(contract.id, { envelopeId: envelope.id, status: 'out_for_signature' });

// 2. In your webhook handler
if (event.event === 'envelope.completed') {
  const pdf = await parapho.envelopes.download(event.data.id);
  await storage.save(`contracts/${event.data.id}.pdf`, pdf);
  await db.contracts.updateByEnvelope(event.data.id, { status: 'signed', sha256: event.data.final_sha256 });
}
# 1. When the contract is ready
envelope = parapho.envelopes.create(
    "tpl_8Kq2vX0bR1aZ9mWc",
    [
        {"role": "Client", "name": customer.name, "email": customer.email, "order": 1},
        {"role": "Company", "name": "Grace Hopper", "email": "grace@yourcompany.com", "order": 2},  # emailed once the client signs
    ],
    title=f"Service agreement – {customer.company}",
    message="Please review and sign. Reply to this email with any questions.",
    reminder_days=3,
    expires_days=21,
)
contract.update(envelope_id=envelope["id"], status="out_for_signature")

# 2. In your webhook handler
if event["event"] == "envelope.completed":
    pdf = parapho.envelopes.download(event["data"]["id"])
    storage.save(f"contracts/{event['data']['id']}.pdf", pdf)
    Contract.mark_signed(event["data"]["id"], sha256=event["data"]["final_sha256"])

Emails come from “Your workspace” via Parapho. On the Team plan you can set the sender name, a reply-to address, your logo and brand color under Settings & API → Branding. Signing links in emails are never rewritten by click tracking.

Guide: full embedded signing example

A complete Express + browser example: the user clicks “Sign agreement”, signs in a modal, and your server marks the deal signed when the webhook arrives.

import express from 'express';
import { Parapho, verifyWebhook } from '@parapho/node';

const parapho = new Parapho(); // PARAPHO_API_KEY
const app = express();

// Webhook first: it needs the raw body.
app.post('/webhooks/parapho', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;
  try { event = verifyWebhook(req.body, req.get('parapho-signature'), process.env.PARAPHO_WEBHOOK_SECRET); }
  catch { return res.sendStatus(400); }
  res.sendStatus(200);

  const deal = await db.deals.findOne({ envelopeId: event.data.id });
  if (!deal) return;
  if (event.data.status === 'completed' && deal.signatureStatus !== 'signed') {
    const pdf = await parapho.envelopes.download(event.data.id);
    await storage.save(`deals/${deal.id}/agreement.pdf`, pdf);
    await db.deals.update(deal.id, { signatureStatus: 'signed', signedAt: event.data.completed_at });
  } else if (['declined', 'voided', 'expired'].includes(event.data.status)) {
    await db.deals.update(deal.id, { signatureStatus: event.data.status });
  }
});

app.use(express.json());

// Called by your front end when the logged-in user is ready to sign.
app.post('/api/deals/:id/sign', requireUser, async (req, res) => {
  const deal = await db.deals.findOne({ id: req.params.id, userId: req.user.id });
  if (!deal) return res.sendStatus(404);

  const envelope = await parapho.envelopes.create({
    template_id: process.env.PARAPHO_TEMPLATE_ID,
    title: `Agreement – ${deal.company}`,
    send_email: false,
    expires_days: 7,
    recipients: [{ role: 'Client', name: req.user.name, email: req.user.email }],
  });
  await db.deals.update(deal.id, { envelopeId: envelope.id, signatureStatus: 'sent' });
  res.json({ embedUrl: envelope.recipients[0].embed_url });
});

app.listen(3000);
<button id="sign-btn">Sign agreement</button>
<dialog id="sign-dialog" style="width:min(960px,100vw);padding:0;border:0">
  <div id="sign-here"></div>
</dialog>

<script src="https://app.parapho.com/sdk/embed.js"></script>
<script type="module">
  const dialog = document.getElementById('sign-dialog');
  let session;

  document.getElementById('sign-btn').onclick = async () => {
    const res = await fetch('/api/deals/42/sign', { method: 'POST', credentials: 'same-origin' });
    const { embedUrl } = await res.json();
    dialog.showModal();
    session = Parapho.mount('#sign-here', {
      url: embedUrl,
      onComplete: () => { session.destroy(); dialog.close(); location.reload(); },
      onDecline:  () => { session.destroy(); dialog.close(); },
    });
  };
</script>
Tip: create the envelope when the user clicks “Sign”.Creating on demand means abandoned sessions simply expire (here after 7 days). If the user comes back before then, get them a fresh link for the same envelope with POST /envelopes/:id/recipients/:rid/link instead of storing the old one. Only completed API envelopes are billed.

Guide: several signers

Add one role per signer to the template, then pass one recipient per role.

  • One after another ("routing": "sequential"): give each recipient an order. Same order = sign at the same time within that step.
  • All at once ("routing": "parallel"): everyone gets their link immediately.
  • Mixed delivery: to embed signing for your customer and have Parapho email your colleague afterwards, put the customer in step 1 and the colleague in step 2, keep send_email: true, and show the customer's embed_url in your app. The customer also gets the invitation email, which is harmless: it opens the same session. We email the colleague once the customer has signed.
{
  "template_id": "tpl_lease",
  "routing": "sequential",
  "recipients": [
    { "role": "Tenant 1", "name": "Ada Lovelace",    "email": "ada@example.com",     "order": 1 },
    { "role": "Tenant 2", "name": "Mary Somerville", "email": "mary@example.com",    "order": 1 },
    { "role": "Landlord", "name": "Charles Babbage", "email": "charles@example.com", "order": 2 }
  ]
}

The webhook sends recipient.signed after each signature except the last, then envelope.completed.

Guide: witnesses

Some documents (deeds, some leases and guarantees) need a witness to sign after a signer. Set this up in the template builder: add a role such as Witness, give it fields, and mark it as a witness for another role. The template's role_settings then shows {"Witness": {"witnessFor": "Tenant", "chosenBySigner": false}}.

  • You name the witness (chosenBySigner: false): pass them like any recipient. Their order is set automatically to just after the person they witness.
  • The signer chooses (chosenBySigner: true): leave the witness role out of recipients (or pass it with no name and email). After signing, the signer enters their witness's name and email, and the witness is invited by email. Until then the witness's signing_url is null.

Webhook and API recipients include witness_for: the ID of the recipient they witness.

Guide: access codes

An access code is a second factor for a signer: they must type it before they can see the document. Use one when the signing link could be forwarded, or when the email address isn't verified.

{ "role": "Client", "name": "Ada Lovelace", "email": "ada@example.com", "accessCode": "482913" }
  • Send the code by a different channel from the link (SMS, phone call, or shown in your logged-in app).
  • We store only a keyed hash of the code. After 8 wrong attempts in 15 minutes the signer is locked out for a while.
  • GET /envelopes/:id shows has_access_code: true on that recipient.

Guide: reminders and expiry

  • expires_days (default 30, max 365): after this, the envelope becomes expired, all unsigned links stop working, and envelope.expired is sent. To try again, create a new envelope.
  • reminder_days (1–30): emails each unsigned recipient whose turn it is every N days, up to 10 reminders. Reminders need send_email: true (they're skipped when you deliver links yourself) and the Solo plan or above on live keys.
  • Each reminder email contains a fresh link, and the previous link for that recipient stops working. The same happens if someone clicks Remind, or Signing link next to a recipient, on the envelope page in the dashboard, or calls the matching API endpoints. If you embed signing, don't combine it with reminders or manual resends for the same recipient.
  • You can also remind, void or create a fresh link for any envelope from the dashboard (Envelopes → open the envelope).

Guide: store and verify signed PDFs

  • On envelope.completed, download the PDF with GET /envelopes/:id/download and store it in your own system. Also store data.final_sha256.
  • The PDF is digitally sealed. Adobe Acrobat and other readers show the signature panel and warn if anyone edits the file afterwards.
  • Anyone can check a copy at /verify: the file is fingerprinted in the browser and only its SHA-256 is sent, never the document. A match shows the envelope title, sender, completion time and (masked) signers.
  • To check a file in your own code, compare its SHA-256 with final_sha256:
import { createHash } from 'node:crypto';
const ok = createHash('sha256').update(pdfBuffer).digest('hex') === envelope.final_sha256;
import hashlib
ok = hashlib.sha256(pdf_bytes).hexdigest() == envelope["final_sha256"]

Envelope data is kept in Parapho according to the workspace's retention settings; keep your own copy of anything you need long term.

No-code: public signing links

On the Solo plan and above, any template can have a public link (Templates → the globe icon on the template → Create public link), for example https://app.parapho.com/f/abc123. Anyone who opens it enters their name and email and signs their own copy. Each submission creates a new envelope with source: "form", and your webhook receives envelope.sent and envelope.completed as usual, so you can still automate what happens next. Public links use the template's first role, so they're for single-signer documents.

Guide: HIPAA and protected health information

To send documents containing protected health information (PHI), a workspace owner on the Team plan signs our Business Associate Agreement in Settings & API → HIPAA & BAA. That turns on HIPAA mode for the workspace. The API works the same way, with these differences:

  • Emails carry no PHI. When Parapho sends the emails, invitations, reminders and notices leave out the envelope title and message and any decline or void reason, and go only through Amazon SES. Recipients see the details after opening their link. If you deliver links yourself (send_email: false), keep PHI out of your own messages too.
  • Download links in "completed" emails expire after 7 days. After that they return 410. GET /envelopes/:id/download with your API key always works.
  • Reads are logged. GET /envelopes/:id, /fields, /download and /attachments/:aid are recorded in the workspace's access log under the key's prefix (for example API key sk_live_AbCd…), along with dashboard and signer access. Owners and admins can export it as CSV.
  • Verification is private. /verify confirms a sealed file without returning its title or signers ("redacted": true).
  • Webhooks and the API return PHI to you. Payloads include titles, recipient names and emails, and metadata, as usual. Your endpoint must be covered by your own HIPAA safeguards. URLs must use https.
  • Keep PHI out of identifiers. Workspace names, API key names, webhook URLs and embed origins appear outside the protected documents, so they mustn't contain PHI. Envelope titles, field values, prefill and metadata are fine.

HIPAA mode can't be turned off in the app. To end the BAA, export or delete your PHI and email us.

Node.js SDK

@parapho/node: zero dependencies, Node 18+, ESM, with TypeScript types.

npm install @parapho/node
import { Parapho, ParaphoError, verifyWebhook } from '@parapho/node';

const parapho = new Parapho({
  apiKey: process.env.PARAPHO_API_KEY,   // default: PARAPHO_API_KEY
  baseUrl: 'https://app.parapho.com',    // default: PARAPHO_BASE_URL or https://app.parapho.com
  timeoutMs: 30_000,                     // per request
});

await parapho.templates.list();                    // Template[]
await parapho.envelopes.create({ template_id, recipients, ... }); // CreatedEnvelope
await parapho.envelopes.get(id);                   // Envelope
await parapho.envelopes.download(id);              // Buffer

try {
  await parapho.envelopes.create({ template_id: 'tpl_x', recipients: [] });
} catch (err) {
  if (err instanceof ParaphoError) console.error(err.status, err.message, err.body);
}

// verifyWebhook(rawBody, signatureHeader, secret, { toleranceSeconds = 300 })
// returns the parsed event or throws.

Using CommonJS? Load it with const { Parapho } = await import('@parapho/node').

Python SDK

parapho: standard library only, Python 3.9+.

pip install parapho
from parapho import Parapho, ParaphoError, verify_webhook, WebhookVerificationError

parapho = Parapho(
    api_key="sk_live_...",                  # default: PARAPHO_API_KEY
    base_url="https://app.parapho.com",     # default: PARAPHO_BASE_URL or https://app.parapho.com
    timeout=30.0,
)

parapho.templates.list()                                   # list[dict]
parapho.envelopes.create(template_id, recipients,          # dict
    title=None, message=None, routing="sequential",
    expires_days=None, send_email=True, reminder_days=None)
parapho.envelopes.get(envelope_id)                         # dict
parapho.envelopes.download(envelope_id)                    # bytes

try:
    parapho.envelopes.create("tpl_x", [])
except ParaphoError as e:
    print(e.status, e, e.body)

# verify_webhook(raw_body, signature_header, secret, tolerance_seconds=300)
# returns the parsed event or raises WebhookVerificationError.

React SDK

@parapho/react renders <ParaphoSigning>, an auto-sizing iframe with typed callbacks. See Embedding → React for a full example.

<ParaphoSigning
  url={embedUrl}                                   // required
  onLoad={({ envelopeId, status }) => {}}
  onComplete={({ envelopeId, envelopeCompleted }) => {}}
  onDecline={({ envelopeId }) => {}}
  onEvent={(type, detail) => {}}
  height="auto"                                    // or a number of pixels
  minHeight={640}
  title="Review and sign document"
  className="signing-frame"
/>

Browser embed SDK

https://app.parapho.com/sdk/embed.js: no build step, about 5 KB. It defines the <parapho-signing> element and window.Parapho.mount(). See Embedding → Script tag.

No SDK for your language? The API is plain HTTPS and JSON, so any HTTP client works. The cURL examples on this page show every request, and webhook verification has examples for PHP, Ruby, Go and C#.

Troubleshooting

The iframe is blank, or says “refused to connect”

Your page's origin isn't in Settings & API → Embedded signing → Allowed origins. Check the browser console for the exact origin it's blocking, and add exactly that (scheme + host + port, no path or trailing slash). www. and non-www. are different, and so are http and https. Also make sure the URL in the frame ends in ?embed=1 (use embed_url, not signing_url).

If your own site has a Content Security Policy, it must allow Parapho in frame-src (and in script-src for embed.js).

Webhooks never arrive
  1. Is the URL public and https? localhost, private IPs and plain http are rejected in production. Use a tunnel for local development.
  2. Does the URL redirect (for example http → https, or adding a trailing slash)? Redirects are treated as failures. Register the final URL.
  3. Does your endpoint answer within 8 seconds with a 2xx? Firewalls, WAFs, CSRF middleware and authentication middleware often reject our POST. Exempt the webhook route.
  4. Is the endpoint enabled, and subscribed to the event you expect? Check Settings & API → Webhooks.
  5. Look under Webhook deliveries: it shows each attempt with your server's status code or the connection error. Then create a test envelope: envelope.sent should arrive within a second or two.
Webhook signature verification fails
  • You're almost certainly verifying a re-serialized body. Verify the raw bytes (express.raw() registered before express.json(), request.get_data() in Flask, request.body in Django, await request.text() in Next.js, php://input in PHP).
  • You're using another endpoint's secret. Each endpoint has its own; if you've lost it, click Rotate secret next to the endpoint and use the new value.
  • You're using an old secret. Each Rotate secret replaces it.
  • The secret includes the whsec_ prefix: use the whole string.
  • Your server clock is off by more than 5 minutes. Turn on NTP.
“Unknown role Client.” or “Assign someone to Client.”

Role names must match the template exactly. Call GET /templates with the same key and copy the strings from roles. Every role that has fields needs a recipient; roles with no fields can be left out. If someone renamed a role in the builder, update your code or rename it back.

“Template not found.”

The template belongs to a different workspace from the API key (common when people have several workspaces), it was deleted, or the ID has a typo. GET /templates lists everything the key can see.

The signer sees “This signing link is invalid or has been replaced by a newer one”

A newer link was issued for that recipient, so the old one stopped working. That happens when a reminder is emailed, when someone clicks Remind or Signing link on the envelope in the dashboard or calls /remind or /recipients/:rid/link in the API, or when an envelope is voided or expired. If you embed signing, get a fresh link with POST /envelopes/:id/recipients/:rid/link each time the user opens the signing page, and don't turn on reminders for that envelope.

The second signer can't sign: the page says it's waiting for someone

That's sequential routing working as intended: they can sign once everyone with a lower order has. Use "routing": "parallel" or give them the same order if they don't need to wait.

Nobody received an email
  • Test keys never deliver email. Look under Email log in the dashboard instead.
  • You passed send_email: false.
  • In sequential routing, later signers are emailed only once everyone before them has signed. Check recipients[].status: waiting means it isn't their turn yet.
  • Check the recipient's spam folder, and that the address is right in GET /envelopes/:id.
402 “plan_limit”

A live key hit a plan limit or used a paid feature. The error message and needs say which. Most often: more than 100 live API envelopes this month without API billing (turn it on in Billing), or reminder_days on the Free plan. Test keys are never limited, so this won't show up until you go live: check the plan before launch.

404 on an envelope I just created

You created it with a test key and are reading it with a live key, or the other way round. Use the same mode for both.

Download returns 404 “No sealed document for this envelope yet.”

The envelope isn't completed yet. Wait for envelope.completed, or check status first.

401 with a key that looks right

Check for a stray space, quotes or newline from copying the key into an environment variable, the Bearer prefix, and that the key wasn't revoked (Settings & API → API keys shows Revoked). Keys belong to one Parapho instance: a key from staging won't work against production.

Current limitations

So you can plan around them. Tell us if any of these block you: it helps us prioritise.

Not available yetWorkaround
SMS delivery, SSO/SAML, ID verificationDeliver links yourself (send_email: false) through your own SMS provider; use access codes as a second factor.

Go-live checklist

  • A sk_live_ key is stored in your secret manager (not in code), and test keys aren't used in production.
  • Your plan covers your volume: API billing is on if you'll send more than 100 live API envelopes a month, and you're on Solo or above if you use reminders or public links.
  • Your production webhook endpoint is added, subscribed to the events you handle, and your production server has its signing secret.
  • Your webhook handler verifies signatures against the raw body, responds within 8 seconds, ignores data.test: true events in production, and skips event ids it has already processed.
  • A reconciliation job lists open envelopes with GET /envelopes?status=sent in case your endpoint was down for longer than the retry window.
  • You handle every final status: completed, declined, voided and expired, not just the happy path.
  • Signed PDFs are downloaded and stored on envelope.completed, with final_sha256.
  • For embedding: production origins (with and without www. if needed) are in Allowed origins, your CSP allows Parapho in frame-src, and embed_urls are only given to the authenticated user they belong to.
  • You confirm signatures on the server, never only from browser events.
  • Retries on 429 and 5xx use back-off and send an Idempotency-Key, so they can't create duplicate envelopes.
  • Branding (logo, color, sender name, reply-to) is set if you're on the Team plan.
  • If documents contain PHI, a workspace owner has signed the BAA and HIPAA mode is on before the first real envelope is sent (see HIPAA).
  • You've signed one real envelope end to end with a live key.

Support

Stuck? Email hello@parapho.com with your workspace name, the envelope ID (env_…) or template ID, the request you sent (remove the API key), and the response you got.

Security issues: see /legal/security.