API Reference

Complete reference for the zSign REST API. Send documents, manage signing sessions, and integrate webhooks.

Prefer a terminal workflow? Validate, send, track, and test webhooks with the zSign CLI.

Base URL

https://zsign.io

Authentication

Bearer YOUR_API_KEY

Documents

Upload, send, and manage documents

GET

List Documents

/api/documents
Auth required

Retrieve documents with optional filtering.

Query Parameters

NameTypeDescription
offsetinteger= 0Pagination offset
limitinteger= 20Items per page
statusstringFilter by status
recipientstringFilter by recipient email

Request

curl -X GET "https://zsign.io/api/documents?limit=20" \
-H "Authorization: Bearer YOUR_API_KEY"

Response 200

List of documents

{
"items": [
{
"document_id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Contract.pdf",
"status": "pending",
"created_at": "2024-01-15T10:30:00Z"
}
],
"total": 1,
"offset": 0,
"limit": 20
}
GET

List Envelopes (API key)

/api/v1/documents
Auth required

List envelopes in the caller's organization. Same filter semantics as the dashboard JWT list. Includes unsent drafts (status draft or ready_to_send, session_id null) in the default All mix. limit max 50. Each item includes thin recipients[] (recipient_id, email, name, status, can_remind) for Remind. Sender email and the rich session payload stay on GET /api/v1/documents/{document_id}.

Query Parameters

NameTypeDescription
offsetinteger= 0Pagination offset
limitinteger= 20Items per page (max 50)
statusstringSession status, cancelled (voided+declined), or draft / ready_to_send
qstringSubstring search on name, filename, recipient, or id
date_fromstringISO-8601 inclusive created_at lower bound
date_tostringISO-8601 inclusive created_at upper bound

Request

curl -X GET "https://zsign.io/api/v1/documents?q=Contract&limit=20" \
-H "Authorization: Bearer zs_live_xxxxxxxxxxxxxxxxxxxxxxxx"

Response 200

Org-scoped envelope page

{
"items": [
{
"document_id": "550e8400-e29b-41d4-a716-446655440000",
"session_id": "660e8400-e29b-41d4-a716-446655440000",
"name": "Contract.pdf",
"status": "pending",
"created_at": "2024-01-15T10:30:00Z",
"recipient_count": 1,
"completed_recipient_count": 0,
"recipients": [
{
"recipient_id": "770e8400-e29b-41d4-a716-446655440000",
"email": "signer@example.com",
"name": "Alex Signer",
"status": "pending",
"can_remind": true
}
]
}
],
"total": 1,
"offset": 0,
"limit": 20
}
POST

Upload and Send Document

/api/v1/documents/send
Auth required

Upload a PDF and send it for signing in one request. PDFs must include field annotations using {type:party:name} syntax (e.g., {signature:signer}, {text:signer:name}). The party must exactly match a recipient's "role" — matching is case-sensitive, so {signature:signer} pairs with "role": "signer", not "Signer". Add * after type for required fields: {type*:party:name}. The sender's account default also applies: "Require new fields by default" (Settings → Sending) is on by default and makes every unstarred tag a required field; senders who turn it off get optional fields unless they star them. * marks required either way. Supported types: signature, text, date, initials, radio (radio is four parts and visible-text only — see notes below).

Request Body

Content-Type: multipart/form-data

Fields

NameTypeDescription
filerequiredfilePDF file to upload
recipientsrequiredstringJSON array of recipients
namestringDocument name
send_invitestring ("true"/"false")= trueOmitting it is unchanged behavior. Set false when you deliver the signing link yourself: suppresses zSign's invite email AND reminders for this envelope. signing_urls in the response is unchanged, and completion emails are still sent. Sent as a string (this endpoint is multipart); an unrecognized value 422s.
send_completion_emailstring ("true"/"false")= trueIndependent of send_invite. Omitting it is unchanged behavior. Set false when you deliver the completed document to your signers yourself: suppresses zSign's "fully signed" email to every signer for this envelope. Does not affect the notification the envelope owner receives. Sent as a string (this endpoint is multipart); an unrecognized value 422s.
sequentialstring ("true"/"false")= trueSequential send-body flag. Default true. true = one at a time (only the current signer can use their invite; later recipients wait). false = everyone at once. Sent as a string (this endpoint is multipart); an unrecognized value 422s.
Example
[
{
"email": "john@example.com",
"name": "John Doe",
"role": "signer"
}
]

