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:
- A Parapho workspace, created at /signup. Two-factor authentication is required for every account.
- A template: a PDF with signature and data fields placed on it and assigned to roles such as
ClientandCompany. Most teams build templates in the dashboard; you can also create them through the API. - An API key, from Settings & API → API keys. Start with a
sk_test_key: nothing is emailed or billed. - 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.
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:
POST /api/v1/envelopes with the template ID and a person for each role.Your backendembed_url.Your customerrecipient.signed, then envelope.completed.Parapho → your serverGET /api/v1/envelopes/:id/download returns the sealed PDF.Your backendAn 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
API + our emails
One API call. We email each signer a link, send reminders, and email everyone the signed copy. Good for contracts sent to customers.
In your productEmbedded signing
Set send_email: false and show the embed_url in an iframe. Signers never leave your app. Good for onboarding and checkout flows.
Public link
Turn a template into a link anyone can open to sign their own copy. Put it on your website or in an email. Webhooks still fire.
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.
-
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.
-
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
ClientandCompany. Your code will refer to these names exactly, including capitals and spaces. The default isSigner 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. - Rename the signer roles in the left panel to something meaningful and stable, such as
-
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_…" -
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" } ] -
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
idand each recipient'sidagainst your own record (for example your deal or customer). Webhooks refer to them. -
Sign it
Open Ada's
signing_urlin 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. -
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.pdfimport { 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"]))
Templates, roles and envelopes
| Object | ID prefix | What it is |
|---|---|---|
| Workspace | org_ | 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. |
| Template | tpl_ | 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. |
| Envelope | env_ | 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. |
| Recipient | rcp_ | One person on an envelope, filling one role. Each has a private signing link, a status and an audit trail. |
| API key | sk_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 status | Meaning | Final? |
|---|---|---|
sent | Out for signature. At least one recipient still has to sign. | No |
completed | Everyone signed. The sealed PDF is ready to download and final_sha256 is set. | Yes |
declined | A recipient declined and gave a reason (recipients[].decline_reason). Nobody else can sign. | Yes |
voided | Someone in your workspace cancelled it from the dashboard. All links stop working. | Yes |
expired | The deadline (expires_at, 30 days by default) passed before everyone signed. | Yes |
| Recipient status | Meaning |
|---|---|
waiting | Not their turn yet (an earlier signer, or the person they witness, hasn't signed). |
sent | It's their turn and their link is active. |
viewed | They opened the document. |
signed | They finished. signed_at is set. |
declined | They declined; the envelope is now declined. |
Signing order
"routing": "sequential"(the default): recipients sign in ascendingorder. Recipients with the sameordersign in parallel within that step. Someone withorder: 2can open their link early but can't sign until everyone withorder: 1has."routing": "parallel": everyone can sign at once, in any order.orderis 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 withsigning_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_idworks 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
ClienttoCustomerin the builder, API calls sending"role": "Client"fail withUnknown 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
CompanyorAmount). 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
| Type | What the signer does | Value stored |
|---|---|---|
signature | Types (4 fonts), draws, or uploads a signature | PNG/JPEG image |
initials | Same as signature, smaller | PNG/JPEG image |
date | Filled in automatically with the signing date | Text |
name / email | Prefilled with the recipient's name or email | Text (up to 200 characters) |
text | Free text | Text (up to 1,000 characters) |
number | A number, up to 6 decimal places | Text |
phone | A phone number | Text |
checkbox | Ticks a box | true / false |
select | Picks one option from a dropdown | One of the options |
radio | Picks one option from a list | One of the options |
image | Uploads an image or stamp | PNG/JPEG image |
file | Attaches 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 URL | https://app.parapho.com/api/v1 |
| Format | JSON request and response bodies. Send Content-Type: application/json on POST. |
| Auth | Authorization: Bearer sk_live_… or sk_test_… |
| Timestamps | ISO 8601 in UTC, for example 2026-10-28T14:03:11.402Z |
| Calls from | Your server only. The API doesn't send CORS headers, and your API key must never reach a browser or mobile app. |
| Endpoint | What it does |
|---|---|
GET /templates | List templates and their roles |
POST /documents | Upload a PDF to build a template from |
POST /templates, GET / PUT / DELETE /templates/:id | Create a template with fields, read it (with field IDs), change it, delete it |
POST /envelopes | Create and send an envelope, optionally prefilled; returns a signing link per recipient |
GET /envelopes | List and search envelopes by status, template, title, recipient, metadata or date |
GET /envelopes/:id | Status, recipients and the full audit trail |
GET /envelopes/:id/fields | Every field's value as JSON; attachments via /attachments/:id |
POST /envelopes/:id/void, /remind, /recipients/:rid/link | Cancel an envelope, send reminders, issue a new signing link |
GET /envelopes/:id/download | The sealed, signed PDF |
GET /events, GET /events/:id, POST /events/:id/resend | Webhook 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 returns401 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
422withcode: "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.
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()
| Field | Description |
|---|---|
id string | Template ID to pass as template_id. |
name string | Name shown in the dashboard; the default envelope title. |
roles string[] | Role names, in builder order. Use these exactly in recipients[].role. |
role_settings object | Witness settings per role, for example {"Witness": {"witnessFor": "Tenant", "chosenBySigner": true}}. See Witnesses. |
field_count integer | Number of fields. A template with 0 fields can't be sent. |
document_id string | The PDF the template is built on. |
updated_at timestamp | Last edit. |
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.
Creates a template on an uploaded document. Returns 201 Created with the full template, including the ID of every field.
Body parameters
| Parameter | Description |
|---|---|
document_idrequired string | From 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 string | Defaults to the document's name. |
role_settingsoptional object | Witnesses, for example {"Witness": {"witnessFor": "Client", "chosenBySigner": true}}. See Witnesses. |
hiddenoptional boolean | Default 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
| Parameter | Description |
|---|---|
typerequired string | One of the field types. |
rolerequired string | The role that fills it in; must be in roles. |
pagerequired integer | 0-based page number. |
x, y, w, hrequired number | Position 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 string | 4–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 string | Shown to the signer; also usable as the prefill key. |
requiredoptional boolean | Default true, except checkboxes (false). |
optionsoptional string[] | Required for select and radio. |
conditionoptional object | Show 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: changename,roles,fields,role_settingsorhidden. Anything you leave out is kept;fieldsreplaces 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.
Creates an envelope from a template and sends it. Returns 201 Created with a signing link for every recipient.
Body parameters
| Parameter | Description |
|---|---|
template_idrequired string | The template to send. |
recipientsrequired object[] | One entry per role. See the recipient table below. |
titleoptional string | Shown to signers, in emails and on the certificate. Defaults to the template name. Max 200 characters. |
messageoptional string | A note included in the invitation email. Max 2,000 characters. |
routingoptional string | "sequential" (default) or "parallel". See Signing order. |
send_emailoptional boolean | Default 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 integer | Days until the envelope expires, 1–365. Default 30. |
reminder_daysoptional integer | Email 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 object | Values to fill in for the signers, keyed by field ID or label. See Prefill fields. |
metadataoptional object | Up 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
| Parameter | Description |
|---|---|
rolerequired string | A role name from the template, matched exactly (case- and space-sensitive). Each role can appear once. |
namerequired string | Full name, max 120 characters. Prefills name fields and appears on the certificate. |
emailrequired string | Email address. Required even with send_email: false: it identifies the signer on the certificate and receives the signed copy. |
orderoptional integer | Signing step for sequential routing, starting at 1. Default 1. Ignored for parallel routing and for witnesses. |
accessCodeoptional string | A 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
| Field | Description |
|---|---|
id string | The envelope ID. Store it. |
test boolean | true when created with a test key. |
metadata object | Your metadata, as stored. |
recipients[].id string | Recipient ID. |
recipients[].role, name, email, order | As stored. |
recipients[].signing_url string | null | The 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 | null | signing_url with ?embed=1: the version to put in an iframe. |
POST /envelopes/:id/recipients/:rid/link when you need one: it issues a new link, and the previous one stops working.Validation errors (400)
| Message | Fix |
|---|---|
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,selectandradiofields. 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/fieldslike any other value.
Lists envelopes, newest first, in the key's mode (a test key sees only test envelopes). Combine any of these query parameters:
| Parameter | Description |
|---|---|
status | One status, or several separated by commas: status=sent,expired. |
template_id | Envelopes sent from one template. |
q | Text anywhere in the title, case-insensitive. |
recipient_email | Envelopes 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_before | ISO 8601 timestamps. |
limit | 1–100, default 25. |
starting_after | For 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.
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_idxis the signing step (it's calledorderon create and in webhooks).sourceisapi,app(sent from the dashboard),bulkorform(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
expiredright away.
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…" }
]
}
valueis text for most fields,true/falsefor checkboxes, adata:URL (PNG or JPEG) for signatures, initials and images, and file details for attachments. It'snulluntil 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).
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"}.
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.
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.
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)
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, pluspayload: 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" }
| Status | Meaning | What to do |
|---|---|---|
400 | Invalid request. error says what's wrong. | Fix the request; don't retry as-is. |
401 | Missing, malformed, revoked or unknown API key. | Check the header is exactly Bearer sk_…, with no quotes or line breaks. |
402 | code: "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. |
403 | code: "suspended": the workspace has been suspended. | Contact support. |
404 | Not found, or it belongs to the other mode (test vs live). | Check the ID and that you're using the matching key. |
409 | code: "idempotency_key_in_use": the first request with this key hasn't finished. | Retry in a moment with the same key. |
413 | Request body too large. | JSON bodies are limited to 8 MB, PDF uploads to 25 MB. |
422 | code: "idempotency_key_reused": the idempotency key was used for a different request. | Use a new key for a new request. |
429 | Rate limit exceeded. | Back off and retry (see below). |
5xx | Our 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
402until 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-Keyon everyPOSTand 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
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 usehttpsand resolve to a public IP address.Save the signing secret
When you add the endpoint we show its
whsec_…signing secret once. Copy it into your server's configuration (for examplePARAPHO_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).
Verify and respond
In your handler, verify the
Parapho-Signatureheader against the raw body (examples below), then return any2xxstatus within 8 seconds. Do slow work (downloading the PDF, sending emails) after responding, or in a background job.Test it
Create a test envelope with your
sk_test_key. You'll receiveenvelope.sentimmediately, 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 itshttpsURL as a second endpoint.
Events
| Event | When it's sent |
|---|---|
envelope.sent | The envelope was created. Sent once per envelope. |
recipient.viewed | A recipient opened the document while it was their turn (once per recipient). |
recipient.signed | A recipient signed and others still have to. Not sent for the final signature: that sends envelope.completed instead. |
envelope.completed | Everyone signed and the sealed PDF is ready to download. data.final_sha256 is set. |
envelope.declined | A recipient declined. The reason is in GET /envelopes/:id (recipients[].decline_reason). |
envelope.voided | Someone in your workspace voided the envelope. |
envelope.expired | The 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 }
]
}
}
| Field | Description |
|---|---|
id | The 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. |
event | One of the event names above. |
created_at | When the event happened (unchanged on retries). |
data | A 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:
tis the Unix time (seconds) the request was signed.v1is the hex HMAC-SHA256 of the string{t}.{raw body}, keyed with yourwhsec_…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).
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
2xxwithin 8 seconds. Redirects aren't followed and count as failures, so register the final URL (includinghttps://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 treatdata.statusas 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=sentand 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.
- Allow your site's origin (once).
- Create the envelope on your server with
send_email: falseand pass the recipient'sembed_urlto your front end. - Render it with the script tag, the React component, or a plain iframe.
- React to the
completed/declinedbrowser 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.comandhttps://www.yourcompany.comare different origins. Add both if you use both.- Production deployments of Parapho only accept
httpsorigins.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 / attribute | Description |
|---|---|
url / src | The 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. |
minHeight | Minimum height in pixels with 'auto'. Default 640. |
title | Accessible title for the iframe. Default “Review and sign document”. |
onLoad, onComplete, onDecline | Callbacks; 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.
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 type | Web component event / callback | detail | When |
|---|---|---|---|
parapho:loaded | parapho:loaded / onLoad | { envelopeId, status } | The document has loaded. |
parapho:completed | parapho:completed / onComplete | { envelopeId, envelopeCompleted } | This signer finished. envelopeCompleted is true if they were the last one. |
parapho:declined | parapho: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_urlcan 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
accessCodeand 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.originandevent.sourcethe 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>
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 anorder. Sameorder= 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'sembed_urlin 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. Theirorderis set automatically to just after the person they witness. - The signer chooses (
chosenBySigner: true): leave the witness role out ofrecipients(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'ssigning_urlisnull.
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/:idshowshas_access_code: trueon that recipient.
Guide: reminders and expiry
expires_days(default 30, max 365): after this, the envelope becomesexpired, all unsigned links stop working, andenvelope.expiredis 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 needsend_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 withGET /envelopes/:id/downloadand store it in your own system. Also storedata.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
titleandmessageand 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/downloadwith your API key always works. - Reads are logged.
GET /envelopes/:id,/fields,/downloadand/attachments/:aidare recorded in the workspace's access log under the key's prefix (for exampleAPI key sk_live_AbCd…), along with dashboard and signer access. Owners and admins can export it as CSV. - Verification is private.
/verifyconfirms 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 usehttps. - 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,
prefillandmetadataare 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
- Is the URL public and
https?localhost, private IPs and plainhttpare rejected in production. Use a tunnel for local development. - Does the URL redirect (for example
http→https, or adding a trailing slash)? Redirects are treated as failures. Register the final URL. - 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. - Is the endpoint enabled, and subscribed to the event you expect? Check Settings & API → Webhooks.
- Look under Webhook deliveries: it shows each attempt with your server's status code or the connection error. Then create a test envelope:
envelope.sentshould 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 beforeexpress.json(),request.get_data()in Flask,request.bodyin Django,await request.text()in Next.js,php://inputin 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:waitingmeans 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 yet | Workaround |
|---|---|
| SMS delivery, SSO/SAML, ID verification | Deliver 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: trueevents in production, and skips eventids it has already processed. - A reconciliation job lists open envelopes with
GET /envelopes?status=sentin case your endpoint was down for longer than the retry window. - You handle every final status:
completed,declined,voidedandexpired, not just the happy path. - Signed PDFs are downloaded and stored on
envelope.completed, withfinal_sha256. - For embedding: production origins (with and without
www.if needed) are in Allowed origins, your CSP allows Parapho inframe-src, andembed_urls are only given to the authenticated user they belong to. - You confirm signatures on the server, never only from browser events.
- Retries on
429and5xxuse back-off and send anIdempotency-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.