API reference
Generated from the router. If an endpoint takes a field, it is in this table, because the table and the validator read the same declaration.
Base URL
https://api.signsealer.com. Everything is JSON, everything takes
Authorization: Bearer mk_live_…, and every response carries
x-request-id.
Account
GET /v1/account
Show the account this key belongs to
The first call any integration makes: prove the credential works and show the operator the name of the account they just connected.
{
"tenant_id": "3a1f…",
"name": "Fern Hollow Stables",
"slug": "fern-hollow",
"key_label": "Booking system",
"scopes": [
"signing:read",
"signing:write"
]
}
Branding
GET /v1/branding
The brand the account shows its signers
The business's name, colours, whether it has logos on file (fetch them at /brand/{tenant_id}/logo.png and logo-dark.png) and the host its signing links use. SignSealer's own until the business has the white-label add-on and has set its brand.
{
"businessName": "Fern Hollow Stables",
"whiteLabelEnabled": true,
"primaryColor": "#2f5d3a",
"accentColor": "#c98b2b",
"backgroundColor": "#f6f4ee",
"textColor": "#1f2a22",
"buttonTextColor": "#ffffff",
"hasLogo": true,
"hasDarkLogo": true,
"signingHost": "fern-hollow.signsealer.com",
"footerText": "Questions? Call the barn.",
"tenantId": "3a1f…",
"logoUrl": "https://api.signsealer.com/brand/3a1f…/logo.png",
"logoDarkUrl": "https://api.signsealer.com/brand/3a1f…/logo-dark.png"
}
Sms-consents
POST /v1/sms-consents
Record consent to text a number
For a number you took somewhere SignSealer is not: your booking form, an order, the counter. Send the exact wording that was in front of the person, because that is the record a carrier asks for and it is kept verbatim. It has to say that replying STOP opts out, that HELP gets help, that message and data rates may apply, and that message frequency varies; a disclosure missing any of those is refused with 422 naming which, and nothing is recorded. One call covers the link, the reminder and the confirmation, because somebody who agreed to those agreed to all three. A number that has replied STOP cannot be re-consented by you — they have to opt in again themselves.
| Field | Type | Constraints | |
|---|---|---|---|
| phone | string | required | 7–20 chars |
| obtained_via | string | required | 3–200 chars |
| wording | string | required | 20–2000 chars |
{
"phone": "+15035550142",
"purposes": [
"signing.link",
"signing.reminder",
"signing.completed"
]
}
GET /v1/sms-consents
Whether a number may be texted
Pass phone. Answers for that one number — so an integration can decide whether to put the tick box in front of somebody again. It deliberately does not list every number the account holds. may_text is what the engine will actually do rather than what one of its checks says: consent has to be live *and* the number must not be suppressed, because a number that hard-bounced keeps its consent and still cannot be reached. When it is false, why_not says which of revoked, suppressed or no_consent.
{
"phone": "+15035550142",
"may_text": true,
"why_not": null,
"obtained_via": "booking form, checkbox beside the phone field",
"obtained_at": "2026-10-03T14:22:09Z"
}
Templates
GET /v1/templates
List templates
Every version of every template, newest first, with whether each is published and how many documents were prepared from it.
{
"templates": [
{
"template_id": "8c2e…",
"code": "liability-waiver",
"name": "Liability waiver",
"kind": "waiver",
"version": 2,
"is_published": true,
"published_at": "2026-10-03T14:22:09Z",
"variables": [
"participant_name",
"date",
"emergency_contact"
],
"grants": {},
"documents": 412,
"is_latest": true
}
]
}
POST /v1/templates
Create a template
Creates version 1, as a draft. A template is not usable until it is published, and publishing is what freezes the text people will have signed. fields says what the document asks for besides a signature — a date of birth, an address, one of a list — each required unless it says otherwise, and each checked when a signer supplies it. Marking one sensitive masks the answer wherever staff read it back; it is not masked on the sealed PDF, because the PDF is the document that was signed.
| Field | Type | Constraints | |
|---|---|---|---|
| code | string | required | matches ^[a-z][a-z0-9_-]*$ |
| name | string | required | 1–200 chars |
| kind | "nda" | "waiver" | "contract" | "consent" | "policy" | "other" | required | |
| body | string | required | 1–∞ chars |
| variables | string[] | optional | |
| grants | object | optional | |
| consent_text | string | null | optional | |
| fields | object[] | optional | 0–50 items |
{
"template_id": "8c2e…"
}
GET /v1/templates/{id}/pdf
Whether a template is an uploaded PDF
has_pdf says whether the template is a PDF signed where it is rather than text. If it is: page_count is what the file has, drawn is how many pages have a picture a signer can read, and ready is whether those agree — a PDF template cannot be published until they do. sha256 is the fingerprint of the file itself, which is the fingerprint the template's body names and the certificate commits to. Uploading the file is a dashboard step; see /docs/pdf.
{
"has_pdf": true,
"page_count": 4,
"drawn": 4,
"ready": true,
"pages": [
{
"n": 1,
"width": 612,
"height": 792,
"rotation": 0
}
],
"bytes": 284113,
"sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"original_name": "tenancy.pdf",
"uploaded_at": "2026-10-03T14:22:09Z"
}
Documents
GET /v1/documents
List documents
Filter with status (a state, or open for draft and sent) and q (title, subject or signer).
{
"documents": [
{
"document_id": "d41c…",
"title": "Liability waiver — Dana Reyes",
"status": "sent",
"template_code": "liability-waiver",
"template_version": 2,
"subject_name": null,
"signers": 2,
"signed": 1,
"waiting_on": "Sam Manager",
"sequential": true,
"expires_at": "2026-10-17T00:00:00Z",
"sent_at": "2026-10-03T14:22:09Z",
"completed_at": null,
"void_reason": null
}
]
}
GET /v1/documents/{id}
Show one document
The document, its signers, and the whole event trail with the text hash each event was made against.
{
"document_id": "d41c…",
"title": "Liability waiver — Dana Reyes",
"status": "completed",
"body": "I, Dana Reyes, …",
"body_sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"template_code": "liability-waiver",
"template_version": 2,
"grants": {},
"sequential": false,
"sent_at": "2026-10-03T14:22:09Z",
"completed_at": "2026-10-03T14:22:09Z",
"voided_at": null,
"void_reason": null,
"integrity_ok": true,
"signers": [
{
"signer_id": "5f0a…",
"email": "dana@example.com",
"full_name": "Dana Reyes",
"role": "signer",
"sign_order": 1,
"status": "signed",
"party_id": null,
"consented_at": "2026-10-03T14:22:09Z",
"signed_at": "2026-10-03T14:22:09Z",
"declined_at": null,
"decline_reason": null,
"token_expires_at": "2026-10-17T00:00:00Z"
}
],
"events": [
{
"event_id": "e1…",
"kind": "sent",
"occurred_at": "2026-10-03T14:22:09Z",
"signer_email": null,
"ip": null,
"user_agent": null,
"against_sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"detail": null
},
{
"event_id": "e2…",
"kind": "consented",
"occurred_at": "2026-10-03T14:22:09Z",
"signer_email": "dana@example.com",
"ip": "203.0.113.9",
"user_agent": "Mozilla/5.0 …",
"against_sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"detail": null
},
{
"event_id": "e3…",
"kind": "signed",
"occurred_at": "2026-10-03T14:22:09Z",
"signer_email": "dana@example.com",
"ip": "203.0.113.9",
"user_agent": "Mozilla/5.0 …",
"signature_method": "typed",
"against_sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"detail": null
}
]
}
POST /v1/documents
Prepare a document from a template
Renders the published template with the values given and stores the result on the document. Every declared variable needs a value: a contract that ships with a placeholder still in it is worse than one that failed to send.
| Field | Type | Constraints | |
|---|---|---|---|
| template_code | string | required | |
| title | string | optional | 0–200 chars |
| values | object | optional | |
| expires_at | string (date-time) | null | optional | |
| sequential | boolean | optional |
{
"document_id": "d41c…"
}
POST /v1/documents/{id}/send
Send a document for signature
Returns one signing link per signer, once. The tokens are not stored, only their hashes, so a lost link is replaced by a reminder rather than looked up.
| Field | Type | Constraints | |
|---|---|---|---|
| signers | object[] | required | 1–50 items |
{
"links": [
{
"signer_id": "5f0a…",
"email": "dana@example.com",
"token": "3f9c…64 hex characters"
}
]
}
POST /v1/documents/{id}/void
Void a document
Needs a reason, which goes on the audit trail. An executed agreement cannot be voided: it is terminated by agreement, not by deletion.
| Field | Type | Constraints | |
|---|---|---|---|
| reason | string | required | 1–500 chars |
{
"voided": true
}
GET /v1/documents/{id}/certificate
Fetch the certificate of completion
Issued when the document completes, frozen at issue and hashed. still_matches_record re-derives it from the live record and reports any divergence rather than correcting it.
{
"certificate_id": "c7d0…",
"verification_code": "K7QP3MTV9XBD2FHR",
"issued_at": "2026-10-03T14:22:09Z",
"document_sha256_hex": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"certificate_sha256_hex": "592f…",
"still_matches_record": true,
"payload": {
"issuer": {
"tenant": "Fern Hollow Stables",
"tenant_id": "3a1f…"
},
"document": {
"id": "d41c…",
"title": "Liability waiver — Dana Reyes",
"sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"completed_at": "2026-10-03T14:22:09Z",
"template": {
"code": "liability-waiver",
"version": 2
}
},
"signers": [
{
"full_name": "Dana Reyes",
"email": "dana@example.com",
"role": "signer",
"sign_order": 1,
"status": "signed",
"consented_at": "2026-10-03T14:22:09Z",
"signed_at": "2026-10-03T14:22:09Z",
"signature": {
"method": "typed",
"value_sha256": "017a…",
"signed_against_sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"ip": "203.0.113.9"
}
}
],
"events": [
{
"kind": "signed",
"occurred_at": "2026-10-03T14:22:09Z",
"signer": "dana@example.com",
"ip": "203.0.113.9",
"against_sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324"
}
]
}
}
GET /v1/documents/{id}/pdf
Download the sealed PDF
The certificate as a PAdES-sealed PDF, with an RFC 3161 timestamp over the signature. Returns base64 bytes and the filename to save them under.
{
"filename": "certificate-K7QP3MTV9XBD2FHR.pdf",
"content_type": "application/pdf",
"seal": {
"commonName": "SignSealer",
"selfIssued": true,
"trust": "unverified",
"timestamped": true
},
"bytes_base64": "JVBERi0xLjcK…"
}
GET /v1/documents/{id}/bundle
Download the evidence bundle
The sealed PDF with the evidence package printed after the audit trail: every message sent about the document, every webhook delivered, and the record's state when the file was made, with the package's SHA-256 on the last page. One PAdES-sealed file. Returns base64 bytes and the filename to save them under.
{
"filename": "evidence-bundle-K7QP3MTV9XBD2FHR.pdf",
"content_type": "application/pdf",
"seal": {
"commonName": "SignSealer",
"selfIssued": true,
"trust": "unverified",
"timestamped": true
},
"bytes_base64": "JVBERi0xLjcK…"
}
Workflows
GET /v1/workflows
List workflows
A workflow is a published template plus the rules for running it: who signs in what order, how long a link lives, and whether it has a public address.
{
"workflows": [
{
"id": "w9b1…",
"code": "waiver",
"name": "Kayak rental waiver",
"kind": "waiver",
"template_code": "liability-waiver",
"template_published": true,
"signer_roles": [
{
"label": "Participant",
"role": "signer",
"order": 1,
"participant": true
}
],
"sequential": false,
"countersign": false,
"expires_days": 7,
"remind_after_days": 3,
"public_slug": "waiver",
"intro": "Read it, answer two questions, sign.",
"form_fields": [
{
"key": "emergency_contact",
"label": "Emergency contact",
"required": true
}
],
"active": true,
"drafted_by_run": null,
"created_at": "2026-10-03T14:22:09Z",
"updated_at": "2026-10-03T14:22:09Z",
"started": 412,
"completed": 398
}
]
}
GET /v1/workflows/{id}/qr
The QR code for a workflow's public form
The public address of a workflow, drawn as a QR code: an SVG to put on a page and a PNG (1024px, base64) to print. Anybody who scans it opens the form on their own phone and signs there. 404 for a workflow with no public address.
{
"workflow_id": "w9b1…",
"name": "Kayak rental waiver",
"url": "https://signsealer.com/f/harbour-rentals/waiver",
"svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 33 33\" …>…</svg>",
"png_base64": "iVBORw0KGgo…"
}
POST /v1/workflows/{id}/start
Start a workflow for one participant
Prepares the document, sends it to the participant and to every fixed role, and returns the tokens once.
| Field | Type | Constraints | |
|---|---|---|---|
| string (email) | required | ||
| full_name | string | required | 1–200 chars |
| phone | string | optional | 0–40 chars |
| fields | object | optional | |
| values | object | optional | |
| external_ref | string | optional | 0–200 chars |
{
"document_id": "d41c…",
"title": "Kayak rental waiver — Dana Reyes",
"expires_at": "2026-10-10T14:22:09Z",
"signers": [
{
"signer_id": "5f0a…",
"email": "dana@example.com",
"full_name": "Dana Reyes",
"role": "signer",
"participant": true,
"token": "3f9c…"
},
{
"signer_id": "5f0b…",
"email": "manager@fernhollow.example",
"full_name": "Sam Manager",
"role": "signer",
"participant": false,
"token": "7a21…"
}
]
}
Devices
POST /v1/devices/pair
Trade a pairing code for a device credential
The code is shown once on the fleet screen, lives fifteen minutes and is spent on first use. What comes back is an API key scoped to this device alone and the workflow it has been given, if it has one. Every refusal answers identically — a code that is wrong, expired, spent or belongs to a stopped device all read the same, because telling them apart tells a guesser whether they are warm.
| Field | Type | Constraints | |
|---|---|---|---|
| code | string | required | 8–32 chars |
| platform | "ios" | "android" | "other" | optional | |
| app_version | string | optional | 0–40 chars |
| os_version | string | optional | 0–40 chars |
{
"device_id": "de91…",
"name": "Front counter iPad",
"key": "mk_live_9f2a…",
"workflow_id": "wf3c…",
"workflow_name": "River float waiver"
}
POST /v1/devices/heartbeat
Say the device is still here, and read back what it should be running
The device does not say which device it is — the credential it called with does. So a tablet cannot report on another's behalf, and a heartbeat from a revoked credential resolves to no device at all. The answer carries the device's name and assigned workflow, so a change made on the fleet screen reaches the tablet without re-pairing.
| Field | Type | Constraints | |
|---|---|---|---|
| app_version | string | optional | 0–40 chars |
| os_version | string | optional | 0–40 chars |
{
"device_id": "de91…",
"name": "Front counter iPad",
"workflow_id": "wf3c…",
"workflow_name": "River float waiver"
}
Subjects
PUT /v1/subjects
Import a reservation or activity, and build its packet
Keyed on (provider, external_id), so importing the same reservation twice is one reservation, which is why this needs no idempotency key. Participants and the packet are built in the same call, because a caller who imported a reservation always wants to know what is now required.
| Field | Type | Constraints | |
|---|---|---|---|
| kind | "reservation" | "activity" | optional | |
| provider | string | optional | matches ^[a-z][a-z0-9_-]*$ |
| external_id | string | required | 1–200 chars |
| label | string | optional | 0–200 chars |
| starts_on | string | required | matches ^\d{4}-\d{2}-\d{2}$ |
| ends_on | string | null | optional | matches ^\d{4}-\d{2}-\d{2}$ |
| facts | object | optional | |
| participants | object[] | optional | 0–60 items |
{
"subject_id": "s2e4…",
"packet": {
"packet_id": "p6c9…",
"code": "RES-88213",
"added": 2,
"dropped": 0,
"state": "open",
"required": 2,
"completed": 0,
"outstanding": 2,
"not_started": 2
}
}
Packets
GET /v1/packets/{id}
Show a packet
Who owes what, where each one has got to, and a readiness state a door lock can wait on.
{
"packet_id": "p6c9…",
"code": "RES-88213",
"status": "open",
"ready_at": null,
"subject": {
"id": "s2e4…",
"kind": "reservation",
"label": "Reyes party, 4 Oct",
"provider": "deckpos",
"external_id": "RES-88213",
"starts_on": "2026-10-04",
"ends_on": "2026-10-04",
"facts": {}
},
"readiness": {
"required": 2,
"completed": 1,
"outstanding": 1,
"blocked": 0
},
"items": [
{
"item_id": "i1…",
"requirement": "waiver",
"workflow": "waiver",
"required": true,
"participant": {
"id": "pa1…",
"full_name": "Dana Reyes",
"email": "dana@example.com",
"is_minor": false,
"signs_for_them": null
},
"document_id": "d41c…",
"status": "completed"
}
]
}
POST /v1/packets/items/{id}/start
Start one packet item
Renders the item's workflow for the person who owes it. A minor's document is started for their guardian, because the guardian is who signs.
{
"document_id": "d41d…",
"title": "Kayak rental waiver — Dana Reyes",
"expires_at": "2026-10-10T14:22:09Z",
"signers": [
{
"signer_id": "5f0c…",
"email": "dana@example.com",
"full_name": "Dana Reyes",
"role": "signer",
"participant": true,
"token": "3f9c…"
}
]
}
Webhooks
GET /v1/webhooks
List webhook endpoints
{
"endpoints": [
{
"id": "h4a7…",
"url": "https://pms.example.com/hooks/signsealer",
"label": "Booking system",
"events": [
"signing.completed",
"signing.declined"
],
"active": true,
"failures": 0,
"paused_at": null,
"last_delivered_at": "2026-10-03T14:22:09Z",
"created_at": "2026-10-03T14:22:09Z",
"queued": 0,
"dead": 0
}
]
}
POST /v1/webhooks
Add a webhook endpoint
Returns the signing secret once and never again. It is sealed at rest; a caller who loses it replaces the endpoint.
| Field | Type | Constraints | |
|---|---|---|---|
| url | string (uri) | required | matches ^https:// |
| events | string[] | optional | 1–30 items |
| label | string | optional | 0–80 chars |
{
"id": "h4a7…",
"secret": "whsec_…48 hex characters, shown once"
}
PATCH /v1/webhooks/{id}
Change a webhook endpoint
Turning an endpoint back on forgives its failure count and releases what it held. A paused endpoint keeps receiving events — they wait rather than being lost (00289) — so the answer says how many are waiting to go out, and how many were dropped because the hold was full while it was paused.
| Field | Type | Constraints | |
|---|---|---|---|
| active | boolean | optional | |
| events | string[] | optional | 1–30 items |
| label | string | optional | 0–80 chars |
{
"updated": true
}
POST /v1/webhooks/{id}/rotate
Rotate a webhook endpoint's secret
A new secret, returned once. For 24 hours every delivery is signed with both — the signature header carries two comma-separated v1= values, the new secret's first — so a receiver can switch without dropping a delivery. After that the old secret is forgotten.
{
"secret": "whsec_…shown once",
"previous_valid_for_seconds": 86400
}
GET /v1/webhooks/{id}/deliveries
List an endpoint's recent deliveries
The last fifty, newest first: event, status, attempts, the next attempt, the last error and the document. A replay names the delivery it repeats in replay_of.
{
"deliveries": [
{
"id": "dl1…",
"endpoint_id": "h4a7…",
"event": "signing.completed",
"status": "delivered",
"attempts": 1,
"next_attempt_at": "2026-10-03T14:22:09Z",
"last_error": null,
"response_status": 200,
"created_at": "2026-10-03T14:22:09Z",
"delivered_at": "2026-10-03T14:22:09Z",
"replay_of": null,
"document_id": "d41c…",
"document_title": "Liability waiver — Dana Reyes"
}
]
}
POST /v1/webhooks/deliveries/{id}/replay
Send a delivery again
Queues a delivered, failed or dead delivery again as a new delivery with the same event and body, marked as a replay of the original. One still in the queue is refused; so is a second replay while the first is queued. A replay waits like any other if the endpoint is off or paused.
{
"delivery_id": "dl2…"
}
DELETE /v1/webhooks/{id}
Remove a webhook endpoint
Needs ?reason=, which goes on the audit trail.
{
"deleted": true
}
Sign
GET /v1/sign/{token}
Read a document from a signing link
Everything the signing page needs from one token: the text and its hash, who is being asked, who else is on it, whether it is this signer's turn, and — when it will not take a signature — why, in a sentence a person can read. When the template is an uploaded PDF, pages lists its pages and each field may carry a place saying where on one it belongs; fetch a page as an image from /s/{token}/page?n=1. Returns a body even for a token that matches nothing, so a bad link and an expired one fail identically.
{
"ok": true,
"reason": null,
"document_id": "d41c…",
"title": "Liability waiver — Dana Reyes",
"body": "I, Dana Reyes, …",
"body_sha256_hex": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"document_status": "sent",
"sequential": false,
"expires_at": "2026-10-17T00:00:00Z",
"tenant_name": "Fern Hollow Stables",
"signer_id": "5f0a…",
"full_name": "Dana Reyes",
"email": "dana@example.com",
"signer_role": "signer",
"sign_order": 1,
"signer_status": "pending",
"consented_at": null,
"signed_at": null,
"template_kind": "waiver",
"grants": {},
"is_my_turn": true,
"waiting_on": null,
"consent_text": "By continuing you agree to sign this document electronically and to receive a copy by email. …",
"consent_version": "consent/1",
"intent_text": "By signing below I confirm that I have read this document, …",
"intent_version": "intent/1",
"signers": [
{
"full_name": "Dana Reyes",
"role": "signer",
"sign_order": 1,
"status": "pending",
"signed_at": null,
"is_me": true
}
],
"fields": [
{
"key": "initials",
"label": "Initial here",
"kind": "initials",
"required": true,
"about": null,
"supplied": false
}
]
}
POST /v1/sign/{token}/consent
Record consent to sign electronically
Must happen before the signature, and is its own event for that reason: ESIGN requires consent to precede the transaction, and a system that recorded both in the same instant could not show that it did.
| Field | Type | Constraints | |
|---|---|---|---|
| captured | object | optional |
{
"consented": true
}
POST /v1/sign/{token}/code
Send the signer a one-time code
Only when the business asks signers for a code (Settings → Signing) and a link was delivered to this signer. The code goes to the address or number the link went to; the answer says where, masked. One a minute, five an hour. Not an identity check: it proves control of that inbox or phone at the moment of signing.
{
"channel": "email",
"sent_to": "d•••@example.com",
"resend_after": "2026-10-03T14:22:09Z"
}
POST /v1/sign/{token}/verify
Confirm the one-time code
A wrong code is answered with ok false and the tries left rather than an error; after five wrong tries the code is dead and a new one must be sent. A confirmed code is a verified event on the trail and the certificate says email_otp or sms_otp.
| Field | Type | Constraints | |
|---|---|---|---|
| code | string | required | 4–12 chars |
{
"ok": true
}
POST /v1/sign/{token}/fields
Supply a required field
Initials, a checkbox, a date. A signature is refused while anything required is unsupplied, and that refusal is in the database rather than in the form — so a second interface cannot go around it.
| Field | Type | Constraints | |
|---|---|---|---|
| key | string | required | matches ^[a-z][a-z0-9_]{0,58}$ |
| value | string | required | 0–20000 chars |
| about | string | null | optional | 0–200 chars |
{
"supplied": true
}
POST /v1/sign/{token}
Sign
Typed or drawn, both equally valid: what matters is intent. captured is for a signature taken where there was no network — the server records when it received it and keeps the device's claim beside it, with the clock skew computed rather than accepted.
| Field | Type | Constraints | |
|---|---|---|---|
| method | "typed" | "drawn" | "clicked" | "uploaded" | required | |
| value | string | required | 1–400000 chars |
| captured | object | optional |
{
"event_id": "e3…"
}
POST /v1/sign/{token}/decline
Decline to sign
With a reason. A decline is a person saying no, which is not the same as still waiting — a packet containing one is blocked rather than outstanding, so nobody chases somebody who already answered.
| Field | Type | Constraints | |
|---|---|---|---|
| reason | string | required | 1–500 chars |
{
"declined": true
}
Verify
GET /v1/verify/{code}
Check a certificate by its printed code
Anonymous, because the point is that somebody holding the document can check it. Answers three separate questions: whether the certificate exists, whether a document you hold matches it, and whether it still matches the record behind it. The third is a finding, never a correction. With the code alone you get the issuer, title, dates, fingerprint and how many signed; pass ?sha256= of the document you hold and, when it matches, the signers (never their email addresses) and the trail.
{
"found": true,
"verification_code": "K7QP3MTV9XBD2FHR",
"issued_at": "2026-10-03T14:22:09Z",
"issuer": "Fern Hollow Stables",
"title": "Liability waiver — Dana Reyes",
"document_sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"document_matches": true,
"still_matches_record": true,
"divergence": null,
"completed_at": "2026-10-03T14:22:09Z",
"redacted_at": null,
"signer_count": 1,
"signers": [
{
"full_name": "Dana Reyes",
"role": "signer",
"sign_order": 1,
"status": "signed",
"consented_at": "2026-10-03T14:22:09Z",
"signed_at": "2026-10-03T14:22:09Z"
}
],
"events": [
{
"kind": "signed",
"occurred_at": "2026-10-03T14:22:09Z",
"signer": "dana@example.com",
"against_sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324"
}
]
}
Ready to build? An API key takes a minute in the portal, and the free plan covers the first 25 agreements a month.
Get an API key