Request

curl -X POST https://zsign.io/api/v1/documents/send \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@contract.pdf" \
-F 'recipients=[{"email":"john@example.com","name":"John Doe","role":"signer"}]'

Response 201

Document sent successfully

{
"document_id": "550e8400-e29b-41d4-a716-446655440000",
"session_id": "660e8400-e29b-41d4-a716-446655440000",
"signing_urls": [
{
"recipient_email": "john@example.com",
"signing_url": "https://zsign.io/sign/eyJ..."
}
]
}
### PDF Field Annotation Requirements **Sample LLM prompt to format PDF documents with correct annotation syntax:** ``` Create a fillable PDF document with embedded form field annotations. The form fields must follow these specifications: ## Field Naming Syntax: {type:party:name} Format: `{type:party:name}` or `{type*:party:name}` for required fields **Field Types:** - `signature` - Signature field - `text` - Text input field - `date` - Date field - `initials` - Initials field - `radio` - One button of an exclusive radio set (four parts: `{radio:party:group:option}`, see below) **Components:** 1. **Type**: text, date, signature, initials, or radio 2. **Party**: The role/party who fills it (e.g., signer, borrower, employee, manager, landlord, tenant). This must exactly match the `role` you send for that recipient. Matching is **case-sensitive**: `{signature:signer}` is assigned to a recipient with `"role": "signer"`, and will NOT match `"Signer"`. A party that matches no recipient's role leaves the field assigned to nobody — the document still sends, but the signer sees nothing to fill in. 3. **Name**: Field identifier (e.g., name, email, address, company_name) **Required Fields:** Add `*` after the type to make a field required: - `{text*:signer:name}` - Required text field - `{signature*:signer}` - Required signature Whether an *unstarred* field is required depends on the sender's account: 'Require new fields by default' (Settings → Sending) is on by default and makes every unmarked field required; senders who turn it off get optional fields unless they star them. `*` marks required either way. **Examples:** - `{text:signer:full_name}` - `{text*:signer:email}` - `{date:signer:birthdate}` - `{signature:signer}` - `{initials:signer}` - `{text*:borrower:company_name}` - `{signature*:landlord}` ## Radio Fields Radio takes FOUR parts instead of three: `{radio:party:group:option}` / `{radio*:party:group:option}`. Every tag sharing a `party` and `group` forms one exclusive set — the signer picks exactly one, and the chosen `option` is the value reported back. `*` marks the whole **set** required, not the individual button (mixed markers within a set are legal and mean required). `group` is letters/digits/underscores; `option` may also contain spaces and hyphens, so `Option 1` is legal. ``` {radio*:client:term_sheet_option:Option 1} {radio*:client:term_sheet_option:Option 2} {radio*:client:term_sheet_option:Option 3} ``` **Radio tags must be visible text in the PDF body.** Unlike the other types, a radio tag cannot be the name of a PDF form field — the form-field name grammar accepts neither a fourth segment nor a space. ## Critical Technical Requirements This section applies to signature/text/date/initials only — radio is visible-text only and is never set as a widget annotation name. The field name MUST be set directly on each widget annotation's `/T` attribute, NOT only in a parent AcroForm field object. Many PDF signing services read the `/T` value directly from page annotations. **Each annotation must have:** - `/Type`: `/Annot` - `/Subtype`: `/Widget` - `/FT`: `/Tx` (for text fields) - `/T`: The field name in `{type:party:name}` format (THIS IS CRITICAL) - `/F`: `4` (print flag) - `/Rect`: `[left, bottom, right, top]` coordinates ``` **Sample PDF:** [Download simple_contract_1.pdf](https://storage.googleapis.com/zsign-public/simple_contract_1.pdf)
GET

Get Submitted Field Values

/api/v1/documents/{document_id}/fields
Auth required

