# SignSealer documentation > Every page of the SignSealer docs (https://signsealer.com/docs) as Markdown, in the order the docs list them. > The API is at https://api.signsealer.com; the OpenAPI description is https://signsealer.com/openapi.json. --- Source: https://signsealer.com/docs (SignSealer API documentation) # SignSealer API documentation Signatures, and the evidence behind them. One API for waivers, rental agreements, NDAs and contracts. What makes an electronic signature enforceable under ESIGN and UETA is evidence of five things. Cryptography is how that evidence is kept tamper-evident, and SignSealer uses it throughout; it is the record it protects that a court reads. - **Intent** — the signer meant this act to be their signature. - **Consent** — they agreed to do business electronically, *before* signing. - **Association** — the signature is attached to this record and no other. - **Attribution** — it can be shown to be them. - **Retention** — the record can be accurately reproduced later. Courts have thrown signatures out for missing the fourth. So SignSealer records each of the five as its own event, hashes the exact text into every one, chains the trail so nothing can be edited afterwards, and seals the finished document so that a single changed byte shows, with the time of sealing fixed by an independent RFC 3161 timestamp authority. ## What that means in practice - Consent is recorded as its own event, before the signature is accepted — not alongside it, because a system that records both in the same instant cannot show that consent came first. - The rendered text is stored on the document and hashed into every event. A document edited after signing no longer matches, and we say so. - A published template cannot be edited. Revising it makes a new version, because editing one changes what people signed last week. - The trail is append-only. A record that can be edited is a record of what somebody was willing to leave behind. ## Where to start - [How-to videos](https://signsealer.com/guides): Short films of each part of SignSealer, from the first day to the API, each with its guide. - [Quickstart](https://signsealer.com/docs/quickstart): Send a document for signature in four calls, with curl or the Node client. - [Node client](https://signsealer.com/docs/sdk): @signsealer/sdk: no dependencies, retries with idempotency keys, the API's own error sentences. - [AI assistants (MCP)](https://signsealer.com/docs/mcp): Connect Claude, ChatGPT, Codex or your editor, and send and check documents by asking. - [Templates and versions](https://signsealer.com/docs/templates): Create, publish and revise the text people sign, over the API, with every version kept. - [Signing inside your product](https://signsealer.com/docs/embedding): The signing page in a frame on your own site, and the events it sends you. - [Waivers and workflows](https://signsealer.com/docs/waivers): A public form, a QR at the counter, a kiosk: people start their own document. - [Signing a PDF](https://signsealer.com/docs/pdf): A lease with initials in the margin: the file kept whole, the boxes on the page, the signed copy still theirs. - [Reservations and packets](https://signsealer.com/docs/packets): Several documents about one booking, several people, one link, one ready state. - [Webhooks](https://signsealer.com/docs/webhooks): Signed events with a retry ladder, so your system hears back without polling. - [Platforms and partners](https://signsealer.com/docs/partners): Embedding signing in your own product, and sharing in the revenue of every business you bring. ## How it fits together ```text your application ──── POST /v1/documents ──────▶ SignSealer API ▲ │ │ mints one link per signer │ │ │ email · text · QR · kiosk · your own message │ │ │ ▼ │ the signer │ reads · consents · answers · signs │ │ │ ▼ │ append-only trail + certificate + sealed PDF │ │ └──────── signed webhook: signing.completed ─────┘ ``` Every arrow is in the reference. The signer's page is ours and loads no script; what your application sees is JSON in and a signed event out. ## Recipes, by outcome - [Send a signing request](https://signsealer.com/docs/quickstart) — template, publish, prepare, send. - [Change a template people have already signed](https://signsealer.com/docs/templates) — revise, publish, and ask them to sign again. - [Show the signing page inside your app](https://signsealer.com/docs/embedding) — an embedded signer and a framed link. - [Put a waiver behind a QR code or a link on your site](https://signsealer.com/docs/waivers) — a public workflow. - [Take waivers on a tablet at the counter](https://signsealer.com/docs/waivers) — the kiosk. - [Get every guest on a booking to sign before check-in](https://signsealer.com/docs/packets) — a packet with a ready state. - [Handle a minor and a guardian](https://signsealer.com/docs/packets) — roles in a packet, the guardian judged on the day. - [Download the evidence after signing](https://signsealer.com/docs/evidence) — the package and the sealed PDF. - [Verify a certificate](https://signsealer.com/docs/evidence) — without an account, from the code and the document. - [Hear when a document completes](https://signsealer.com/docs/webhooks) — and verify the event's signature. - [Send and check documents from Claude, ChatGPT or Codex](https://signsealer.com/docs/mcp) — the MCP server. ## Done in the dashboard Some things you will look for in the API are done by a person in the dashboard, and have no route. This is where each one is, and what the API sees of it. | What | Where | What the API sees | | --- | --- | --- | | Sending one agreement to a list of people, from a CSV file or a pasted list: a column for the name, one for the email and one for each blank, up to 250 rows, and every row checked before any is sent. | [Send to a list](https://app.signsealer.com/send/list) | A document for each row, each with its one signer, listed by `GET /v1/documents` and reported to your webhooks like any other. To send a list from your own system, prepare and send a document for each person. | | The activity log: every send, void, setting changed and key issued in the account, in order and append-only, each entry sealed to the one before it and the newest re-checked when the page opens. Owners and admins see it unless a person's permissions say otherwise. | [Activity](https://app.signsealer.com/activity) | Nothing. No route reads it. | | The words a signer reads: the signing request by email and by text, the reminder by email and by text, the confirmation by text, the signed copy, the renewal reminder and the link for somebody added to a booking, each in the business's own words with blanks such as `{{link}}`. | [Messages](https://app.signsealer.com/settings/messages) | No route sets or reads them, and a send carries no wording of its own: whatever starts a document, its messages go out in the words set here. | | Creating, changing and deleting a workflow: the template it runs, its roles (the participant, any fixed signers, and their order), how long a link lives, when to remind, its public address and its questions; and for a public address, its checks before it goes live, the email check and its limits ([Waivers and workflows](https://signsealer.com/docs/waivers), "A signing link on your own website"). | [Forms & QR](https://app.signsealer.com/workflows) | `GET /v1/workflows` lists them, with `signer_roles`, `sequential`, `public_slug`, `email_check`, the link's limits (`stop_after_signed`, `closes_on`, `once_within_days`), `closed`, why a link takes no more signatures, and `needs_attention`, the check a live link fails. `POST /v1/workflows/{id}/start` starts one and `GET /v1/workflows/{id}/qr` draws its public address. No route creates, changes or deletes one. | | Documents for bookings: which documents a booking owes and who signs each one, from which bookings, with a default for the rest. A connected booking system has its own on its page; the bookings sent through the API have theirs. | [Documents for bookings](https://app.signsealer.com/integrations/documents) | `PUT /v1/subjects` builds the packet from them, and `GET /v1/packets/{id}` shows who owes what ([Reservations and packets](https://signsealer.com/docs/packets)). No route lists or changes them. | Whether SignSealer itself is up is not in the API either. [status.signsealer.com](https://status.signsealer.com) shows it, and `https://status.signsealer.com/status.json` is the same as JSON, with no key, cached for a minute: `all_operational`, each component's `state` with ninety days of checks, and the incidents. --- Source: https://signsealer.com/docs/quickstart (Quickstart) # Quickstart Four calls: create a template, publish it, prepare a document, send it. Watch it done: [Connect your software: the API, the SDK and the docs](https://signsealer.com/guides/your-first-api-call) (2:38) ## 1. Create a template Placeholders are `{{like_this}}`, and every one of them has to be declared. Publishing checks that, so a contract cannot ship with an unfilled placeholder in it. ``` curl https://api.signsealer.com/v1/templates \ -H "Authorization: Bearer $SIGNSEALER_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "code": "rental-agreement", "name": "Short-term rental agreement", "kind": "contract", "variables": ["guest_name", "property", "arrival"], "body": "This agreement is between Harbour Rentals and {{guest_name}} for {{property}}, arriving {{arrival}}." }' ``` ## 2. Publish it The create answers `{"template_id": "…"}`. Publishing that version freezes its text. From then on it is immutable and `POST /v1/templates` with the same code is refused — revise it instead (`POST /v1/templates/{id}/revise`), which makes version 2 and leaves version 1 exactly as the people who signed it saw it. [Templates and versions](https://signsealer.com/docs/templates) has the whole of it. ``` curl -X POST https://api.signsealer.com/v1/templates/$TEMPLATE/publish \ -H "Authorization: Bearer $SIGNSEALER_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` ## 3. Prepare a document The rendered text is stored on the document, not a reference to the template. That is what retention means: the template can change, and the whole point of versioning it is that it might. ``` curl https://api.signsealer.com/v1/documents \ -H "Authorization: Bearer $SIGNSEALER_KEY" \ -H "Content-Type: application/json" \ -d '{ "template_code": "rental-agreement", "values": { "guest_name": "Dana Reyes", "property": "River Bend Cabin", "arrival": "3 October" } }' ``` ## 4. Send it You get one link per signer, once. The tokens are never stored — only their hashes — so a database dump is not a set of signing links, and a lost link is replaced rather than looked up: `POST /v1/documents/{id}/signers/{signer}/remind` sends a fresh one. Each signer needs an `email`, a `phone`, or both. Given both, the same link goes out twice — by mail and by text — because a person who does not check one inbox checks the other. A text goes only where the business holds consent for `signing.link`; where it does not, the email still goes and the reason is on the evidence trail. A new business sends to a few people an hour at first, and to more as it is trusted ([Limits and paging](https://signsealer.com/docs/limits)); past that the send is `429` and nobody on it is sent anything. Every message is read before it leaves, so the answer means the links were made, not that they arrived. ``` curl https://api.signsealer.com/v1/documents/$DOC/send \ -H "Authorization: Bearer $SIGNSEALER_KEY" \ -H "Content-Type: application/json" \ -d '{"signers":[{"email":"dana@example.signsealer.com","phone":"+16155550142","full_name":"Dana Reyes"}]}' ``` ## The same in Node `@signsealer/sdk` has no dependencies and runs anywhere `fetch` does. It adds the idempotency key itself and retries only the failures worth retrying. ``` import { SignSealer } from "@signsealer/sdk"; const signsealer = new SignSealer({ apiKey: process.env.SIGNSEALER_KEY! }); const { template_id } = await signsealer.createTemplate({ code: "rental-agreement", name: "Short-term rental agreement", kind: "contract", variables: ["guest_name", "property", "arrival"], body: "This agreement is between Harbour Rentals and {{guest_name}} for {{property}}, arriving {{arrival}}.", }); await signsealer.publishTemplate(template_id); const { document_id } = await signsealer.prepareDocument({ template_code: "rental-agreement", values: { guest_name: "Dana Reyes", property: "River Bend Cabin", arrival: "3 October" }, external_ref: "RES-88213", // yours; every webhook about it carries it back }); const { links } = await signsealer.send(document_id, [ { email: "dana@example.signsealer.com", phone: "+1 615 555 0142", full_name: "Dana Reyes" }, ]); ``` ## The same in Python No client library yet; the API is small enough that `requests` is the client. Send your own `Idempotency-Key` on the writes, and retry only `429` and `500`. ``` import os, uuid, requests API = "https://api.signsealer.com" headers = {"Authorization": f"Bearer {os.environ['SIGNSEALER_KEY']}"} def post(path, body): r = requests.post(API + path, json=body, timeout=15, headers={**headers, "Idempotency-Key": str(uuid.uuid4())}) r.raise_for_status() return r.json() doc = post("/v1/documents", { "template_code": "rental-agreement", "values": {"guest_name": "Dana Reyes", "property": "River Bend Cabin", "arrival": "3 October"}, }) links = post(f"/v1/documents/{doc['document_id']}/send", { "signers": [{"email": "dana@example.signsealer.com", "phone": "+16155550142", "full_name": "Dana Reyes"}], })["links"] ``` > **Then what?** Add a [webhook](https://signsealer.com/docs/webhooks) and wait for `signing.completed`. Polling works and is the wrong shape: a completion is an event, and the event carries everything you need to act without a second call. --- Source: https://signsealer.com/docs/authentication (Authentication) # Authentication A bearer key, scoped to what it may do. ``` Authorization: Bearer mk_live_1a2b3c4d… ``` Keys are shown once, when issued, and stored only as their SHA-256 — the same way a signing token is, and for the same reason. A key that is lost is replaced, not recovered. ## Scopes A key's scopes are its capabilities. There is no "full access" scope, because the useful question about an integration is not whether it is trusted but what it needs. The four parts of `signing:write` below are for connected apps, so that the person connecting one can let it send without letting it void. A key made in the dashboard takes `signing:write` whole, or not at all ([Teams, roles and sign-in](https://signsealer.com/docs/teams)). | Scope | What it carries | | --- | --- | | signing:read | Read templates, documents, certificates and packets. | | signing:write | Prepare, send, remind and void documents; void a packet; hand out signing links to frame; record consent to text. All four of the next scopes. | | signing:send | Prepare and send documents, and record consent to text. Part of `signing:write`. A connected app’s, not a key’s. | | signing:remind | Remind a signer who has not signed. Part of `signing:write`. A connected app’s, not a key’s. | | signing:links | Hand out a fresh signing link, or one to frame. Part of `signing:write`. A connected app’s, not a key’s. | | signing:void | Void a document or a packet. Part of `signing:write`. A connected app’s, not a key’s. | | signing:templates | Create templates, change drafts, publish, revise and discard them, and change their settings. | | signing:workflows | Start a workflow for a participant. | | signing:subjects | Import reservations, set guest lists, build and start packets. | | signing:webhooks | Manage endpoints. | | signing:devices | Manage counter tablets: add one and get its pairing code, list, re-pair, rename, stop. | > **A booking integration wants `signing:subjects`, not `signing:write`.** The two are different trust levels: one is "act on my behalf", the other is "decide who my guests are". ## Only from where it should come from A key can be pinned to addresses or ranges when it is made — one per line under *Only from these addresses*, up to twenty, IPv4 or IPv6. Presented from anywhere else it is refused exactly as a wrong key is (`401`, the same sentence), so a copy that leaks from a log or a laptop does nothing. A key with no list works from anywhere. The list travels with the key: to change it, make a new key and revoke the old one, the way scopes change. The address checked is the one the request arrives from, as our edge sees it. An integration behind an egress with a fixed address pins to that; one whose address moves should not pin at all rather than pin to a range it does not control. ## Connecting through OAuth A product that serves many businesses does not want to ask each one to paste a key. It sends the business to SignSealer once, the business agrees, and the product is handed tokens — an access token that is a scoped key for an hour, and a refresh token that gets the next one. This is OAuth 2.0 with the authorization code flow and PKCE, and nothing else: no implicit flow, no password grant, and `S256` is the only challenge method accepted. Register your product with [partners@signsealer.com](mailto:partners@signsealer.com) (a name, a homepage, the exact redirect address or addresses, and the scopes it will ask for); you get a `client_id` and, shown once, a `client_secret`. A WordPress site running our plugin does not need any of this: the plugin registers a client for that one site itself, after SignSealer has fetched the plugin's own page on the site to confirm it is there (`POST /oauth/register`), and the site owner only presses Connect and approves. Because such a client vouched for itself -- we checked that the site runs the plugin, not who runs the site -- its consent screen says so, it may have `signing:read`, `signing:write`, `signing:workflows` and `signing:webhooks` but never `signing:templates`, and a request we cannot answer is explained on our page rather than sent back to the site. A person pressing decline is sent back, with `error=access_denied`. An AI assistant connecting to [the MCP server](https://signsealer.com/docs/mcp) needs none of it either. It finds this flow from the server's metadata and identifies itself, by registering (RFC 7591, `POST /oauth/register` with `redirect_uris`) or by using the https address of its own client metadata document as its `client_id`. Such an app is held to `signing:read` and `signing:write`, its consent screen says SignSealer has not checked it, and it may return to the loopback (`http://127.0.0.1:/…`) when it runs on the person's own computer. **1. Send the browser to authorize.** ``` GET https://api.signsealer.com/oauth/authorize ?response_type=code &client_id=ss_client_… &redirect_uri=https://your.product/callback &scope=signing:read%20signing:write &state=… # yours, returned untouched &code_challenge=… # base64url(sha256(code_verifier)) &code_challenge_method=S256 ``` The person signs in if they are not already, sees your product's registered name, homepage and the scopes asked, and agrees or declines. The `redirect_uri` must match one you registered exactly — not a prefix, not a pattern — and a request whose client or redirect does not check out is answered as a page, never as a redirect. Everything else that is wrong comes back to your redirect as `error=` with `state`, the way RFC 6749 says -- for a client we registered. A client a site registered for itself gets a page instead (RFC 9700 §4.11.2), and a scope it may not have is left out of the grant rather than refused. Agreement comes back as `?code=…&state=…`; the code lives sixty seconds and is good once. **2. Exchange the code, server to server.** ``` POST https://api.signsealer.com/oauth/token Authorization: Basic base64(client_id:client_secret) Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=… &redirect_uri=https://your.product/callback &code_verifier=… ``` ``` { "access_token": "mk_live_…", "token_type": "Bearer", "expires_in": 3600, "scope": "signing:read signing:write", "refresh_token": "…" } ``` The access token is a key like any other on this page — send it as `Authorization: Bearer`, and every scope rule above applies. The client may also be sent as `client_id` and `client_secret` in the body. Refresh with `grant_type=refresh_token&refresh_token=…`; each refresh token is good once and the answer carries the next. A refresh token presented twice is treated as stolen: the connection is revoked, and the reason is on the business's audit trail. An AI app that registered itself without naming `refresh_token` in its `grant_types` is given no `refresh_token`: when its hour is up it sends the person through `/oauth/authorize` again ([AI assistants](https://signsealer.com/docs/mcp)). The scopes a business can grant are the ones you registered, narrowed to what the person agreeing can do themselves. The person pressing Connect has to hold the permission to manage API keys, which only owners and admins have unless it is given to somebody else ([Teams, roles and sign-in](https://signsealer.com/docs/teams)). The business sees the connection under *Integrations → Connected apps* and can end it there at any time, with a reason on its audit trail; every token under it stops working that minute, and your next call answers `401`. The connection is also the person's who agreed to it. If they are removed from the business, leave it, or are given a role or permissions that no longer include everything the connection holds, it ends the same way, with its tokens; and a refresh asks the same question every time, so one that changed any other way ends at the next refresh. A refresh token is good once, for thirty days. A refresh that is refused answers `400 invalid_grant` with a sentence saying why: send the business through `/oauth/authorize` again, as somebody who can still give what your app asks for. ## Testing Build against your sandbox with a **test key** (`mk_test_`). Make one in the portal under *Integrations → API keys → + API key*, with *Test* chosen. The first one makes the sandbox: a second account beside yours, with your address and a copy of your published templates and forms as they are that moment. `GET /v1/account` answers `"mode": "test"` for a test key and `"live"` for any other. - **Nothing is sent to anybody.** A document is prepared, sent and signed exactly as it is live, and the send's answer carries each signing link, but no email or text leaves: the sandbox is a rehearsal account, and the worker closes its messages without handing them to a provider. - **Nothing is billed.** No usage is recorded for a sandbox at all, so nothing is drawn from credit, no allowance runs out, and it can never carry a card. - **Webhooks are delivered** to the endpoints you register with the test key, signed the same way, so your handler is tested against the real thing. - **It says so.** Every signing page and public form of the sandbox opens with a line saying it is a test document and not a real agreement. - **It is framed where you are.** The sites your pages may be framed on and sent back to are your live account's, so an embedded test signing works on the site it will run on. - **Nothing crosses over.** A test key sees only the sandbox and a live key only your account. Nothing made with a test key appears in your documents, your bill or your records. Templates changed after the sandbox was made are changed in the sandbox, with the test key, like any other. Revoke a test key, with a reason, on the same page. ## Rate limits Each key may make 300 requests a minute. Every answer to a keyed request says where it stands: `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (when the window turns over, in seconds since the epoch). Past it, `429` with `Retry-After`. A business holds ten live keys until it is paying and fifty from then, and how many people it may send signing links to in an hour and a day grows with how far it is trusted. [Limits and paging](https://signsealer.com/docs/limits) has the rest. ## Revoking Revoking a key needs a reason, and the reason goes on the audit trail. A key revoked is a membership deactivated in the same statement — there is no window in which the key is "revoked but still working". --- Source: https://signsealer.com/docs/idempotency (Idempotency) # Idempotency Your connection dropped. Did the waiver send? You cannot tell a lost response from a lost request, so every write that creates something takes an `Idempotency-Key` header — your own key, 8 to 255 characters, one per logical operation. ``` Idempotency-Key: 7d3f9e2a-… ``` The semantics are Stripe's, deliberately, because you should not have to read our documentation to find out we were creative: | You send | We answer | | --- | --- | | A key never seen before | Do the work, and store the response under the key. | | The same key, the same body | The stored response, without doing the work again. | | The same key, a *different* body | `409 conflict`. Refused, never absorbed. | | The same key while the first is still running | `409`, and try again shortly. | | The same key after a failure | Do the work. A failure is retried, not replayed. | > **The third row is the one worth arguing about.** Returning the first response to a materially different request would be worse than an error: you would believe your second request happened, and it did not. So the request is fingerprinted, and a key reused across different bodies is reported. That is a bug in the caller, and a bug found loudly on the first test run is a kindness. Keys live 24 hours — long enough for any retry a sane client makes, and no longer. The body itself is never stored; only a hash of it, so comparing two requests does not mean keeping somebody's name and address in a cache for a day to solve a plumbing problem. Reads take no key. `PUT /v1/subjects` takes none either: it is keyed on `(provider, external_id)`, so importing the same reservation twice is one reservation and a key would be belt on braces. --- Source: https://signsealer.com/docs/sdk (Node client) # Node client `@signsealer/sdk`. No dependencies; runs anywhere `fetch` does. ``` npm install @signsealer/sdk ``` Node 18 or later, every edge runtime, and the browser — though an API key belongs on a server. TypeScript types ship in the package, every version on npm is published by a workflow from a tagged commit, never from somebody's machine, and the changelog ships in the package. ## Three things it does that a hand-rolled client usually does not - **Retries the failures worth retrying, with an idempotency key.** Every write that creates something gets one automatically, so a network blip does not become a second waiver. A `422` is never retried; it will be refused the same way every time, and the message says why. Pass your own key as the last argument when you have one, for example your order id. A write the server cannot de-duplicate — rotating a webhook secret, adding or pairing a tablet, a replay, the gate’s link — is never repeated after an answer that leaves it unknown whether it happened; it throws, and you decide. - **Surfaces the API's own sentence.** `SignSealerError` carries `message`, `hint`, `code`, `status` and `requestId`. The message is frequently the database's own words, which are more specific than anything a client layer could write. - **Keeps the request id.** Every response carries one; quote it and we can find the request. ## The client with a key ``` import { SignSealer, SignSealerError } from "@signsealer/sdk"; const signsealer = new SignSealer({ apiKey: process.env.SIGNSEALER_KEY!, baseUrl: "https://api.signsealer.com", // the default maxAttempts: 3, // total, the first included }); ``` | Method | Calls | Returns | | --- | --- | --- | | account() | GET /v1/account | Whose key this is: the business, the key's scopes, `live` or `test`, the sites its pages may be framed on, and whether it can text. | | branding() | GET /v1/branding | The name, colors and logos the business shows its signers, and its signing host. | | templates({ code? }) | GET /v1/templates | Every version of every template, or one template's versions; no text. | | template(id) | GET /v1/templates/:id | One version whole: text, questions, settings, and every version of its code. | | createTemplate(input, key?) | POST /v1/templates | `{ template_id }`: version 1, as a draft. | | editTemplate(id, input) | PATCH /v1/templates/:id | A draft changed; a published version refuses. | | publishTemplate(id, key?) | POST /v1/templates/:id/publish | The draft published: new documents use it, and its text is frozen. | | reviseTemplate(id, input?, key?) | POST /v1/templates/:id/revise | The next version, as a draft, from the newest. | | restoreTemplate(id, input?, key?) | POST /v1/templates/:id/restore | An older published version back, as a new draft above the newest. | | discardTemplate(id) | DELETE /v1/templates/:id | A draft nothing was made from, gone. | | setTemplateSettings(id, input) | PATCH /v1/templates/:id/settings | One-time, address, photographs in the copy, how long a signature lasts, reminders, sign again. | | templatePdf(id) | GET /v1/templates/:id/pdf | Whether a template is an uploaded PDF, and whether every page is drawn. | | documents({ status, q, external_ref, limit, offset }) | GET /v1/documents | Documents, newest first, with `total` and `has_more`. | | document(id) | GET /v1/documents/:id | One document with its signers, trail, answers and your reference. | | prepareDocument({ template_code, values, title, expires_at, sequential, external_ref, metadata, return_url, time_zone }, key?) | POST /v1/documents | `{ document_id }`. Refused with the placeholder's name if a value is missing. | | send(documentId, signers, key?) | POST /v1/documents/:id/send | `{ links }`: one per signer, shown once, each with its `url`. Each needs an email, a mobile, or both; `delivery: "embedded"` sends them nothing. | | remind(documentId, signerId, key?) | POST /v1/documents/:id/signers/:signer/remind | A fresh link, sent to them. | | embedLink(documentId, signerId) | POST /v1/documents/:id/signers/:signer/embed | A fresh link to frame, with the sites it may be framed on. | | void(documentId, reason, key?) | POST /v1/documents/:id/void | Every link dead; the reason on the trail. | | certificate(documentId) | GET /v1/documents/:id/certificate | The certificate, or null before completion. | | pdf(documentId) | GET /v1/documents/:id/pdf | `{ filename, bytes }`: the sealed PDF. | | bundle(documentId) | GET /v1/documents/:id/bundle | `{ filename, bytes }`: the evidence bundle. | | workflows() | GET /v1/workflows | Public forms, QR and kiosk workflows. | | workflowQr(workflowId) | GET /v1/workflows/:id/qr | The public form's address as a QR code: SVG and a 1024px PNG. | | startWorkflow(workflowId, participant, key?) | POST /v1/workflows/:id/start | A link for that person. With `expected_template_version` and `captured`, a start a tablet ran with no network; 409 `template_changed` if the form moved on. | | workflowBundle(workflowId) · workflowPage(workflowId, n) | GET /v1/workflows/:id/bundle · /pages/:n | The published form, its wording and versions, and its PDF pages, to hold on a device ahead of time. | | upsertReservation(input) | PUT /v1/subjects | A packet for the booking; idempotent on your reference, so it takes no key. | | packet(id) | GET /v1/packets/:id | Every item, every person, and whether the packet is ready. | | startPacket(id) · voidPacket(id, reason) | POST /v1/packets/:id/start · /void | Start everything it still owes, safe to repeat; or void what nobody has finished. | | packetByCode(code) | GET /v1/packets/code/:code | A signing code from the receipt, resolved for a paired tablet: what is owed, by first name and last initial. | | documentSigners(id) | GET /v1/documents/:id/signers | Who has signed one document, and nothing else; once signed, whether it is still good. | | standing({ template, email?, phone? }) | GET /v1/standing | Whether a person's signature on a template is still good: `current`, `expired`, `resign` or `on_file`, with `valid_until`. | | gateLink(documentId, signerId) | POST /v1/documents/:id/signers/:signer/link | A fresh link for a signer who has not signed, with a QR code as SVG. | | reportProblem(input) | POST /v1/devices/report | A tablet's problem as names and numbers; never a message. | | startPacketItem(itemId, key?) | POST /v1/packets/items/:id/start | A link for one item. | | recordSmsConsent({ phone, obtainedVia, wording }, key?) | POST /v1/sms-consents | Consent you took on your own form. [What the wording must say](https://signsealer.com/docs/texting). | | smsConsent(phone) | GET /v1/sms-consents | Whether you may text that number, and where the consent came from. | | webhooks() · addWebhook(input, key?) · setWebhook(id, input) · removeWebhook(id, reason) | /v1/webhooks | Endpoints; the secret is shown once on add. | | rotateWebhookSecret(id) · webhookDeliveries(id) · replayWebhookDelivery(deliveryId, key?) | /v1/webhooks/:id/rotate · /deliveries · /v1/webhooks/deliveries/:id/replay | A new secret, both signing for 24 hours; the last fifty deliveries; one sent again. | | integrations() · integrationEvents(connectionId) | GET /v1/integrations · /:id/events | The booking systems it can connect and has, and what arrived from one. | | devices() · addDevice(input) · newDeviceCode(id) · setDevice(id, input) · removeDevice(id, reason) | /v1/devices | Tablets from your own software; a pairing code is shown once. | | pairDevice(input) · deviceHeartbeat(input?) | POST /v1/devices/pair · /heartbeat | On the tablet: trade a code for its own credential (no key), then say it is still here. | Each method's request and response are the ones on the [API reference](https://signsealer.com/docs/api), which is generated from the router; the client adds nothing the API does not have. ## The signer's side, with no key `SignSealerSigning` takes no API key, because on those routes a signing token is the whole credential. It is a separate class on purpose: a client that could send a key alongside a token would be a client that widens somebody's authority by accident. The one credential it will carry is a paired tablet's own, given as `deviceKey`, and only on a signature that says when it was taken offline (below). ``` import { SignSealerSigning } from "@signsealer/sdk"; const signing = new SignSealerSigning(); const link = await signing.link(token); if (!link.ok) return show(link.reason); // written for a person to read await signing.consent(token); // before signing, always await signing.supply(token, "emergency_contact", "Sam Reyes, +1 503 555 0199"); await signing.sign(token, "typed", "Dana Reyes"); // or: await signing.decline(token, "wrong dates"); const result = await signing.verify("4KJ72M9PXQ3TVW8H"); // { certificate_found: true, still_matches_record: true, … } ``` Consent is a separate call and that is not an oversight: ESIGN requires consent to electronic records to precede the transaction, and a system that records both in the same instant cannot show that it did. If you would rather not build a signing page, send the signer to `https://api.signsealer.com/s/` and skip all of it. ### Signing where there is no network A paired tablet that took a signature offline passes `captured` with what the device thought the time was; the server records when it received the event and keeps the claim beside it, marked as a claim. The claim is kept only from a paired tablet of the business the link belongs to, because the day it carries is the day written into the document: build the client with the tablet's credential (the `key` `pairDevice` answered) and it is sent with that one call. From anybody else `captured` is left out without an error and the signature is dated when it arrives. ``` const signing = new SignSealerSigning({ deviceKey: tabletKey }); await signing.sign(token, "drawn", dataUri, { offline: true, at: capturedAt.toISOString(), device: "counter-2" }); ``` ### A template that is a PDF When the template behind a link is an uploaded PDF, `link.pages` lists its pages and a field may carry a `place`. Draw the pages rather than `link.body`: a PDF template's body is a sentence naming the file and its fingerprint, so rendering it would show somebody a hash where their lease should be. [Signing a PDF](https://signsealer.com/docs/pdf) has the whole of it. ``` for (const p of link.pages ?? []) { const img = new Image(); img.src = signing.pageUrl(token, p.n); // no API key: the token is the credential } /* And on the keyed side, whether a template is one of these at all. */ const pdf = await signsealer.templatePdf(templateId); if (pdf.has_pdf && !pdf.ready) { /* not every page is drawn; it cannot be published */ } ``` ## Errors ``` try { await signsealer.prepareDocument({ template_code: "rental-agreement", values: {} }); } catch (e) { if (e instanceof SignSealerError) { console.error(e.status); // 422 console.error(e.code); // "invalid" console.error(e.message); // "no value for guest_name" console.error(e.hint); // "A contract that ships with a placeholder…" console.error(e.requestId); // quote this and we can find the request } } ``` ## Other languages Python, Ruby, PHP and Go clients are not written. The API is small enough that a hand-rolled client is a morning's work; the [quickstart](https://signsealer.com/docs/quickstart) shows the four calls in Python with `requests`, and [openapi.json](https://signsealer.com/openapi.json) feeds any generator. ## Versions This page describes `@signsealer/sdk` 1.17.0. The package declares Node 18 or later (`engines` in its `package.json`), as it has since 1.0.0; outside Node it needs a runtime with `fetch`. On Node 18, use 1.11.1 or later: before it, every write not given its own idempotency key threw unless Node was started with `--experimental-global-webcrypto`. Each version's changes are in `CHANGELOG.md`, which ships in the package. ### What you have ``` npm ls @signsealer/sdk # the version installed here npm view @signsealer/sdk version # the newest on npm ``` From code, the package exports its own `package.json` (from an ES module, make `require` with `createRequire`): ``` const { version } = require("@signsealer/sdk/package.json"); ``` In TypeScript, calling a method your copy does not have is a compile error; in JavaScript it is a `TypeError` when the line runs. ### Numbers that never reached npm Eight versions in the changelog were never published: 1.0.1, 1.1.0, 1.2.0, 1.3.0, 1.5.0, 1.6.0, 1.8.0 and 1.9.0. What each added first reached npm in the next version that was. | Added in | First on npm in | | --- | --- | | 1.0.1, 1.1.0, 1.2.0, 1.3.0 | 1.4.0 | | 1.5.0, 1.6.0 | 1.7.0 | | 1.8.0, 1.9.0 | 1.10.0 | ### When each method arrived The options listed are the ones you send. Fields in an answer are not listed: the client hands back whatever the API sends, so a newer version only declares them in its types. | Method | Added in | Since then | | --- | --- | --- | | `new SignSealer({ apiKey, baseUrl, maxAttempts })` | 1.0.0 | 1.1.0: `apiKey` may be left out, for `pairDevice`. | | `account()` | 1.0.0 | | | `branding()` | 1.0.0 | 1.11.0: the `Branding` type names what the API sends (`businessName`, `primaryColor`, `hasLogo`, `signingHost`, `logoUrl` and the rest). The names it declared before were never sent, so code written against them no longer compiles. | | `templates({ code? })` | 1.0.0 | 1.6.0: `code`. | | `template(id)` | 1.6.0 | | | `createTemplate(input, key?)` | 1.0.0 | | | `editTemplate`, `publishTemplate`, `reviseTemplate`, `discardTemplate`, `setTemplateSettings` | 1.6.0 | 1.7.0: `change_note` on `editTemplate` and `reviseTemplate`. 1.10.0: `photos_in_copy` on `setTemplateSettings`. | | `restoreTemplate` | 1.7.0 | | | `templatePdf(id)` | 1.0.0 | | | `documents(filter)` | 1.0.0 | 1.0.1: `offset`. 1.6.0: `external_ref`. | | `document(id)` | 1.0.0 | | | `prepareDocument(input, key?)` | 1.0.0 | 1.6.0: `external_ref`, `metadata` and `return_url`. 1.11.0: `time_zone`. | | `send(documentId, signers, key?)` | 1.0.0 | 1.3.0: `phone` on a signer. 1.6.0: `delivery: "embedded"`. 1.14.0: a signer held for their turn has `token: null` and `held: true`. 1.16.0: `for_business` on a signer. | | `remind`, `embedLink` | 1.6.0 | | | `void`, `certificate`, `pdf`, `bundle` | 1.0.0 | | | `workflows()`, `workflowQr(workflowId)` | 1.0.0 | 1.17.0: `email_check`, the website link's limits, `closed` and `needs_attention` on each workflow, and `address` among its questions' kinds. | | `startWorkflow(workflowId, participant, key?)` | 1.0.0 | 1.1.0: `expected_template_version` and `captured`. 1.14.0: a fixed role held for their turn has `token: null` and `held: true`. 1.16.0: `for_business` on each link. | | `workflowBundle`, `workflowPage` | 1.1.0 | | | `upsertReservation`, `packet`, `startPacketItem` | 1.0.0 | | | `startPacket`, `voidPacket` | 1.9.0 | | | `packetByCode`, `documentSigners`, `gateLink`, `reportProblem` | 1.2.0 | | | `standing({ template, email?, phone? })` | 1.5.0 | | | `recordSmsConsent`, `smsConsent` | 1.0.0 | | | `textingNumberInvitation(prefill?)` | 1.13.0 | | | `webhooks`, `addWebhook`, `setWebhook`, `removeWebhook`, `rotateWebhookSecret`, `webhookDeliveries`, `replayWebhookDelivery` | 1.0.0 | | | `integrations`, `integrationEvents` | 1.9.0 | | | `devices`, `addDevice`, `newDeviceCode`, `setDevice`, `removeDevice` | 1.1.0 | 1.7.0: `hand_off` and `gate` on `setDevice`. 1.11.0: `workflow_ids`. | | `pairDevice`, `deviceHeartbeat` | 1.0.0 | 1.2.0: `queue_waiting`, `queue_refused` and `synced` on `deviceHeartbeat`. | | `SignSealerSigning`: `link`, `consent`, `supply`, `sign`, `decline`, `verify`, `pageUrl` | 1.0.0 | 1.11.0: `deviceKey` on the constructor. | | `SignSealerSigning`: `addParty`, `partyShare`, `partyLink` | 1.15.0 | | | `SignSealerPartner`: `profile`, `businesses`, `statements`, `SignSealerPartner.signupUrl` | 1.8.0 | | > **Retries changed in 1.6.0.** Before it, `rotateWebhookSecret`, `replayWebhookDelivery`, `pairDevice`, `addDevice`, `reportProblem` and `gateLink` were sent again after a lost answer or a `5xx`, and could happen twice. From 1.6.0 each is sent once and throws, and you decide. On npm that is 1.7.0 or later. --- Source: https://signsealer.com/docs/mcp (AI assistants (MCP)) # AI assistants (MCP) SignSealer is a remote MCP server at `https://api.signsealer.com/mcp`. Connect it to Claude, ChatGPT, Codex, Claude Code, Cursor or VS Code, and ask: "send the rental agreement to Ada Lovelace", "who still hasn't signed tomorrow's waivers?", "remind them". The [Model Context Protocol](https://modelcontextprotocol.io) is how an assistant is given tools. Ours are the API: each tool makes the same `/v1` call a key would, with the same scopes, limits and answers, so an assistant can do exactly what the person who connected it allowed and nothing the API would refuse. ## Connect it Every client below signs in the same way: it opens SignSealer's sign-in, you choose the business, see what it is asking for, and press **Connect**. There is no key to copy. You need the permission to manage API keys in that business, which owners and admins have ([Teams, roles and sign-in](https://signsealer.com/docs/teams)). You can disconnect it at any time under **Integrations → Connected apps**, and it stops that minute. ### Claude (claude.ai and Claude Desktop) Settings → Connectors → **Add custom connector**. Name it SignSealer, give the URL `https://api.signsealer.com/mcp`, and press **Connect**. ### ChatGPT Settings → Apps & Connectors. With developer mode on, create a connector with the URL `https://api.signsealer.com/mcp` and OAuth as its authentication. ### Claude Code ``` claude mcp add --transport http signsealer https://api.signsealer.com/mcp ``` Then `/mcp` in a session to sign in. ### Codex In `~/.codex/config.toml`: ``` [mcp_servers.signsealer] url = "https://api.signsealer.com/mcp" ``` ``` codex mcp login signsealer ``` ### Cursor In `~/.cursor/mcp.json` for every project, or `.cursor/mcp.json` in one: ``` { "mcpServers": { "signsealer": { "url": "https://api.signsealer.com/mcp" } } } ``` Cursor signs in when it first connects. To add it in one step instead, open this link in a browser on the computer Cursor is on: ``` cursor://anysphere.cursor-deeplink/mcp/install?name=signsealer&config=eyJ1cmwiOiJodHRwczovL2FwaS5zaWduc2VhbGVyLmNvbS9tY3AifQ== ``` ### VS Code In `.vscode/mcp.json` in a workspace, or in your own `mcp.json` (the command **MCP: Open User Configuration**): ``` { "servers": { "signsealer": { "type": "http", "url": "https://api.signsealer.com/mcp" } } } ``` VS Code shows **Start** above the server, and signs in when it starts. ### Anything else A client that speaks MCP over HTTP finds the sign-in by itself from the URL: ``` { "mcpServers": { "signsealer": { "url": "https://api.signsealer.com/mcp" } } } ``` One that cannot sign in can send an API key instead, from **Integrations → API keys** with `signing:read` and `signing:write`, as a header: ``` { "mcpServers": { "signsealer": { "url": "https://api.signsealer.com/mcp", "headers": { "Authorization": "Bearer ${env:SIGNSEALER_KEY}" } } } } ``` Keep the key in the environment, not in a file you commit. A key that holds only `signing:read` gives an assistant that can look but not send. ## What it can do | Tool | What it does | Kind | Needs | | --- | --- | --- | --- | | signsealer_get_account | **Who am I connected as.** The SignSealer business this connection acts for: its name, what this connection may do (its scopes), whether it is a test sandbox where nothing reaches anybody, and whether the business can text people. Call it first, and tell the user which business they are connected to. | Reads | `signing:read` | | signsealer_list_templates | **List templates.** The business's templates (the documents it sends), newest version first, with each one's code, title and whether it is published. Pass `code` for one template's versions. | Reads | `signing:read` | | signsealer_get_template | **Read a template.** One template version: its text and the values it takes (the blanks a sender fills in), which signsealer_send_document's `values` answers. | Reads | `signing:read` | | signsealer_list_documents | **List documents.** Documents sent for signature, newest first. Filter by `status` (draft, sent, completed, declined, expired, void, or open for draft and sent) or `q` (part of a title or a signer's name, email or number). Paged with `limit` and `offset`; the answer says `total` and `has_more`. | Reads | `signing:read` | | signsealer_get_document | **Read a document and who has signed.** One document: its status and its signers, each with where they are (pending, signed, declined). Use it to answer "has everyone signed?". Personal detail is left out unless asked for: the text (include_text), the signers' answers to its questions (include_answers), and the event trail with addresses and devices (include_trail). | Reads | `signing:read` | | signsealer_send_document | **Send a document for signature.** Prepares a document from a published template, fills in its values, and sends each signer their own link by email (or by text to a number that agreed to texts). This emails or texts real people: confirm the template, the values and every recipient with the user first. Answers with the document's id and each signer's link. If a send is refused after the document was prepared, the refusal names it: correct what it says and call this again with that document_id instead of template_code, so no second copy is made. | Changes something | `signing:send` | | signsealer_remind_signer | **Remind a signer.** Sends one signer who has not signed a reminder with their link. Emails or texts a real person, at most as often as the business's reminder rules allow. | Changes something | `signing:remind` | | signsealer_new_signing_link | **Make a fresh signing link.** A new signing link for one signer, to hand over yourself (in a chat, on a screen, as a QR code). The signer's previous link stops working. | Changes something | `signing:links` or `signing:workflows` | | signsealer_void_document | **Void a document.** Cancels a document that is not finished: its links stop working and its signers are told. A completed document cannot be voided. Cannot be undone. | Changes something | `signing:void` | | signsealer_get_certificate | **Read a document's certificate.** The certificate of completion of a signed document: who signed, when, from where, the fingerprint of what they signed, and the code anyone can check it with. | Reads | `signing:read` | The four that change something are marked as such to the client, which asks you before it runs one. The server also tells the assistant to confirm the template, the values and every recipient with you before it sends anything, and never to send to an address you did not give it. Sending takes an `idempotency_key` the assistant makes up for that one send (reminding and voiding take one too, if given). An assistant whose connection drops calls again with the same key and is answered with what the first call did, so a retry never sends a document twice. Reading a document gives its status and its signers; the text, the signers' answers and the event trail, with its network addresses and devices, come only when asked for. ## What you are asked to allow An assistant asks for `signing:read`, `signing:send`, `signing:remind`, `signing:links`, `signing:void`. Reading documents, templates and who has signed is given when you connect it. Each of the four that reaches people or stops a link is a box on SignSealer's sign-in page, and none is ticked until you tick it: - **Send documents for signature** (`signing:send`) - **Remind a signer who has not signed** (`signing:remind`) - **Make a fresh signing link** (`signing:links`) - **Void a document that is not finished** (`signing:void`) An assistant that only needs to tell you who has signed gets no box ticked. One asked to send a document is refused if *Send documents for signature* was left clear, and says so: nothing is sent. To change what it may do, connect it again and tick the boxes you want; what you tick replaces what it had, at once, even for the sign-in it already holds. It is never given more than you hold yourself, and never the scopes that manage templates, webhooks, workflows or keys. An API key with `signing:write` holds all four. The sign-in page says **Not checked by SignSealer** for an app that introduced itself, which every assistant does, and names where your browser goes back to afterwards: the assistant's own site, or *an app on this computer* for Codex and Claude Code. Connect it only when you started the connection yourself, just now. ## For client builders | What | Where, and how | | --- | --- | | Transport | Streamable HTTP, stateless: one JSON-RPC message per `POST /mcp`, answered with one JSON object; a notification is answered `202`. No event stream (`GET` is `405`) and no session. Protocol version `2026-07-28`, with no handshake: each request names its version in `_meta`, and `server/discover` says which versions this server speaks. And, opened with `initialize`, `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05`. | | Without a token | `401` with `WWW-Authenticate: Bearer resource_metadata="https://api.signsealer.com/.well-known/oauth-protected-resource/mcp"` and the scopes. | | Protected resource (RFC 9728) | `https://api.signsealer.com/.well-known/oauth-protected-resource/mcp` | | Authorization server (RFC 8414) | `https://api.signsealer.com/.well-known/oauth-authorization-server` | | Identifying your app | An OAuth Client ID Metadata Document: use its https address as `client_id`, and we read it, check its `client_id` is that address, and take its name and `redirect_uris`. Or register (RFC 7591) at `POST https://api.signsealer.com/oauth/register`. Redirects are https, or the loopback (`http://127.0.0.1`, `http://[::1]`, `http://localhost`) with any port. | | The grant | Authorization code with PKCE (`S256` only). Refresh tokens only for an app that asks for them: name `refresh_token` in `grant_types`, in its registration or its metadata document; left out, the grant is the code alone, as RFC 7591 reads it. A `resource` (RFC 8707) must be this API, `https://api.signsealer.com` or `https://api.signsealer.com/mcp`; anything else is `invalid_target`. | | Limits | The API's, per token: see [Limits and paging](https://signsealer.com/docs/limits). A tool's refusal is the API's own status, message and hint. | Building on the API directly instead? The [quickstart](https://signsealer.com/docs/quickstart) and the [Node client](https://signsealer.com/docs/sdk) are the way in, and [llms-full.txt](https://signsealer.com/llms-full.txt) is every page of these docs as one text file, for a coding assistant to read. --- Source: https://signsealer.com/docs/templates (Templates and versions) # Templates and versions The text people sign, and why it is never changed under them. Watch it done: [Have a waiver drafted, then have it read](https://signsealer.com/guides/have-a-waiver-drafted-then-read) (2:49) A template has a **code** (`rental-agreement`) and numbered **versions**. A version is a draft until it is published, and a published version is frozen: the database refuses to change or delete it, because somebody may have signed it. Changing the words means a new version. Each document records the exact version it was made from, and the certificate says which, so "what did they sign?" always has one answer. ## The lifecycle | Step | Call | What happens | | --- | --- | --- | | Create | POST /v1/templates | Version 1, as a draft, with its text, placeholders, consent wording and questions. | | Change the draft | PATCH /v1/templates/{id} | `name`, `body`, `body_format`, `variables`, `fields` (the whole list). Only while it is a draft. | | Publish | POST /v1/templates/{id}/publish | New documents from this code use this version, and its text is frozen. Refused, by name, for a placeholder the template does not declare. | | Revise | POST /v1/templates/{id}/revise | A new draft, one version higher, copied from the newest with whatever you change. Publish it to put it into use. | | Go back | POST /v1/templates/{id}/restore | An older published version's text, questions, consent wording and file, as a new draft above the newest. Its `change_note` says where it came from. Not while a draft is open. | | Throw a draft away | DELETE /v1/templates/{id} | Only a draft nothing was made from. The only version of a code going frees the code. | | Read one | GET /v1/templates/{id} | The version whole, with `versions`: every version of its code, newest first. | | List | GET /v1/templates?code= | Every version of every template, or one code's; no text. | ``` // A new clause for next season, without touching what people signed this one. const current = await signsealer.template(templateId); const next = await signsealer.reviseTemplate(current.template_id, { body: current.body + "\n\nHelmets are required on every trail.", }); await signsealer.publishTemplate(next.template_id); ``` Revise only from the newest version. Revising an older one would quietly throw away every change made since, so it answers `409 conflict`, and the hint names the newest version's id. A draft is not revised; it is changed with `PATCH`. **What changed, and going back.** Each version carries a `change_note` of up to 500 characters: set it on `revise` or with `PATCH` while it is a draft, and it is frozen with the version when it is published. `GET /v1/templates/{id}` lists every version with its note, name, whether it has a PDF and how many documents were made from it -- the same list as the dashboard's history page, which also compares any two versions line by line. To go back to an older wording, `POST /v1/templates/{id}/restore` on that version makes a new draft above the newest; the versions in between keep their numbers and their signatures. ## Questions and fields A template's `fields` are what it asks besides the signature: a date of birth, a tick against each safety statement, a photo of the boat before it leaves. Send them as a list on `POST /v1/templates`, or as the whole list on `PATCH /v1/templates/{id}` and `POST /v1/templates/{id}/revise`, where the list you send replaces the one it had. `GET /v1/templates/{id}` answers with them in the same shape. A template asks at most fifty. The list is checked twice. The API checks each question's shape and answers `422 invalid` naming the property. The engine then reads the list as a whole and refuses it with `422` and a sentence naming the question: a condition on a question below it, a block with two headings, a choice with one option. A refused list on a create leaves no template behind. > **A property that is not in the table below is dropped without a word.** A misspelt `show_if` saves, answers `201`, and leaves a question that is always asked. Read the template back with `GET /v1/templates/{id}` when you are building the list. ### Kinds Every answer is checked by the engine when it is given, whatever the page or your client did first. An empty answer to a required question is refused as required; an optional one may be left empty. | Kind | What it accepts | How the signing page asks it | | --- | --- | --- | | `signature` | Anything that is not empty. Cannot be optional. | Placed on an uploaded PDF, it is the box the signature itself fills, and is never asked. Asked under the document, it is a typed line: a second signature, typed. | | `initials` | Anything that is not empty. Cannot be optional. | A one-line box. On a page, up to six characters. | | `checkbox` | Ticked is `true`, `yes`, `on` or `1`, in any case. A required one has to be ticked. | On its own, a button: *I confirm:* and the label, and *Leave it unticked* beside it when it is optional. In a block, a tickbox. | | `text` | Anything that is not empty. | A one-line box. | | `paragraph` | Anything that is not empty. No minimum length: "No damage" is an answer. | A box several lines tall. | | `date` | `YYYY-MM-DD`, and a day the calendar has: `2026-02-31` is refused. | A date picker. Placed on a page with a signing-date key or label, it is filled by signing instead ([Signing a PDF](https://signsealer.com/docs/pdf)). | | `birthdate` | As a date, and no later than tomorrow (a day of slack for time zones), with the year 1900 or later, so `0084-05-02` is refused. | Month and day picked, the year typed. On a page, a date picker. | | `time` | `HH:MM` on a 24-hour clock: `14:30`, `9:05`. | A time picker. | | `datetime` | `YYYY-MM-DDTHH:MM`, or a space for the `T`, on a day the calendar has. | A date-and-time picker. | | `photo` | A JPEG or PNG, sent to the photo route. Text is refused. The answer becomes `sha256:`. | A camera button. An optional one offers *Carry on without a photo*. | | `email` | Something, an `@`, then a domain with a dot in it, and no spaces. | An email box, with the email keyboard. | | `phone` | At least seven digits; anything else in it is ignored for the count. | A country code to pick and the number to type. On a page, one box. | | `address` | At least five characters. | A box several lines tall, rather than street, city and zip: those are not the shape of an address everywhere. | | `number` | Digits, with a minus in front and a decimal point if needed: `12`, `-3`, `4.5`. No commas, no units. | A number box, with the number keyboard. | | `choice` | Exactly one of its `options`, as written. | Six options or fewer are buttons to tap; more are a list. | ### Properties | Property | Takes | What it does | | --- | --- | --- | | `key` | Required. A lower-case letter, then up to 58 lower-case letters, digits and underscores. Once per list. | Names the answer everywhere: in `answers`, on `POST /v1/sign/{token}/fields`, and in `values` for a box the sender fills. | | `kind` | Required. One of the fifteen above. | What the answer must be, and the box it is asked in. | | `label` | 1 to 200 characters. | What the signer reads. Without one, the key is shown. | | `required` | `true` or `false`. `true` when absent. | The signature is refused until a required question is answered. An optional one can be passed, and the signature does not wait for it. A `signature` or `initials` cannot be `false`. | | `hint` | Up to 200 characters. | A line under the question, read with it by a screen reader. | | `options` | A `choice` only: 2 to 30 of them, each 1 to 80 characters, none blank. | What can be picked. Any other kind carrying `options`, even an empty list, is refused. | | `sensitive` | `true` or `false`. `false` when absent. | The answer is masked wherever the business's staff read it back and in the API. It is not masked on the sealed PDF, because the PDF is the document that was signed. On the page the box turns off the browser's autofill and suggestions, and a box on a PDF page does not show the answer back. | | `supplied_by` | `adult` (when absent), `per_child`, `staff` or `business`. | Who answers it: below. | | `show_if` | An object: `field`, the key of a question earlier in the list, and one rule. Or several such rules, joined by all or any. | Asked only when those earlier answers match: below. | | `flag_when` | An object with one rule, about this question's own answer. | Marks the answer for a person to read: below. | | `block` | A name shaped like a key. | Questions with the same `block` are asked together, as one thing. | | `block_label` | Up to 200 characters. | The block's heading. Given once, on any question in the block. Every block needs one; two different ones are refused, and so is one on a question with no `block`. | | `block_intro` | Up to 2,000 characters. | A sentence or two under the heading, given once on the same terms. | | `block_step` | `true` or `false`. | `true` on any question of a block gives the whole block a page of its own. Refused on a question with no `block`. | | `place` | `{page, x, y, w, h}`: a page counted from one and no higher than the file has, and four fractions of the page from its top left, for a box with a width and a height that fits on it. | Where on an uploaded PDF's page the question is answered ([Signing a PDF](https://signsealer.com/docs/pdf)). | `show_if`, `flag_when`, `block`, `block_label`, `block_intro`, `block_step` and `place` may also be `null`, which is the same as leaving them out. ### Conditions and flags `show_if` and `flag_when` both take these four rules, exactly one of them per object: | Rule | Matches | | --- | --- | | `{"ticked": true}` | A ticked box. A `checkbox` only. | | `{"unticked": true}` | A box not ticked, including one never answered. A `checkbox` only. | | `{"answered": true}` | Any answer that is not empty. | | `{"equals": ["Yes", "Not sure"]}` | One of these answers, ignoring case and the spaces around it. Not for a `photo`. | For `show_if` the rule is about the question it names, so `ticked` needs that question to be a checkbox: `{"field": "injured", "ticked": true}`. The named question has to be above this one, because a form is read top to bottom; one below it, or the question itself, is refused. A hidden question is not required, and the signature does not wait for it. Change the earlier answer so that a question is hidden again and its answer is cleared, along with any that depended on it, so nothing is kept in answer to a question the signed document never asked. A `show_if` rule can also say more than the four: | Rule | Matches | | --- | --- | | `{"not_equals": ["No"]}` | An answer, and none of these. An empty answer does not match, so the question waits for the one it depends on. Not for a `checkbox`, `photo`, `signature` or `initials`. | | `{"contains": "allergy"}` | An answer with these words in it, ignoring case. A `text`, `paragraph`, `email`, `phone` or `address`. | | `{"not_contains": "nuts"}` | An answer without these words in it, ignoring case. Like `not_equals`, an empty answer does not match. The same kinds as `contains`. | | `{"gt": 10000}`, `{"lt": 500}` | A number above or below this one. A `number` only. | | `{"age_under": 18}`, `{"age_at_least": 18}` | Whole years from the date in the answer to today, under or at least this many. A `birthdate`, `date` or `datetime`; from 1 to 120. | Several rules, up to ten, are joined: `{"match": "all", "rules": [...]}` asks the question when every rule is met, and `"match": "any"` when one is enough. Each rule names its own question: ``` "show_if": { "match": "any", "rules": [ { "field": "birthday", "age_under": 18 }, { "field": "with_school", "ticked": true } ] } ``` A part of a condition can be a group of two or more rules with its own `match`, which is how *(A and B) or C* is written. A group holds rules, not further groups, and a condition has at most ten rules in all, counting those in its groups. And `"action": "hide"` turns a condition round: the question is hidden when the condition holds, and asked otherwise. ``` "show_if": { "action": "hide", "match": "any", "rules": [ { "match": "all", "rules": [ { "field": "member", "unticked": true }, { "field": "birthday", "age_under": 18 } ] }, { "field": "injured", "answered": true } ] } ``` In the dashboard's builder, the condition says **Show** or **Hide**, and **+ Add a group** adds one. In a form's lines the same are `hide if` in place of `show if` on every part, a group as `show if all: member is unticked; birthday is under age: 18` (its rules between semicolons), and `show if notes does not contain: nuts`. A rule about a question that was itself not asked is not met, whatever it says: its answer was cleared when it was hidden. ### A paragraph that depends on an answer A paragraph of the document can be there only when an answer says so: the treatment consent's extra clause for somebody who is pregnant, the photo release left out for somebody who said no. Put a line of its own above it and another below it, in the words a question's `show if` takes: ``` [[show if pregnant is: Yes]] My doctor has cleared me for this treatment while pregnant. [[end]] [[show if birthday is under age: 18 | show if with_school is ticked | show if any]] A parent or guardian has read this with me. [[end]] [[hide if photos is ticked]] We will not take or use any photographs of you. [[end]] ``` `[[hide if ...]]` leaves a paragraph out when its condition holds, and a group is written as on a question's line: `[[show if any: birthday is under age: 18; with_school is ticked | show if notes does not contain: cleared]]`. In the dashboard, **Show a paragraph only if…** under the text writes the two lines for you. A paragraph can name a question the signer answers, or a box you fill in on an uploaded PDF; not one asked once per child, not a question your staff fill in that has no place on the page, since nobody answers it on the document, and not a box the signature fills in, such as the signing date, which has no answer until after the paragraphs are shown. The markers are refused when the text is saved if they cannot be read, and a question they name that the template does not have, or cannot name, is refused when it is published. **The first signer's answers decide it**: the first person in signing order who is not only copied in. One document has one text, so it is decided once. That signer is asked every question before the document, optional ones too, and reads it with the paragraphs their answers called for; when they sign, which paragraphs applied is frozen, and anybody else on the document waits until then, so nobody signs a text that could still change. The signed copy has only the paragraphs that applied, and its certificate says how many and whose answers decided them. The fingerprint is of the text as it was sent, every paragraph and its condition included, and the answers that decided it are in the evidence. The printable blank copy and the previews show every paragraph, each with the sentence saying when it applies. Over the API, `GET /v1/sign/{token}` says so under `paragraphs`, and a page that draws the document itself passes back which paragraphs it drew when it signs. While they are undecided, the first signer's signature is refused without that, so an app or an integration that predates it cannot freeze a paragraph its signer never saw; see [the API reference](https://signsealer.com/docs/api). `flag_when` marks an answer somebody at the business should read: the shooter who left *I am not under the influence of alcohol* unticked. The rule is kept with the document when it is sent, and the mark is worked out from the rule and the answer, so it cannot disagree with either. A flagged answer is listed under **Needs review** in the dashboard once the document is finished, and comes back `flagged: true` in the API. The signer is never told: a page that said so would teach the next signer which answer avoids the question. The flag is not on the certificate either, which is evidence of what was said, not of what the business made of it. ### Who answers Every question is put to every person on the document who signs, approvers and witnesses included, and to nobody copied in, except the person signing for the business. A document with two signers asks each of them the whole list, and each answers for themselves. `supplied_by` changes that: - **`adult`**: the person signing. The ordinary case. - **`per_child`**: asked once for each child the document covers, named, with the child's name after the label. A document knows its children when it was started from a packet ([Reservations and packets](https://signsealer.com/docs/packets)) or by a booking's party ([Signers, roles and order](https://signsealer.com/docs/signers)); one sent with `POST /v1/documents/{id}/send` covers nobody, so it asks none. It cannot be placed on a page: one box cannot hold an answer per child. - **`staff`**: never asked of a signer. Placed on an uploaded PDF, it is filled in by whoever sends the document, under `values` on `POST /v1/documents` or `fields` or `values` on a workflow start, and a required one that is missing refuses the send. The signer sees it filled in and cannot change it. Such a box cannot be a signature, initials, a photo or the signing date, cannot be `sensitive`, and cannot have a `show_if`, because it is filled in before anybody has answered anything. `GET /v1/workflows` lists a workflow's as `sender_fields`. - **`business`**: answered by the person who signs for the business, and nobody else. On an uploaded PDF, the business's own signature line, its date and its name. That person is the signer sent with `for_business: true`, or a workflow's fixed role with `business: true`. Everybody else's questions are not asked of them, so each box on the page has one person's answer. A `business` question's `show_if` can name only another `business` question, and nobody else's can name one. Sent with nobody for the business, its boxes stay empty. ### What the signer sees The signing page runs no script, so every box is one the browser draws and checks itself. None of that is the check; the engine is. - The questions come after the signer agrees to sign electronically and before the signature, under the document, in the order of the list. A workflow can ask them before the document instead (`document_after_questions` on `GET /v1/sign/{token}`). - A block's questions are drawn under its heading and introduction. Several in a block are one form with one **Continue**, unless the block holds a photo or a per-child question; then each is asked on its own. The page groups a block's questions where they sit next to each other in the list, so keep them together. - A block with `block_step` is a page of its own. The page always opens on the first step with anything left to answer, so a signer who comes back lands where they stopped, and a step whose questions are all hidden is skipped. - An optional question says *(optional)* beside its label and can be passed: *Save or skip*, *Leave it unticked*, *Carry on without a photo*. - A question placed on an uploaded PDF is a box on the page, filled in on the same step as the signature. A signature box and a signing-date box are filled by signing, and a box the sender filled is shown and not asked ([Signing a PDF](https://signsealer.com/docs/pdf)). Your own signing screen reads the questions from `GET /v1/sign/{token}`, each with `visible`, the engine's own answer to its condition: draw only the visible ones. A screen that decides conditions itself as the answers come in has `rules_at_start`, the engine's answer to each rule on its own, for a rule about a question it does not ask, and `condition_zone`, the zone whose day an age is counted on. Answer with `POST /v1/sign/{token}/fields`, taking `key`, `value` (up to 20,000 characters) and, for a per-child question, `about`; a photo goes to `POST /v1/sign/{token}/fields/{key}/photo`. A refused answer is `422` with the engine's sentence, such as *Date of birth needs the year in full, like 1984*. While a required, visible question is unanswered, `POST /v1/sign/{token}` is refused with `422` naming each one: *still needed before signing: …*. ### An example A kayak rental waiver: a safety statement that is optional but flagged when left unticked, a birthday, a section on its own page with a follow-up that is only asked after a yes, a medical note that is masked and always flagged when given, an optional photo, and initials. ``` "fields": [ { "key": "swimmer", "label": "I can swim 50 metres unaided", "kind": "checkbox", "required": false, "flag_when": { "unticked": true } }, { "key": "date_of_birth", "label": "Date of birth", "kind": "birthdate" }, { "key": "paddled_before", "label": "Have you paddled before?", "kind": "choice", "options": ["Yes", "No"], "block": "experience", "block_label": "Your paddling", "block_intro": "So we can give you the right boat.", "block_step": true }, { "key": "trips", "label": "Roughly how many trips?", "kind": "number", "block": "experience", "show_if": { "field": "paddled_before", "equals": ["Yes"] } }, { "key": "medical", "label": "Anything medical we should know about?", "kind": "paragraph", "required": false, "sensitive": true, "hint": "Only what affects you on the water.", "flag_when": { "answered": true } }, { "key": "hull_photo", "label": "Photo of the boat before you launch", "kind": "photo", "required": false }, { "key": "rules_initials", "label": "Initial to confirm you read the safety rules", "kind": "initials" } ] ``` ### How answers come back `GET /v1/documents/{id}` carries `answers`: every question on the document, in the order the form asks it, for each signer in turn. ``` "answers": [ { "signer_id": "5f0a…", "key": "swimmer", "label": "I can swim 50 metres unaided", "kind": "checkbox", "block": null, "about": null, "required": false, "sensitive": false, "asked": true, "value": "", "supplied_at": "2026-10-03T14:20:51Z", "flagged": true }, { "signer_id": "5f0a…", "key": "medical", "label": "Anything medical we should know about?", "kind": "paragraph", "block": null, "about": null, "required": false, "sensitive": true, "asked": true, "value": "•••••••thma", "supplied_at": "2026-10-03T14:21:30Z", "flagged": true } ], "flagged": 2 ``` - `asked` is whether the question was on that signer's page. A question its condition hid is `asked: false`, has no value, and is never flagged. - A `sensitive` value is masked: shorter than eight characters, it is all dots; longer, dots and its last four. - A photo's value is `sha256:`, the fingerprint of the photograph kept with the document. - `about` names the child a per-child answer is for, and `block` the block it was asked in. - `flagged` on the document counts the flagged answers. - A redacted document keeps its questions and gives up every value, and each child's name: `about` reads *Redacted child 1*, *Redacted child 2* and so on, the same child the same number on every question. The AI assistants' `signsealer_get_document` leaves the answers out, and says how many there are, unless asked with `include_answers: true` ([AI assistants](https://signsealer.com/docs/mcp)). Webhooks do not carry answers, on purpose: a webhook goes to whatever address was typed into a form, and some answers are about somebody's health. Two events come close. `signing.photo_supplied` names the question and the child it was about and carries the photo's SHA-256, never the image; and when the sender filled boxes on an uploaded PDF, `signing.created` lists their keys under `detail.filled_by_sender`, never the values ([Webhooks](https://signsealer.com/docs/webhooks)). Wait for `signing.completed`, then read the document. ### What is frozen, and when The list is part of the version. While the version is a draft it changes freely; once it is published, `PATCH` answers `422`, and the way to ask something different is the next version, with `fields` on `POST /v1/templates/{id}/revise`. What you were asked to initial is part of what you agreed to. Then each document keeps its own copy. When a document is sent, every question is written onto it for each signer: its label, kind, options, hint, whether it is required or sensitive, its block's heading and introduction, its condition, its flag rule and its place on the page. A later version changes nothing about a document already sent, so a flag rule changed next season does not decide what last season's answers meant. ## What happens to what was already sent - **Documents already sent keep their version.** The text is stored on the document when it is prepared, and a new version changes nothing about a document on its way to a signer. - **New documents use the newest published version.** A draft is never sent. - **A tablet that ran the form with no network** names the version it showed (`expected_template_version`) when it catches up. If a newer one was published meanwhile, the start answers `409 template_changed` and nothing is sealed against words the person did not read. ## Settings that outlive a version Some settings belong to the code, not one version, and carry over to every version after: `PATCH /v1/templates/{id}/settings` with any of | Setting | Means | | --- | --- | | valid_months | How long a signature stays good, in months; `null` for as long as the document is kept. `GET /v1/standing` answers against it. | | remind_days | Days before the end each signer is emailed a link to sign again. Needs `valid_months`. | | resign | `true`: everybody who signed an older version must sign the newest published one; their standing reads `resign`. `false` lifts it. | | one_time | Made to send once rather than reused. | | show_address | The business's address printed under it. | | photos_in_copy | The photographs a signer takes as answers are printed after the certificate in the signed PDF, each with its SHA-256, under the same seal. On unless turned off; the certificate keeps every hash either way. | ### What an NDA grants An NDA's template can carry `grants`: a JSON object, set when the template is created (`POST /v1/templates`), recording what signing it gives the signer. SignSealer keeps it and does not act on it: who may see your material is for the system that holds the material to decide. A template of any other `kind` that carries one is refused with `422` (“only an NDA carries a grant”). Grants belong to a version, as its text does. `PATCH` does not change them; a revision carries the newest version's forward, and a restore brings back the restored version's. They come back on the template (`GET /v1/templates/{id}`); on each document made from it, as `grants` on `GET /v1/documents/{id}`, where each signer whose role is `signer` is also marked `grants: true`; on the signing link (`GET /v1/sign/{token}`); and in the certificate, as `payload.document.grants` from `GET /v1/documents/{id}/certificate`, sealed into its hash when the document completes. The sealed PDF does not print them, and the `signing.completed` webhook does not carry them: read the document or its certificate when it arrives. ## A template that is a PDF Uploading the file is a dashboard step, where the boxes are placed on its pages; there is no API route for the upload yet ([Signing a PDF](https://signsealer.com/docs/pdf)). Everything on this page works on it afterwards, except changing its text: a PDF's text is the file. Revise it to rename it or change its questions; to change what it says, start a new template from the new file. --- Source: https://signsealer.com/docs/embedding (Signing inside your product) # Signing inside your product The person is already in your app. Do not send them to their inbox. Watch it done: [Embedded signing in your own product](https://signsealer.com/guides/embedded-signing-in-your-own-product) (1:55) Three ways to put signing in front of somebody, from least work to most: - **Send them to our page.** The `url` in the send's answer, in a new tab or as a redirect; set `return_url` on the document and the signed page offers the way back. - **Frame our page** on your own site. This page. - **Build your own page** on the signing routes (`/v1/sign/{token}`), which take no key and answer a browser on any site. The [Node client](https://signsealer.com/docs/sdk)'s `SignSealerSigning` is that client. ## 1. Send without sending Give the signer `delivery: "embedded"`. Nothing is emailed or texted to them, and the automatic reminders leave them alone: you are showing them the page yourself. They still need an email address or a mobile number, because the certificate records how they could be reached and the completed copy goes to them. ``` const { links } = await signsealer.send(documentId, [ { email: "dana@example.signsealer.com", full_name: "Dana Reyes", delivery: "embedded" }, ]); ``` ## 2. A fresh link when the page is shown Do not keep the link from the send in your database: it is a bearer credential. Mint one when the person opens the screen that shows it. The link they had stops working, and the trail records that one was shown. ``` const { url, frame_origins } = await signsealer.embedLink(documentId, signerId); // POST /v1/documents/{id}/signers/{signer}/embed ``` ## 3. Put it in a frame, on a site the business has proven The signing page may be framed only on sites the business has proven are its own, and the browser enforces it (`frame-ancestors`): anywhere else the frame stays empty. A business proves a site in one of three ways, the same three that let a signed page send somebody back to it: - a **signing domain** it verified (Settings, under *Your own domains*), and every host under it; - a **company sign-in domain** it proved (Settings → Single sign-on), and every host under it; - the **site of an app it connected**, such as a WordPress site running our plugin: that one host. Not an AI assistant's: connecting Claude does not make claude.ai one. `frame_origins` on the embed answer, and on `GET /v1/account`, lists them. Empty means nowhere yet. The business's public forms (`/f/{business}/{form}`) may be framed on the same sites. ``` ``` `allow="camera"` lets a template that asks for a photograph use it; `geolocation`, one that asks where the signer is. ## 4. Hear what happened Inside a frame the page posts a message to the page around it: ``` { source: "signsealer", event, document_id, signer_id, height } ``` | event | When | | --- | --- | | ready | Every page, as it loads. | | resize | The page's height changed: set the frame's height to `height` and there is no inner scrollbar. | | signed | They have signed. Other signers may still be to come; `signing.completed` on your webhook is when everyone has. | | declined | They declined. | ``` window.addEventListener("message", (e) => { if (e.origin !== new URL(url).origin) return; // only the signing page const m = e.data; if (!m || m.source !== "signsealer") return; if (m.event === "resize" || m.event === "ready") frame.style.height = m.height + "px"; if (m.event === "signed") showThanks(); if (m.event === "declined") showDeclined(); }); ``` Treat the message as a hint to update the screen, not as proof: anything that matters — releasing an order, unlocking a booking — waits for the webhook, which is signed, or for `GET /v1/documents/{id}`. ## What the page does and does not do in a frame - It sets no cookie, so a browser that blocks third-party cookies changes nothing. - It carries one script in a frame, pinned by its hash in the page's own policy, and none at all when opened in a tab. The message is the only thing it does. - The way back on the signed page (`return_url`) leaves the frame for the whole window. - A frame on a site that is not proven shows nothing, and that is the browser refusing on our instruction, not an error to work around. --- Source: https://signsealer.com/docs/waivers (Waivers and workflows) # Waivers and workflows A gym or a rental counter does not prepare a document per customer. A workflow does it for them. A **workflow** is a published template plus the rules for running it: who signs and in what order, whether the business countersigns, how long a link lives, when to remind, and what the form asks for beyond a name and an email. ## Two ways in - **A public page.** Give the workflow a `public_slug` and a participant starts their own document by filling in a form at `/f//` on signsealer.com. No API call from you at all. The portal draws the QR code for it (Forms & QR in the dashboard: a page, a PNG, a counter card to print, and a button to paste on your own website), and `GET /v1/workflows/:id/qr` hands the same code to your own system. The form itself offers the person a code to scan when it is open on a counter tablet, so they can fill it in and sign on their own phone instead. - **An API call.** `POST /v1/workflows/{id}/start` with the participant's details. Use this when your system already knows who is coming. ## A signing link on your own website A form with a public address is a website signing link: a visitor opens it, answers the form's questions, reads the document with their answers in it and signs, and the signed document is yours, under Documents. You set one up in the dashboard under [Forms & QR](https://app.signsealer.com/workflows), or from the last step of *New template* with *Make a website signing link*, which saves the template and opens the form with the link switched on and the template's blanks already asked as questions. ### Before the link goes live The form's page lists the checks a link has to pass, with the way to fix each one. A save that would put the link live, or keep it live, while one of the first kind fails is refused with that check's sentence, beside the field it is about, and so is switching it back on. - **Must pass:** the template is published; every visitor gives their name and email; no box on the document is one only your business fills in when it sends, unless the visitor is asked it (by a question of the form's, or by a blank of the same name in the document's text); a line for your business to sign has somebody on the form who signs for the business; everybody else on the form has a name and an email; and your account can send (it is not closed, and not waiting for you to agree to updated terms). A pause on your sending, which holds messages while we look at something, does not stop a link. - **Said, not stopped:** blanks nothing else fills, which are asked as questions of their own (“Will be asked as …”); an optional box only your business fills in, which is left empty; and the email check below, when it is off. Visitors are turned away only for the template not being published or your account not being able to send, which stopped them before these checks existed: the form's page says it is not ready yet. A link that was live before the checks keeps taking signatures whatever else it fails. The forms list and the form's own page mark it *Needs attention* and say what to fix; until it is fixed, a change to the form is not saved, and nobody is emailed about it. ### The email check A visitor who signs on the device in front of them first types a six-digit code we email to the address they gave, so you know the address is theirs. A visitor who asks for the link by email proves it by opening that email. One who asks for it by text confirms their number with a code, which proves the number and not the address, so when they open the texted link they type the code we email them too. Until the address is proven, nobody else on the form is sent their link: on a form signed all at once, the others' links go the moment the code is typed (or the emailed link is opened and agreed to), and on one signed in order they wait for their turn as always. Somebody who takes a place from a party link on such a form confirms a code too. An address that asked you to stop emailing it (mail to it bounced, or its owner unsubscribed) cannot be sent the code, so the page says so beside the box and asks for another, and nothing is made; so does a form that emails every visitor (Limits, below). It is on for a new form with a link, part of the form, and free: it is not the one-time passcodes under Settings, and nothing is counted for it. Turn it off only if the link sits behind your own login, since without it anyone could type anyone's email. A form made before the check keeps working as it did: its link stays live, with the check off until you tick it, and its page says so. A tablet at the counter is not asked. ### Limits **Taking signatures**, on or off, at the top of the form's page: one tap stops the form, its link and a tablet alike. The limits below are the website link's, shown on a form that has one: a tablet at the counter and the API start the form whatever they say, and the documents those start are not counted. - **Stop after this many signed**: the link closes once that many documents started from it are signed. Anybody part-way through can still finish. - **Stop taking signatures after** a day: the link closes after it, on the form's own calendar. This is not how long each person's link lasts. - **Somebody who signed recently**: off unless you choose it, since a waiver is often signed at every visit. Set to a number of days, the link emails every visitor rather than letting them sign on the page: a link to sign, or, to an address that signed within those days, its signed copy again rather than a new document. The page says the same to everybody, so nobody can learn whether an address signed by typing it. Each of those links is one of your signing links: once you have sent as many as your account allows for now, the form tells every visitor to try again later, and makes nothing. A closed link says why on its page, before anybody types anything, and the forms list says *Link closed by its limits*. ### Sharing it Once the link is live, the form's page has it in one place: the link with a Copy button; *Try it as a visitor*, which opens it in a new tab (use a private window to see exactly what a stranger sees); the QR code, with a counter card to print and a PNG; the button to paste into your website; and, once you have proven your website is yours (a verified signing domain, a company sign-in domain, or a connected app's site), the code that puts the form itself inside one of its pages. ### An address A form's question can be an address: the visitor is asked the street, the city, the state or region and the ZIP or postal code, and the document gets them as one line (“12 Main St, Bend, OR 97701”) where the question's blank is, such as `{{address}}`. The street, city and ZIP or postal code are needed for a required one, and for an optional one that was begun; the state or region may be left empty, for a country without one. ## Sending them back afterwards Either way in takes a way back: `return_url` on the start, or `?return=` on the public page's address. Once they have signed, the page offers a button back to it — "Back to shop.example" — so the person who came from an order lands on the order, and the person who came from a form lands on the page it was on. It is honored on exactly three kinds of host, because anything else would be an open redirect with a signature on the end of it: a signing domain you have verified, a company sign-in domain you have proven, or the site an [application](https://signsealer.com/docs/partners) you connected lives on — the redirect address it registered, since the consent flow completed there. A subdomain of the first two counts. It must be https. Anything else is refused with a 422 that says so, and no document is made. The check runs again when the signed page reads it, so a domain that stops being proven stops being somewhere we send people. ## What comes back The document id and one token per signer — the participant's and every fixed role's — so you can hand the participant straight on to theirs rather than making them wait for an email. ``` { "document_id": "…", "title": "Liability waiver — Dana Reyes", "expires_at": "2026-10-15T00:00:00Z", "signers": [ { "signer_id": "…", "email": "dana@example.signsealer.com", "participant": true, "token": "…" } ] } ``` ## How long a signature stays good A waiver signed once a year is only useful if somebody can tell, on the day, whether this person's year has run out. Set the period on the template in the dashboard (Templates → How long it lasts), to match what the document's own wording says. The wording is what binds; the period is how the engine reads it. Every signed document then carries `standing` and `valid_until` on `GET /v1/documents`, `GET /v1/documents/:id` and the gate's `GET /v1/documents/:id/signers`: - `current`: inside its period. - `expired`: past it; the person signs again. - `resign`: signed on a version the business has since asked everybody to sign again, because the terms changed. - `renewed`: the same person has signed the template again since. This one is history, not a problem. - `on_file`: signed, and the template has no period. Before a check-in, ask for one person by their email or phone: ``` GET /v1/standing?template=liability-waiver&email=dana%40example.com { "template": "liability-waiver", "found": true, "document_id": "…", "signed_at": "2026-10-03T14:22:09Z", "template_version": 2, "standing": "current", "valid_until": "2027-10-03T14:22:09Z" } ``` `found: false` means they have never signed it: send them the form. The period is read when you ask, not stamped on the document, so waivers signed before the period was set are judged by it too. With a reminder set, each signer is emailed the business's public form a chosen number of days before theirs runs out, once. ## One person, two roles A signer role with `"self": true` is the participant again in a second role — a sole proprietor signing for the business and again as the personal guarantor, a parent for themselves and as guardian. The engine makes a second signer at the same address, named with the role's label after the person's name, emailed its own link rather than handed on, and in a sequential workflow that link refuses a signature until the earlier one is in. The [two-party demo](https://signsealer.com/f/demo/agreement) runs on it. Neither the API nor the dashboard's workflow editor can set it yet; to have one person sign twice over the API, send them as two signers at two steps ([Signers, roles and order](https://signsealer.com/docs/signers)). ## Fields beyond a signature A template can require initials, a checkbox, a date or a photo, and the requirement travels with the template — it freezes when the template is published, because what you were asked to initial is part of what you agreed to. A `per_child` requirement becomes one row per covered minor, *named*. A guardian initialling once for three children is not the same act as initialling three times, and a court asked which child a release covered should not be told "the template said per child". > **The refusal is in the database, not the form.** A signature is refused while anything required is unsupplied — so a second interface, a kiosk or an API caller cannot go around it. Your step form checking first is a courtesy that stops somebody being bounced; it is not the guarantee. --- Source: https://signsealer.com/docs/pdf (Signing a PDF where it is) # Signing a PDF where it is Some documents are a page rather than words. A lease with initials in the margin of page four; a county form with a box in the corner. Those get signed where they are. Watch it done: [Sign a PDF you already have](https://signsealer.com/guides/sign-a-pdf-you-already-have) (3:19) · [Upload your own PDF, in depth: an equipment rental agreement](https://signsealer.com/guides/upload-a-pdf-in-depth) (7:57) · [What your signer sees](https://signsealer.com/guides/what-your-signer-sees) (2:34) Most templates here are text: the wording is the template, and the certificate's fingerprint is of exactly those bytes. A PDF is a different job, because the layout *is* the document — the whole reason a business has one is that somebody cared where things sit. Extracting the text would throw away the thing they are trying to keep. So the file is kept whole, byte for byte. The signer sees the pages. The signature goes where the business put it. And the finished document is that same file with the answers drawn on it and the certificate after it. The one exception is anything in the file that could act on its own once it is signed, in a reader that is not ours: a script that runs when it is opened or a box is pressed, a file attached inside it, a form field somebody could still type in, a layer that shows one thing on screen and another on paper. Those are taken out when it is uploaded, a form field drawn onto the page as it looked, and the business is told what was taken. The file kept, fingerprinted and signed is the file without them. An XFA form, which only Adobe's reader can draw, is refused: printed to a PDF, it uploads. ## The boxes it asks for Every PDF starts with a signature and the date. When the file says where they go, that is where they are put. The rest of what it asks becomes a question on its own box. That covers: - A line to write on, typed as underscores or drawn, named by its label beside or under it: *Signature*, *Date*, *Initials*, *Printed name* or anything else. A blank inside a sentence is named by the words around it. - A box to tick, typed (☐) or drawn. Two or more after a question (*Paid by: ☐ Cash ☐ Card*) are its answers, each a tick named for both, and any or all of them may be ticked. When the page says to pick one (*select one*, *check one*), only one can be: ticking one hides the others after it, as radio buttons are. - A box drawn to write in, named by the words to its left, above it, or in its own top corner. - A fillable form's own fields, named by their tooltip, their own name or the words beside them: a list as a choice, a radio button as a tick for its answer. Text in square brackets, such as `[DATE]` or `[CLIENT NAME]`, is usually the template’s own words for you to replace, not a place anybody can write, so it is never a box. It is flagged instead, as a note for an attorney is. Every piece of it is highlighted where it is on the pages of the Place step, and listed with its page after the upload, on the Place step and on the Send screen, each with what to do about it: - **A blank:** replace it, in the program that made the file, with the words it stands for or with a blank line (`__________`) where your signer fills it in. Upload the file again and that line gets its box. - **Meant to be there** (*[Company seal]*, *[emphasis added]*): press *Keep it* beside it. It stays on the page as written, part of the document, and is not flagged again; SignSealer never fills it in or asks for it. A kept one is listed under *Kept as part of the document*, with *Flag it again* beside it. Keeping one changes nothing anybody signs, so a published template can have them kept too. A footnote’s number (`[1]`), a letter changed in a quotation (`[t]he`) and the lines a finished agreement carries (*[Signature page follows]*, *[Intentionally left blank]*) are never flagged. Nor is a field already filled in, a line under a heading, or a box with writing in it, ever a box. This works on a PDF with its own text: one saved from the program that made it. A scan is pictures of its pages, with nothing to read where it asks to be signed, so its boxes are yours to drag on; the next screen and the Place step say so, and name any page of a typed file that has no words on it. The file can come from your computer or, with **Choose it from Google Drive** on the upload step, from your Google Drive: Google asks you to let SignSealer see the one file you pick, it is brought in exactly as an upload is, and a Google Doc comes as a PDF. Nothing of your Google account is kept. And to have each signed copy put in your Drive as well, connect **Google Drive** under Settings: every document completed from then on goes into a SignSealer folder there, as the same sealed PDF the completion email attaches. Unless you switch on **Read scanned PDFs** under Settings. Then a scan is sent to Google Document AI, which reads the words on its first fifteen pages and where each one sits, and its boxes are placed from them within a minute or two, as a typed PDF's are at once; the checks and the AI review read those words too. Up to 100 pages a day. A scan you upload again is placed at once, from the reading already made. On an agreement between two parties, the signature line under the customer's heading (*Client*, *Renter*, *Employee*) is the signer's. The business's own line is found too: one under its heading (*Coach*, *Company*), labelled as its own (*Landlord signature*), or the other party's line when the customer's is named. It becomes the business's signature, with its date and printed name, signed by whoever the Send screen names as signing for your business, once the person it is sent to has signed. Nobody else is asked for those boxes, and that person is asked for nothing else. A line for a witness or a notary is left without a box. The next screen says what was placed, and how many more places to sign the file has, and the Place step shows every box, the business's in their own colour, to move or remove before you publish. ## Where the pages are drawn, and why it matters **By SignSealer, from the file that is signed.** The pictures a signer reads are drawn on our own servers, in the API's own process, from the exact bytes that are kept, fingerprinted and sealed; no other service sees the file. Each picture records the SHA-256 of the file it was drawn from, and a signer, a tablet or your own signing page is shown a picture only when that is the template's file now. They were once drawn in the browser of the person who uploaded the file, which kept every lease off our servers and tied no picture to the file: a picture that said one price over a file that said another would have been read and the other sealed. Since 1 October 2026 the server draws them (00474), at upload and, for anything that missed it, within the minute after. > A PDF that cannot be drawn fails *visibly*, at setup, to the person who chose it — rather than silently at a counter, to somebody who has never seen it before. Publishing is refused until every page has a picture drawn from the file. ## What a signer gets One image per page, with ordinary HTML inputs positioned over them. The signing page carries `default-src 'none'` and **no script at all** — that is checkable in the response headers, and it does not change for a PDF. An image needs nothing relaxed. A box on a page is filled in on the page. A question with no box is asked underneath the document, the way every field is. Both hold the signature back until they are answered, if they are required. ## Where a box sits A placement is a page and a rectangle, as **fractions of the page a reader sees**, with the origin at the top left: ``` { "key": "tenant_sig", "label": "Sign here", "kind": "signature", "place": { "page": 2, "x": 0.12, "y": 0.71, "w": 0.3, "h": 0.05 } } ``` Fractions rather than points, because the same box has to mean the same thing in three places that measure differently: a picture at whatever resolution the browser produced, a CSS box at whatever width the signer's phone is, and the PDF itself in points. A fraction survives all three; a pixel survives none of them. Top-left rather than the PDF's own bottom-left origin, because two of those three are CSS and the third is one subtraction — which lives in the sealer, once, rather than in every screen. Rotation is handled. A page carrying `/Rotate 90` is *shown* turned, so a fraction is a fraction of the turned page, and the mark is placed and drawn to match. ### What can be placed - Any field the signing adult supplies. - A staff field, which whoever sends the document fills in: it is shown to the signer filled, and never asked ([Questions and fields](https://signsealer.com/docs/templates#fields)). It cannot be a signature, initials, a photo or the signing date. - **Not** a per-child field: one box cannot hold an answer for four children. What cannot be placed is refused when the template is saved, rather than accepted and found empty at a counter. ## What the certificate says A template's body is what the certificate fingerprints, and a PDF template has no text to fingerprint. So its body **names the file's own SHA-256**: ``` This agreement is the attached document: lease.pdf (4 pages). The file is identified by its SHA-256 fingerprint 9f2c…8ab1. The signed copy is that same file, with each answer drawn where it was placed on the page, and the certificate of completion after it. ``` The signed fingerprint therefore commits to those exact bytes. Change one byte of the original and the certificate no longer describes it. ## The text, and why you cannot edit it A PDF template's text is the sentence naming the file, and it is fixed. That is not tidiness: it is the sentence's whole job. The certificate fingerprints the body, so naming the file's SHA-256 there is what makes the signature commit to those exact bytes. Take the fingerprint out and the pages are still signed, but nothing in the record says which file they were. So the dashboard shows it read-only, offers no tidying of it, and the engine refuses a change to it — including through the API. The name, the questions and the boxes change freely; none of those is what the certificate commits to. To change what the text says, use a different file. ## Setting one up In the dashboard, under **Templates → a PDF, signed where it is**: choose the file, let the pages draw, then drag a box for each question — or type the page and four percentages, which is the same thing and works without JavaScript. Publishing is refused until every page has a picture. A template with a missing page would send somebody a row of gaps. ## What the signer does They agree to sign electronically, answer anything asked underneath the document, then fill in the boxes on the page, on one step with one **Continue**. A box that still needs them is red and turns green as they fill it in; an optional one stays blue. Then they sign. Nothing they type is ever on a step whose button posts something else, so nothing arrives blank on the next one. Two kinds of box are never typed into, because signing fills them: - **A signature box**, any signature question placed on a page. It carries the signature they type to sign. They sign once. - **A signing-date box**: a date question placed on a page whose key is `date`, `signed_on`, `date_signed`, `signing_date` or `today`, or whose label reads *Date*, *Date signed*, *Signed on* or *Today's date*. It carries the day they signed, in your timezone (asked when you name your business, and under *Settings → Messages*; Central time until you set one). Any other date, such as *Date of arrival*, is still asked. A written template works the same way. `{{date}}`, and the same names as above (`{{today}}`, `{{signed_on}}`, `{{date_signed}}` and the rest), is never asked for: it is the day the document is first signed, in your timezone, written out in words (*September 25, 2026*). The signing page shows today's date there; the signature writes it in. A date you mean for something else, such as the day of an event or a rental, needs its own name, like `{{event_date}}`. Every time shown for a signing is in the same timezone, and says which: the document's page in the dashboard, the Activity log, the certificate and the verify page all read like *26 Sep 2026, 10:24 pm PDT*, with the UTC time the evidence records beside it. So a document signed late in the evening carries the same day in its text as on its record, and the certificate says which timezone that day was taken in. If you change your timezone later, a document already signed keeps the day it was signed with: its times are then shown in your new timezone, and the certificate and the verify page no longer name it as the one that day was taken in. A blank's name also picks the box it is asked in. `{{trip_date}}` is a calendar; `{{tee_time}}` is a time; `{{rental_starts}}` and `{{rental_ends}}` are each a day and a time, which is how a rental of a few hours and one of several days are both written. What is picked goes into the document in words (*September 27, 2026 at 2:00 pm*), and a period whose end comes before its start is refused: `_starts` and `_ends`, `start_` and `end_`, `arrival` and `departure`, `drop_off` and `pick_up`, `check_in` and `check_out` are read as the two ends of one period. A question you add yourself can be a *time* or a *date and a time* too. A box keyed `full_name` or `name` starts with the signer's name filled in, for them to check. Initials are written in the middle of their box, in the same letters as every other answer. ## Before it goes out A PDF template's own text is the sentence naming the file, so SignSealer reads the words on its pages instead, once, as the file is uploaded, and keeps them with the file: a new version that keeps the file keeps its words. A file from before this, or one whose reading failed, is read within a minute. Straight after the upload, and again on the last step before it is published, the walk says what the **pre-send checks** found on the pages: text in square brackets like *[insert name]* that you have not kept, *TBD*, *lorem ipsum*, a document that never says what signing it means. Each is named with its page. A line of underscores is not one of them: on a PDF it is where a box goes. The Send screen runs the same checks on the pages as it runs on a typed text, beside the ones about the people it is going to and the blanks that were filled in. A **note for your attorney** on the pages, *[Attorney: …]*, is refused as it is in a typed text: the PDF is not published, and no document is made or sent from one published before the pages were read, and the refusal names the page. Settle it with your attorney and upload the file without it. What was not read is said rather than passed over. A page with no words on it (a picture, or blank) is named, and a scanned PDF, whose pages are pictures of words, is said to have had nothing to read. Look at those pages yourself. The **AI review** reads the same words, on the template's own page (**Check with AI**, when the business has switched AI review on under Settings). On a membership with the review switched on, it runs on its own as a PDF is uploaded: the upload asks for it, the review runs within a minute, and what it found is on the walk's last step and the template's page. It counts against the day's model runs like any other review. It never reads the naming sentence: that would put a reading of a fingerprint on the record as a reading of the agreement. A scanned PDF is refused in a sentence rather than reviewed as empty, unless Read scanned PDFs is on: then the review waits for Google's reading and reads its words. The review on the send screen still refuses a PDF, and points to the template's page. The same holds on a public form at `/f/…`: the panel headed “What you are about to read” says what the document is and that its pages are on the next screen, rather than showing the fingerprint. ## From the API Everything after setup is ordinary. A PDF template is a template: prepare a document from it, send it, read the link, supply fields, sign, and download the sealed copy exactly as for any other. `GET /v1/sign/{token}` carries the pages, so an integrator building their own signing screen gets the same pictures the hosted page uses: ``` { "title": "Tenancy — Flat 2", "body": "This agreement is the attached document: lease.pdf …", "pages": [ { "n": 1, "width": 1275, "height": 1650 }, { "n": 2, "width": 1275, "height": 1650 } ], "fields": [ { "key": "tenant_sig", "label": "Sign here", "kind": "signature", "required": true, "supplied": false, "place": { "page": 2, "x": 0.12, "y": 0.71, "w": 0.3, "h": 0.05 }, "at_signing": "signature", "placed_value": null, "placed_words": null } ] } ``` `at_signing` marks a box that signing fills: draw it, do not ask for it, and `POST /v1/sign/{token}` will not refuse for it. `placed_value` is what a box carries, and `placed_words` the same as it is printed on the sealed copy — a date as `October 5, 2026`, a time as `9:00 am` — so your screen can show the page the way it will be sealed. `from_sender` marks a box filled in by whoever sent the document: a question marked `staff` and placed on the page, such as `pickup_date | Pickup date | date | staff`. It is given when the document is sent — under `values` on `POST /v1/documents`, or under `fields` or `values` when a workflow starts — and a required one that is missing refuses the send, naming it. The signer's link carries it already answered: draw it with its `placed_value`, do not ask for it, and supplying it is refused. Each page is an image at `GET /s/{token}/page?n=1` — the token is the whole credential, so no API key goes near it and an `` can load it directly. The images are drawn by SignSealer from the template's file itself, so they are always pictures of the document being signed, and the file itself, before anything is filled in, is at `GET /s/{token}/document.pdf`. `GET /v1/templates/{id}/pdf` says whether a template is a PDF one and whether it is ready to use: ``` { "has_pdf": true, "page_count": 4, "drawn": 4, "ready": true, "sha256": "9f2c…8ab1", "original_name": "lease.pdf" } ``` > **Uploading the file itself is a dashboard step, not an API call.** If you need this from a server, say so and we will design it with you rather than guess. ## What is kept - **The file**, exactly as uploaded, on the template version. A new version copies it; a new upload replaces it and takes the old pictures and every box with it, because a box chosen against a page that no longer exists is a signature in the margin of the wrong page. - **A picture of each page**, drawn by SignSealer from the file, which is what a signer sees. - **The placements**, frozen onto each document when it is prepared — so revising a template afterwards never moves a signature on a document somebody already signed. Retention and redaction work as they do for any document: [evidence and certificates](https://signsealer.com/docs/evidence) explains the package, and the sealed copy is the same artifact with the customer's own pages at the front. --- Source: https://signsealer.com/docs/packets (Reservations and packets) # Reservations and packets One booking. Four guests, two of them children. A rental agreement, house rules and a hot-tub waiver. One link, and one state your system can wait on. ## Import the reservation `PUT /v1/subjects` is keyed on `(provider, external_id)`, so it is safe to call on every webhook from your PMS. Send the guest list with it and the packet is built in the same call. ``` { "provider": "hostaway", "external_id": "RES-88213", "label": "River Bend Cabin, 3–7 Oct", "starts_on": "2026-10-03", "facts": { "adults": 2, "minors": 2, "pets": 1, "amenities": ["hot_tub"] }, "participants": [ { "ref": "a1", "full_name": "Dana Reyes", "email": "dana@example.signsealer.com", "is_primary": true }, { "ref": "c1", "full_name": "Sam Reyes", "date_of_birth": "2014-06-02", "guardian_ref": "a1" } ] } ``` > **SignSealer is not a booking system.** No availability, no rates, no calendar. A subject is a thin shadow of your record, and it stays that way on purpose: the day we grow a booking engine we start competing with the platforms this integrates with. ## What each guest owes The documents a booking needs are chosen in the dashboard, under **Documents for bookings**: on each connected booking system's own page, and, for reservations sent with `PUT /v1/subjects`, on [Bookings from Zapier, Make or the API](https://app.signsealer.com/integrations/documents). Each answers *whether* a document is needed, from the facts — a property with a hot tub needs the hot-tub waiver — and *who* owes it: the person who booked, every adult, or a parent or guardian for each minor. Two questions, kept apart, because folding them together is how a rules engine becomes a programming language nobody can debug. A default answers for the bookings nothing else fits, and a person is asked for a document once however many choices want it of them. A reservation that matched nothing has an empty packet: `ready`, with nothing `required`. Check `required` before treating it as done. ## Minors Minority is judged on the **arrival date**, not the signing date. A seventeen-year-old booking for the week of their eighteenth birthday is still a minor when they get on the water. A blank date of birth is refused rather than read as an adult — that is the one wrong answer, and it files a child as an adult with nobody's signature behind them. ## Readiness A packet reports counts as well as a state, so a dashboard can say "3 of 5" and a door lock can wait on `ready` without either of them guessing what "partially complete" meant. A decline makes the packet `blocked`, not "still waiting" — somebody already answered. Subscribe to `packet.ready` and it fires the moment the last required document completes. Not on a sweep: "ready ten minutes after the guest finished" is a guest standing outside a locked door. --- Source: https://signsealer.com/docs/evidence (Evidence and certificates) # Evidence and certificates Somebody disputing a signature three years from now does not get a database. They get a PDF and a claim. Watch it done: [What the evidence pack proves](https://signsealer.com/guides/what-the-evidence-pack-proves) (1:42) · [Every agreement, on one trail](https://signsealer.com/guides/every-agreement-on-one-trail) (1:09) When a document completes, a **certificate** is issued: the document and its hash, every signer with the method and value of their signature, and the whole event chain with the address, the browser, the language and the device at each step. It is frozen at issue and hashed. Where the address was, the certificate prints as *Network region (approximate)*: what the network edge in front of us resolved it to, and only when the request provably crossed that edge. A caller who reaches the API directly cannot supply it, so a signer cannot choose the place printed on their own certificate; nothing is looked up from anybody else either. ## Frozen, and checked against the live record Freezing sounds like the wrong instinct — a derived value that has drifted is the one somebody quotes. But a certificate is *evidence*, and evidence says what was true at a moment. Deriving it later means the document somebody holds and the document we render can differ with nothing to compare. So both properties are kept. The payload is frozen and hashed at issue, *and* verification re-derives it from the live record and reports any divergence: ``` { "certificate_found": true, "document_matches": true, "still_matches_record": false, "divergence": "The document text has changed since this certificate was issued." } ``` > **A divergence is a finding, not a correction.** We report it and change nothing. A verification service that quietly re-derived would be unable to answer the one question it exists for. ## What the signer was told, and how the link reached them A certificate does not only say *that* consent was given. For each signer it carries the consent disclosure in the words that were on the page, with its SHA-256 and a version reference (`consent/1`, plus the template's own consent wording when the business wrote one), the address and browser the agreement came from, and the statement that stood above the signature, hashed and versioned the same way. What the person agreed to is on the record, not reconstructed from a later copy of the page. Each signer also carries an **assurance level**: what the signature proves about who was at the keyboard. None of these is an identity check, and the printed certificate says so in each case. | Level | What it proves | | --- | --- | | `email_link` | A link we emailed to this address was used: control of the inbox. | | `sms_link` | A link we texted to this number was used: control of the phone. | | `email_otp` / `sms_otp` | As above, and a one-time code sent to the same address or number was entered before consent: control of that inbox or phone at the moment of signing, not only when the link arrived. | | `in_person` | The signing was started by a tablet you paired, using that tablet's own credential: our record of which device opened it, not anything the signer's browser said. | | `link` | A link handed over some other way — a public form, a QR code, your own application — was used: possession of the link. Also what a signature says when the link used was one shown at the gate or put in a frame after an email went out: an inbox proves nothing about who scanned a code. | | `party_link` | When everybody in a booking's party signs: a link the person who booked was given to hand over — to somebody they added with no email address, or a number that has not agreed to your texts — was used. Possession of the link, which the person who booked also had; the signature's event names them (`handed_out_by`), and the printed certificate says the link was handed out by them. Anybody in the party with an email address is sent their own link, and the person who booked never sees it. | The one-time code is a choice each business makes under Settings ("Ask signers for a code"). It applies to signers a link was delivered to; somebody signing at the counter is not asked. It comes with the membership: on Free no code is asked, and a choice made on the membership is kept for when it starts again. Over the API the step is `POST /v1/sign/{token}/code` and `/verify`. Certificates issued before these fields existed carry the times and addresses they always did, and nothing invented. ### ID Evidence A business may ask the people it sends a link to for a photograph of a government-issued ID, and optionally of themselves, after consent and before the signature (Settings → "Ask signers for ID evidence"). The image is resized on the way in, kept encrypted under a key derived for that business, shown only to its team from the document's page (each look is audited), and dropped after thirty days or with a redaction. The certificate carries each capture by kind, SHA-256, type, size, time and the words the person was told, with `"verified": false` beside it and "not verified against anything" in print: it is evidence that somebody held that ID up when they signed, not a verification of who they are, and a certificate issued before the image is dropped still matches its record after. At the counter the tablet offers the same photo, and the customer may take it or skip it. ID Evidence comes with the membership: on Free nobody is asked, and a choice made on the membership is kept for when it starts again. ## The sealed PDF `GET /v1/documents/{id}/pdf` returns the certificate as a PDF sealed with a detached digital signature (PAdES) and an RFC 3161 timestamp — over the signature, not over the document, which is what lets a reader tell when the seal was applied rather than when the file was made. It is sealed **once**, when the document completes, and kept. Every download — from the API, the dashboard, the signer's own page or the completion email — is that file, byte for byte, so two copies of one agreement are the same copy, and a later change to the business's logo or to how we draw a certificate does not reach one already made. Its SHA-256 is recorded, and is what public verification compares a file with (below). A document that completed before copies were kept is sealed no later than its first download, and that copy is kept the same way. The seal is **self-issued**, and the PDF's last page says so. It proves the file has not changed since it was sealed: alter a byte and a reader reports the signature as broken. It does not prove who produced the file — no certificate authority stands behind it, and a reader will show the sealer's identity as unverified. Identity is established by the verification code and the record it points to, not by the seal. If a counterparty requires a seal that validates as trusted in Adobe Reader (an AATL certificate), ask; it is not something SignSealer sells today. ### The photographs When a document asked for photographs as answers (a field of kind **photo**), the sealed PDF prints them on pages after the certificate: each with its question, who took it and when, and its SHA-256, under the same seal as the rest of the file. A JPEG is embedded exactly as it was kept, so extracting it from the PDF and hashing it gives the fingerprint on the certificate; a PNG is redrawn into the PDF's own image format and says so. The images are decrypted only while the file is made. A business can leave them out for a template (`photos_in_copy: false`, or Templates in the dashboard); the certificate carries every hash either way, and a redacted document prints none. ID evidence is not among them: it is dropped after thirty days and stays off the signed copy. ## The evidence bundle `GET /v1/documents/{id}/bundle` returns the same sealed PDF with the evidence package printed after the audit trail: every message sent about the document and whether it arrived, every webhook delivered to your systems, what an AI review flagged before it was sent (when the business ran one, and that it was sent anyway), and the record's state when the file was made — voided, redacted, still matching. The package's SHA-256 is on the last page, so the JSON and the PDF can be tied together if both are produced. One file, sealed as one, for an insurer, an adjuster or a court; the JSON stays the machine-readable form and is available before the document completes, when the bundle is not. ## Public verification Every certificate carries a sixteen-character code, printable and typable: no I, L, O or U, because those are the four that get mistyped as 1, 1, 0 and V. Anybody holding the document can check the certificate without an account: the code alone confirms it exists, who issued it, when it completed, how many signed and that the record still matches. Add the SHA-256 of the sealed PDF you were sent — `shasum -a 256 the-file.pdf` — and, when it is the file we issued, the page says so and names the signers and shows the trail; the SHA-256 of the document's text is accepted too, as it always was. Over the API, `GET /v1/verify/{code}?sha256=` answers `copy_matches` when it was the file and `document_matches` for either. A copy handed out before copies were kept was never recorded, so its SHA-256 cannot match; its code still verifies. Email addresses are never on the public page; they are in the sealed PDF and your evidence package. --- Source: https://signsealer.com/docs/partners (Platforms and partners) # Platforms and partners A point-of-sale system, a property manager, a booking engine: your product has customers of its own, and each of them signs waivers or agreements. This page is how that works, and who pays whom. ## How your customers get credentials Every business you serve is a **business** in SignSealer — its own templates, documents, evidence and team, isolated from every other by SignSealer, not by anything your code has to get right. Our servers connect to the database as the owner of its tables, so the isolation they keep is in the functions they call: each checks which business the caller belongs to and filters what it reads and writes by that business. Row level security on every table binds anybody who reaches the tables directly through the data API. Today each one connects the same way any customer does: somebody at that business signs in to the portal, opens *Integrations → API keys*, and creates an API key with the scopes your integration needs (`signing:read` and `signing:write` for most). Your product stores that key for that business and sends it as `Authorization: Bearer`. Keys are scoped, revocable, and shown once; the [authentication](https://signsealer.com/docs/authentication) page has the details. Or your product asks the business once. Send the person to `/oauth/authorize`, they agree on a consent screen that shows your registered name and the scopes you asked for, and your server exchanges the code at `/oauth/token` for an access token that works for an hour and a refresh token that gets the next one. It is standard OAuth 2.0 with PKCE; any client library speaks it, and the [authentication](https://signsealer.com/docs/authentication) page has the two requests in full. The business can end the connection under *Integrations → Connected apps* whenever it likes. Nothing about the key-based path changes. ## We handle the signing; you share in the revenue The businesses you bring pay SignSealer directly, and we run everything for them — billing, support, the signing pages, the texts, the evidence. You share in what they pay, month after month, for as long as they stay. The terms are in your partner agreement; [tell us about your product](https://signsealer.com/partners) and we will send you yours. - **Which businesses are yours.** One that signs up through your link, installs your app in its first weeks, or redeems a promotion code made for you. Once it is yours it stays yours. A business your own people own — somebody on your partner dashboard, or one of your addresses — is not one you bring, and is not counted. - **When your share starts.** A person here looks at every new partner account first. Your link works from the day you enroll and businesses that sign up through it are recorded as yours; your share starts on the day we approve the account, on what they pay from then on. - **When you are paid.** Each month closes into a statement that does not change afterwards: each business, what it added, and what you are owed. - **Seeing it yourself.** Your partner dashboard shows your link, your businesses and every statement, and the same figures are in the API at `/v1/partner`, read with your OAuth client's credentials — never a business's key. ## Custom branding The businesses you bring can sign on pages wearing your name, colors and logo, with **Powered by SignSealer** at the foot, and their mail says it was sent through you. You set it up yourself from your partner dashboard, at no charge. Each business that texts does so from a number of its own, registered in its own name (coming soon). A business that buys white label for itself shows its own brand instead. ## Texting numbers for the businesses you serve Each 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: the carriers register the business, not you, so you cannot apply for it. What you can do is take each owner to the right page with your answers already in it, and show where each one has got to on your own screen. - **Which businesses need one.** `GET /v1/account` with each business's key or token: `texting.why_not` is `number` while it waits for its own, and `texting.application` says where its application has got to (`status`, `phone` once verified, `reason` when the carriers asked for a change), or null when it has not applied. - **A link for its owner.** `POST /v1/texting/number/invitations` with what you know, in the application's own names (`legal_name`, `ein`, `website`, the address, the contact, the `kind` of number). Put the `url` it returns in front of the owner. It opens, after they sign in, on that business, with your answers filled in and your app named as the one that filled them; they read each one, tick that it is true and apply. Fourteen days, used up when they apply. - **Each step as it happens.** Subscribe the business's endpoint to `texting.number_updated`, or to `*` ([Webhooks](https://signsealer.com/docs/webhooks)). ## Billing your customers yourself, by agreement Where you would rather your customer paid you alone, a business can be **billed through a partner**: it pays SignSealer nothing, its billing page says who bills it, and its completed agreements roll up onto your monthly statement at a wholesale rate agreed with us. It is set up by hand, for the deal that needs it. Either way, start with [the partner form](https://signsealer.com/partners): a few questions about your product and your customers, and a quote comes back to you by email. ## What stays the same - **Isolation.** A key for one business reaches that business and no other, whoever holds it. - **Evidence.** The certificate, the trail and the sealed PDF belong to the business that sent the document, and it can download them without you. - **Webhooks.** Register your endpoint per business, or one endpoint that reads `tenant_id` from the payload; either way the signature is per business. - **The signer's experience.** The signing page is served by us, in our name or with your custom branding over it; embed the link, text it, or open it on a tablet. It loads nothing from anywhere else. ## The partner API Three routes, and all three only read: your program and this month so far, the businesses that are yours, and every month that has closed. They answer on `https://api.signsealer.com`, beside the rest of the API. Nothing you send changes what you earn; the share and the program are set by SignSealer. ### Credentials Send your OAuth client's `client_id` (`ss_client_…`) and `client_secret` (`ss_secret_…`) by HTTP Basic, the same pair `/oauth/token` takes. ``` curl -u "$SIGNSEALER_CLIENT_ID:$SIGNSEALER_CLIENT_SECRET" https://api.signsealer.com/v1/partner ``` Your client id is on your partner dashboard, under *API and app installs*. The secret is shown once and kept only as a hash. A partner account set up from its quote is not sent one, so make it there: the dashboard's owner presses *Make a new client secret*, and only the owner can. > **A new secret replaces the old one at once.** It is the same secret your app sends to `/oauth/token`, so give your app the new one at the same time: until it has it, exchanging a code or refreshing a token there fails. The credentials work from the day you enroll, while your account is still under review. A business's API key or access token is refused, and so are a public client (one with no secret), a disabled client and a client that is not enrolled as a partner. Each of them gets the same `401 unauthorised` with a `WWW-Authenticate: Basic` header: which part was wrong is not the caller's to learn. ### GET /v1/partner Your program, this month so far, and the link to hand a business. The figures in these examples are made up; your share is the one on your quote. ``` { "partner": "DeckPOS", "client_id": "ss_client_0123456789abcdef0123456789abcdef", "referral_code": "deckpos", "share_percent": 10, "active": true, "custom_branding": true, "review": "approved", "earning": true, "earning_since": "2026-09-02T15:04:05+00:00", "businesses": 12, "month_to_date": { "month": "2026-10-01", "businesses": 9, "gross_cents": 43900, "fees_cents": 1650, "sms_cost_cents": 1000, "revenue_cents": 41250, "share_bp": 1000, "share_cents": 4125 }, "signup_url": "https://app.signsealer.com/signup?partner=deckpos" } ``` | Field | Means | | --- | --- | | `partner` | The name your OAuth client is registered under. | | `referral_code` | The code on the end of your signup link. | | `signup_url` | Your signup link: `https://app.signsealer.com/signup?partner=` and your code. | | `share_percent` | Your share, as a percentage: `10` is 10%. | | `active` | `false` while your program is paused. No new business is made yours; the ones you have stay yours and keep earning. | | `custom_branding` | Whether your custom branding is on. | | `review` | `pending`, `approved` or `declined`. `pending` until a person here has looked at your account. | | `earning` | `true` once your account is approved. | | `earning_since` | When your share started counting; `null` until you are approved. | | `businesses` | How many businesses are yours, closed ones included. | | `month_to_date` | This calendar month so far, in UTC, worked out the way its statement will be (below). `month` is its first day; `businesses` counts the ones that added something; `share_bp` is your share in hundredths of a percent; `share_cents` is truncated to the cent. | Nothing accrues before your account is approved, so until then every amount in `month_to_date` is `0`. Businesses that sign up through your link meanwhile are still recorded as yours. ### GET /v1/partner/businesses The businesses that are yours, newest first by when they became yours. | Query | Means | | --- | --- | | `limit` | A whole number from 1 to 200; 50 when left out. | | `offset` | How many to skip: 0 or more; 0 when left out. | ``` { "data": [ { "tenant_id": "7d9c1c1e-7a57-4f55-9a86-0f7f3f9c2b11", "name": "Harbour Rentals", "status": "active", "source": "referral_link", "attributed_at": "2026-08-02T15:04:05+00:00", "month_to_date_cents": 1840 } ], "has_more": false } ``` There is no `total`. Ask again with `offset` plus `limit` until `has_more` is `false`. A business that becomes yours while you walk the list appears at the front and moves the rest along by one. - **`status`** is `active`, `suspended` or `closed`. - **`source`** is how it became yours: `referral_link` (it signed up through your link), `oauth_install` (it installed your app in its first thirty days), `promotion` (it redeemed a promotion code made for you) or `ops` (SignSealer set it). - **`month_to_date_cents`** is what it has added this month after card fees and the carrier's cost of its texts. It counts from the latest of the start of the month, when the business became yours and when your account was approved, and it is `0` until you are approved. Credit SignSealer gave away earns nothing. ### GET /v1/partner/statements Every closed month, newest first, under `data`. The list is not paged. A month closes on the first run of our sweep after midnight UTC on the 1st, and only for an approved account, so there are none while yours is under review. A statement is written once: afterwards only `paid` and `paid_at` change, when SignSealer records the payment. | Field | Means | | --- | --- | | `month` | `YYYY-MM`. | | `businesses` | How many businesses added something that month: one entry each in `lines`. | | `gross_cents` | What they paid for and used. | | `fees_cents` | The card fees on that, in proportion. | | `sms_cost_cents` | The carrier's cost of their texts, in proportion. | | `revenue_cents` | `gross_cents` less the fees and the texts' cost: what your share is of. | | `share_percent` | Your share at the rate in force when the month closed. | | `share_cents` | That share of `revenue_cents`, truncated to the cent once, on the month's total. | | `carried_in_cents` | A shortfall from your previous statement: 0 or negative. | | `payable_cents` | What you are owed for the month: 0 or more. | | `carried_out_cents` | A shortfall carried into the next month: 0 or negative. | | `paid`, `paid_at` | Whether SignSealer has recorded the payment, and when. | | `lines` | One per business that added something: `tenant_id`, `name`, `gross_cents`, `fees_cents`, `sms_cost_cents` and `revenue_cents`. A refund can make a line negative, and it is kept. | The sums always meet: `payable_cents` plus `carried_out_cents` is `share_cents` plus `carried_in_cents`. A month that comes to less than nothing is carried into the next rather than invoiced. ### Errors The body is the API's usual `{ "error": { "code": …, "message": … } }`, and every answer says `Cache-Control: no-store`. | Status | Code | When | | --- | --- | --- | | 401 | `unauthorised` | No Basic credentials, or ones that do not open a partner account. | | 400 | `invalid_limit` | `limit` on `/v1/partner/businesses` is not a whole number from 1 to 200. | | 400 | `invalid_offset` | `offset` is not a whole number, 0 or more. | | 404 | `not_found` | A path under `/v1/partner` that is not one of the three. | | 405 | `method_not_allowed` | Anything but `GET` (or `HEAD`), with `Allow: GET`. | ### From the Node client `SignSealerPartner` in [@signsealer/sdk](https://signsealer.com/docs/sdk), from version 1.8.0, which reached npm in 1.10.0. It takes your client id and secret and refuses a business's API key (anything starting `mk_`) when it is built. Every call only reads, so a network failure, a `429` or a `5xx` is tried again as it is, up to `maxAttempts` (3, the first included); anything else throws a `SignSealerError` at once. Keep the secret on a server. ``` import { SignSealerPartner } from "@signsealer/sdk"; const partner = new SignSealerPartner({ clientId: process.env.SIGNSEALER_CLIENT_ID!, clientSecret: process.env.SIGNSEALER_CLIENT_SECRET!, }); const me = await partner.profile(); // GET /v1/partner if (!me.earning) console.log("under review:", me.review); for (let offset = 0; ; offset += 200) { // GET /v1/partner/businesses const page = await partner.businesses({ limit: 200, offset }); for (const b of page.data) console.log(b.name, b.source, b.month_to_date_cents); if (!page.has_more) break; } const months = await partner.statements(); // GET /v1/partner/statements: the data array SignSealerPartner.signupUrl(me.referral_code); // me.signup_url, built without a call ``` ### In the OpenAPI description The three routes are in `/openapi.json` under the `Partners` tag, with a security scheme of their own, `partnerAuth` (HTTP Basic), in place of the API key the document declares by default. The [API reference](https://signsealer.com/docs/api#partner) draws them from the same declarations. --- Source: https://signsealer.com/docs/texting (Texting and consent) # Texting and consent SignSealer sends texts on your behalf, from your business's own toll-free or local number, registered with the carriers under your name, and every text names your business. Texting from your own number is coming soon. The carriers hold *you* to what the person agreed to, and they ask for the exact wording. This page is what to collect and how to hand it over. Watch it done: [Text the link instead](https://signsealer.com/guides/text-the-link-instead) (2:43) ## Why a text goes nowhere without this The engine will not text a number it has no consent for. That is not a policy on a page — it is a check in the database, and a send to a number with no consent is recorded `suppressed`, not queued. If you collect phone numbers on your own booking form, order page or at a counter, SignSealer never saw the person tick the box. Until you tell it, it assumes nothing, and every text you expect to go will not go. Send the consent once, when you take the number. ## The seven things the wording has to say Send the exact words that were in front of the person — not a summary, not a description. It is kept verbatim, because it is what a carrier asks for when they audit your traffic. It must carry all seven of these, and a disclosure missing any is refused with `422` naming which: | What | Why | | --- | --- | | **STOP** opts out | The one instruction every recipient must have. | | **HELP** gets help | Required alongside it, and as often forgotten. | | Message and data **rates** may apply | The cost is theirs, and they have to know. | | Message **frequency** | "Frequency varies", or a number — "up to 4 msgs/month". | | Consent is not a **condition of purchase** | Nobody may be made to agree to texts to buy something. | | Their number is **not shared or sold** for marketing | Said as a promise: "we will not share or sell", "never sold". | | Where the **terms** and the **privacy** policy are | Both, because a disclosure that links one is the half a reviewer asks about. | Wording that passes. It is not short, and that is the trade: every sentence in it is one a carrier checks for, and a disclosure missing any of them is refused when you try to record it (`00327`). ``` I agree to receive text messages from [Your business name] via Sign Sealer about documents I need to read and sign: the signing link, at most one reminder if I have not signed, and a confirmation when everyone has signed. Message frequency may vary. Message and data rates may apply. Consent is not a condition of purchase. We will not share or sell your mobile information with third parties for promotional or marketing purposes. Reply HELP for help. Reply STOP to opt out. Terms: https://signsealer.com/legal/terms Privacy: https://signsealer.com/legal/privacy-policy ``` Name your own business where the placeholder is. The recipient has to be able to tell who is texting them, and "a platform they have never heard of" is not an answer a carrier accepts. The tick box must be the person's own act. A pre-ticked box, a checkbox bundled with your terms of service, or consent as a condition of booking are all things a carrier will take your numbers away for. ## Recording it ``` POST /v1/sms-consents { "phone": "(615) 555-0142", "obtained_via": "booking form, checkbox beside the phone field", "wording": "I agree to receive text messages from Harbour Rentals via Sign Sealer about documents I need to read and sign: the signing link, at most one reminder if I have not signed, and a confirmation when everyone has signed. Message frequency may vary. Message and data rates may apply. Consent is not a condition of purchase. We will not share or sell your mobile information with third parties for promotional or marketing purposes. Reply HELP for help. Reply STOP to opt out. Terms: https://signsealer.com/legal/terms Privacy: https://signsealer.com/legal/privacy-policy" } ``` One call covers all three things we would text about — the signing link, a reminder, and the confirmation — because somebody who agreed to those agreed to all three. It answers with the number in E.164 and the purposes recorded: ``` { "phone": "+16155550142", "purposes": ["signing.link", "signing.reminder", "signing.completed"] } ``` `obtained_via` is for you, later. "Booking form" is enough; "the checkbox beside the phone field on /book" is what you will want when somebody asks in eighteen months. ## Then send the link to it A consent on its own sends nothing. Put the number on the signer, beside the address or instead of it: ``` POST /v1/documents/{id}/send { "signers": [ { "full_name": "Dana Reyes", "email": "dana@example.signsealer.com", "phone": "+16155550142" }, { "full_name": "Sam Ortiz", "phone": "+16155550188" } ] } ``` Dana gets the same link twice, by mail and by text, because a person who does not check one inbox checks the other. Sam gets it once, by text, and their `email` comes back `null` in the answer. The text goes only where `may_text` is true for `signing.link`. Where it is not, the send is not refused: the email still goes, and the reason the text did not is on the document's evidence trail as `signing.link.not_texted`. A signer with no email and no textable number is a different matter and is refused by name, before anything is minted — there is nowhere for their link to go. From then on the number carries: the reminder and the completion note follow the road the link took, each under its own consent. The wording promises at most one reminder, and the engine keeps it: a signer is texted one reminder over the life of their link, whoever asks for it and however often, and every reminder after that goes by email alone — or, for a signer with no email address, is refused with `422` saying why ([Limits and paging](https://signsealer.com/docs/limits)). ## Asking before you ask again ``` GET /v1/sms-consents?phone=%2B16155550142 ``` ``` { "phone": "+16155550142", "may_text": true, "why_not": null, "obtained_via": "booking form, checkbox beside the phone field", "obtained_at": "2026-10-03T14:22:09Z" } ``` Use it to decide whether to put the tick box in front of a returning customer again. It answers for one number at a time and deliberately will not list every number you hold. `may_text` is what the engine will actually do, not what one of its checks says. Consent has to be live *and* the number must not be suppressed — a disconnected phone keeps its consent and still cannot be reached. When it is false, `why_not` tells you which: | `why_not` | What happened | What to do | | --- | --- | --- | | `no_consent` | Nobody ever recorded one. | Ask, and record it. | | `revoked` | They replied STOP. | Nothing. It is theirs to undo. | | `suppressed` | The number could not be reached — disconnected, or a landline. | Check the number with them; email in the meantime. | ## STOP is theirs, not yours When somebody replies STOP, the consent is *revoked*, not merely suppressed — and you cannot re-consent them by calling this endpoint again. They have to opt in themselves. A door that let a business overwrite a withdrawal would make the whole record worthless, since the business is not who withdrew it. A number is one person however it is written. `(615) 555-0142`, `615-555-0142` and `+1 615 555 0142` are the same consent and the same STOP, so you can send numbers in whatever shape your database holds them. ## What we never do SignSealer does not send marketing texts. The only purposes that exist are the signing link, one reminder, and the confirmation; a request for anything else is refused by the engine rather than by a policy. That is worth saying to your own customers, because it is the reason this consent is narrow enough to be easy to collect. --- Source: https://signsealer.com/docs/api (API reference) # 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 Scope `signing:read` **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`. Returns ``` { "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 Scope `signing:read` **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](https://signsealer.com/docs/branding). Returns ``` { "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 Scope `signing:send` (or `signing:write`, which includes it) · answers `201` · takes an `Idempotency-Key` (`sms_consents.record`) **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 | Returns ``` { "phone": "+15035550142", "purposes": [ "signing.link", "signing.reminder", "signing.completed" ] } ``` ### GET /v1/sms-consents Scope `signing:read` **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`. Query | Parameter | Type | | What it is | | --- | --- | --- | --- | | phone | string | required | The number, as a person would type it. | Returns ``` { "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 Scope `signing:read` · answers `201` **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 | Returns ``` { "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 Scope `signing:read` **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. Query | Parameter | Type | | What it is | | --- | --- | --- | --- | | code | string | optional | Only this template's versions. | Returns ``` { "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 Scope `signing:templates` · answers `201` · takes an `Idempotency-Key` (`templates.create`) **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 | | Returns ``` { "template_id": "8c2e…" } ``` ### GET /v1/templates/{id} Scope `signing:read` **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. Returns ``` { "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} Scope `signing:templates` **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 | Returns ``` { "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 Scope `signing:templates` · takes an `Idempotency-Key` (`templates.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. Returns ``` { "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 Scope `signing:templates` · answers `201` · takes an `Idempotency-Key` (`templates.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 | Returns ``` { "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 Scope `signing:templates` · answers `201` · takes an `Idempotency-Key` (`templates.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 | Returns ``` { "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} Scope `signing:templates` **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. Returns ``` { "template_id": "93aa…", "code": "liability-waiver", "version": 3, "discarded": true } ``` ### PATCH /v1/templates/{id}/settings Scope `signing:templates` **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 | | Returns ``` { "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 Scope `signing:read` **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. Returns ``` { "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 Scope `signing:read` **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. Query | Parameter | Type | | What it is | | --- | --- | --- | --- | | template | string | required | The template's code. | | email | string (email) | optional | Whose standing, by email address. This or `phone`. | | phone | string | optional | Whose standing, by mobile number. This or `email`. | Returns ``` { "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 Scope `signing:read` **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`. Query | 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. | Returns ``` { "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 } ``` ### GET /v1/documents/{id} Scope `signing:read` **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. Returns ``` { "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 Scope `signing:read` **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. Returns ``` { "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 Scope `signing:links` (or `signing:write`, which includes it) or `signing:workflows` · answers `201` **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. Returns ``` { "document_id": "d41c…", "signer_id": "5f0a…", "url": "https://api.signsealer.com/s/9k3…", "qr_svg": "…" } ``` ### POST /v1/documents/{id}/signers/{signer}/remind Scope `signing:remind` (or `signing:write`, which includes it) · takes an `Idempotency-Key` (`documents.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`). Returns ``` { "document_id": "d41c…", "signer_id": "5f0a…", "reminded": true, "by": "email" } ``` ### POST /v1/documents/{id}/signers/{signer}/embed Scope `signing:links` (or `signing:write`, which includes it) · answers `201` **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 `