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, and every response
carries x-request-id. Every route takes
Authorization: Bearer mk_live_… (a key, or a partner's OAuth token)
except the signing and verifying routes, where the token or code in the path is
the credential and a key is ignored; those also answer a browser on another
site.
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. frame_origins lists the sites its signing pages may be framed on (see POST /v1/documents/{id}/signers/{signer}/embed). mode is test for a test key (mk_test_), whose account is the business's sandbox: nothing it sends reaches anybody and nothing it does is billed; live otherwise. texting says whether this business can text people (available; why_not is plan when its plan has no texting, number while it waits for its own verified texting number, beta while the business is in the beta, which does not text, and test for a sandbox), and wording is the opt-in sentence to put beside a "text me" box: record exactly those words with POST /v1/sms-consents when the box is ticked. Offer no such box when available is false -- the consent would be recorded and no text would follow. number is the business's own verified texting number, and application where its application for one has got to (status, kind, phone once verified, reason when the carriers asked for a change or it is held up, since), or null when it has not applied: send its owner a link to apply with POST /v1/texting/number/invitations, and hear each step as texting.number_updated.
{
"tenant_id": "3a1f…",
"name": "Fern Hollow Stables",
"slug": "fern-hollow",
"key_label": "Booking system",
"scopes": [
"signing:read",
"signing:write"
],
"frame_origins": [
"https://*.fernhollow.com",
"https://fernhollow.com"
],
"mode": "live",
"texting": {
"available": true,
"why_not": null,
"number": "+18885550142",
"application": {
"status": "verified",
"kind": "toll_free",
"phone": "+18885550142",
"area_code": null,
"reason": null,
"brought": false,
"since": "2026-10-03T14:22:09Z"
},
"wording": "I agree to receive text messages from Fern Hollow Stables via Sign Sealer: the link, at most one reminder if I have not signed, and a confirmation when everyone has signed. … Reply STOP to opt out. Terms: https://signsealer.com/legal/terms Privacy: https://signsealer.com/legal/privacy-policy"
}
}
Branding
GET /v1/branding
The brand the account shows its signers
The business's name, colors, 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. Apply the name, the colors and the dark logo only when whiteLabelEnabled is true, as SignSealer's own tablet app does; see Branding and domains.
{
"businessName": "Fern Hollow Stables",
"whiteLabelEnabled": true,
"primaryColor": "#2f5d3a",
"accentColor": "#c98b2b",
"backgroundColor": "#f6f4ee",
"textColor": "#1f2a22",
"buttonTextColor": "#ffffff",
"hasLogo": true,
"hasDarkLogo": true,
"hasOpening": true,
"openingType": "image/jpeg",
"openingUpdatedAt": "2026-10-03T14:22:09Z",
"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",
"openingUrl": "https://api.signsealer.com/brand/3a1f…/opening.jpg?v=1789000000"
}
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 (GET /v1/account gives the sentence to show, under texting.wording). It has to say that replying STOP opts out, that HELP gets help, that message and data rates may apply, that message frequency varies, that consent is not a condition of purchase, that the number is not shared or sold, and link the terms and the privacy policy; 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 consented again: the call is refused, and it is not texted for you again.
| 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.
| Parameter | Type | What it is | |
|---|---|---|---|
| phone | string | required | The number, as a person would type it. |
{
"phone": "+15035550142",
"may_text": true,
"why_not": null,
"obtained_via": "booking form, checkbox beside the phone field",
"obtained_at": "2026-10-03T14:22:09Z"
}
Texting
POST /v1/texting/number/invitations
A link for the owner to apply for a texting number
Every business that texts does so from a number of its own, applied for by its owner or an admin under the business's legal name (see texting on GET /v1/account). When texting.why_not is number, ask for a link and put it in front of the business's owner: it opens their texting-number application on SignSealer, after they sign in, on this business, with prefill filled in. Send what you know, in the application's own names (legal_name, ein, website, the address, the contact, kind of toll_free, local or sole_proprietor, area_code, volume, vertical, mobile, and the business's own privacy_url, terms_url and opt_in_url); everything is optional, and nothing you send is filed until the owner has read it, ticked that it is true and applied. The link opens only for somebody already in the business, and fills in the form only for an owner or an admin. It lasts 14 days and is used up when the business applies; ask again for a new one, up to 20 a day. A name the application has no field for, or an answer over 300 characters, is refused with 422 naming it. A test key is refused: a sandbox never texts. Watch texting.application on GET /v1/account, or subscribe to texting.number_updated, to see where the application has got to.
| Field | Type | Constraints | |
|---|---|---|---|
| prefill | object | optional | |
| prefill.kind | "toll_free" | "local" | "sole_proprietor" | optional | |
| prefill.legal_name | string | optional | 0–300 chars |
| prefill.entity_type | string | optional | 0–300 chars |
| prefill.ein | string | optional | 0–300 chars |
| prefill.website | string | optional | 0–300 chars |
| prefill.address_line1 | string | optional | 0–300 chars |
| prefill.address_line2 | string | optional | 0–300 chars |
| prefill.city | string | optional | 0–300 chars |
| prefill.state | string | optional | 0–300 chars |
| prefill.postal_code | string | optional | 0–300 chars |
| prefill.contact_first_name | string | optional | 0–300 chars |
| prefill.contact_last_name | string | optional | 0–300 chars |
| prefill.contact_email | string | optional | 0–300 chars |
| prefill.contact_phone | string | optional | 0–300 chars |
| prefill.volume | string | optional | 0–300 chars |
| prefill.vertical | string | optional | 0–300 chars |
| prefill.area_code | string | optional | 0–300 chars |
| prefill.mobile | string | optional | 0–300 chars |
| prefill.privacy_url | string | optional | 0–300 chars |
| prefill.terms_url | string | optional | 0–300 chars |
| prefill.opt_in_url | string | optional | 0–300 chars |
{
"url": "https://app.signsealer.com/texting/invite/9f3c…",
"from": "DeckPOS",
"kind": "toll_free",
"fields": [
"address_line1",
"city",
"contact_email",
"kind",
"legal_name",
"postal_code",
"state",
"website"
],
"expires_at": "2026-10-17T14: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. Without the text: GET /v1/templates/{id} has that. Pass code for one template's versions.
| Parameter | Type | What it is | |
|---|---|---|---|
| code | string | optional | Only this template's versions. |
{
"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,
"one_time": false,
"show_address": false,
"valid_months": 12,
"remind_days": 14,
"resign_from_version": null
}
]
}
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. A question can be asked only when earlier answers say so (show_if: {"field": "injured", "ticked": true}, or equals a list of choices, or answered; or not_equals, contains, not_contains, gt/lt for a number and age_under/age_at_least for a date of birth; or several such rules as {"match": "all" | "any", "rules": [...]}, any of them a group of two or more with its own match, up to ten rules in all; and "action": "hide" beside rules to hide the question when it holds); flagged for a person to read (flag_when, the same rules); grouped with others under a heading (block, with block_label and block_intro given once, and block_step: true for a page of its own); or placed on an uploaded PDF (place). The engine checks all of it and refuses by name -- a condition on a question below it, a block with two headings -- and a refused list leaves no template behind.
| 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 |
| fields[].key | string | required | matches ^[a-z][a-z0-9_]{0,58}$ |
| fields[].label | string | optional | 1–200 chars |
| fields[].kind | string | required | 1–20 chars |
| fields[].required | boolean | optional | |
| fields[].sensitive | boolean | optional | |
| fields[].supplied_by | "adult" | "per_child" | "staff" | "business" | optional | |
| fields[].options | string[] | optional | 0–30 items |
| fields[].hint | string | optional | 0–200 chars |
| fields[].show_if | object | null | optional | |
| fields[].flag_when | object | null | optional | |
| fields[].block | string | null | optional | 0–59 chars |
| fields[].block_label | string | null | optional | 0–200 chars |
| fields[].block_intro | string | null | optional | 0–2000 chars |
| fields[].block_step | boolean | null | optional | |
| fields[].place | object | null | optional |
{
"template_id": "8c2e…"
}
GET /v1/templates/{id}
Show one template version
The version's text (body, and body_format: text or markdown), its declared variables, the consent wording, the questions it asks (fields, in the shape POST /v1/templates takes them), whether it is published and whether it is the newest version of its code. settings belong to the code rather than the version: one_time, show_address, photos_in_copy, valid_months, remind_days and resign_from_version. versions lists every version of the code, newest first, so a caller can walk the history -- a published version is never changed or removed, and each document names the exact version it was sent on.
{
"template_id": "8c2e…",
"code": "liability-waiver",
"name": "Liability waiver",
"kind": "waiver",
"version": 2,
"is_published": true,
"published_at": "2026-10-03T14:22:09Z",
"is_latest": true,
"created_at": "2026-10-03T14:22:09Z",
"body": "# Liability waiver\\n\\nI, {{participant}}, understand that riding carries risk…",
"body_format": "markdown",
"variables": [
"participant"
],
"grants": {},
"consent_text": "I agree to sign this electronically.",
"fields": [
{
"key": "emergency_contact",
"label": "Emergency contact",
"kind": "text",
"required": true
}
],
"has_pdf": false,
"documents": 118,
"change_note": "Names the e-bike and asks for an emergency contact.",
"settings": {
"one_time": false,
"show_address": true,
"photos_in_copy": true,
"valid_months": 12,
"remind_days": 14,
"resign_from_version": null
},
"versions": [
{
"template_id": "8c2e…",
"version": 2,
"is_published": true,
"published_at": "2026-10-03T14:22:09Z",
"name": "Liability waiver",
"created_at": "2026-10-03T14:22:09Z",
"change_note": "Names the e-bike and asks for an emergency contact.",
"has_pdf": false,
"documents": 118
},
{
"template_id": "51d7…",
"version": 1,
"is_published": true,
"published_at": "2025-04-02T09:10:00Z",
"name": "Liability waiver",
"created_at": "2025-04-02T09:02:00Z",
"change_note": null,
"has_pdf": false,
"documents": 342
}
]
}
PATCH /v1/templates/{id}
Change a draft
A draft is changed in place until it is published. Any of name, body, body_format, variables, fields (the whole list, replacing the one it had) and change_note (what changed in this version, up to 500 characters, frozen with it when it is published). A published version refuses with 422: what people signed does not change, so make the next version with POST /v1/templates/{id}/revise. Answers with the template as GET /v1/templates/{id} does.
| Field | Type | Constraints | |
|---|---|---|---|
| name | string | optional | 1–200 chars |
| body | string | optional | 1–∞ chars |
| body_format | "text" | "markdown" | optional | |
| variables | string[] | optional | |
| fields | object[] | optional | 0–50 items |
| fields[].key | string | required | matches ^[a-z][a-z0-9_]{0,58}$ |
| fields[].label | string | optional | 1–200 chars |
| fields[].kind | string | required | 1–20 chars |
| fields[].required | boolean | optional | |
| fields[].sensitive | boolean | optional | |
| fields[].supplied_by | "adult" | "per_child" | "staff" | "business" | optional | |
| fields[].options | string[] | optional | 0–30 items |
| fields[].hint | string | optional | 0–200 chars |
| fields[].show_if | object | null | optional | |
| fields[].flag_when | object | null | optional | |
| fields[].block | string | null | optional | 0–59 chars |
| fields[].block_label | string | null | optional | 0–200 chars |
| fields[].block_intro | string | null | optional | 0–2000 chars |
| fields[].block_step | boolean | null | optional | |
| fields[].place | object | null | optional | |
| change_note | string | null | optional | 0–500 chars |
{
"template_id": "93aa…",
"code": "liability-waiver",
"name": "Liability waiver",
"kind": "waiver",
"version": 3,
"is_published": false,
"published_at": null,
"is_latest": true,
"created_at": "2026-10-03T14:22:09Z",
"body": "# Liability waiver\\n\\nI, {{participant}}, understand that riding carries risk…",
"body_format": "markdown",
"variables": [
"participant"
],
"grants": {},
"consent_text": "I agree to sign this electronically.",
"fields": [
{
"key": "emergency_contact",
"label": "Emergency contact",
"kind": "text",
"required": true
}
],
"has_pdf": false,
"documents": 0,
"change_note": "Asks for an emergency contact.",
"settings": {
"one_time": false,
"show_address": true,
"photos_in_copy": true,
"valid_months": 12,
"remind_days": 14,
"resign_from_version": null
},
"versions": [
{
"template_id": "93aa…",
"version": 3,
"is_published": false,
"published_at": null,
"name": "Liability waiver",
"created_at": "2026-10-03T14:22:09Z",
"change_note": "Asks for an emergency contact.",
"has_pdf": false,
"documents": 0
},
{
"template_id": "8c2e…",
"version": 2,
"is_published": true,
"published_at": "2026-10-03T14:22:09Z",
"name": "Liability waiver",
"created_at": "2026-10-03T14:22:09Z",
"change_note": "Names the e-bike and asks for an emergency contact.",
"has_pdf": false,
"documents": 118
},
{
"template_id": "51d7…",
"version": 1,
"is_published": true,
"published_at": "2025-04-02T09:10:00Z",
"name": "Liability waiver",
"created_at": "2025-04-02T09:02:00Z",
"change_note": null,
"has_pdf": false,
"documents": 342
}
]
}
POST /v1/templates/{id}/publish
Publish a draft
From now on documents made from this template's code use this version, and its text can no longer change. Refused with 422, naming what is wrong, when the body uses a placeholder the template does not declare, while its text still has a note for your attorney ([Attorney: …], as the starters, the library and the drafter write them: settle it and take it out first; 00600), when an uploaded PDF has pages SignSealer has not yet drawn from the file, or when it is already published. Answers with the template.
{
"template_id": "8c2e…",
"code": "liability-waiver",
"name": "Liability waiver",
"kind": "waiver",
"version": 2,
"is_published": true,
"published_at": "2026-10-03T14:22:09Z",
"is_latest": true,
"created_at": "2026-10-03T14:22:09Z",
"body": "# Liability waiver\\n\\nI, {{participant}}, understand that riding carries risk…",
"body_format": "markdown",
"variables": [
"participant"
],
"grants": {},
"consent_text": "I agree to sign this electronically.",
"fields": [
{
"key": "emergency_contact",
"label": "Emergency contact",
"kind": "text",
"required": true
}
],
"has_pdf": false,
"documents": 118,
"change_note": "Names the e-bike and asks for an emergency contact.",
"settings": {
"one_time": false,
"show_address": true,
"photos_in_copy": true,
"valid_months": 12,
"remind_days": 14,
"resign_from_version": null
},
"versions": [
{
"template_id": "8c2e…",
"version": 2,
"is_published": true,
"published_at": "2026-10-03T14:22:09Z",
"name": "Liability waiver",
"created_at": "2026-10-03T14:22:09Z",
"change_note": "Names the e-bike and asks for an emergency contact.",
"has_pdf": false,
"documents": 118
},
{
"template_id": "51d7…",
"version": 1,
"is_published": true,
"published_at": "2025-04-02T09:10:00Z",
"name": "Liability waiver",
"created_at": "2025-04-02T09:02:00Z",
"change_note": null,
"has_pdf": false,
"documents": 342
}
]
}
POST /v1/templates/{id}/revise
Make the next version of a template
Copies the newest version of this template's code into a new draft, one version higher, with any of name, body, variables, fields and change_note (what changed, kept in its history) changed; publish it to put it into use. Documents already sent keep the version they were sent on. The id must be the newest version: revising an older one answers 409 with the newest version's id in the hint. A draft is refused too -- change it with PATCH instead. Text on an uploaded PDF cannot change; its name and questions can.
| Field | Type | Constraints | |
|---|---|---|---|
| name | string | optional | 1–200 chars |
| body | string | optional | 1–∞ chars |
| variables | string[] | optional | |
| fields | object[] | optional | 0–50 items |
| fields[].key | string | required | matches ^[a-z][a-z0-9_]{0,58}$ |
| fields[].label | string | optional | 1–200 chars |
| fields[].kind | string | required | 1–20 chars |
| fields[].required | boolean | optional | |
| fields[].sensitive | boolean | optional | |
| fields[].supplied_by | "adult" | "per_child" | "staff" | "business" | optional | |
| fields[].options | string[] | optional | 0–30 items |
| fields[].hint | string | optional | 0–200 chars |
| fields[].show_if | object | null | optional | |
| fields[].flag_when | object | null | optional | |
| fields[].block | string | null | optional | 0–59 chars |
| fields[].block_label | string | null | optional | 0–200 chars |
| fields[].block_intro | string | null | optional | 0–2000 chars |
| fields[].block_step | boolean | null | optional | |
| fields[].place | object | null | optional | |
| change_note | string | optional | 0–500 chars |
{
"template_id": "93aa…",
"code": "liability-waiver",
"name": "Liability waiver",
"kind": "waiver",
"version": 3,
"is_published": false,
"published_at": null,
"is_latest": true,
"created_at": "2026-10-03T14:22:09Z",
"body": "# Liability waiver\\n\\nI, {{participant}}, understand that riding carries risk…",
"body_format": "markdown",
"variables": [
"participant"
],
"grants": {},
"consent_text": "I agree to sign this electronically.",
"fields": [
{
"key": "emergency_contact",
"label": "Emergency contact",
"kind": "text",
"required": true
}
],
"has_pdf": false,
"documents": 0,
"change_note": "Asks for an emergency contact.",
"settings": {
"one_time": false,
"show_address": true,
"photos_in_copy": true,
"valid_months": 12,
"remind_days": 14,
"resign_from_version": null
},
"versions": [
{
"template_id": "93aa…",
"version": 3,
"is_published": false,
"published_at": null,
"name": "Liability waiver",
"created_at": "2026-10-03T14:22:09Z",
"change_note": "Asks for an emergency contact.",
"has_pdf": false,
"documents": 0
},
{
"template_id": "8c2e…",
"version": 2,
"is_published": true,
"published_at": "2026-10-03T14:22:09Z",
"name": "Liability waiver",
"created_at": "2026-10-03T14:22:09Z",
"change_note": "Names the e-bike and asks for an emergency contact.",
"has_pdf": false,
"documents": 118
},
{
"template_id": "51d7…",
"version": 1,
"is_published": true,
"published_at": "2025-04-02T09:10:00Z",
"name": "Liability waiver",
"created_at": "2025-04-02T09:02:00Z",
"change_note": null,
"has_pdf": false,
"documents": 342
}
]
}
POST /v1/templates/{id}/restore
Bring an older version back as a new draft
Makes a new draft, one version above the newest, carrying this version's text, questions, consent wording, format and uploaded file; its change_note says Restored from version N, followed by yours if you give one. Publish it to put it into use. The older version keeps its number and its signatures, and so does every version since. Refused with 422 for the newest version (revise it instead), for a draft, and while the template has an open draft (publish or discard it first).
| Field | Type | Constraints | |
|---|---|---|---|
| change_note | string | optional | 0–450 chars |
{
"template_id": "c4f0…",
"code": "liability-waiver",
"name": "Liability waiver",
"kind": "waiver",
"version": 3,
"is_published": false,
"published_at": null,
"is_latest": true,
"created_at": "2026-10-03T14:22:09Z",
"body": "I, {{participant}}, understand that riding carries risk…",
"body_format": "text",
"variables": [
"participant"
],
"grants": {},
"consent_text": "I agree to sign this electronically.",
"fields": [],
"has_pdf": false,
"documents": 0,
"change_note": "Restored from version 1: the shorter wording tested better at the counter.",
"settings": {
"one_time": false,
"show_address": true,
"photos_in_copy": true,
"valid_months": 12,
"remind_days": 14,
"resign_from_version": null
},
"versions": [
{
"template_id": "c4f0…",
"version": 3,
"is_published": false,
"published_at": null,
"name": "Liability waiver",
"created_at": "2026-10-03T14:22:09Z",
"change_note": "Restored from version 1: the shorter wording tested better at the counter.",
"has_pdf": false,
"documents": 0
},
{
"template_id": "8c2e…",
"version": 2,
"is_published": true,
"published_at": "2026-10-03T14:22:09Z",
"name": "Liability waiver",
"created_at": "2026-10-03T14:22:09Z",
"change_note": "Names the e-bike and asks for an emergency contact.",
"has_pdf": false,
"documents": 118
},
{
"template_id": "51d7…",
"version": 1,
"is_published": true,
"published_at": "2025-04-02T09:10:00Z",
"name": "Liability waiver",
"created_at": "2025-04-02T09:02:00Z",
"change_note": null,
"has_pdf": false,
"documents": 342
}
]
}
DELETE /v1/templates/{id}
Discard a draft
Only a draft with no documents made from it. A published version is kept for as long as anything signed on it is, so it answers 422; to stop using a template, revise it or stop starting it. Discarding the only version of a code frees the code.
{
"template_id": "93aa…",
"code": "liability-waiver",
"version": 3,
"discarded": true
}
PATCH /v1/templates/{id}/settings
Change a template's settings
Settings belong to the template's code, not one version, so they carry over to every version after: one_time (made to send once, not reused), show_address (the business's address printed under it), photos_in_copy (the photographs a signer takes as answers are printed, each with its SHA-256, on pages after the certificate in the signed PDF; on unless turned off, and the certificate keeps every hash either way), valid_months (how long a signature stays good; null for never), remind_days (days before the end each signer is sent a link to sign again; needs valid_months) and resign: true (everybody who signed an older version must sign the newest published one; false lifts that). Answers with the template.
| Field | Type | Constraints | |
|---|---|---|---|
| one_time | boolean | optional | |
| show_address | boolean | optional | |
| photos_in_copy | boolean | optional | |
| valid_months | integer | null | optional | 1–120 |
| remind_days | integer | null | optional | 1–90 |
| resign | boolean | optional |
{
"template_id": "8c2e…",
"code": "liability-waiver",
"name": "Liability waiver",
"kind": "waiver",
"version": 2,
"is_published": true,
"published_at": "2026-10-03T14:22:09Z",
"is_latest": true,
"created_at": "2026-10-03T14:22:09Z",
"body": "# Liability waiver\\n\\nI, {{participant}}, understand that riding carries risk…",
"body_format": "markdown",
"variables": [
"participant"
],
"grants": {},
"consent_text": "I agree to sign this electronically.",
"fields": [
{
"key": "emergency_contact",
"label": "Emergency contact",
"kind": "text",
"required": true
}
],
"has_pdf": false,
"documents": 118,
"change_note": "Names the e-bike and asks for an emergency contact.",
"settings": {
"one_time": false,
"show_address": true,
"photos_in_copy": true,
"valid_months": 12,
"remind_days": 14,
"resign_from_version": null
},
"versions": [
{
"template_id": "8c2e…",
"version": 2,
"is_published": true,
"published_at": "2026-10-03T14:22:09Z",
"name": "Liability waiver",
"created_at": "2026-10-03T14:22:09Z",
"change_note": "Names the e-bike and asks for an emergency contact.",
"has_pdf": false,
"documents": 118
},
{
"template_id": "51d7…",
"version": 1,
"is_published": true,
"published_at": "2025-04-02T09:10:00Z",
"name": "Liability waiver",
"created_at": "2025-04-02T09:02:00Z",
"change_note": null,
"has_pdf": false,
"documents": 342
}
]
}
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 SignSealer has drawn a picture of from the file itself (only those are shown to a signer), and ready is whether those agree — a PDF template cannot be published until they do. The pages are drawn on our side as the file is uploaded, and any that missed it within a minute; drawing_failed says why, when the file could not be drawn. 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,
"drawing_failed": null,
"pages": [
{
"n": 1,
"width": 612,
"height": 792,
"rotation": 0
}
],
"bytes": 284113,
"sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"original_name": "tenancy.pdf",
"uploaded_at": "2026-10-03T14:22:09Z"
}
Standing
GET /v1/standing
Whether somebody's signature on a template is still good
Pass template (the template's code) and email or phone. Answers with that person's newest completed signature on any version of the template: standing is current (inside the template's period), expired (past it), resign (signed on a version the business has since asked everybody to sign again), or on_file (signed, and the template has no period); valid_until is the day it runs out. found: false means they have never signed it, which is the answer "send them the form", not an error. The period is set per template in the dashboard, to match what the document's wording says.
| Parameter | Type | What it is | |
|---|---|---|---|
| template | string | required | The template's code. |
| string (email) | optional | Whose standing, by email address. This or phone. |
|
| phone | string | optional | Whose standing, by mobile number. This or email. |
{
"template": "liability-waiver",
"found": true,
"document_id": "c30b…",
"signed_at": "2026-10-03T14:22:09Z",
"template_version": 2,
"standing": "current",
"valid_until": "2027-10-03T14:22:09Z"
}
Documents
GET /v1/documents
List documents
Newest first. Filter with status (a state, or open for draft and sent), q (title, subject or signer) and external_ref (your own reference, exactly). Page with limit (1–200, 50 by default; more than 200 gets 200) and offset (0 by default); total is how many match, and has_more whether another page follows. Either paging value that is not a whole number is a 400 saying which. A signed document carries standing and valid_until (00406): whether it is still good, by the period set on its template -- current, expired, resign, renewed or on_file.
| Parameter | Type | What it is | |
|---|---|---|---|
| status | string | optional | A state -- draft, sent, completed, declined, expired, void -- or open for draft and sent. |
| q | string | optional | Part of a title, a subject's name, or a signer's name, email or number. |
| external_ref | string | optional | Your own reference, as you gave it when you made the document. Exact. |
| limit | integer | optional | 1 to 200; 50 when absent. |
| offset | integer | optional | How many to skip; 0 when absent. |
{
"documents": [
{
"document_id": "d41c…",
"title": "Liability waiver — Dana Reyes",
"status": "sent",
"template_code": "liability-waiver",
"template_version": 2,
"subject_name": null,
"subject_party_id": 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,
"integrity_ok": true,
"standing": null,
"valid_until": null,
"external_ref": null,
"total": "2"
},
{
"document_id": "c30b…",
"title": "Liability waiver — Sam Lee",
"status": "completed",
"template_code": "liability-waiver",
"template_version": 2,
"subject_name": null,
"subject_party_id": null,
"signers": 1,
"signed": 1,
"waiting_on": null,
"sequential": false,
"expires_at": null,
"sent_at": "2026-10-03T14:22:09Z",
"completed_at": "2026-10-03T14:22:09Z",
"void_reason": null,
"integrity_ok": true,
"standing": "current",
"valid_until": "2027-10-03T14:22:09Z",
"external_ref": "booking-20931",
"total": "2"
}
],
"total": 2,
"has_more": false,
"limit": 50,
"offset": 0
}
POST /v1/documents/from-pdf
Prepare a document from a PDF and the places to sign it
Needs signing:templates and signing:send on the key (signing:write covers the second). pdf_base64 is the file, up to 12 MB and 60 pages. It is read, sanitised (anything in it that could act on its own is taken out) and drawn exactly as an upload in the dashboard is, kept as a one-time template -- listed apart from the templates the business reuses -- published, and the document is prepared from it, as a draft. Each box in fields has a kind (signature, initials, date, text, checkbox), a recipient -- the sign_order you will give that signer on POST /v1/documents/{id}/send -- and its page and x, y, w, h as fractions of the page as a reader sees it, from its top-left. With text_tags: true, boxes are also found from tags in the file's words: {{signature:1}}, {{initials:2}}, {{date:1}}, {{text:1:Job number}}, {{checkbox:2:I have read the terms}} (or s, i, d, t, c), each one run of text, ideally white so it does not show under the box. A box is that recipient's alone. A signature box is filled with their signature and a date box (labelled Date signed, the default) with the day they sign; the others are asked on their signing page. Then send it with POST /v1/documents/{id}/send: each signer's sign_order is their recipient number, and a send that leaves a recipient's boxes with no signer is refused with 422. external_ref, metadata and return_url are as on POST /v1/documents. Refused with 422 when the file is not a PDF, cannot be read, has a note left for an attorney in its words, or a box does not fit its page.
| Field | Type | Constraints | |
|---|---|---|---|
| title | string | required | 1–200 chars |
| pdf_base64 | string | required | 8–∞ chars |
| filename | string | optional | 0–200 chars |
| fields | object[] | optional | 0–200 items |
| fields[].kind | "signature" | "initials" | "date" | "text" | "checkbox" | required | |
| fields[].recipient | integer | required | 1–50 |
| fields[].key | string | optional | 0–59 chars |
| fields[].label | string | optional | 1–200 chars |
| fields[].required | boolean | optional | |
| fields[].page | integer | required | 1–∞ |
| fields[].x | number | required | 0–1 |
| fields[].y | number | required | 0–1 |
| fields[].w | number | required | 0–1 |
| fields[].h | number | required | 0–1 |
| text_tags | boolean | optional | |
| expires_at | string (date-time) | optional | |
| sequential | boolean | optional | |
| external_ref | string | optional | 0–200 chars |
| metadata | object | optional | |
| return_url | string (uri) | optional | 0–2000 chars |
{
"document_id": "d41c…",
"template_id": "t9e2…",
"template_code": "pdf-3fa91c0b2d7e",
"pages": 2,
"sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"recipients": [
1,
2
],
"fields": [
{
"key": "signature_1",
"kind": "signature",
"recipient": 1,
"label": "Signature",
"required": true,
"place": {
"page": 2,
"x": 0.12,
"y": 0.71,
"w": 0.27,
"h": 0.045
},
"from": "tag"
},
{
"key": "date_signed_1",
"kind": "date",
"recipient": 1,
"label": "Date signed",
"required": true,
"place": {
"page": 2,
"x": 0.45,
"y": 0.725,
"w": 0.15,
"h": 0.023
},
"from": "tag"
},
{
"key": "signature_2",
"kind": "signature",
"recipient": 2,
"label": "Signature",
"required": true,
"place": {
"page": 2,
"x": 0.12,
"y": 0.82,
"w": 0.27,
"h": 0.045
},
"from": "given"
}
],
"removed": []
}
GET /v1/documents/{id}
Show one document
The document with its text and body_sha256_hex, its signers (each with the assurance their signature carries), the whole event trail -- each event with matches_current_text, false when it was made against different text than the document holds now -- and every answer the signers gave. external_ref, metadata and return_url are what you set when you made it. answers lists each question in the order the form asks it, with the signer it belongs to. asked says whether the question was on that signer's page -- a question hidden by its condition is asked: false, has no value, and is never flagged. flagged on an answer means the template's flag rule says a person should read it; flagged on the document counts them. A question marked sensitive has its value masked, as it is for the business's own staff; a redacted document keeps its questions and gives up every value. Answers are here and not in webhooks on purpose: a webhook goes to whatever address was typed, and some answers are about somebody's health.
{
"document_id": "d41c…",
"title": "Liability waiver — Dana Reyes",
"status": "completed",
"body": "I, Dana Reyes, …",
"body_format": "text",
"body_sha256_hex": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"template_code": "liability-waiver",
"template_version": 2,
"template_kind": "waiver",
"grants": {},
"subject_name": null,
"subject_party_id": null,
"sequential": false,
"expires_at": null,
"sent_at": "2026-10-03T14:22:09Z",
"completed_at": "2026-10-03T14:22:09Z",
"voided_at": null,
"void_reason": null,
"integrity_ok": true,
"redacted_at": null,
"redaction_reason": null,
"standing": "current",
"valid_until": "2027-10-03T14:22:09Z",
"renewed_by": null,
"signers": [
{
"signer_id": "5f0a…",
"email": "dana@example.signsealer.com",
"phone": null,
"full_name": "Dana Reyes",
"role": "signer",
"sign_order": 1,
"status": "signed",
"party_id": null,
"grants": false,
"consent_ref": "consent/1",
"consented_at": "2026-10-03T14:22:09Z",
"assurance": "email_link",
"verified_at": null,
"signed_at": "2026-10-03T14:22:09Z",
"declined_at": null,
"decline_reason": null,
"token_expires_at": "2026-10-17T00:00:00Z",
"for_business": false
}
],
"events": [
{
"event_id": "e1…",
"kind": "sent",
"occurred_at": "2026-10-03T14:22:09Z",
"signer_email": null,
"ip": null,
"user_agent": null,
"signature_method": null,
"signature_value": null,
"matches_current_text": true,
"detail": {}
},
{
"event_id": "e2…",
"kind": "consented",
"occurred_at": "2026-10-03T14:22:09Z",
"signer_email": "dana@example.signsealer.com",
"ip": "203.0.113.9/32",
"user_agent": "Mozilla/5.0 …",
"signature_method": null,
"signature_value": null,
"matches_current_text": true,
"detail": {
"consent": {
"ref": "consent/1",
"text": "By continuing you agree to sign this document electronically …",
"sha256": "5ae7…"
}
}
},
{
"event_id": "e3…",
"kind": "signed",
"occurred_at": "2026-10-03T14:22:09Z",
"signer_email": "dana@example.signsealer.com",
"ip": "203.0.113.9/32",
"user_agent": "Mozilla/5.0 …",
"signature_method": "typed",
"signature_value": "Dana Reyes",
"matches_current_text": true,
"detail": {
"assurance": "email_link",
"intent": {
"ref": "intent/1",
"text": "By signing below I confirm …",
"sha256": "ccf3…"
}
}
}
],
"answers": [
{
"signer_id": "5f0a…",
"key": "rode_before",
"label": "Have you ridden before?",
"kind": "choice",
"block": {
"key": "experience",
"label": "Your riding"
},
"about": null,
"required": true,
"sensitive": false,
"asked": true,
"value": "No",
"supplied_at": "2026-10-03T14:22:09Z",
"flagged": true
},
{
"signer_id": "5f0a…",
"key": "lessons",
"label": "How many lessons have you had?",
"kind": "number",
"block": {
"key": "experience",
"label": "Your riding"
},
"about": null,
"required": true,
"sensitive": false,
"asked": false,
"value": null,
"supplied_at": null,
"flagged": false
},
{
"signer_id": "5f0a…",
"key": "medical",
"label": "Anything we should know medically?",
"kind": "paragraph",
"block": null,
"about": null,
"required": false,
"sensitive": true,
"asked": true,
"value": "••••••••••ergy",
"supplied_at": "2026-10-03T14:22:09Z",
"flagged": false
}
],
"flagged": 1,
"external_ref": "booking-20931",
"metadata": {
"lesson": "Saturday 10am"
},
"return_url": null
}
GET /v1/documents/{id}/signers
Who has signed one document
The gate's question (00340): each signer's name, role and state, and nothing else -- no text, no addresses, no event trail. The route a paired tablet reads; the full document is GET /v1/documents/:id. Once it is signed, standing says whether it is still good (00406): current, expired, resign (the terms changed since), renewed (the same person has signed it again since) or on_file (no period), with valid_until. link_allowed says whether this caller may mint a fresh link for it (00409): always for a key, and for a paired tablet only on a document it or its form started, unless the business let that tablet show links for every open document.
{
"document_id": "d41c…",
"title": "Liability waiver — Dana Reyes",
"status": "sent",
"sent_at": "2026-10-03T14:22:09Z",
"completed_at": null,
"standing": null,
"valid_until": null,
"link_allowed": true,
"signers": [
{
"signer_id": "5f0a…",
"full_name": "Dana Reyes",
"role": "signer",
"sign_order": 1,
"status": "signed",
"signed_at": "2026-10-03T14:22:09Z",
"declined_at": null
},
{
"signer_id": "6a1b…",
"full_name": "Sam Manager",
"role": "signer",
"sign_order": 2,
"status": "pending",
"signed_at": null,
"declined_at": null
}
]
}
POST /v1/documents/{id}/signers/{signer}/link
A fresh signing link for a signer who has not signed, as a QR code
The last check at the gate (00340). Mints a new link for the signer -- the one they were sent stops working -- and returns it with an SVG QR code to hold up, so a customer who never signed at the counter scans it and signs on their own phone. Recorded on the trail as link_shown. Refused for a signer who has signed or declined, a copied recipient, or a document that is not open; ten a minute per document.
{
"document_id": "d41c…",
"signer_id": "5f0a…",
"url": "https://api.signsealer.com/s/9k3…",
"qr_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" …>…</svg>"
}
POST /v1/documents/{id}/signers/{signer}/remind
Send a signer a reminder with a fresh link
Emails (or texts, where they agreed to texts) a new link to one signer who has not signed, and the link they had stops working. Works for an embedded signer too, because you asked by name. Refused with 422 inside an hour of their last reminder, past three reminders to one signer in a day or twenty-five for one document in a day (the automatic reminders and the dashboard's Remind count too), for a signer who has signed or declined, a copied recipient, a document that is not out for signature, somebody there is no way to reach, or a signer held for their turn on a document signed in order (00609: their link is sent when the people before them have signed) -- and in every one of those cases their link is left as it was. A signer is texted at most one reminder over the life of their link, because the words they agreed to before we texted them say "at most one reminder": after that one, counting the dashboard's, the automatic one and a party's link sent again, a reminder goes by email alone (by: "email"), and for a signer with no email address we may use it is refused with 422, saying so. reminded: true means the reminder was queued, not that it arrived: like every message to somebody outside the business it is screened before it leaves and may be held for review (see POST /v1/documents/{id}/send).
{
"document_id": "d41c…",
"signer_id": "5f0a…",
"reminded": true,
"by": "email"
}
POST /v1/documents/{id}/signers/{signer}/embed
A fresh signing link to show in a frame
Mints a new link for a signer who has not signed -- the one they had stops working -- and answers with its url and frame_origins: the sites the signing page may be framed on, which are the ones the business has proven (a verified signing domain, a proven company sign-in domain, or the site of an app it connected, other than an AI assistant). Put the url in an <iframe> on one of them; anywhere else the browser refuses to show it, and an empty list means nowhere yet. Inside a frame the page tells its parent what happened with postMessage: {source: "signsealer", event, document_id, signer_id, height}, where event is ready on every page, signed once they have signed, and declined. Recorded on the trail as link_shown; ten a minute per document.
{
"document_id": "d41c…",
"signer_id": "7b21…",
"url": "https://sign.fernhollow.com/s/b91d…",
"frame_origins": [
"https://sign.fernhollow.com",
"https://*.sign.fernhollow.com",
"https://fernhollow.com",
"https://*.fernhollow.com"
],
"expires_at": "2026-11-02T14:22:09Z"
}
POST /v1/documents
Prepare a document from a template
Renders the newest published version of the template with the values given and stores the result on the document, as a draft. Every declared variable needs a value: a contract that ships with a placeholder still in it is worse than one that failed to send. Nor is a document made whose text still has a note for your attorney ([Attorney: …]), from a template published before such notes were refused at publishing: 422, quoting the note (00600). The exception is the signing date -- date and its synonyms (today, todays_date, date_signed, signed_date, signed_on, signing_date, sign_date): it is never supplied, a value sent for it is ignored, and the text keeps {{date}} until the first signature writes in that day, in the business's timezone. On an uploaded PDF, a question marked staff and placed on the page is filled in by whoever sends the document: give it under values, keyed as the question is (a date as YYYY-MM-DD). It is checked like an answer, stored as every signer's answer before they sign, shown on their page and not theirs to change, and drawn on the sealed PDF; a required one that is missing is refused with 422, naming it. external_ref is your own reference (an order number, a booking id), up to 200 characters: every webhook about the document carries it back as document.metadata.external_ref, and GET /v1/documents?external_ref= finds it. metadata is up to 20 names of your own with text values; it comes back as document.metadata.custom. return_url is where the signed page offers to take the signer next -- https, on a host the business has proven. time_zone is the zone the document's times are in, as a zone name (America/Denver): a value for a time blank (start_time, rental_starts) given as 14:30 or 2026-10-03T09:00 is written into the text in words with the zone named ("October 3, 2026 at 9:00 am MDT"), and a time a signer gives later is shown the same way. A zone the engine does not know is refused. Without it, values go into the text exactly as sent.
| 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 | |
| external_ref | string | optional | 1–200 chars |
| metadata | object | optional | |
| return_url | string (uri) | optional | 0–2000 chars |
| time_zone | string | optional | 1–64 chars |
{
"document_id": "d41c…"
}
POST /v1/documents/{id}/send
Send a document for signature
Returns one signing link per signer, once: token, and url, the page to send them to. The tokens are not stored, only their hashes, so a lost link is replaced rather than looked up -- by POST /v1/documents/{id}/signers/{signer}/remind, which sends a new one, or .../embed, which hands you one. Each signer is emailed or texted their link unless you send them with delivery: "embedded": then nothing is sent to them, and the automatic reminders leave them alone, because you are showing them the page yourself -- in a frame on your site, or in your app. They still need an email address or a mobile number: the certificate records how they could be reached, and the completed copy goes to them. On a document prepared with sequential: true, only the first step is sent its links now. A later step's signer is held: their token and url are null and held is true, and their link is made and sent to them when every signer before them has signed (00609). An embedded signer is not held, and nor is anybody when the business sends its own links. Every link that goes out by email or text counts against the business's sending allowance for the hour and the day, which grows with how far the business is trusted (/docs/limits). A send that would cross it is refused whole with 429 rate_limited and Retry-After, and nobody on it is sent anything. A 200 here says the links were made and the messages queued, not that they arrived. Every message to somebody outside the business is screened before it leaves: it may be held for review -- usually a few hours -- or, rarely, not sent at all, and the business is told on the document's page in the dashboard. Nothing in this answer, and nothing else in the API yet, changes when that happens: signing.delivered says the email carrying a signer's link reached their mail server, signing.viewed is the first sign that somebody opened it, and the evidence bundle, once the document completes, lists every message and whether it arrived.
| Field | Type | Constraints | |
|---|---|---|---|
| signers | object[] | required | 1–50 items |
| signers[].email | string (email) | optional | |
| signers[].phone | string | optional | 7–40 chars |
| signers[].full_name | string | required | 1–200 chars |
| signers[].role | "signer" | "approver" | "witness" | "cc" | optional | |
| signers[].sign_order | integer | optional | 1–50 |
| signers[].party_id | string (uuid) | null | optional | |
| signers[].delivery | "send" | "embedded" | optional | |
| signers[].for_business | boolean | optional | |
| signers[].application | object | optional | |
| signers[].application.id | string (uuid) | required | |
| signers[].application.subject | string | required | 1–200 chars |
{
"links": [
{
"signer_id": "5f0a…",
"email": "dana@example.signsealer.com",
"phone": "+16155550142",
"delivery": "send",
"for_business": false,
"token": "3f9c…44 base64 characters",
"url": "https://sign.fernhollow.com/s/3f9c…"
},
{
"signer_id": "7b21…",
"email": "sam@example.signsealer.com",
"phone": null,
"delivery": "embedded",
"for_business": false,
"token": "a08e…44 base64 characters",
"url": "https://sign.fernhollow.com/s/a08e…"
},
{
"signer_id": "9c42…",
"email": "sam@example.signsealer.com",
"phone": null,
"delivery": "send",
"for_business": true,
"token": null,
"url": null,
"held": true
}
]
}
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.signsealer.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/32"
}
}
],
"events": [
{
"kind": "signed",
"occurred_at": "2026-10-03T14:22:09Z",
"signer": "dana@example.signsealer.com",
"ip": "203.0.113.9/32",
"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. Sealed once, when the document completed, and kept: every call returns the same bytes, and their SHA-256 is what GET /v1/verify/:code?sha256= matches. Returns base64 bytes and the filename to save them under. kept is false in the rare case the copy could not be kept: that file is recorded and still verifies, and a later call may return a different one, which is kept.
{
"filename": "certificate-K7QP3MTV9XBD2FHR.pdf",
"content_type": "application/pdf",
"seal": {
"commonName": "SignSealer",
"selfIssued": true,
"trust": "unverified",
"timestamped": true
},
"kept": 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
}
],
"asked_fields": [
{
"key": "emergency_contact",
"label": "Emergency contact",
"required": true
}
],
"sender_fields": [],
"document_after_questions": false,
"time_zone": "America/Denver",
"each_person_signs": false,
"party_ages": "tick",
"majority_age": 18,
"minors_also_sign": false,
"party_confirm_code": false,
"party_documents": "one",
"email_check": true,
"stop_after_signed": null,
"closes_on": "2026-12-31",
"once_within_days": null,
"closed": null,
"needs_attention": null,
"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 every fixed role, and returns the tokens once: the participant's link comes back to the caller, to hand the person on. On a workflow signed in order, a fixed role whose turn has not come is held: their token is null and held is true, and they are sent their link when the people before them have signed (00609). Nobody is held when the business sends its own links (sending turned off in the dashboard): every token comes back now, and handing each one over in its turn is yours. With send_link: true SignSealer sends the participant their link too, by email and by text where it may text the number, and the answer says whether it went (link_sent, false when the business delivers its own links); for a start made for somebody who is not there (00601). A fixed role with a show_if (00541) is on the document only when the form's answers in fields meet it; the roles they did not need are listed under not_needed. An answer to a form question with a kind is checked as a template's answer is, and one that is not what its question is (a date of birth that is not a date) is refused with 422, naming the question. A tablet reconciling a start it ran with no network names expected_template_version (refused with 409 template_changed if the published form has moved on, so nothing is sealed against words nobody read) and captured (its own clock, kept as a claim with the skew computed); it sends the date it showed under values so the frozen text carries the day the customer was there, kept only when it is a day in words within a day of the tablet's clock and not after today. captured from anything but a paired tablet is refused with 422. A start the business cannot take (Free's signings for the month used, a balance that does not cover the next, its own spending limit reached, or a stopped subscription) is refused with 402 quota_exceeded and nothing is made; a tablet's start made with no network is answered 429 allowance_reached with Retry-After instead, to keep and send again, because the person has already signed (00681). From any other caller a date (or a synonym) under values is ignored: the text keeps {{date}} and the first signature writes in its day. On an uploaded PDF, a box filled in when it is sent (a question marked staff and placed on the page) is taken from fields or values under its key; a required one that is missing is refused, naming it. return_url is where the signed page sends the person afterwards: https, on a domain this business has proven (a verified signing domain, a proven company sign-in domain, or the site of a connected app), else 422.
| 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 |
| send_link | boolean | optional | |
| expected_template_version | integer | optional | 0–∞ |
| captured | object | optional | |
| captured.offline | boolean | required | |
| captured.at | string (date-time) | required | |
| captured.device | string | required | 0–120 chars |
| return_url | string (uri) | optional | 0–2000 chars |
{
"document_id": "d41c…",
"title": "Kayak rental waiver — Dana Reyes",
"expires_at": "2026-10-10T14:22:09Z",
"template_version": 3,
"party_size": null,
"signers": [
{
"signer_id": "5f0a…",
"email": "dana@example.signsealer.com",
"phone": null,
"full_name": "Dana Reyes",
"role": "signer",
"participant": true,
"for_business": false,
"token": "3f9c…"
},
{
"signer_id": "5f0b…",
"email": "manager@fernhollow.example",
"phone": null,
"full_name": "Sam Manager",
"role": "signer",
"participant": false,
"for_business": true,
"token": "7a21…"
}
]
}
GET /v1/workflows/{id}/bundle
Everything needed to run a workflow offline
The workflow's fields and roles, the published template's text, variables and version, the consent and intent wording a signer is shown with their versions, the business's name, and the pages of an uploaded PDF (fetch each at /v1/workflows/{id}/pages/{n}). A tablet keeps this so it can start a signing with no network, then reconciles the start naming the version it rendered. condition_zone is the zone whose calendar day a document started from it reads its conditions on (an age, for one).
{
"workflow": {
"id": "w9b1…",
"code": "waiver",
"name": "Kayak rental waiver",
"kind": "waiver",
"intro": "Please read and sign before launch.",
"form_fields": [
{
"key": "boat",
"label": "Boat",
"required": true,
"derived": true
}
],
"signer_roles": [
{
"role": "signer",
"label": "Participant",
"participant": true,
"order": 1
}
],
"sequential": false,
"countersign": false
},
"template": {
"id": "t7d2…",
"code": "kayak-waiver",
"name": "Kayak rental waiver",
"kind": "waiver",
"version": 3,
"variables": [
"participant_name",
"date",
"boat"
],
"body": "Between {{business_name}} and {{participant_name}}, on {{date}}. Boat: {{boat}}. …",
"body_format": "text",
"body_sha256": "9c1e…"
},
"consent": {
"text": "By continuing you agree to sign this document electronically …",
"version": "consent/1"
},
"intent": {
"text": "By signing below I confirm that I have read this document, …",
"version": "intent/1"
},
"id_evidence": {
"offered": "off",
"id_disclosure": null,
"selfie_disclosure": null
},
"location": {
"offered": "off",
"disclosure": null
},
"photos": [],
"questions": [
{
"key": "helmet",
"label": "Will you wear the helmet provided?",
"kind": "checkbox",
"required": true,
"hint": null,
"options": null,
"show_if": null,
"show_if_rules": null,
"block": null,
"block_label": null,
"block_intro": null,
"block_step": null,
"placed": false
}
],
"document_after_questions": false,
"condition_zone": "America/Denver",
"business_name": "Harbour Rentals",
"pages": [],
"fetched_at": "2026-09-16T09:48:15Z"
}
GET /v1/workflows/{id}/pages/{n}
One page of a workflow's PDF template, as an image
The published template's page n as a PNG, for a tablet keeping the form on the device: base64 bytes and the content type, like the PDF routes. 404 for a text template or a page it does not have. A published template's pages do not change, so the tablet may keep the file.
{
"page": 1,
"content_type": "image/png",
"bytes_base64": "iVBORw0KGgo…"
}
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 — and, from 00420, workflows: every form it runs, in the order its kiosk lists them — so a change made on the fleet screen reaches the tablet without re-pairing; and update: the newest Android build available with where to get it, and where this tablet stands against the version rules set in Ops (required below the minimum, recommended_now below the recommended version). A business on the preview list is offered the preview build, when one newer than the shipped build exists, and channel says preview.
| Field | Type | Constraints | |
|---|---|---|---|
| app_version | string | optional | 0–40 chars |
| os_version | string | optional | 0–40 chars |
| queue_waiting | integer | optional | 0–100000 |
| queue_refused | integer | optional | 0–100000 |
| synced | boolean | optional |
{
"device_id": "de91…",
"name": "Front counter iPad",
"platform": "android",
"workflow_id": "wf3c…",
"workflow_name": "River float waiver",
"hand_off": "separate",
"waiting": [
{
"packet_id": "p6c9…",
"code": "ABCD234567",
"since": "2026-10-03T14:22:09Z",
"outstanding": 2,
"who": [
"Dana R.",
"Sam L."
]
}
],
"workflows": [
{
"id": "wf3c…",
"name": "River float waiver",
"kind": "waiver",
"active": true
},
{
"id": "wf7a…",
"name": "Campsite rules",
"kind": "policy",
"active": true
}
],
"latest_android_version": "1.2.0",
"download_url": "https://app.signsealer.com/devices",
"update": {
"latest": "1.2.0",
"channel": "stable",
"download_url": "https://app.signsealer.com/devices",
"minimum": "1.1.0",
"recommended": "1.2.0",
"required": false,
"recommended_now": true,
"announce": true,
"preview": false
}
}
POST /v1/devices/report
Report a problem from a tablet
Names and numbers, never text (00341): a code such as bundle_page_missing, the screen and step the tablet was on, whether it was online, how many items were waiting to send, and the HTTP status if a request was refused. There is no field for a message. Kept against the tablet for ninety days, shown on the fleet screens, and forwarded to error reporting with the same fields and no others. Thirty an hour per tablet.
| Field | Type | Constraints | |
|---|---|---|---|
| code | string | required | matches ^[a-z][a-z0-9_]{1,39}$ |
| screen | string | optional | matches ^[a-z][a-z0-9_/ |
| step | string | optional | matches ^[a-z][a-z0-9_]{0,39}$ |
| online | boolean | optional | |
| queue_waiting | integer | optional | 0–100000 |
| http_status | integer | optional | 100–599 |
| app_version | string | optional | 0–40 chars |
| os_version | string | optional | 0–40 chars |
{
"report_id": "r7a1…",
"device_id": "de91…"
}
GET /v1/devices
List tablets
Every tablet of the account: paired or waiting, what it runs (workflows, in the order its kiosk lists them), when it was last seen, and whether a pairing code is outstanding. Never the code itself, which is shown once when it is made.
{
"devices": [
{
"id": "de91…",
"name": "Front counter iPad",
"workflow_id": "wf3c…",
"workflow_name": "River float waiver",
"paired": true,
"paired_at": "2026-09-02T15:04:11Z",
"platform": "android",
"app_version": "1.0.3",
"os_version": "14",
"last_seen_at": "2026-09-16T09:40:02Z",
"revoked_at": null,
"revoke_reason": null,
"pairing_open": false,
"pair_expires_at": null,
"review": false,
"hand_off": "walk_up",
"gate": "own",
"below_minimum": false,
"below_recommended": false,
"queue_waiting": 0,
"queue_refused": 0,
"last_synced_at": "2026-09-16T09:40:02Z",
"last_problem_code": null,
"last_problem_at": null,
"workflows": [
{
"id": "wf3c…",
"name": "River float waiver",
"kind": "waiver",
"active": true
},
{
"id": "wf7a…",
"name": "Campsite rules",
"kind": "policy",
"active": true
}
]
}
]
}
POST /v1/devices
Add a tablet and get its pairing code
Returns the pairing code once and never again: eight characters, fifteen minutes, one use. Type it into the SignSealer app on the tablet, which trades it for a credential of its own. workflow_id locks the tablet to one form (a kiosk); leave it out for a staff tablet that chooses. for_review makes a code that lasts thirty days, for an app store reviewer.
| Field | Type | Constraints | |
|---|---|---|---|
| name | string | required | 1–60 chars |
| workflow_id | string (uuid) | null | optional | |
| for_review | boolean | optional |
{
"id": "de92…",
"code": "K7PQ2M9D",
"expires_at": "2026-09-16T10:03:15Z",
"review": false
}
POST /v1/devices/{id}/code
A new pairing code for a tablet
For a tablet that never paired, or one being set up again on new hardware. Any tablet paired to this one stops working the moment the new code is made, so a lost tablet is one call here rather than a new key for everything else.
{
"id": "de92…",
"code": "R4WX8NHB",
"expires_at": "2026-09-16T10:05:40Z",
"review": false
}
PATCH /v1/devices/{id}
Rename a tablet, change what it runs, how it is handed a customer, or whose links it may show
Any of the fields. A change reaches a paired tablet on its next heartbeat, without re-pairing. workflow_id: null makes it a staff tablet that chooses. workflow_ids (00420) gives it several forms, in the order its kiosk lists them — a campground's site waiver and its kayak waiver on one tablet — and workflow_id becomes the first; [] makes it a staff tablet. hand_off (00321, 00343) is walk_up (a person types their name), same_device (your till app hands the signing code to ours on the same tablet) or separate (a separate till; the tablet lists who is waiting). gate (00409) is own (the default for a tablet added from 24 September 2026: the Gate screen shows a signing link only for a document this tablet or its form started, and its search matches names, not addresses) or account (for every open document, for a tablet at the gate that checks people you emailed; tablets paired earlier were left here). A link a tablet shows is one whoever holds it could open, so open it only on the tablet that needs it.
| Field | Type | Constraints | |
|---|---|---|---|
| name | string | optional | 1–60 chars |
| workflow_id | string (uuid) | null | optional | |
| workflow_ids | string (uuid)[] | optional | 0–12 items |
| hand_off | "walk_up" | "same_device" | "separate" | optional | |
| gate | "own" | "account" | optional |
{
"updated": true
}
DELETE /v1/devices/{id}
Stop a tablet
Its credential stops in the same breath. Needs ?reason=, which goes on the audit trail beside who stopped it and when.
| Parameter | Type | What it is | |
|---|---|---|---|
| reason | string | required | Why, for the audit trail. |
{
"revoked": true
}
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. What is required is decided by the documents chosen for bookings from the API, in the dashboard under Integrations; a reservation none of them fits has an empty packet, with required 0.
| 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 |
| participants[].ref | string | optional | 0–64 chars |
| participants[].full_name | string | required | 1–200 chars |
| participants[].email | string (email) | optional | |
| participants[].phone | string | optional | 0–40 chars |
| participants[].date_of_birth | string | optional | matches ^\d{4}-\d{2}-\d{2}$ |
| participants[].is_primary | boolean | optional | |
| participants[].guardian_ref | string | optional | 0–64 chars |
{
"subject_id": "s2e4…",
"packet": {
"packet_id": "p6c9…",
"code": "ABCD234567",
"added": 2,
"dropped": 0,
"state": "open",
"required": 2,
"completed": 0,
"outstanding": 2,
"not_started": 2
}
}
Packets
GET /v1/packets/code/{code}
Resolve a signing code, from a tablet
The last few feet of a sale made in your own software (00321): the customer holds the signing code from the receipt, the tablet at the counter resolves it and sees what is still owed -- each item's first name and last initial, workflow and state -- and nothing else: no address, no full guest list. Only a paired tablet's credential may ask; a code from another business, a code nobody holds and a malformed code all answer found: false the same way. Rate limited per tablet.
{
"found": true,
"packet_id": "p6c9…",
"code": "ABCD234567",
"state": "open",
"required": 2,
"completed": 1,
"outstanding": 1,
"declined": 0,
"expires_at": "2026-10-01T00:00:00Z",
"items": [
{
"item_id": "i1a…",
"who": "Dana R.",
"workflow": "river-float",
"required": true,
"status": "completed"
},
{
"item_id": "i2b…",
"who": "Sam L.",
"workflow": "river-float",
"required": true,
"status": "not_started"
}
]
}
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": "ABCD234567",
"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": {
"state": "open",
"required": 2,
"completed": 1,
"outstanding": 1,
"not_started": 0,
"declined": 0
},
"items": [
{
"item_id": "i1…",
"requirement": "waiver",
"workflow": "waiver",
"required": true,
"participant": {
"id": "pa1…",
"full_name": "Dana Reyes",
"email": "dana@example.signsealer.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. A paired tablet may start an item it resolved from a signing code, the way it starts its own workflow. On a workflow signed in order, a fixed role whose turn has not come is held, as at POST /v1/workflows/{id}/start: their token is null and held is true, and they are sent their link when the people before them have signed (00609). Nobody is held when the business sends its own links (sending turned off in the dashboard): every token comes back now, and handing each one over in its turn is yours.
{
"document_id": "d41d…",
"title": "Kayak rental waiver — Dana Reyes",
"expires_at": "2026-10-10T14:22:09Z",
"template_version": 3,
"party_size": null,
"signers": [
{
"signer_id": "5f0c…",
"email": "dana@example.signsealer.com",
"phone": null,
"full_name": "Dana Reyes",
"role": "signer",
"participant": true,
"for_business": false,
"token": "3f9c…"
}
]
}
POST /v1/packets/{id}/start
Start everything a packet still owes
Starts every item nobody has started yet, each for the person who owes it (a minor's for their guardian). Items already started are left alone, which makes this safe to call on every booking update. An item that cannot be started -- nobody with an email address, an archived workflow -- is listed under skipped with the reason, and the others still go. Refused on a voided packet.
{
"packet_id": "p6c9…",
"started": [
{
"item_id": "i2…",
"requirement": "waiver",
"participant": "Sam Reyes",
"document_id": "d41e…"
}
],
"skipped": [
{
"item_id": "i3…",
"requirement": "waiver",
"participant": "Ana Reyes",
"reason": "no address to send Ana Reyes's document to"
}
]
}
POST /v1/packets/{id}/void
Void a packet: the booking was canceled
Voids every document the packet started that nobody has finished, with the reason on each, and marks the packet void. A document somebody already signed is kept -- an executed waiver is evidence of what was agreed, and a cancellation afterwards does not unsign it -- and counted under kept.
| Field | Type | Constraints | |
|---|---|---|---|
| reason | string | required | 1–500 chars |
{
"packet_id": "p6c9…",
"voided": 2,
"kept": 1
}
Integrations
GET /v1/integrations
List the booking systems this business can connect, and what it has connected
Every provider with its status (available, beta, pending_partner_access, planned, deprecated, disabled), how it connects and what API version it is built against; and the business's connections with their state, why one is paused when the membership ended (paused_reason), when a booking last arrived, the last error and how many events are waiting or gave up. needs_membership is true when the business is on Free: connecting a booking system comes with the membership (00425), and a connection paused because the membership ended resumes by itself when it starts again. Credentials and the address a provider posts to are never included. Connecting is done in the dashboard, where the provider is asked to confirm the credentials.
{
"providers": [
{
"key": "guesty",
"label": "Guesty",
"category": "vacation_rentals",
"status": "beta",
"connects": "webhook",
"auth_type": "oauth_client_credentials",
"verification": "svix",
"connector_version": "1.0.0",
"api_version": "Open API v1; reservations v3; webhooks v2",
"deprecates_at": null,
"sunsets_at": null
}
],
"connections": [
{
"id": "c7a1…",
"provider": "guesty",
"label": "Guesty (main)",
"status": "active",
"paused_reason": null,
"config": {
"send": "on_booking",
"void_on_cancel": true,
"kind": "reservation"
},
"created_at": "2026-10-03T14:22:09Z",
"last_event_at": "2026-10-03T14:22:09Z",
"last_success_at": "2026-10-03T14:22:09Z",
"last_error": null,
"last_error_at": null,
"waiting": 0,
"dead": 0
}
],
"needs_membership": false
}
GET /v1/integrations/{id}/events
What arrived from one connection, and what became of it
The latest fifty deliveries, newest first: the provider's booking id, the event, its state (queued, processing, done, ignored, superseded, failed, dead), the error when there is one, what the provider's data said about consent to text (consent_evidence), the reservation it made and a summary of the packet (how many documents were required, started and skipped, and for each guest with a number whether the link was also texted and why not). What arrived is never returned.
{
"events": [
{
"id": "e9b2…",
"event_type": "reservation.updated.v2",
"external_ref": "64f1c2…",
"status": "done",
"attempts": 1,
"received_at": "2026-10-03T14:22:09Z",
"processed_at": "2026-10-03T14:22:09Z",
"next_attempt_at": "2026-10-03T14:22:09Z",
"error": null,
"verified_by": "svix",
"consent_evidence": "INSUFFICIENT_EVIDENCE",
"subject_id": "s2e4…",
"result": {
"packet_id": "p6c9…",
"required": 2,
"state": "open",
"started": 2,
"skipped": [],
"sent": true,
"texts": [
{
"guest": "Dana Reyes",
"may_text": false,
"why": "no consent on file; the link goes by email"
}
]
}
}
]
}
Webhooks
GET /v1/webhooks
List webhook endpoints
Each endpoint with its address, label and events, whether it is on, its consecutive failures and paused_at (twenty failures in a row pause it), how many deliveries are queued and dead, and held_dropped: deliveries not kept because ten thousand were already waiting while it was paused. unknown_events lists any of its events that are not events we send -- names stored before 3 October, when a change to an endpoint's events began to be checked -- which never arrive; change its events to drop them. Never the secret.
{
"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,
"held_dropped": 0,
"unknown_events": []
}
]
}
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 rotates it (POST /v1/webhooks/{id}/rotate). events defaults to ["*"], which is every event but signing.location_shared: a signer's position goes only to an endpoint that names it. A name not on the list in /docs/webhooks is refused with 422. Twenty endpoints per business.
| 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"
}
PUT /v1/webhooks
Make sure an endpoint is registered for an address
Idempotent by the address itself: the business's endpoint for url, its events and label set to what you send, or a new one when it has none -- so calling this on every start-up never makes a second. created says which. The secret is in the answer only when the endpoint is new (it is never shown again; POST /v1/webhooks/{id}/rotate replaces a lost one), and is null otherwise. An endpoint the business paused or turned off stays as it was left: active and paused say so, and PATCH /v1/webhooks/{id} turns it back on.
| Field | Type | Constraints | |
|---|---|---|---|
| url | string (uri) | required | matches ^https:// |
| events | string[] | optional | 1–30 items |
| label | string | optional | 0–80 chars |
{
"id": "h4a7…",
"created": false,
"secret": null,
"url": "https://example.com/hooks/signsealer",
"events": [
"signing.completed",
"signing.declined",
"signing.viewed",
"signing.expired"
],
"label": "Field app",
"active": true,
"paused": false
}
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. events, when it is sent, replaces the list and is checked as POST /v1/webhooks checks it: a name that is not an event is refused with 422 invalid, whose hint lists every event there is, and nothing is changed. A change that sends no events is not asked about the ones the endpoint already holds, even one that is not an event (see unknown_events on GET /v1/webhooks).
| Field | Type | Constraints | |
|---|---|---|---|
| active | boolean | optional | |
| events | string[] | optional | 1–30 items |
| label | string | optional | 0–80 chars |
{
"updated": true,
"resumed": true,
"waiting": 14,
"dropped_while_paused": 0
}
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. The body is the one kept: once the document is redacted, or the business's message retention has passed (thirty days unless it set otherwise), the people have been taken out of it -- the title, the signer's name, address, number and reason, and external_ref -- and a replay sends it that way. A signing.location_shared delivery is not replayed to an endpoint that does not name that event now (422): a signer's position goes only where it is asked for by name.
{
"delivery_id": "dl2…"
}
DELETE /v1/webhooks/{id}
Remove a webhook endpoint
Needs ?reason=, which goes on the audit trail.
| Parameter | Type | What it is | |
|---|---|---|---|
| reason | string | required | Why, for the audit trail. |
{
"deleted": true
}
Signing-applications
GET /v1/signing-applications
List the business's trusted applications and their keys
Every application the business registered, disabled ones included, each with its public keys -- live, expired and revoked -- as JWKs. Nothing private is ever stored: the application keeps its private keys.
{
"applications": [
{
"id": "3c9d2b1a-8e7f-4a6b-9c5d-2e1f0a9b8c7d",
"name": "Field inspections",
"created_at": "2026-10-03T14:22:09Z",
"disabled_at": null,
"disabled_reason": null,
"keys": [
{
"id": "7a3e2f10-5b1c-4d7e-9f0a-1c2d3e4f5a6b",
"kid": "kq3V0b6m2Z4Yq1hXy8s9d0F7gT5rE3wQ1aB2cD4eF6g",
"alg": "ES256",
"public_jwk": {
"kty": "EC",
"crv": "P-256",
"x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
"y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"
},
"created_at": "2026-10-03T14:22:09Z",
"expires_at": null,
"revoked_at": null,
"revoked_reason": null
}
]
}
]
}
POST /v1/signing-applications
Register a trusted application
A business's own software whose server will vouch for its people's approvals (POST /v1/signing-transactions). Register its public keys next. Ten live applications per business.
| Field | Type | Constraints | |
|---|---|---|---|
| name | string | required | 1–120 chars |
{
"id": "3c9d2b1a-8e7f-4a6b-9c5d-2e1f0a9b8c7d",
"name": "Field inspections",
"created_at": "2026-10-03T14:22:09Z",
"disabled_at": null,
"disabled_reason": null,
"keys": []
}
POST /v1/signing-applications/{id}/keys
Register a public key an application signs approvals with
A JWK: EC P-256 for ES256, or OKP Ed25519 for EdDSA -- the algorithm follows from the key. Only the public half; a key with d is refused. kid is how an approval's header names it, and is the key's RFC 7638 thumbprint when you leave it out. Five live keys per application, so a new one can be added before the old one is revoked.
| Field | Type | Constraints | |
|---|---|---|---|
| public_jwk | object | required | |
| public_jwk.kty | "EC" | "OKP" | required | |
| public_jwk.crv | "P-256" | "Ed25519" | required | |
| public_jwk.x | string | required | 1–200 chars |
| public_jwk.y | string | optional | 1–200 chars |
| kid | string | optional | 8–128 chars |
| expires_at | string (date-time) | optional |
{
"id": "3c9d2b1a-8e7f-4a6b-9c5d-2e1f0a9b8c7d",
"name": "Field inspections",
"created_at": "2026-10-03T14:22:09Z",
"disabled_at": null,
"disabled_reason": null,
"keys": [
{
"id": "7a3e2f10-5b1c-4d7e-9f0a-1c2d3e4f5a6b",
"kid": "kq3V0b6m2Z4Yq1hXy8s9d0F7gT5rE3wQ1aB2cD4eF6g",
"alg": "ES256",
"public_jwk": {
"kty": "EC",
"crv": "P-256",
"x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
"y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"
},
"created_at": "2026-10-03T14:22:09Z",
"expires_at": null,
"revoked_at": null,
"revoked_reason": null
}
]
}
DELETE /v1/signing-applications/{id}/keys/{key}
Revoke an application's key
From now on an approval signed with it is refused, including one already issued and not yet sent. Needs a reason, which goes on the audit trail. What it signed stays signed.
| Parameter | Type | What it is | |
|---|---|---|---|
| reason | string | required | Why, for the audit trail. |
{
"id": "3c9d2b1a-8e7f-4a6b-9c5d-2e1f0a9b8c7d",
"name": "Field inspections",
"created_at": "2026-10-03T14:22:09Z",
"disabled_at": null,
"disabled_reason": null,
"keys": [
{
"id": "7a3e2f10-5b1c-4d7e-9f0a-1c2d3e4f5a6b",
"kid": "kq3V0b6m2Z4Yq1hXy8s9d0F7gT5rE3wQ1aB2cD4eF6g",
"alg": "ES256",
"public_jwk": {
"kty": "EC",
"crv": "P-256",
"x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
"y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"
},
"created_at": "2026-10-03T14:22:09Z",
"expires_at": null,
"revoked_at": "2026-10-03T14:22:09Z",
"revoked_reason": "Rotated"
}
]
}
POST /v1/signing-applications/{id}/disable
Disable a trusted application
Every approval from it is refused from now on, whichever key signed it. Needs a reason, which goes on the audit trail.
| Field | Type | Constraints | |
|---|---|---|---|
| reason | string | required | 1–500 chars |
{
"id": "3c9d2b1a-8e7f-4a6b-9c5d-2e1f0a9b8c7d",
"name": "Field inspections",
"created_at": "2026-10-03T14:22:09Z",
"disabled_at": "2026-10-03T14:22:09Z",
"disabled_reason": "Replaced by the new app",
"keys": [
{
"id": "7a3e2f10-5b1c-4d7e-9f0a-1c2d3e4f5a6b",
"kid": "kq3V0b6m2Z4Yq1hXy8s9d0F7gT5rE3wQ1aB2cD4eF6g",
"alg": "ES256",
"public_jwk": {
"kty": "EC",
"crv": "P-256",
"x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
"y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"
},
"created_at": "2026-10-03T14:22:09Z",
"expires_at": null,
"revoked_at": null,
"revoked_reason": null
}
]
}
Signature-references
POST /v1/signature-references
Hand over one person's saved signature for one signing
Your application keeps its people's saved signatures; this hands one to SignSealer server to server and answers with an opaque reference, bound to your application and the person (subject, as your approvals name them in sub), usable once, and gone after ttl_seconds (300 by default, 30 to 600). The image is never served back from anywhere, and the reference authorises nothing on its own: it is spent only by an approval from the same application naming the same person, whose signature.sha256 is the image's -- sha256 here. Another person's approval cannot use it.
| Field | Type | Constraints | |
|---|---|---|---|
| application_id | string (uuid) | required | |
| subject | string | required | 1–200 chars |
| image | string | required | 0–400000 chars, matches ^data:image/ |
| ttl_seconds | integer | optional | 30–600 |
{
"reference": "ssr_Jr0w…43 characters",
"sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"expires_at": "2026-10-03T14:22:09Z"
}
Signing-transactions
POST /v1/signing-transactions
Sign with an approval your application vouches for
assertion is a compact JWS (typ: signsealer-approval+jwt, alg ES256 or EdDSA, kid a key you registered) your server signs once the person has approved the document in your application. Its claims: iss your application's id, aud signsealer, tenant_id this business, sub and name the person, amr how they signed in to your application (a list) and acr its level if you have one, auth_time, approved_at, iat and exp (seconds; at most five minutes apart), jti the approval's own id (16 to 128 characters), document_id and document_sha256 (the document's body_sha256_hex as the person was shown it), signer_id optionally, signature (method and the sha256 of the image or text), consent: true, origin (online; offline is refused for now), and optionally ip and device, which are recorded as supplied by your application. The line must have been sent bound to your application and that person (signers[].application on POST /v1/documents/{id}/send). signature is the signature itself: drawn with a PNG image, typed with its text, or saved with a reference from POST /v1/signature-references. Refused with 403 assertion_rejected when the approval does not hold -- its signature, key, application, business, time, person, line or signature; 409 document_changed when the text is not the text approved (ask the person again); 409 assertion_replayed when another approval already used its jti. The same approval sent again, byte for byte, answers with the signing it made and replayed: true. Recorded on the trail as authenticated by your application -- assurance external_application -- and never as an identity SignSealer checked.
| Field | Type | Constraints | |
|---|---|---|---|
| assertion | string | required | 20–20000 chars |
| signature | object | object | object | required |
{
"transaction_id": "0e1f2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a5b",
"approval_id": "apr_01JBX4Q7N2W8ZK5T3M9R6Y0C1D",
"application_id": "3c9d2b1a-8e7f-4a6b-9c5d-2e1f0a9b8c7d",
"document_id": "d41c8e2a-1b3c-4d5e-8f6a-7b8c9d0e1f2a",
"signer_id": "5f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
"event_id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b",
"subject": "emp_1042",
"status": "signed",
"signed_at": "2026-10-03T14:22:09Z",
"document_status": "completed",
"document_sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"signature_method": "saved",
"signature_sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"authentication": "external_application",
"replayed": false
}
GET /v1/signing-transactions
Find a signing by your approval's id
The signing an approval made, found by your application's id and the approval's jti -- for a client that lost the answer. 404 when that approval has signed nothing.
| Parameter | Type | What it is | |
|---|---|---|---|
| application_id | string | required | Your application's id. |
| approval_id | string | required | The approval's jti. |
{
"transaction_id": "0e1f2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a5b",
"approval_id": "apr_01JBX4Q7N2W8ZK5T3M9R6Y0C1D",
"application_id": "3c9d2b1a-8e7f-4a6b-9c5d-2e1f0a9b8c7d",
"document_id": "d41c8e2a-1b3c-4d5e-8f6a-7b8c9d0e1f2a",
"signer_id": "5f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
"event_id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b",
"subject": "emp_1042",
"status": "signed",
"signed_at": "2026-10-03T14:22:09Z",
"document_status": "completed",
"document_sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"signature_method": "saved",
"signature_sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"authentication": "external_application"
}
GET /v1/signing-transactions/{id}
Show one signing an approval made
What was signed, by whom as your application named them, against which text, and where the document has got to (document_status, completed once everybody has signed).
{
"transaction_id": "0e1f2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a5b",
"approval_id": "apr_01JBX4Q7N2W8ZK5T3M9R6Y0C1D",
"application_id": "3c9d2b1a-8e7f-4a6b-9c5d-2e1f0a9b8c7d",
"document_id": "d41c8e2a-1b3c-4d5e-8f6a-7b8c9d0e1f2a",
"signer_id": "5f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
"event_id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b",
"subject": "emp_1042",
"status": "signed",
"signed_at": "2026-10-03T14:22:09Z",
"document_status": "completed",
"document_sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"signature_method": "saved",
"signature_sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"authentication": "external_application"
}
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. body_format is text or markdown; a markdown body uses headings, bold, italics and lists and should be drawn as such, not as the raw marks. Each field may carry a block -- a set of questions asked as one, with a heading and an introduction, and step: true when it gets a page of its own -- and a show_if condition on earlier answers: one rule, or several joined by all or any, any of them a group of its own, and with action: "hide" hiding the question rather than showing it. visible is the engine's own answer to that condition: draw only the visible questions. A hidden one is not required, and answering the question it depends on differently clears it. rules_at_start is its answer to each rule on its own, for a page that decides some rules itself and needs the others. A condition is read on the calendar day in condition_zone, so an age means the same to your page as to the engine. document_after_questions is true when the business asked for the questions first and the document after them, and while the first signer's answers still decide which paragraphs the text has. paragraphs (00540) is set when paragraphs of the text depend on the first signer's answers: body then has only the ones that apply as the link is read, sections says which (in order), decided_by whose answers decide them, and settled whether that signer has signed, which freezes them. While they are this signer's to decide, source is the text with its [[show if ...]] and [[end]] lines and conditions each paragraph's condition (with rules_at_start, each of its rules met or not so far), for a page that asks the questions itself; anybody else is waiting, and is refused a signature until then. Pass sections back as paragraphs when you sign; while they are undecided a signature without it is refused. party is set when everybody in a booking's party signs (a workflow with each_person_signs, started with persons above one): for the person it was started for, lead is true, still_to_add says how many people they must add before they can sign, and others lists those added; for somebody they added, added_by names them. A lead whose may_add is true adds the others with POST /v1/sign/{token}/party, or on the signing page at /s/{token}; either way the lead cannot sign until they have. documents is one when the party signs one document, or each when every person added has a document of their own (the workflow's party_documents); the party is read the same either way, from any of its documents.
{
"ok": true,
"reason": null,
"document_id": "d41c…",
"title": "Liability waiver — Dana Reyes",
"body": "# Liability waiver\n\nI, **Dana Reyes**, …",
"body_format": "markdown",
"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.signsealer.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
}
],
"verification_required": null,
"verification_to": null,
"verified_at": null,
"code_sent_at": null,
"powered_by": true,
"theme": null,
"mark": {
"tenant_id": "3a1f…",
"name": "Fern Hollow Stables",
"logo": "png"
},
"footer": null,
"document_after_questions": false,
"time_zone": null,
"condition_zone": "America/Chicago",
"party": null,
"pages": [],
"fields": [
{
"key": "rode_before",
"label": "Have you ridden before?",
"kind": "choice",
"required": true,
"about": null,
"hint": null,
"options": [
"Yes",
"No"
],
"sensitive": false,
"place": null,
"block": {
"key": "experience",
"label": "Your riding",
"intro": "So we can match you to a horse.",
"step": true
},
"show_if": null,
"rules_at_start": null,
"visible": true,
"supplied": false,
"passed": false,
"at_signing": null,
"placed_value": null,
"placed_words": null,
"from_sender": false
},
{
"key": "lessons",
"label": "How many lessons have you had?",
"kind": "number",
"required": true,
"about": null,
"hint": null,
"options": [],
"sensitive": false,
"place": null,
"block": {
"key": "experience",
"label": "Your riding",
"intro": "So we can match you to a horse.",
"step": true
},
"show_if": {
"field": "rode_before",
"equals": [
"Yes"
]
},
"rules_at_start": [
false
],
"visible": false,
"supplied": false,
"passed": false,
"at_signing": null,
"placed_value": null,
"placed_words": null,
"from_sender": false
},
{
"key": "initials",
"label": "Initial here",
"kind": "initials",
"required": true,
"about": null,
"hint": null,
"options": [],
"sensitive": false,
"place": null,
"block": null,
"show_if": null,
"rules_at_start": null,
"visible": true,
"supplied": false,
"passed": false,
"at_signing": null,
"placed_value": null,
"placed_words": null,
"from_sender": 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 | |
| captured.offline | boolean | optional | |
| captured.at | string (date-time) | optional | |
| captured.device | string | optional | 0–80 chars |
{
"consented": true
}
GET /v1/sign/{token}/id-evidence
What the signer is asked for by way of ID evidence
The business's choice (00228, 00339): mode is what the signing page requires of a signer we delivered a link to; offered is what the counter offers a signer standing there, who may skip it; required says whether the signature will be refused without a photo. With what is captured so far, by hash, and the disclosure each capture is taken under. A token that matches nothing is answered 404 not_found.
{
"mode": "off",
"offered": "id_selfie",
"required": false,
"id": {
"captured_at": "2026-10-03T14:22:09Z",
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
},
"selfie": null,
"id_disclosure": {
"ref": "id_capture/1",
"text": "Photograph the front of a government-issued ID. …",
"sha256": "e3b0…"
},
"selfie_disclosure": {
"ref": "selfie_capture/1",
"text": "If you wish, take a photo of yourself. …",
"sha256": "e3b0…"
}
}
POST /v1/sign/{token}/id-evidence
Keep a photo of the ID, or of the person
The tablet app's camera step (00339). The bytes are shrunk here as the signing page's are, hashed, encrypted under the business's key and kept for thirty days; the capture goes on the trail with its hash and the disclosure shown. Refused when the business did not ask (offered is off), when a selfie is sent to a business that asked only for the ID, or when the link is closed -- all before the image is read. The image is never logged. A JPEG or a PNG of at most 52 megapixels, as base64 in a body of at most a megabyte -- about 740 KB of image, which is why the tablet app shrinks a photo before it sends one. Two refusals are about timing rather than the photo: another photo from this link is still being read, or the photos waiting to be read are queueing (each business holds at most two of the places). Both answer 429 busy with Retry-After in seconds (thirty): nothing was kept, so send the same photo again after that long. A refusal of the photo itself, or of a link that is closed, is 422 invalid, and sending the same photo again will not change it.
| Field | Type | Constraints | |
|---|---|---|---|
| kind | "id" | "selfie" | required | |
| content_type | string | required | 0–80 chars |
| bytes_base64 | string | required | 1–1000000 chars |
| captured | object | optional | |
| captured.offline | boolean | optional | |
| captured.at | string (date-time) | optional | |
| captured.device | string | optional | 0–80 chars |
{
"captured": true,
"kind": "id",
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}
GET /v1/sign/{token}/location
Whether the signer is asked where they are
The business's choice (00231): mode is off, ask or require, and require means the signature is refused until a position is on the record — for a signer we sent a link to. offered is what a counter tablet does with the same choice (00363): ask when the business asks anybody, and never more, because a signature at the counter is never refused for the want of a position. position is the answer already given, and disclosure is the wording the capture is taken under, which the client must show before it asks. { "shared": false } answers ask; under require only a shared position lets the signature through. For a token that matches nothing, mode and offered are off.
{
"mode": "require",
"offered": "ask",
"position": null,
"disclosure": {
"ref": "location_capture/2",
"sha256": "10f0…",
"text": "You will be asked whether to share where you are. If you allow it, the latitude and longitude your device reports are recorded on this signature …"
}
}
POST /v1/sign/{token}/location
Record where the signer is, or that they would not say
Send { "shared": true, "lat": …, "lon": …, "accuracy_m": … } when the person allowed it, and { "shared": false } when they refused or the device could not get a fix — both go on the trail. Under ask either is an answer; under require a refusal is recorded and the signature is still refused, so take the person back to the step. Ask for the position only after showing the disclosure from the GET. Refused when the business does not ask for a location at all, and when the document is already signed.
| Field | Type | Constraints | |
|---|---|---|---|
| shared | boolean | required | |
| lat | number | optional | -90–90 |
| lon | number | optional | -180–180 |
| accuracy_m | number | optional | 0–100000 |
| captured | object | optional | |
| captured.offline | boolean | optional | |
| captured.at | string (date-time) | optional | |
| captured.device | string | optional | 0–80 chars |
{
"shared": true,
"lat": 45.523064,
"lon": -122.676483,
"accuracy_m": 12.5,
"disclosure": "location_capture/2"
}
POST /v1/sign/{token}/fields/{key}/photo
Answer a photo field with a photograph
A template field of kind photo (00342) takes a photograph, not text: a JPEG or PNG, shrunk here as the signing page's are, hashed, encrypted under the business's key and kept with the document for as long as the document is kept. The field's value becomes sha256:<hex> and the capture goes on the trail as photo_supplied. A second photograph replaces the first. about names the covered person for a per-child field. Refused for a field that is not a photo, a link that is closed, or an image that is not one. Sizes, and the two 429 busy refusals worth sending again after Retry-After, are as for POST /v1/sign/{token}/id-evidence.
| Field | Type | Constraints | |
|---|---|---|---|
| content_type | string | required | 0–80 chars |
| bytes_base64 | string | required | 1–1000000 chars |
| about | string | optional | 0–200 chars |
| captured | object | optional | |
| captured.offline | boolean | optional | |
| captured.at | string (date-time) | optional | |
| captured.device | string | optional | 0–80 chars |
{
"supplied": true,
"key": "boat_condition",
"sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324"
}
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 for one signer; and a business's signers are sent at most three hundred codes by text an hour between them, past which the answer is 429 rate_limited with Retry-After. Not an identity check: it proves control of that inbox or phone at the moment of signing.
{
"channel": "email",
"sent_to": "d•••@example.signsealer.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. Refused, as a signature is, once the document is voided, declined or expired or the link is past its date; and six hundred answers an hour per link.
| 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. It is kept only from a paired tablet of the business the link belongs to, which sends its device credential as the bearer on this one call (the token still authorizes it); from anybody else -- no bearer, an API key, another business's tablet -- it is left out without an error and the signature is dated when it arrives. paragraphs says which of the document's conditional paragraphs (00540) your page drew, in the order of paragraphs.sections on the link. While the first signer's answers still decide them, it is required: a signature without it is refused with 422, and one whose answers now call for different paragraphs with 409 paragraphs_changed, so nobody signs a paragraph they were not shown. It is kept with the signature.
| Field | Type | Constraints | |
|---|---|---|---|
| method | "typed" | "drawn" | "clicked" | "uploaded" | required | |
| value | string | required | 1–400000 chars |
| captured | object | optional | |
| captured.offline | boolean | optional | |
| captured.at | string (date-time) | optional | |
| captured.device | string | optional | 0–80 chars |
| paragraphs | boolean[] | optional | 0–500 items |
{
"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
}
POST /v1/sign/{token}/party
Add the others in the party, as the person who booked
For a lead whose party.may_add is true on GET /v1/sign/{token}: people is everybody else in the booking, exactly party.still_to_add of them, and the lead cannot sign until they are added. Each adult is full_name with an email or a phone, or with neither: then theirs is a place kept open, which they take from the party link by choosing their name and giving an address (a name left empty is a place for anybody in the party). A child is minor: true -- or, where the business asks for dates of birth (party.ages is dob), date_of_birth as YYYY-MM-DD and the age decides -- with guardian: me for the lead, p<n> for the n-th adult in people, or other with guardian_name and a guardian_email or guardian_phone. over_13 marks a child old enough to sign beside the adult, where the business asks minors to sign too. Each person given an address is sent their own link, by email or by text where the business may text the number, and comes back with sent: true and no link: theirs alone. Anybody it could not be sent to comes back with sent: false and their token and url, shown once, for you to hand over -- that showing is on the trail. party_link is the link the open places are taken from: share it with the party (a text, a QR code). Refused in words: 422 for the wrong number of people, an address that is not one, a child with no adult to sign for them, and once the party is added or the lead has signed; 409 for an address already on the agreement (each person signs with their own); 403 for anybody but the lead.
| Field | Type | Constraints | |
|---|---|---|---|
| people | object[] | required | 1–19 items |
| people[].full_name | string | optional | 0–200 chars |
| people[].email | string | null | optional | 0–320 chars |
| people[].phone | string | null | optional | 0–40 chars |
| people[].minor | boolean | optional | |
| people[].date_of_birth | string | null | optional | matches ^\d{4}-\d{2}-\d{2}$ |
| people[].over_13 | boolean | optional | |
| people[].guardian | string | optional | matches ^(me|other|p([1-9]|1[0-9]))$ |
| people[].guardian_name | string | null | optional | 0–200 chars |
| people[].guardian_email | string | null | optional | 0–320 chars |
| people[].guardian_phone | string | null | optional | 0–40 chars |
{
"people": [
{
"signer_id": "7c2d…",
"full_name": "Riley Hart",
"email": "riley@example.signsealer.com",
"phone": null,
"sent": true,
"token": null,
"url": null
}
],
"party_link": "https://sign.fernhollow.example/p/3f9a…"
}
POST /v1/sign/{token}/party/share
The party link again, for the person who booked
A new party link for the lead to share, and the last one stops working: for a link shared with the wrong people, or lost. The places already taken stay taken. Only the lead's token, once the party is added, while the agreement is waiting for signatures.
{
"party_link": "https://sign.fernhollow.example/p/8b41…"
}
POST /v1/sign/{token}/party/link
One person's link again, for the person who booked
A new link for signer_id, somebody the lead added, and the one they had stops working. Sent to them where it can be, and then sent is true and no link comes back; otherwise token and url, shown once, for you to hand over. address, an email address or a mobile number different from the one on file, corrects where it goes -- only before they have opened their link, and a link that went by email only to another email address. Refused for somebody who has signed or declined, somebody who took their own place from the party link (their link is theirs alone), and anybody not in the lead's party. Thirty an hour from one lead's link, and three links sent to one address in a day.
| Field | Type | Constraints | |
|---|---|---|---|
| signer_id | string (uuid) | required | |
| address | string | optional | 0–320 chars |
{
"signer_id": "7c2d…",
"sent": false,
"token": "c1e0…",
"url": "https://sign.fernhollow.example/s/c1e0…"
}
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 and how many signed; pass ?sha256= of the document you hold and, when it matches, the signers (never their email addresses) and the trail. The file itself is checked first: the SHA-256 of the sealed PDF we issued (copy_matches), then that of the document's text. document_matches is true for either.
| Parameter | Type | What it is | |
|---|---|---|---|
| sha256 | string | optional | The SHA-256 of the sealed PDF you were sent (or of the document's text), in hex. When it matches, the signers and the trail come back too. |
{
"certificate_found": true,
"verification_code": "K7QP3MTV9XBD2FHR",
"issued_at": "2026-10-03T14:22:09Z",
"issuer": "Fern Hollow Stables",
"title": "Liability waiver — Dana Reyes",
"document_sha256_hex": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"certificate_sha256_hex": "592f…",
"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",
"party_id": null,
"consented_at": "2026-10-03T14:22:09Z",
"signed_at": "2026-10-03T14:22:09Z",
"declined_at": null,
"signature": {
"method": "typed",
"value": "Dana Reyes",
"value_sha256": null,
"signed_against_sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"ip": "203.0.113.9",
"user_agent": "Mozilla/5.0 …"
}
}
],
"events": [
{
"kind": "signed",
"occurred_at": "2026-10-03T14:22:09Z",
"signer": "Dana Reyes",
"against_sha256": "e362cc76dae17bbe2a9fc551d9b795fa4e0f77fb488eb748378a125b11f36324",
"detail": null
}
],
"copy_matches": false,
"copy_sha256_hex": null
}
OAuth
How an app is connected to a business: the browser redirect, the token exchange, and the WordPress plugin registering its own client. Served at https://api.signsealer.com, beside the API rather than under /v1. See Authentication for the flow, and AI assistants for an assistant connecting itself.
GET /oauth/authorize
Send a business to approve your app
A browser redirect, not an API call: send the person here and they are shown SignSealer's consent screen, signed in to their own account. Query: response_type=code, client_id, redirect_uri (exactly as registered), scope (space-separated, within what your app may ask for), state, code_challenge and code_challenge_method=S256. They come back to redirect_uri with code and your state, or with error.
POST /oauth/token
Exchange a code, or refresh a token
RFC 6749, as a form body (JSON is accepted too). The client authenticates with client_id and client_secret in the body or by HTTP Basic. grant_type=authorization_code with code, redirect_uri and code_verifier; or grant_type=refresh_token with refresh_token. Answers access_token (good for an hour), token_type: Bearer, expires_in, scope and, to a client that has refresh tokens, refresh_token (good once, for thirty days; the answer carries the next), with Cache-Control: no-store; or an RFC 6749 error object with status 400 or 401. A resource (RFC 8707) names the API the token is for; one on another origin is refused with invalid_target. invalid_grant on a refresh also means the connection is over: the business ended it, a refresh token was presented twice (which revokes the whole connection), or the person who connected your app was removed or can no longer grant what it holds. Send the business through /oauth/authorize again.
| Field | Type | Constraints | |
|---|---|---|---|
| grant_type | "authorization_code" | "refresh_token" | required | |
| code | string | optional | |
| redirect_uri | string (uri) | optional | |
| code_verifier | string | optional | |
| refresh_token | string | optional | |
| client_id | string | optional | |
| client_secret | string | optional | |
| resource | string (uri) | optional |
POST /oauth/register
Register a client: a WordPress site, or an app by RFC 7591
Two kinds of registration, told apart by the body. A WordPress site, which the plugin does itself when a site connects: a form body with kind=wordpress, redirect_uri, manifest_url and token, all on one https host. SignSealer fetches the manifest and registers the client only when the page shows that token's SHA-256, base64url, as token_sha256 (a plugin before 1.16.2 shows the token itself). Ten an hour per address. An app or AI assistant (RFC 7591 dynamic registration, as an MCP client does it): a JSON body with redirect_uris and no kind. Each address is https, or http on localhost, 127.0.0.1 or [::1], with a path; token_endpoint_auth_method none registers a public client, which uses PKCE alone. It is given reading and the four parts of signing:write, whatever it asks for; a scope sent is ignored. It gets refresh tokens only when grant_types lists refresh_token. Thirty an hour per address, and a client nobody connects within a day is removed. Either way the secret, when there is one, is in the answer once.
| Field | Type | Constraints | |
|---|---|---|---|
| kind | "wordpress" | required | |
| redirect_uri | string (uri) | required | |
| manifest_url | string (uri) | required | |
| token | string | required | 16–128 chars |
Partner
What a partner reads about the businesses it brought. Read only. See Platforms and partners.
GET /v1/partner
Your program, this month so far, and your signup link
The share you earn, whether your custom branding is on (it is free), how many businesses you have brought, and this month so far: what they paid for, the card fees and texts' cost taken off, and your share. signup_url is the link to hand a business: signing up through it makes the business yours. So does installing your app in its first thirty days. review is pending until a person at SignSealer has approved your account: businesses are recorded as yours meanwhile, and earning turns true, with your share counted from earning_since, once it is approved.
{
"partner": "DeckPOS",
"client_id": "ss_client_0123456789abcdef0123456789abcdef",
"referral_code": "deckpos",
"share_percent": 20,
"active": true,
"custom_branding": true,
"review": "approved",
"earning": true,
"earning_since": "2026-09-02T15:04:05Z",
"businesses": 12,
"month_to_date": {
"month": "2026-09-01",
"businesses": 9,
"gross_cents": 43100,
"fees_cents": 1250,
"sms_cost_cents": 600,
"revenue_cents": 41250,
"share_bp": 2000,
"share_cents": 8250
},
"signup_url": "https://app.signsealer.com/signup?partner=deckpos"
}
GET /v1/partner/businesses
The businesses you brought
Newest first, with how each came to you (referral_link, oauth_install, promotion or ops), when, and what its paid use has added this month. month_to_date_cents is what it used of what it paid for, after card fees and the carrier's cost of its texts: credit SignSealer gave away earns nothing.
| Parameter | Type | What it is | |
|---|---|---|---|
| limit | integer (1–200) | optional | At most this many; 50 when left out. |
| offset | integer (0–∞) | optional | Skip this many. |
{
"data": [
{
"tenant_id": "7d9c1c1e-7a57-4f55-9a86-0f7f3f9c2b11",
"name": "Harbour Rentals",
"status": "active",
"source": "referral_link",
"attributed_at": "2026-08-02T15:04:05Z",
"month_to_date_cents": 1840
}
],
"has_more": false
}
GET /v1/partner/statements
Every closed month
Newest first. A month is closed on the 1st of the next and never changes after: the businesses and what each added, the share at the rate then in force, the card fees and the carrier's cost of texts taken off before the share, anything carried in, and what is owed. A month that comes to less than nothing is carried into the next rather than invoiced. paid turns true when SignSealer records the payment.
{
"data": [
{
"month": "2026-08",
"businesses": 8,
"gross_cents": 39900,
"fees_cents": 1250,
"sms_cost_cents": 650,
"revenue_cents": 38000,
"share_percent": 19,
"share_cents": 7220,
"carried_in_cents": 0,
"payable_cents": 7220,
"carried_out_cents": 0,
"paid": true,
"paid_at": "2026-09-03T10:00:00Z",
"lines": [
{
"tenant_id": "7d9c1c1e-7a57-4f55-9a86-0f7f3f9c2b11",
"name": "Harbour Rentals",
"gross_cents": 5400,
"fees_cents": 160,
"sms_cost_cents": 40,
"revenue_cents": 5200
}
]
}
]
}
Ready to build? An API key takes a minute in the portal, and the free plan covers the first 10 agreements a month.
Get an API key