Every field on a document, with its submitted value once the signer has answered it — the way to read back which radio option a signer chose, or what they typed into a text field. Accepts either the original document id or the completed document id (the one the document.completed webhook carries). Unanswered optional fields report "value": null, not an omitted key. Field values can carry personal data, so this route is API-key/owner scoped only.

Path Parameters

NameTypeDescription
document_idrequiredstringEither the original document id (from POST /api/v1/documents/send) or the completed document id

Request

curl -X GET "https://zsign.io/api/v1/documents/{document_id}/fields" \
-H "Authorization: Bearer YOUR_API_KEY"

Response 200

Every field defined on the document, with its submitted value

{
"document_id": "550e8400-e29b-41d4-a716-446655440000",
"session_id": "660e8400-e29b-41d4-a716-446655440000",
"fields": [
{
"id": "client:term_sheet_option",
"type": "radio",
"party": "client",
"name": "term_sheet_option",
"group": "term_sheet_option",
"required": true,
"page": 3,
"value": "Option 2",
"submitted_at": "2026-08-19T14:32:00+00:00"
},
{
"id": "client:full_name",
"type": "text",
"party": "client",
"name": "full_name",
"required": true,
"page": 1,
"value": null,
"submitted_at": null
},
{
"id": "client:signature",
"type": "signature",
"party": "client",
"name": "signature",
"required": true,
"page": 1,
"status": "pending",
"signed_at": null
}
]
}
`id` is the stable `"{party}:{name}"` key — the same string used in the signing-session submit payload and in `field_assignments`. A radio group appears once here (keyed by `party:group`, with an extra `group` key) no matter how many buttons it has. `signature`/`initials` entries report `status` (`pending`/`signed`) and `signed_at` instead of `value`/`submitted_at`. A `document_id` you don't own 404s — identical to one that doesn't exist, so this can't be used to probe which ids exist.
POST

Create Draft Envelope

/api/v1/documents/drafts
Auth required

Upload one or more PDFs as an unsent Draft (no credit debit, no invites), or stamp a saved template. Provide exactly one of `file`/`files` or `template_id`. `file` is the single-document form; `files` is repeatable. When both are sent the order is `file` first, then `files`. `document_names` is an optional JSON array of display names aligned to that order. `role_mapping` (template only) is a JSON object `{source_role: destination_role}`; unmapped roles stay literal. `fields` cannot be combined with `template_id`. The same `file` + `files` + `document_names` shape is accepted on POST /api/v1/documents/send. Response `documents[]` is `{id, name, filename, page_start, page_count, position}` plus `revision`.

Request Body

Content-Type: multipart/form-data

Fields

NameTypeDescription
filefileFirst PDF. Combined with `files` when both are sent.
filesfile (repeatable)Additional PDFs. Each counts toward `MAX_ENVELOPE_DOCUMENTS` (default 10).
document_namesstring (JSON array)Display names aligned to `file` first, then `files`. Length must match the file count.
recipientsstring (JSON array)Stored for later send / Approve.
fieldsstring (JSON array)Placed fields. Each may include `document_id` (a `documents[].id`); `position.page_number` is then relative to that document.
revisionintegerOptimistic concurrency token returned on every draft response. Omit to skip the check.
template_iduuidSaved template to stamp. Mutually exclusive with `file`/`files`.
role_mappingstring (JSON object)Template only. `{source_role: destination_role}`; unmapped roles stay literal.
namestringDisplay name for the new draft. Defaults to the template name when stamping.
Example
{
"document_names": [
"NDA",
"MSA"
]
}

Request

curl -X POST "https://zsign.io/api/v1/documents/drafts" \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "files=@nda.pdf" \
-F "files=@msa.pdf" \
-F 'document_names=["NDA","MSA"]'

Response 201

Unsent draft with envelope documents and revision

{
"document_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "draft",
"revision": 1,
"documents": [
{
"id": "660e8400-e29b-41d4-a716-446655440000",
"name": "NDA",
"filename": "nda.pdf",
"page_start": 1,
"page_count": 1,
"position": 0
},
{
"id": "770e8400-e29b-41d4-a716-446655440000",
"name": "MSA",
"filename": "msa.pdf",
"page_start": 2,
"page_count": 3,
"position": 1
}
]
}
Cap is `MAX_ENVELOPE_DOCUMENTS` (default 10). A 422 `too_many_documents` means the envelope would exceed it. PUT /api/v1/documents/drafts/{document_id}/fields accepts the same `document_id` + document-relative `page_number` as `fields` here. Stamp from a template with `template_id` instead of files (API-key twin of the dashboard draft-from-template form).
POST

Save Draft as Template

/api/v1/documents/drafts/{document_id}/save-as-template
Auth required

API-key twin of POST /api/documents/drafts/{id}/save-as-template. Copies the draft's current bytes and parsed_fields into a new reusable template. Free. The draft itself is unchanged. There is no blank-template flow.

Path Parameters

NameTypeDescription
document_idrequireduuidUnsent Draft id from POST /api/v1/documents/drafts

Request Body

Content-Type: application/json

Fields

NameTypeDescription
namerequiredstringTemplate display name
Example
{
"name": "Std NDA"
}

Request

curl -X POST "https://zsign.io/api/v1/documents/drafts/{document_id}/save-as-template" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Std NDA"}'

Response 201

Template summary (id, name, parties, updated_at)

{
"id": "22222222-2222-2222-2222-222222222222",
"name": "Std NDA",
"filename": "contract.pdf",
"parties": [
"signer"
],
"field_count": 1,
"document_count": 1
}
GET

List Templates

/api/v1/documents/templates
Auth required

API-key twin of GET /api/documents/templates. Every template the caller's org owns, with role names (`parties`), updated_at, and public-link status. Same rows as the dashboard list — not a second template model.

Request

curl -X GET "https://zsign.io/api/v1/documents/templates" \
-H "Authorization: Bearer YOUR_API_KEY"

Response 200

Template list

{
"templates": [
{
"id": "22222222-2222-2222-2222-222222222222",
"name": "Std NDA",
"parties": [
"signer"
],
"field_count": 1,
"document_count": 1
}
],
"total": 1
}
POST

Add Document to Draft

/api/v1/documents/drafts/{document_id}/documents
Auth required

Append one uploaded PDF, or every document of a saved template, to a Draft envelope. Provide exactly one of `file` or `template_id`. Inserting a template adds all of its documents; each counts toward `MAX_ENVELOPE_DOCUMENTS` (default 10). `role_mapping` (template only) is a JSON object `{source_role: destination_role}`; unmapped roles stay literal.

Path Parameters

NameTypeDescription
document_idrequiredUUIDDraft envelope id from POST /api/v1/documents/drafts

Request Body

Content-Type: multipart/form-data

Fields

NameTypeDescription
filefilePDF to append. Mutually exclusive with template_id.
template_idUUIDSaved template whose documents are all inserted. Mutually exclusive with file.
namestringDisplay name for a single uploaded file (not allowed with a multi-document template).
role_mappingstring (JSON object)Template only. Maps template roles onto this envelope, e.g. {"customer":"client"}.
revisionintegerOptimistic concurrency token from the last draft response.
Example
{
"template_id": "880e8400-e29b-41d4-a716-446655440000",
"role_mapping": {
"customer": "client"
}
}

Request

curl -X POST "https://zsign.io/api/v1/documents/drafts/{document_id}/documents" \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@msa.pdf" \
-F "name=MSA"

Response 201

Updated draft with the new document(s) appended

{
"document_id": "550e8400-e29b-41d4-a716-446655440000",
"revision": 2,
"documents": [
{
"id": "660e8400-e29b-41d4-a716-446655440000",
"name": "a",
"filename": "a.pdf",
"page_start": 1,
"page_count": 1,
"position": 0
},
{
"id": "770e8400-e29b-41d4-a716-446655440000",
"name": "T",
"filename": "t.pdf",
"page_start": 2,
"page_count": 1,
"position": 1
}
]
}
422 `too_many_documents` if the insert would exceed `MAX_ENVELOPE_DOCUMENTS` (default 10). 422 `unknown_source_role` if `role_mapping` names a role the template does not have. 409 `draft_revision_conflict` when `revision` is stale.
PUT

Reorder Draft Documents

/api/v1/documents/drafts/{document_id}/documents/order
Auth required

Set the order of documents in a Draft. `order` must list every `documents[].id` exactly once. Field pages are remapped to the new ranges. `revision` is the optimistic concurrency token.

Path Parameters

NameTypeDescription
document_idrequiredUUIDDraft envelope id

Request Body

Content-Type: application/json

Fields

NameTypeDescription
orderrequiredarray of UUIDEvery document id in the wanted order
revisionintegerOptimistic concurrency token from the last draft response
Example
{
"order": [
"770e8400-e29b-41d4-a716-446655440000",
"660e8400-e29b-41d4-a716-446655440000"
],
"revision": 2
}

Request

curl -X PUT "https://zsign.io/api/v1/documents/drafts/{document_id}/documents/order" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"order":["770e8400-e29b-41d4-a716-446655440000"],"revision":2}'

Response 200

Updated draft with remapped page ranges

{
"document_id": "550e8400-e29b-41d4-a716-446655440000",
"revision": 3
}
PATCH

Rename Draft Document

/api/v1/documents/drafts/{document_id}/documents/{part_id}
Auth required

Change the display name of one document inside a Draft. Path `{part_id}` is `documents[].id`. Display metadata only — no bytes rebuilt, no field moved. `revision` is the optimistic concurrency token.

Path Parameters

NameTypeDescription
document_idrequiredUUIDDraft envelope id
part_idrequiredUUIDdocuments[].id of the document to rename

Request Body

Content-Type: application/json

Fields

NameTypeDescription
namerequiredstringNew display name (1–255 characters)
revisionintegerOptimistic concurrency token
Example
{
"name": "Second",
"revision": 2
}

Request

curl -X PATCH "https://zsign.io/api/v1/documents/drafts/{document_id}/documents/{part_id}" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Second"}'

Response 200

Updated draft

{
"document_id": "550e8400-e29b-41d4-a716-446655440000",
"revision": 3
}
DELETE

Remove Draft Document

/api/v1/documents/drafts/{document_id}/documents/{part_id}
Auth required

Remove one document from a Draft. Path `{part_id}` is `documents[].id`. An envelope must keep at least one document (`last_document`). `?revision=` is the optimistic concurrency token.

Path Parameters

NameTypeDescription
document_idrequiredUUIDDraft envelope id
part_idrequiredUUIDdocuments[].id of the document to remove

Query Parameters

NameTypeDescription
revisionintegerOptimistic concurrency token from the last draft response

Request

curl -X DELETE "https://zsign.io/api/v1/documents/drafts/{document_id}/documents/{part_id}?revision=2" \
-H "Authorization: Bearer YOUR_API_KEY"

Response 200

Updated draft after the document was removed

{
"document_id": "550e8400-e29b-41d4-a716-446655440000",
"revision": 3
}
422 `last_document` if this is the only document left.
GET

Download Signed Document

/api/v1/documents/{completed_document_id}/download
Auth required

Download the sealed, signed PDF. `{completed_document_id}` is the id from get-envelope-status / document.completed; the original document id from send / list also works and resolves to that document's most recent completed session (a session id does not). `?document=` is one `documents[].id` to download just that document (its pages plus the certificate pages — an extract; the certificate hash identifies the full envelope). `?format=zip` returns every document as its own PDF in one archive. Do not combine `document` and `format`.

Path Parameters

NameTypeDescription
completed_document_idrequiredUUIDCompleted document id from GET /api/v1/documents/{document_id}, or that original document id itself

Query Parameters

NameTypeDescription
documentUUIDdocuments[].id — download only that document plus the certificate pages
formatstring`zip` — every document as its own PDF in one archive

Request

curl -X GET "https://zsign.io/api/v1/documents/{completed_document_id}/download?document={part_id}" \
-H "Authorization: Bearer YOUR_API_KEY" \
-o signed.pdf

Response 200

Sealed PDF (or ZIP when format=zip)

(binary)
GET

Get Signing URLs

/api/v1/documents/{document_id}/signing-urls
Auth required

Recipient signing URLs for an already-sent envelope. Same `{recipient_email, recipient_name, signing_url, token}` objects POST /api/v1/documents/send returns as `signing_urls`. These are the ordinary agent-link values — valid iframe `signingUrl`s for `@zsign/embed`. Optional `recipient_email` keeps one recipient. Drafts and documents with no session 404.

Path Parameters

NameTypeDescription
document_idrequireduuidDocument identifier from POST /api/v1/documents/send or send-from-template

Query Parameters

NameTypeDescription
recipient_emailstringIf set, only that recipient's signing URL is returned

Request

curl -X GET "https://zsign.io/api/v1/documents/{document_id}/signing-urls" \
-H "Authorization: Bearer YOUR_API_KEY"

Response 200

Send-shape signing URLs

{
"document_id": "550e8400-e29b-41d4-a716-446655440000",
"session_id": "660e8400-e29b-41d4-a716-446655440000",
"signing_urls": [
{
"recipient_email": "alice@example.com",
"recipient_name": "Alice",
"signing_url": "https://app.example/sign/TOKEN",
"token": "TOKEN"
}
]
}
POST

Remind Recipient

/api/v1/documents/{document_id}/recipients/{recipient_id}/remind
Auth required

Send one signing reminder to a still-unsigned recipient. Same policy as the dashboard Remind button and the cron sweep: cap, spacing, sequential can_sign_now. REMINDERS_ENABLED does not apply. Envelopes sent with send_invite=false are refused unless override_send_invite is true. MCP remind_envelope is a thin wrapper over this route (envelopes:send).

Path Parameters

NameTypeDescription
document_idrequiredUUIDDocument identifier from POST /api/v1/documents/send
recipient_idrequiredUUIDRecipient identifier from GET /api/v1/documents/{document_id} (session.recipients[].recipient_id)

Request Body

Content-Type: application/json

Fields

NameTypeDescription
override_send_inviteboolean= falseRequired only when the envelope was sent with send_invite=false. The integrator that owns delivery owns reminders too unless this is true.
Example
{
"override_send_invite": false
}

Request

curl -X POST "https://zsign.io/api/v1/documents/{document_id}/recipients/{recipient_id}/remind" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'

Response 200

Reminder sent

{
"session_id": "660e8400-e29b-41d4-a716-446655440000",
"recipient_id": "770e8400-e29b-41d4-a716-446655440000",
"email": "john@example.com",
"reminder_count": 1,
"reminder_last_at": "2026-09-06T12:00:00Z",
"sent": true
}
**Not idempotent.** Each success increments `reminder_count`; a second call inside `REMINDER_DELAY_HOURS` is `429` `{"detail": {"error": "reminder_too_soon"}}`. **Errors:** `404` if the document or recipient does not exist or is not owned by the caller (indistinguishable). `409` `{"detail": {"error": "invite_suppressed"}}` when `send_invite=false` and `override_send_invite` is not true; `409` `not_their_turn` on a sequential envelope for a recipient who cannot sign yet; `409` `envelope_not_active` / `recipient_already_signed` / `reminder_cap_reached` for the other policy refusals.

Signing Sessions

Manage signing sessions

POST

Void Envelope

/api/sessions/{session_id}/void
Auth required

Terminate a live envelope. Signing links stop working immediately and pending recipients are emailed. The send credit is refunded only if no recipient has viewed, signed, or declined the envelope (declining does not require a prior view, so it can block the refund on its own) and no access has otherwise been recorded against it. Idempotent: voiding an already-voided envelope returns 200, not an error. The API-key equivalent is POST /api/v1/documents/{document_id}/void, which voids the newest session for a document.

Path Parameters

NameTypeDescription
session_idrequiredUUIDSession identifier

Request Body

Content-Type: application/json

Fields

NameTypeDescription
reasonstringFree text explaining the void, max 1000 characters
Example
{
"reason": "Contract terms changed"
}

Request

curl -X POST "https://zsign.io/api/sessions/{session_id}/void" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"reason": "Contract terms changed"}'

Response 200

Envelope voided

{
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"document_id": "660e8400-e29b-41d4-a716-446655440000",
"status": "voided",
"voided_at": "2024-01-15T10:30:00Z",
"reason": "Contract terms changed",
"recipients_to_notify": 1,
"credit_refunded": true,
"credit_refund_skipped": null
}
**`credit_refund_skipped`:** `null` when refunded; otherwise one of `already_viewed`, `no_charge_found`, `already_refunded`, `already_voided`, `not_voided`. **Errors:** `404` if the envelope does not exist or is not owned by the caller (indistinguishable, to avoid leaking existence of other accounts' data). `409` `{"detail": {"error": "envelope_not_active", "status": "<current_status>"}}` if the envelope already reached a different terminal state (`completed`, `declined`, `expired`). `422` if `reason` exceeds 1000 characters.

Webhook Event: document.voided

Webhooks

Configure webhook endpoints for real-time notifications. GET /api/webhooks is the current singleton HTTP endpoint (200 + config, or 200 + null when none). Extra destinations are listed at GET /api/webhooks/subscriptions (200 + []). The document.completed payload carries a fields array — every submitted field value, in the same shape as GET /api/v1/documents/{document_id}/fields — additive on top of the existing payload keys.

GET

Get Webhook

/api/webhooks
Auth required

Read the org's current singleton HTTP webhook configuration. Empty is 200 with a null body, not 404. Settings and API-key callers share this route.

Request

curl https://zsign.io/api/webhooks \
-H "Authorization: Bearer YOUR_API_KEY"

Response 200

Current singleton config, or null if none is configured

{
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://example.com/webhook",
"events": [
"document.signed",
"document.completed"
],
"enabled": true
}
**Current vs list:** this GET is the one dashboard/programmatic HTTP endpoint (or `null`). GET /api/webhooks/subscriptions lists independently addressable extra destinations and never clobbers the singleton. PATCH/DELETE/rotate-secret on a missing singleton still 404.
POST

Create Webhook

/api/webhooks
Auth required

Create or replace webhook configuration.

Request Body

Content-Type: application/json

Fields

NameTypeDescription
urlrequiredstringWebhook endpoint URL (HTTPS)
eventsrequiredarrayList of events to subscribe to
Example
{
"url": "https://example.com/webhook",
"events": [
"document.signed",
"document.completed"
]
}

Request

curl -X POST https://zsign.io/api/webhooks \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhook",
"events": ["document.signed", "document.completed"]
}'

Response 201

Webhook created

{
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://example.com/webhook",
"events": [
"document.signed",
"document.completed"
],
"enabled": true,
"secret": "whsec_..."
}

White-Label Branding

Brand the signer-facing experience, subscribe to White-label ($49/month, 100 envelopes included), manage a custom sender domain, and configure embed origins

GET

Get Branding Settings

/api/branding
Auth required

Retrieve the current account's branding configuration.

Request

curl -H "Authorization: Bearer YOUR_API_KEY" https://zsign.io/api/branding

Response 200

Branding settings

{
"id": "550e8400-e29b-41d4-a716-446655440000",
"user_id": "660e8400-e29b-41d4-a716-446655440000",
"company_name": "Acme Corp",
"logo_url": "https://storage.googleapis.com/zsign-public/branding/.../logo.png",
"primary_color": "#2563eb",
"custom_domain": null,
"custom_domain_verified": false,
"custom_domain_verified_at": null,
"custom_domain_target": "zsign-frontend.onrender.com",
"custom_domain_a_record": "216.24.57.1",
"badge_removed": false,
"embed_origins": [
"https://app.acme.com"
],
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
PUT

Update Branding Settings

/api/branding
Auth required

Update company name, logo URL, and accent color shown on signing pages, emails, and the Certificate of Completion.

Request Body

Content-Type: application/json

Fields

NameTypeDescription
company_namestringDisplayed on signing pages, emails, and the certificate
logo_urlstringPublic logo URL (or use POST /api/branding/logo to upload one)
primary_colorstringHex color, e.g. #2563eb, drives the signing page accent
Example
{
"company_name": "Acme Corp",
"primary_color": "#ff6600"
}

Request

curl -X PUT https://zsign.io/api/branding \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"company_name": "Acme Corp", "primary_color": "#ff6600"}'

Response 200

Updated branding settings

{
"company_name": "Acme Corp",
"primary_color": "#ff6600"
}
POST

Start White-Label Checkout

/api/branding/white-label/checkout
Auth required

Start a $49/month White-label subscription via Stripe Checkout (100 envelopes included each month). Returns 409 if the account already has an active subscription.

Request

curl -X POST https://zsign.io/api/branding/white-label/checkout \
-H "Authorization: Bearer YOUR_API_KEY"

Response 200

Stripe Checkout session URL

{
"checkout_url": "https://checkout.stripe.com/c/pay/cs_test_..."
}
POST

Open Billing Portal

/api/branding/white-label/portal
Auth required

Open the Stripe Billing Portal to manage or cancel the White-label subscription. Returns 400 if no subscription is on file.

Request

curl -X POST https://zsign.io/api/branding/white-label/portal \
-H "Authorization: Bearer YOUR_API_KEY"

Response 200

Stripe Billing Portal session URL

{
"portal_url": "https://billing.stripe.com/p/session/..."
}
PUT

Set Sender Domain

/api/branding/sender-domain
Auth required

Register a custom domain for outbound invite and completion emails via Resend. Requires an active White-label subscription (403 if not subscribed). Returns 409 if the domain is already claimed by another account.

Request Body

Content-Type: application/json

Fields

NameTypeDescription
domainrequiredstringDomain to send from, e.g. mail.yourcompany.com
Example
{
"domain": "mail.yourcompany.com"
}

Request

curl -X PUT https://zsign.io/api/branding/sender-domain \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"domain": "mail.yourcompany.com"}'

Response 200

Sender domain registered with Resend, not yet verified

{
"sender_domain": "mail.yourcompany.com",
"sender_domain_verified": false,
"records": [
{
"record": "TXT",
"name": "mail.yourcompany.com",
"value": "resend-verify=..."
}
]
}
Unlike the two endpoints below, this one requires an active subscription (`active`, `trialing`, or `past_due`). Switching to a different domain removes the previous Resend registration.
POST

Verify Sender Domain

/api/branding/sender-domain/verify
Auth required

Ask Resend to re-check DNS for the account's configured sender domain and report status. Not gated by subscription status, so an account whose subscription has lapsed can still confirm its DNS records.

Request

curl -X POST https://zsign.io/api/branding/sender-domain/verify \
-H "Authorization: Bearer YOUR_API_KEY"

Response 200

Current verification status and DNS records

{
"sender_domain": "mail.yourcompany.com",
"sender_domain_verified": true,
"records": [
{
"record": "TXT",
"name": "mail.yourcompany.com",
"value": "resend-verify=...",
"status": "verified"
}
]
}
DELETE

Remove Sender Domain

/api/branding/sender-domain
Auth required

Remove the configured sender domain and deregister it from Resend. Not gated by subscription status, so an account whose subscription has lapsed can still clean up its DNS.

Request

curl -X DELETE https://zsign.io/api/branding/sender-domain \
-H "Authorization: Bearer YOUR_API_KEY"

Response 200

Sender domain removed

{
"success": true
}
PUT

Set Embed Origins

/api/branding/embed-origins
Auth required

Configure the exact origins allowed to embed this account's signing pages in an iframe (default deny -- an empty list blocks all framing). Exact match only, https required except http://localhost[:port], no paths or wildcards, max 10. Parent SDKs: npm install @zsign/embed (createSigningEmbed) or @zsign/react (ZSignEmbed).

Request Body

Content-Type: application/json

Fields

NameTypeDescription
originsrequiredarrayList of allowed origins, e.g. ["https://app.yourcompany.com"]
Example
{
"origins": [
"https://app.yourcompany.com"
]
}

Request

curl -X PUT https://zsign.io/api/branding/embed-origins \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"origins": ["https://app.yourcompany.com"]}'

Response 200

Updated branding settings including the normalized embed_origins list

{
"embed_origins": [
"https://app.yourcompany.com"
]
}