# wesign.now API documentation (full text) > Every page of https://www.wesign.now/docs in the order of the docs navigation, as plain Markdown. Generated from the docs source (content/docs) when this deployment was built, so it matches the published pages. The index with one-line summaries is https://www.wesign.now/llms.txt. - Current OpenAPI spec: https://www.wesign.now/openapi.json (info.version 2026-10-01) - Machine-readable changelog: https://www.wesign.now/api-changelog.json - Pages: 27. Each starts after a horizontal rule with its title as a level-1 heading and its URL. --- # API documentation URL: https://www.wesign.now/docs Summary: Send any PDF for signing with a single POST. The letssign.now API reference, errors, recipes, support. The whole signing flow — anchors, signers, dispatch, signed PDF, audit trail, webhooks — sits behind one Bearer-API-key authed endpoint at `https://api.wesign.now/v1/*`. No templates required, no portal redirect for the recipient, no SDK to install. Three real-life calls ship 90% of integrations. `https://api.letssign.now/v1` is a permanent alias of the same API. The [Base URL](https://www.wesign.now/docs/api-overview#base-url) section says exactly what is — and what isn't — the contract. ## Three steps to ship ### Mint an API key Go to [Developers → API keys](https://www.wesign.now/settings/api) in your workspace and click **Create API key**. Copy the `wsk_live_…` token — we only display the plaintext once. Keys minted before the September 2026 rename start with `lsk_live_` and keep working unchanged. ### Send your first signing request Replace `YOUR_KEY` and run this in a terminal: ```bash curl -X POST https://api.wesign.now/v1/signing-requests \ -H "Authorization: Bearer YOUR_KEY" \ -F "file=@contract.pdf" \ -F 'signers=[{"email":"signer@example.com","role":"signer"}]' ``` Drop `[[ls:signature:signer]]` anywhere in your PDF and we place a signature field there. No marker? We auto-append a signature page. Need the signer to confirm an SMS code before they can sign? Add `phone_e164` and `require_sms_verification` to the signer — see [Second factor by SMS](https://www.wesign.now/docs/signing-requests#second-factor-by-sms), and [SMS verification](https://www.wesign.now/docs/sms-verification) for the whole flow. ### (Optional) Receive real-time events Register an outbound webhook at [Developers → Webhooks](https://www.wesign.now/settings/api) (or pass `callback_url` per-request). We POST `signing_request.signed` as each signer completes, and `document.completed` with a key-authenticated signed-PDF URL when everyone's done. Every delivery is HMAC-signed (`X-WeSign-Signature`) — [verify it](https://www.wesign.now/docs/webhooks#verifying-a-signature) before trusting the body. Sending contracts you author once in the app? The [Template quickstart](https://www.wesign.now/docs/template-quickstart) goes from a template to a sealed PDF: a one-signer rich-text letter, or a PDF two people sign in order. ## What's in this site - [Quickstart](https://www.wesign.now/docs/quickstart): Your own PDF to a sealed, signed PDF in five minutes, terminal only. - [Template quickstart](https://www.wesign.now/docs/template-quickstart): Author a template in the app, fill it over the API, follow two signers in order, download the result. - [API overview](https://www.wesign.now/docs/api-overview): Base URL and its alias, versioning, the map of v1 resources. - [OpenAPI spec](https://www.wesign.now/docs/openapi): The machine-readable contract — import it into Postman, Insomnia or a client generator. - [Authentication](https://www.wesign.now/docs/authentication): Bearer keys, key format, rotation, error shape. - [Create a signing request](https://www.wesign.now/docs/signing-requests): The big endpoint. Multipart or JSON, signers, placement, SMS second factor. Read one signer, remind, withdraw. - [Placement modes](https://www.wesign.now/docs/placement-modes): Anchors, auto-append, or explicit coordinates — and why manual placement is retired. - [Documents](https://www.wesign.now/docs/documents): GET a document and its signers, signed-PDF download, audit-trail PDF. - [Templates](https://www.wesign.now/docs/templates): Instantiate a locked template with field_values; stage for review first. - [Prepare a fill in your app](https://www.wesign.now/docs/prepare-a-fill): Read a template's contract, ask your user for what it needs, dry-run, then send. - [Webhooks](https://www.wesign.now/docs/webhooks): Events we emit, signature verification in Node and PHP, retry semantics. - [Idempotency](https://www.wesign.now/docs/idempotency): Safely retry network blips without double-sending. - [Rate limits](https://www.wesign.now/docs/rate-limits): 60 requests/minute per API key. Retry-After on 429. - [Errors](https://www.wesign.now/docs/errors): Stable error codes mapped to HTTP statuses. - [Security](https://www.wesign.now/docs/security): Encryption at rest and in transit, signature evidence, webhook signing. - [How verification works](https://www.wesign.now/docs/verification): Hash lookup, PAdES validation, eIDAS QES detection, trust scope. ## For AI assistants Building the integration with an AI assistant or coding agent? Give it these instead of pasted pages. The two text files are generated from this site's source on every deploy, so they always match the pages. - [llms.txt](https://www.wesign.now/llms.txt): Every page with a one-line summary, the base URL, keys and versioning, and the machine-readable sources. - [llms-full.txt](https://www.wesign.now/llms-full.txt): Every page of this site as plain Markdown, in one file. - [Integrator FAQ](https://www.wesign.now/docs/integrator-faq): Short answers to what integrating apps ask most: observers, AES by SMS, languages, parallel signing, dates. - [OpenAPI spec and changelog](https://www.wesign.now/api-changelog.json): The contract at /openapi.json; every change with a stable id at /api-changelog.json. --- # Quickstart URL: https://www.wesign.now/docs/quickstart Summary: From zero to a signed PDF in under five minutes — terminal-only, no SDK. This guide goes deeper than the [home page](https://www.wesign.now/docs) summary. By the end you'll have downloaded a real PAdES-sealed PDF, seen a webhook fire against your endpoint, and you'll know which knobs to turn for production. Every URL below uses the canonical host `https://api.wesign.now/v1`. `https://api.letssign.now/v1` is a permanent alias of the same API — see [Base URL](https://www.wesign.now/docs/api-overview#base-url) for what is and isn't the contract. ## Prerequisites - A workspace on **Enterprise**. API keys and the template endpoints both ship with it; Free, Pro and Branded can't mint keys. Only a workspace owner or admin can. - Legacy **Teams** workspaces can still mint keys and use everything on this page. What they can't do is send from templates: `instantiate`, `generate` and `confirm` answer `402 enterprise-required` there (and so does minting an [embedded signing](https://www.wesign.now/docs/embedded) session). Listing and reading templates (`GET /v1/templates`, `GET /v1/templates/{id}`) works on Teams too. - A test PDF on disk. Any PDF works; if it doesn't have an anchor marker we just append a signature page. - An email address you can check (or two — sender + signer can be the same address while you smoke-test). Sending from a template instead of a PDF? The [Template quickstart](https://www.wesign.now/docs/template-quickstart) walks through that flow. ## 1 · Mint an API key In your workspace, open [Developers → API keys](https://www.wesign.now/settings/api) and hit **Create API key**. Copy the `wsk_live_…` plaintext to a terminal env var: ```bash export WSK_KEY="wsk_live_…" # paste the whole key here ``` The plaintext shows once. The dashboard keeps a `wsk_live_a1b…` prefix so you can identify the key later. A key minted before the September 2026 rename starts with `lsk_live_` and works exactly the same — no need to re-mint. ## 2 · Send a real request ```bash curl -X POST https://api.wesign.now/v1/signing-requests \ -H "Authorization: Bearer $WSK_KEY" \ -H "Idempotency-Key: smoke-$(date +%s)" \ -F "file=@/path/to/contract.pdf" \ -F 'signers=[{"email":"you@example.com","role":"signer"}]' \ -F 'placement=auto_append' | jq . ``` Successful response (HTTP 201): ```json { "documentId": "8a1e4f9a-b3c1-4d22-9e0a-3a92ee7c1f88", "metadata": null, "status": "pending", "placement": "auto_append", "presentedSha256": "4f9a…d21c", "anchors": { "found": 0, "fields": 2 }, "signers": [ { "role": "signer", "email": "you@example.com", "status": "pending", "signingUrl": "https://yourco.letssign.now/en/sign/abc…", "signingRequestId": "11111111-…", "emailed": true, "require_sms_verification": false, "phone_masked": null, "sms_verified_at": null, "sms_gate": "before_sign", "signature_level": "SES", "reference": null, "company": null, "job_title": null } ], "observers": [], "warnings": [] } ``` `"placement": "auto_append"` with `anchors.found: 0` means the PDF had no anchor markers, so a signature page was appended. `emailed: true` says we sent the invitation. That email carries the same signing link on the email host — `https://yourco.wesign.now/en/sign/abc…` — while the API hands you the `yourco.letssign.now` form; both open the same page, the token decides. Open `signingUrl` in another browser tab to play the signer side. To deliver the link yourself instead, send `-F 'send_emails=false'`: everything else is created as usual and `emailed` comes back `false`. ## 3 · Watch a webhook fire (optional) Skip if you'd rather poll. To see the events live, the easiest path is a free Webhook.site URL: ```bash # webhook.site/ issued at https://webhook.site curl -X POST https://api.wesign.now/v1/signing-requests \ -H "Authorization: Bearer $WSK_KEY" \ -F "file=@contract.pdf" \ -F 'signers=[{"email":"you@example.com","role":"signer"}]' \ -F 'callback_url=https://webhook.site/YOUR-TOKEN' ``` The response now includes a `callback.secret` — store it. Each event we POST to your URL is signed. The same three values arrive under two header names; the `X-LetsSign-*` family is the pre-rename spelling and is kept forever, so read whichever you like: ``` X-WeSign-Signature: t=1757404800,v1=9c3b…a2f1 X-WeSign-Event-Id: evt_… X-WeSign-Event: signing_request.signed X-LetsSign-Signature: t=1757404800,v1=9c3b…a2f1 X-LetsSign-Event-Id: evt_… X-LetsSign-Event: signing_request.signed ``` [Verify the signature](https://www.wesign.now/docs/webhooks#verifying-a-signature) before trusting the body. ## 4 · Sign Open `signingUrl` on your phone (real test) or in another browser tab (lazy test). Sign. Watch the inbox for the completion email and your webhook receiver for `signing_request.signed` → `document.completed`. ## 5 · Pull the signed PDF programmatically ```bash DOC_ID=8a1e4f9a-b3c1-4d22-9e0a-3a92ee7c1f88 # the documentId from step 2 curl -LOJ -H "Authorization: Bearer $WSK_KEY" \ https://api.wesign.now/v1/documents/$DOC_ID/signed ``` Returns the fully PAdES-sealed PDF — every signer's signature appended, RFC 3161 timestamp embedded, ready for your DMS. This is the same URL `document.completed` hands you as `signed_pdf_url`: it needs your key, so pin that fetch to `api.wesign.now` and send the bearer — see [signed_pdf_url](https://www.wesign.now/docs/documents#signed_pdf_url-in-documentcompleted). ## Testing without a sandbox There are no test keys yet: every key is live, every call creates real documents and sends real email and SMS, and SMS codes count against the monthly allowance. Three switches keep a test contained: - **`send_emails: false`** on `POST /v1/signing-requests` or `instantiate` — the document and the signing links are created and returned, no invitation is emailed, not even to the next signer of a sequential send when their turn comes. Open the links yourself. Automatic reminders (7 and 14 days after the send, by default) and the completion emails still go out, so use addresses you own, and [withdraw](https://www.wesign.now/docs/signing-requests#withdraw) what you leave unsigned. - **`validate_only: true`** on `instantiate` and `generate` — the full check (on `instantiate` the SMS allowance included) and nothing is created. See [Templates → dry run](https://www.wesign.now/docs/templates#dry-run). - **`review: true`** on `instantiate` (or `generate`) — the document is built and staged, nothing is sent. Look at it at the `review_url`, then throw it away with `POST /v1/documents/{id}/discard`. See [Review before sending](https://www.wesign.now/docs/templates#review-before-sending). A sandbox with simulated email and SMS is planned — see [Versioning → No sandbox yet](https://www.wesign.now/docs/versioning#no-sandbox-yet). ## What's next - [Template quickstart](https://www.wesign.now/docs/template-quickstart) — send the same contract many times from a locked template. - [Placement modes](https://www.wesign.now/docs/placement-modes) — pick anchors, auto-append, or explicit coordinates. - [Second factor by SMS](https://www.wesign.now/docs/signing-requests#second-factor-by-sms) — require a one-time code on the signer's phone before they can sign. - [Webhooks](https://www.wesign.now/docs/webhooks) — production-grade event delivery, retries, signature verification. - [Idempotency](https://www.wesign.now/docs/idempotency) — safely retry network blips. - [Errors](https://www.wesign.now/docs/errors) — full code table. --- # Template quickstart URL: https://www.wesign.now/docs/template-quickstart Summary: From zero to a sealed PDF made from a template. A one-signer rich-text letter, or a two-signer PDF your system fills and two people sign in order. This guide takes you from an empty workspace to a PAdES-sealed PDF made from a **template**: a contract you author once in the app and then send many times over the API, with only the values that change per case. It has two tracks. Pick one in any tab group; every tab group on the page switches with it. - **Rich-text template**: "Mandate letter", written in the app's editor. One signer. Your system supplies the person's name and address. - **PDF template**: "Power of attorney (company)", an existing PDF with boxes on it. Two representatives sign **in order**. Your system supplies the company's name and tax ID; each representative types where they sign. Every URL uses the canonical host `https://api.wesign.now/v1` (`https://api.letssign.now/v1` is a permanent alias). Start with your key in a variable: ```bash export WSK_KEY="wsk_live_…" ``` ## Prerequisites - **An Enterprise workspace.** Creating documents from templates is the Enterprise template entitlement. Without it the calls that create or send answer `402 enterprise-required`; reading templates always works: | Call | Needs | | --- | --- | | `GET /v1/templates`, `GET /v1/templates/{id}` | A full-access key, on any plan with API keys | | `POST /v1/templates/{id}/instantiate` and `/generate`, dry runs included | The template entitlement | | `POST /v1/documents/{id}/confirm` | The template entitlement | | `POST /v1/documents/{id}/discard` | A full-access key, so you can clean up after a downgrade | The **Templates** section of the app appears with the same entitlement. Check it over the API: `GET /v1/me` answers `"capabilities": { "templates": true, … }`. Branch on that flag, never on the tier name (see [Feature detection](https://www.wesign.now/docs/api-overview#feature-detection)). - **An owner or admin** of the workspace to create the API key. Members do not see the developer settings. - **Addresses you can check.** There is no sandbox: every call that is not a dry run creates a real document. See [Test without a sandbox](https://www.wesign.now/docs/template-quickstart#test-without-a-sandbox). ## 1 · Create an API key Open [Developers → API keys](https://www.wesign.now/settings/api), give the key a name, keep the scope **Full access (all API)** and click **Create API key**. Copy the `wsk_live_…` value into `WSK_KEY`: it is shown once. A key scoped **Embedded only** is refused on every template call (`401 invalid_key`). Key format, rotation and IP allowlists are in [Authentication](https://www.wesign.now/docs/authentication). Check the key and the entitlement: ```bash curl -s https://api.wesign.now/v1/me \ -H "Authorization: Bearer $WSK_KEY" | jq '.capabilities.templates' # true ``` ## 2 · Author the template in the app **Rich-text template:** 1. Open **Templates → New template**. Under **Who fills it in?** choose **Filled by your system**, then under **Start blank** name it `Mandate letter` and click **Create & open editor**. 2. Write the letter. Where the person's name goes, click **Fill fields** and, under **Custom field**, type `person.full_name`. The box suggests **Standard names** as you type; pick it and add it. Do the same for `address.full`. 3. Each placeholder you add this way to a template filled by your system has `owner: "api"`: your call must send it (`required: true`). The toolbar switch **People fill** / **API fills** changes who fills the values while the template is a draft. With **People fill**, the signer types them on a form before signing and your call may pre-fill them. 4. Add a **Signature line** where the person signs. A template has one signer until you add more in the **Signers** panel; each signature line names its **Signer**, and each signer needs at least one. 5. Click **Lock** and confirm. The panel **Locked · ready for API use** shows the **Template ID** (**Copy template ID**) and the **Fields a caller supplies**. **PDF template:** 1. Open **Templates → New template** and choose **Upload a file**. Pick the PDF. The editor opens on **Add recipients**. 2. Click **Add recipient** twice. A template stores signer **slots**, not people: the first row becomes slot 1 (Representative 1), the second slot 2 (Representative 2). Drag a row to change the order. No email is needed, and only the order is saved with the template. 3. Click **Next: Place fields**. Choose the recipient under **Fields for** and place their boxes: - Representative 1: a **Signature**, a **Date**, and three **Text** boxes for the company name, the company's tax ID and the place of signing. - Representative 2: a **Signature**, a **Date** and a **Text** box for the place of signing. Every recipient needs a Signature box. The editor saves a template without one, but every call that sends it then fails with `422 signer_without_signature`. 4. Select each text box. In its **Text field** settings: - **Filled by**: **Sender / API** for the company name and the tax ID: your system supplies them and they print as fixed text. **Signer** for the two places: the representative types it while signing. Every new box starts as **Signer**. - **API name**: the key your API call uses. Type and pick from **Standard names** (they carry a **Standard** badge): `company.legal_name`, `company.tax_id`, `signing.place`. On Representative 2's box the same pick gives `signing.place_s2`: names are unique in a template, and `_s2` marks the second signer's own copy. - Leave **Optional for the sender** off, so the API requires both Sender / API values. 5. Click **Save as template**, name it `Power of attorney (company)` and click **Save template**. The template page opens as a draft. Its field list shows every key with **Filled by** and **Required**, per signer; you can still rename keys there. 6. Click **Lock**. This freezes the keys and gives the template a stable ID. **Unlock** later starts a new version; a call can pin the one it was built against with `version`. Use `_s2` only for a value that **differs per signer**, like the place each representative signs. A value both share, like the company name, is one Sender / API box with one key. **Note:** **A Sender / API value is document content.** It prints as fixed text that every signer sees from their first view, in any signing order: sent in parallel, Representative 2 may sign first and still sees the company name. Every seal covers it, and it is printed once. Nothing a signer submits can change it. The box's `slot` only says whose field set it is copied into, never who sees it. More in [Who fills a value](https://www.wesign.now/docs/fields-placeholders#who-fills-a-value). ## 3 · Read the template's contract List the workspace's templates and take the `id` of the one with `instantiable: true` (`status: "locked"`): ```bash curl -s https://api.wesign.now/v1/templates \ -H "Authorization: Bearer $WSK_KEY" | jq . ``` ```jsonc { "templates": [ { "id": "c7a41e2d-9b3f-4f60-8d15-6e2b0a9c7f48", "name": "Power of attorney (company)", "description": null, "status": "locked", "version": 3, "pages": 1, "content_kind": "pdf", "instantiable": true, "updated_at": "2026-09-22T14:05:11.402Z" }, { "id": "3b2f7c1e-5a4d-4e8b-9c61-0f2a7d9e4b13", "name": "Mandate letter", "description": null, "status": "locked", "version": 2, "pages": 1, "content_kind": "richtext", "instantiable": true, "updated_at": "2026-09-21T08:40:02.117Z" } ] } ``` Then read what that template needs: ```bash curl -s https://api.wesign.now/v1/templates/$TPL_ID \ -H "Authorization: Bearer $WSK_KEY" | jq . ``` **Rich-text template:** ```bash export TPL_ID=3b2f7c1e-5a4d-4e8b-9c61-0f2a7d9e4b13 ``` ```jsonc { "template": { "id": "3b2f7c1e-5a4d-4e8b-9c61-0f2a7d9e4b13", "name": "Mandate letter", "status": "locked", "version": 2, "content_kind": "richtext", "instantiable": true, … }, "recipients": [{ "slot": 1 }], "field_values": [ { "key": "person.full_name", "kind": "scalar", "owner": "api", "required": true, "label": "Full name", "type": "text", "example": "Anna Muster", "standard": true, … }, { "key": "address.full", "kind": "scalar", "owner": "api", "required": true, "label": "Address", "type": "text", "example": "Bahnhofstrasse 1, 8001 Zürich", "standard": true, … } ], "signing_fields": [{ "kind": "signature", "slot": 1, "required": true }] } ``` **PDF template:** ```bash export TPL_ID=c7a41e2d-9b3f-4f60-8d15-6e2b0a9c7f48 ``` ```jsonc { "template": { "id": "c7a41e2d-9b3f-4f60-8d15-6e2b0a9c7f48", "name": "Power of attorney (company)", "status": "locked", "version": 3, "content_kind": "pdf", "instantiable": true, … }, "recipients": [{ "slot": 1 }, { "slot": 2 }], "field_values": [ { "key": "company.legal_name", "kind": "scalar", "owner": "sender", "required": true, "signer_required": false, "has_default": false, "slot": 1, "label": "Company name", "type": "text", "example": "Muster AG", "standard": true, … }, { "key": "company.tax_id", "kind": "scalar", "owner": "sender", "required": true, "signer_required": false, "has_default": false, "slot": 1, "label": "Company tax ID", "type": "text", "example": "DE123456789-00001", "standard": true, … }, { "key": "signing.place", "kind": "scalar", "owner": "signer", "required": false, "signer_required": false, "has_default": false, "slot": 1, "label": "Place of signing", "type": "text", "example": "Zürich", "standard": true, … }, { "key": "signing.place_s2", "kind": "scalar", "owner": "signer", "required": false, "signer_required": false, "has_default": false, "slot": 2, "label": "Place of signing", "type": "text", "example": "Zürich", "standard": true, … } ], "signing_fields": [ { "kind": "signature", "slot": 1, "required": true }, { "kind": "date", "slot": 1, "required": true }, { "kind": "signature", "slot": 2, "required": true }, { "kind": "date", "slot": 2, "required": true } ] } ``` Three properties drive your call (`…` marks what is left out here; every property is in [Get a template](https://www.wesign.now/docs/templates#get-a-template)): - **`recipients`**: one `slot` per signer. Your call assigns one recipient to each (`400 missing_recipients` otherwise). - **`required`**: send every entry that says `true`, or the call fails with `422 template_input_invalid`. Read the flag; never re-derive it from `owner`. - **`owner`**: who fills the entry. `api` and `sender` are your side; `signer` is the signer, and you may pre-fill it. **`slot`** on a PDF box says which signer's field set it belongs to. ## 4 · Dry run Send the body you are about to send, plus `"validate_only": true`. Nothing is created, nothing is sent, and no `Idempotency-Key` is read. **Rich-text template:** ```bash curl -s -X POST https://api.wesign.now/v1/templates/$TPL_ID/instantiate \ -H "Authorization: Bearer $WSK_KEY" \ -H "Content-Type: application/json" \ -d '{ "recipients": [{ "slot": 1, "email": "anna@example.ch", "name": "Anna Muster" }], "field_values": { "person.full_name": "Anna Muster", "address.full": "Bahnhofstrasse 1, 8001 Zürich" }, "metadata": { "case_id": "ZT-2026-0142" }, "validate_only": true }' | jq . ``` ```json { "ok": true, "template_id": "3b2f7c1e-5a4d-4e8b-9c61-0f2a7d9e4b13", "template_version": 2, "warnings": [], "problems": [], "missing_optional": [] } ``` **PDF template:** This body forgets the tax ID: ```bash curl -s -X POST https://api.wesign.now/v1/templates/$TPL_ID/instantiate \ -H "Authorization: Bearer $WSK_KEY" \ -H "Content-Type: application/json" \ -d '{ "recipients": [ { "slot": 1, "email": "anna@example.ch", "name": "Anna Muster" }, { "slot": 2, "email": "ben@example.ch", "name": "Ben Keller" } ], "field_values": { "company.legal_name": "Muster AG" }, "signing_mode": "sequential", "locale": "de", "metadata": { "case_id": "ZT-2026-0142" }, "validate_only": true }' | jq . ``` ```jsonc // HTTP/1.1 422 Unprocessable Entity { "error": "1 problem with this request.", "code": "template_input_invalid", "template_id": "c7a41e2d-9b3f-4f60-8d15-6e2b0a9c7f48", "template_name": "Power of attorney (company)", "template_version": 3, "problems": [ { "field": "company.tax_id", "label": "Company tax ID", "code": "field_required", "message": "\"Company tax ID\" is required by this template but was not supplied." } ], "warnings": [], "docs": "https://www.letssign.now/de/settings/api", "missing_optional": [ { "key": "signing.place", "label": "Place of signing", "owner": "signer", "type": "text", "standard": true, "example": "Zürich", "labels": { … }, "slot": 1 }, { "key": "signing.place_s2", "label": "Place of signing", "owner": "signer", "type": "text", "standard": true, "example": "Zürich", "labels": { … }, "slot": 2 } ] } ``` Add `"company.tax_id": "DE123456789-00001"` and the dry run answers `200` with `"ok": true`. `missing_optional` still lists the two places: boxes you may pre-fill, never a reason to refuse. - **`problems`** block the call, all of them in one answer. Fix and dry-run again. - **`warnings`** never block. An `unknown_field` warning is a key the template does not have, usually a spelling mismatch. - Read **`ok`**, not only the status: a `200` with `ok: false` means the real call would be refused for the workspace's monthly document cap or its SMS allowance. Everything a dry run checks, and what it does not, is under [Dry run](https://www.wesign.now/docs/templates#dry-run). Building a form for your own users from these answers is [Prepare a fill in your app](https://www.wesign.now/docs/prepare-a-fill). ## 5 · Send The same body without `validate_only`, with an [`Idempotency-Key`](https://www.wesign.now/docs/idempotency) so a retry never sends twice: **Rich-text template:** ```bash curl -s -X POST https://api.wesign.now/v1/templates/$TPL_ID/instantiate \ -H "Authorization: Bearer $WSK_KEY" \ -H "Idempotency-Key: ZT-2026-0142-mandate" \ -H "Content-Type: application/json" \ -d '{ "recipients": [{ "slot": 1, "email": "anna@example.ch", "name": "Anna Muster" }], "field_values": { "person.full_name": "Anna Muster", "address.full": "Bahnhofstrasse 1, 8001 Zürich" }, "metadata": { "case_id": "ZT-2026-0142" } }' | jq . ``` ```json { "document_id": "5d8e2a41-7c3b-4f9e-a012-3b6c9d8e1f27", "metadata": { "case_id": "ZT-2026-0142" }, "template_version": 2, "signing_mode": "parallel", "recipients": [ { "slot": 1, "email": "anna@example.ch", "signing_request_id": "4c2e8a17-3b5d-4f6a-9e0c-1d7b3a5f9e82", "signing_url": "https://yourco.letssign.now/en/sign/5b0e…", "emailed": true, "require_sms_verification": false, "phone_masked": null, "sms_verified_at": null, "sms_gate": "before_sign", "signature_level": "SES", "reference": null, "company": null, "job_title": null } ], "field_values_echoed": { "person.full_name": "Anna Muster", "address.full": "Bahnhofstrasse 1, 8001 Zürich" }, "warnings": [] } ``` **PDF template:** ```bash curl -s -X POST https://api.wesign.now/v1/templates/$TPL_ID/instantiate \ -H "Authorization: Bearer $WSK_KEY" \ -H "Idempotency-Key: ZT-2026-0142-poa" \ -H "Content-Type: application/json" \ -d '{ "recipients": [ { "slot": 1, "email": "anna@example.ch", "name": "Anna Muster" }, { "slot": 2, "email": "ben@example.ch", "name": "Ben Keller" } ], "field_values": { "company.legal_name": "Muster AG", "company.tax_id": "DE123456789-00001" }, "signing_mode": "sequential", "locale": "de", "metadata": { "case_id": "ZT-2026-0142" } }' | jq . ``` ```jsonc { "document_id": "9f3c6b12-4e7a-4d85-b1c9-2a5e8f0d7c36", "metadata": { "case_id": "ZT-2026-0142" }, "template_version": 3, "signing_mode": "sequential", "recipients": [ { "slot": 1, "email": "anna@example.ch", "signing_request_id": "6e1d9b34-8a2c-4e7f-b5d0-3c9a1e7f2b45", "signing_url": "https://yourco.letssign.now/de/sign/3f9a…", "emailed": true, "require_sms_verification": false, "phone_masked": null, "sms_verified_at": null, "sms_gate": "before_sign", "signature_level": "SES", "reference": null, "company": null, "job_title": null }, { "slot": 2, "email": "ben@example.ch", "signing_request_id": "7f2eac45-9b3d-4f80-a6e1-4dab2f803c56", "signing_url": "https://yourco.letssign.now/de/sign/8c21…", "emailed": false, // queued: invited once slot 1 has signed "require_sms_verification": false, "phone_masked": null, "sms_verified_at": null, "sms_gate": "before_sign", "signature_level": "SES", "reference": null, "company": null, "job_title": null } ], "field_values_echoed": { "company.legal_name": "Muster AG", "company.tax_id": "DE123456789-00001" }, "warnings": [] } ``` `"signing_mode": "sequential"` signs in slot order. Slot 2 is created `queued`; its link shows a "waiting for the previous signer" notice until slot 1 has signed. Then slot 2 is released: invited by email automatically, or, with `"send_emails": false`, not emailed — the release's `signing_request.sent` is your cue to deliver the `signing_url` you stored (step 6). The places are left to the representatives; send `signing.place` or `signing.place_s2` to pre-fill a box they can still change. A direct send answers `200`. Store `document_id` and every `signing_request_id`: the webhooks name them. - **`recipients`** is `[{ slot, email, name }]`, one per slot. `name` is optional and fills `{{recipient_name}}`. `phone_e164`, `require_sms_verification`, `sms_gate`, `reference`, `company` and `job_title` are optional too; any other key is a `400 unknown_recipient_field`. The whole body is in [Templates → Instantiate](https://www.wesign.now/docs/templates#instantiate). - **`signing_url`** sits on your workspace's host on `letssign.now` (`https://yourco.letssign.now/…`). The invitation email links the same token on `wesign.now` (`https://yourco.wesign.now/…`). Both open the same signing page. - **`warnings`**: check it on every call. A misspelt key is a warning, not an error, and its value is not printed. ### Or stage it for a colleague Add `"review": true` and the call **stages** the document instead of sending it: `201`, `"status": "staged"`, a `review_url`, and no `signing_url` (the links only work once the instance is confirmed). Nothing is emailed or texted. For the power of attorney: ```jsonc // HTTP/1.1 201 Created { "document_id": "9f3c6b12-4e7a-4d85-b1c9-2a5e8f0d7c36", "status": "staged", "review_url": "https://www.letssign.now/de/documents/9f3c6b12-4e7a-4d85-b1c9-2a5e8f0d7c36/review", "review_expires_at": "2026-10-08T09:14:00.000Z", … } ``` 1. A teammate opens `review_url`. It needs a signed-in member of your workspace. The page shows the PDF exactly as it will go out, and the recipients, which they can correct. 2. Before sending they can correct the values your system supplied: on a PDF template **Edit values** retypes the Sender / API boxes (never a signer's box); on a rich-text template **Edit content** opens the body and its values. See [What a reviewer can change](https://www.wesign.now/docs/templates#what-a-reviewer-can-change). 3. They click **Send to 2 recipients**, then **Confirm**. Or your system confirms over the API, with `DOC_ID` set to the `document_id` of the `201`: ```bash curl -s -X POST https://api.wesign.now/v1/documents/$DOC_ID/confirm \ -H "Authorization: Bearer $WSK_KEY" \ -H "Content-Type: application/json" \ -d '{}' | jq . ``` An empty body sends what was staged. The confirm body takes `recipients` corrections (addressed by `signing_request_id`), `send_emails` and `expires_in_days`, and **no `field_values`**: values are corrected by a person on the review page, or you discard the instance and create a new one. The answer carries a `signing_url` per recipient; in a sequential document the later ones come back `"status": "queued"`. Unconfirmed instances are discarded after 14 days. Details: [Review before sending](https://www.wesign.now/docs/templates#review-before-sending) and [Confirm and discard](https://www.wesign.now/docs/templates#confirm-and-discard). ## 6 · Follow it with webhooks `instantiate` has no `callback_url`: register a workspace hook once, in [Developers → Webhooks](https://www.wesign.now/settings/api) or over the API. Without `events` it receives every event type: ```bash curl -s -X POST https://api.wesign.now/v1/hooks \ -H "Authorization: Bearer $WSK_KEY" \ -H "Content-Type: application/json" \ -d '{ "target_url": "https://yourapp.example.com/wesign/webhooks" }' | jq . ``` The answer carries the signing `secret` **once**. Store it, [verify every delivery](https://www.wesign.now/docs/webhooks#verifying-a-signature), and answer `2xx` to event types you do not handle. For the two-signer PDF, in order: | When | Event | What it tells you | | --- | --- | --- | | Instantiate answers `200` | `signing_request.sent` | The document went out, to slot 1. Once per document, with your `metadata`. | | Anna opens her link | `signing_request.viewed` | `signer.slot: 1` | | Anna signs | `signing_request.signed` | Anna's `signing_request_id`. | | Slot 2 is released | `signing_request.sent` | Ben's `signing_request_id`, `signer.slot: 2`, `"source": "sequential_release"`, and `emailed`: `false` with `send_emails: false` — deliver his link now. Arrives around Anna's `signed`, in either order. | | Ben opens his link | `signing_request.viewed` | `signer.slot: 2` | | Ben signs | `signing_request.signed`, then `document.completed` | Download the sealed PDF and the audit trail. | The one-signer letter stops after Anna: her `signing_request.signed` is followed by `document.completed`. So `signing_request.sent` fires twice for this document: once when it goes out (`"source": "v1_api"`, slot 1) and once when slot 2's turn comes (`"source": "sequential_release"`). The release carries no link — match its `signing_request_id` to the `signing_url` from your `200`, or read it from [`GET /v1/signing-requests/{id}`](https://www.wesign.now/docs/signing-requests#read-one-signer). Details: [Following a sequential send](https://www.wesign.now/docs/webhooks#following-a-sequential-send). ```jsonc // signing_request.sent { "event": "signing_request.sent", "event_id": "evt_3f9c1a7b2d4e4c6f8a1b2c3d4e5f6a7b", "created_at": "2026-09-24T09:14:03.120Z", "workspace_id": "3c1f…", "metadata": { "case_id": "ZT-2026-0142" }, "signing_request_id": "6e1d9b34-8a2c-4e7f-b5d0-3c9a1e7f2b45", "document_id": "9f3c6b12-4e7a-4d85-b1c9-2a5e8f0d7c36", "signer": { "email": "anna@example.ch", "name": "Anna Muster", "role": null, "slot": 1, "reference": null, "company": null, "job_title": null }, "locale": "de", "template_id": "c7a41e2d-9b3f-4f60-8d15-6e2b0a9c7f48", "template_version": 3, "source": "v1_api" } ``` ```jsonc // document.completed { "event": "document.completed", "event_id": "evt_9a0b…", "created_at": "2026-09-25T16:02:41.508Z", "workspace_id": "3c1f…", "metadata": { "case_id": "ZT-2026-0142" }, "signing_request_id": "7f2eac45-9b3d-4f80-a6e1-4dab2f803c56", "document_id": "9f3c6b12-4e7a-4d85-b1c9-2a5e8f0d7c36", "signer": { "email": "ben@example.ch", "name": "Ben Keller", "role": null, "slot": 2, "reference": null, "company": null, "job_title": null }, "signed_pdf_url": "https://api.wesign.now/v1/documents/9f3c6b12-4e7a-4d85-b1c9-2a5e8f0d7c36/signed", "audit_trail_url": "https://api.wesign.now/v1/documents/9f3c6b12-4e7a-4d85-b1c9-2a5e8f0d7c36/audit-trail", "sha256": "4f9a…d21c", "cert_serial": "1a2b…", "tsa_provider": "freetsa", "tsa_signed_at": "2026-09-25T16:02:39Z" } ``` On `signing_request.sent`, `signer.role` is the slot's role name when the template has one (a rich-text template's **Signers** panel), else `null`; the later events carry `role: null` for a template recipient, so match them on `slot` or `signing_request_id`. A staged instance fires `template_instance.staged` first; its confirm fires `template_instance.confirmed` and then `signing_request.sent` with `"source": "template_instance"`. Every event and payload: [Webhooks](https://www.wesign.now/docs/webhooks#events). ## 7 · Download the sealed PDF and the audit trail After `document.completed`, fetch both with the same key: ```bash export DOC_ID=9f3c6b12-4e7a-4d85-b1c9-2a5e8f0d7c36 curl -OJ -H "Authorization: Bearer $WSK_KEY" \ https://api.wesign.now/v1/documents/$DOC_ID/signed curl -OJ -H "Authorization: Bearer $WSK_KEY" \ https://api.wesign.now/v1/documents/$DOC_ID/audit-trail ``` `/signed` is the PAdES-sealed PDF: every signature, an RFC 3161 timestamp, and the Sender / API values printed once. Before every signer has signed, both answer `409 not_complete`. These are the `signed_pdf_url` and `audit_trail_url` of the webhook: pin the fetch to `api.wesign.now` and send the key (see [Documents](https://www.wesign.now/docs/documents#signed_pdf_url-in-documentcompleted)). ## Test without a sandbox Every key is live: a real call creates a real document and sends real email ([No sandbox yet](https://www.wesign.now/docs/versioning#no-sandbox-yet)). Three ways to test safely: | Use | What happens | Watch out for | | --- | --- | --- | | `"validate_only": true` | Every check the real call makes, the SMS allowance included. Nothing is created, emailed or cached. | Needs the entitlement and costs one request of the [rate limit](https://www.wesign.now/docs/rate-limits). It does not render the PDF or deliver email. | | `"send_emails": false` | A real document; no invitation email, not even for a sequential signer when their turn comes. Open each `signing_url` yourself and sign. | Some mail still goes out: an SMS code when a signer asks for one, reminders and completion emails. Use addresses you control. | | `"review": true`, then discard | A staged instance: nothing is emailed or texted and the signing links do not work. Open `review_url` to see the exact PDF. | Clean up with `POST /v1/documents/{id}/discard`. The recipients are withdrawn and were never contacted; the document stays in the app's document list as withdrawn. | ```bash # DOC_ID: the document_id of the staged instance's 201 curl -s -X POST https://api.wesign.now/v1/documents/$DOC_ID/discard \ -H "Authorization: Bearer $WSK_KEY" | jq . # { "ok": true, "status": "discarded", "kind": "esign", "document_id": "…", # "already_discarded": false, "withdrawn": 2, "deleted": false } ``` ## What's next - [Prepare a fill in your app](https://www.wesign.now/docs/prepare-a-fill): build a form for your users from the template's contract. - [Templates](https://www.wesign.now/docs/templates): the full instantiate, review, confirm and generate reference. - [Fields & placeholders](https://www.wesign.now/docs/fields-placeholders): who fills a value, and the [standard field names](https://www.wesign.now/docs/fields-placeholders#standard-field-names). - [Recipes](https://www.wesign.now/docs/recipes#two-signer-power-of-attorney-from-a-template): the two-signer flow as a production pattern. - [Errors](https://www.wesign.now/docs/errors#templates): every template error code. --- # Prepare a fill in your app URL: https://www.wesign.now/docs/prepare-a-fill Summary: Read a template's contract, ask your user for what it needs, dry-run, then send. Required fields, missing_optional and standard field names. An app that fills templates for its users asks them for the data **before** it calls the API. Everything it needs is in one published contract per template: which values are mandatory, who fills each one, what to call it in your user's language, and what a value usually looks like. A dry run then says what is still wrong and what could still be filled. Four steps: 1. [Read the contract](https://www.wesign.now/docs/prepare-a-fill#1-read-the-contract): `GET /v1/templates/{id}`. 2. [Ask your user](https://www.wesign.now/docs/prepare-a-fill#2-ask-your-user) for every `required` input, and for the others as your product likes. 3. [Dry run](https://www.wesign.now/docs/prepare-a-fill#3-dry-run): the same body with `"validate_only": true`. Fix `problems`, offer `missing_optional`. 4. [Send](https://www.wesign.now/docs/prepare-a-fill#4-send) the same body without `validate_only`. The template must be locked (`instantiable: true`). Reading the contract needs only an [API key](https://www.wesign.now/docs/authentication); `instantiate` and `generate`, dry runs included, also need the Enterprise template entitlement (`402 enterprise-required` without it). ## 1. Read the contract ```bash curl -H "Authorization: Bearer $WSK_KEY" \ https://api.wesign.now/v1/templates/$TEMPLATE_ID ``` The answer holds the template (`version`, `instantiable`), its recipient slots (`recipients[]`), the inputs (`field_values[]`) and the boxes the signers sign or date (`signing_fields[]`). The "Power of attorney (company)" PDF template, version 3, has two slots — two representatives of one company — and four inputs: ```jsonc { "template": { "id": "…", "name": "Power of attorney (company)", "status": "locked", "version": 3, "instantiable": true, … }, "recipients": [{ "slot": 1 }, { "slot": 2 }], "field_values": [ // Two text boxes the author set to "Filled by: Sender / API", on slot 1. // Every signer sees them; you send each once. { "key": "company.legal_name", "kind": "scalar", "owner": "sender", "required": true, "signer_required": false, "has_default": false, "slot": 1, "label": "Company name", "type": "text", "source": "lead", "example": "Muster AG", "standard": true, "labels": { "en": "Company name", "de": "Firma", "fr": "Raison sociale", "it": "Ragione sociale", "es": "Razón social", "nl": "Bedrijfsnaam" }, "description": "The company's registered legal name, including its legal form (Muster AG)." }, { "key": "company.tax_id", "kind": "scalar", "owner": "sender", "required": true, "signer_required": false, "has_default": false, "slot": 1, "label": "Company tax ID", "type": "text", "source": "lead", "example": "DE123456789-00001", "standard": true, "labels": { "en": "Company tax ID", "de": "Steuer-ID des Unternehmens (z. B. W-IdNr.)", … }, "description": "The company's tax number, e.g. the German W-IdNr DE123456789-00001. The format depends on the country." }, // One "Filled by: Signer" box per representative; you may pre-fill it { "key": "signing.place", "kind": "scalar", "owner": "signer", "required": false, "signer_required": false, "has_default": false, "slot": 1, "label": "Place of signing", "type": "text", "source": "lead", "example": "Zürich", "standard": true, "labels": { "en": "Place of signing", "de": "Ort der Unterzeichnung", "fr": "Lieu de signature", "it": "Luogo della firma", "es": "Lugar de la firma", "nl": "Plaats van ondertekening" }, "description": "Place where this signer signs, usually a city. For signer 2: signing.place_s2." }, { "key": "signing.place_s2", "kind": "scalar", "owner": "signer", "required": false, "signer_required": false, "has_default": false, "slot": 2, "label": "Place of signing", … // as signing.place } ], "signing_fields": [ { "kind": "signature", "slot": 1, "required": true }, { "kind": "date", "slot": 1, "required": true }, { "kind": "signature", "slot": 2, "required": true }, { "kind": "date", "slot": 2, "required": true } ] } ``` Every property is defined under [Templates → Get a template](https://www.wesign.now/docs/templates#get-a-template). What your app does with the ones that shape a form: - **`required`** — ask for it, or take it from your own data: the call fails without it. This flag is the whole contract; never re-derive it from `owner`. - **`owner`** — `api` and `sender` are your side. `signer` is the signer's box: you may pre-fill it, and the signer can change it. See [Who fills what](https://www.wesign.now/docs/prepare-a-fill#who-fills-what). - **`auto_filled`** — skip it: the platform fills `date_today` and the `recipient_*` tokens. The `sender_*` letterhead tokens stay blank on a direct `instantiate` unless you send them; see [which fields you must supply](https://www.wesign.now/docs/templates#which-fields-you-must-supply). - **`labels`, `label`** — the input's name: `labels[yourUsersLanguage]` when present, else `label`. - **`type`, `options`, `max_length`** — the input control. Treat a type you do not know as `text`. A longer value is shortened with `…`, not refused. - **`example`, `pattern`** — placeholder text and a shape hint. The API never enforces `pattern`: warn, do not block. - **`slot`** — which recipient a positioned box belongs to. The schema has no role names, so label a per-signer input yourself ("Place of signing · signer 2"). - **`has_default`** — the author's default prints (or pre-fills the signer's box) when you omit the key. Sending `""` replaces it. - **`date_format`, `time_format`, `lang`** — how the value **prints**, never how you send it: dates go day first or as `YYYY-MM-DD`, times as `HH:MM` or `h:MM AM/PM`. - **`kind: "collection"`** — a table: send an array of row objects. - **`source`** — legacy; ignore it. A Sender / API box usually reads `"source": "lead"`, which says nothing about who fills it ([why](https://www.wesign.now/docs/fields-placeholders#source-legacy)). A custom key has `labels` only when the template itself labels it and records the language it is written in (a rich-text template records it on its signature lines). A PDF template does not, so use `label` there. `labels` never holds a machine translation, nor your field registry's label (which `label` prefers), because the registry does not say what language it is in. `GET /v1/templates/{id}` describes the template's current version. If you pin `version` on `instantiate`, send it on the dry run too: the dry run then checks the positioned fields of that version. ## 2. Ask your user Build the form from `field_values`: ```js const res = await fetch(`https://api.wesign.now/v1/templates/${id}`, { headers: { Authorization: `Bearer ${process.env.WSK_KEY}` }, }) const { recipients, field_values } = await res.json() const inputs = field_values.filter((f) => !f.auto_filled) const mustAsk = inputs.filter((f) => f.required) // cannot send without these const mayAsk = inputs.filter((f) => !f.required) // optional, signer boxes included const name = (f) => f.labels?.[userLang] ?? f.label ?? f.key ``` - Ask for every **required** input, unless your own data already holds it. For a standard key it usually does: `company.tax_id` is the same datum in every template. - Ask for **optional** inputs as your product likes. An input owned by the signer can be left out: the signer then sees an empty box, or the author's default, and may leave it empty. Text never blocks signing. - Ask for **one recipient per slot** in `recipients`: every slot that has fields needs one (`400 missing_recipients`). Here that is two representatives, slot 1 and slot 2. - Ask for a shared Sender / API value **once**, even though its box sits on one slot: every signer sees it. - Send every value as a **string**. Dates day first (`15.03.1990`) or as `YYYY-MM-DD`, never month first. Booleans as `"yes"` / `"no"`. The full rules are in [the field contract](https://www.wesign.now/docs/templates#the-field-contract). A required input inside a conditional section is only demanded while your values show that section. The dry run applies the conditions, so let it decide. ## 3. Dry run Send the body you are about to send, with `"validate_only": true`: ```http POST https://api.wesign.now/v1/templates/{id}/instantiate Authorization: Bearer wsk_live_… Content-Type: application/json { "recipients": [ { "slot": 1, "email": "anna@example.ch", "name": "Anna Muster" }, { "slot": 2, "email": "ben@example.ch", "name": "Ben Keller" } ], "field_values": { "company.legal_name": "Muster AG" }, "signing_mode": "sequential", "metadata": { "case_id": "ZT-2026-0142" }, "validate_only": true } ``` Nothing is created, and no `Idempotency-Key` is read. With a value missing, the answer is the same `422` the real call would give, plus the inputs you could still add: ```jsonc HTTP/1.1 422 Unprocessable Entity { "error": "1 problem with this request.", "code": "template_input_invalid", "template_id": "…", "template_name": "Power of attorney (company)", "template_version": 3, "problems": [ { "field": "company.tax_id", "label": "Company tax ID", "code": "field_required", "message": "\"Company tax ID\" is required by this template but was not supplied." } ], "warnings": [], "docs": "https://www.letssign.now/en/settings/api", "missing_optional": [ { "key": "signing.place", "label": "Place of signing", "owner": "signer", "type": "text", "standard": true, "example": "Zürich", "labels": { "en": "Place of signing", "de": "Ort der Unterzeichnung", "fr": "Lieu de signature", "it": "Luogo della firma", "es": "Lugar de la firma", "nl": "Plaats van ondertekening" }, "slot": 1 }, { "key": "signing.place_s2", "label": "Place of signing", "owner": "signer", "type": "text", "standard": true, "example": "Zürich", "labels": { … }, "slot": 2 } ] } ``` - **`problems`** block the call, and every problem comes back at once. Each carries the input's `label` and a readable `message`, so you can show it to your user; branch on `code`, never on the message. Fix them and dry-run again. - **`missing_optional`** never blocks. It lists the inputs you may still supply and left empty (absent, or `""`), in schema order: a signer's box you could pre-fill (here each representative's place of signing, which they would otherwise type) or an input the author made optional or gave a default (`has_default: true`). Each entry carries what you need to ask for it: `key`, `label`, `owner`, `type`, `standard`, and where known `example`, `pattern`, `labels`, `slot`, `has_default` and the print format. Platform tokens, tables and inputs in a hidden conditional section are never listed. Send a value and its entry goes away. - **`warnings`** never block. An `unknown_field` warning is a key the template does not have: usually a spelling mismatch between your keys and the template's. - **`docs`** links to the Settings → API page of the app, for a signed-in member of your workspace — useful to a person, not to your code. Once every problem is fixed, the dry run answers `200`: ```jsonc { "ok": true, "template_id": "…", "template_version": 3, "warnings": [], "problems": [], "missing_optional": [ /* what you could still send, as above */ ] } ``` Read `ok`, not only the status: it is `false` (still `200`) when the real call would be refused for the workspace's monthly document cap or its SMS allowance, which `problems` then explains. Any other refusal (a missing recipient, a slot without a signature box) comes back exactly as the real call would give it. A dry run counts against the [rate limit](https://www.wesign.now/docs/rate-limits) like any request. What it does and does not check is listed under [Dry run](https://www.wesign.now/docs/templates#dry-run). ## 4. Send Send the same body without `validate_only`, with an [`Idempotency-Key`](https://www.wesign.now/docs/idempotency) so a retry never sends twice: ```http POST https://api.wesign.now/v1/templates/{id}/instantiate Authorization: Bearer wsk_live_… Idempotency-Key: poa-ZT-2026-0142 Content-Type: application/json { "recipients": [ { "slot": 1, "email": "anna@example.ch", "name": "Anna Muster" }, { "slot": 2, "email": "ben@example.ch", "name": "Ben Keller" } ], "field_values": { "company.legal_name": "Muster AG", "company.tax_id": "DE123456789-00001", "signing.place": "Zürich" }, "signing_mode": "sequential", "metadata": { "case_id": "ZT-2026-0142" } } ``` The `200` carries a `signing_url` per recipient and `field_values_echoed`, the values as stored (dates as `YYYY-MM-DD`). Slot 1 is invited now; slot 2 when slot 1 has signed. The full answer is under [Several signers](https://www.wesign.now/docs/templates#several-signers). Add `"review": true` to stage the document for a colleague instead of sending it; see [Review before sending](https://www.wesign.now/docs/templates#review-before-sending). ## Who fills what Every input has an `owner`. On a rich-text template the author's choice of who fills the template (**People fill** or **API fills**) sets it per placeholder; see [Who fills a value](https://www.wesign.now/docs/fields-placeholders#who-fills-a-value). On a PDF template the author picks **Filled by** on each text box: | Filled by | `owner` | `required` | What signers see | | --- | --- | --- | --- | | **Sender / API** | `sender` | `true`, unless the author made it optional or gave it a default | Your value (or the author's default), printed as fixed text. **Every** signer sees it from their first view, in any signing order, whichever slot carries the box. No input, and nothing a signer sends changes it. An optional one left empty prints nothing. | | **Signer** (every box until an author chooses) | `signer` | `false` | The signer of the box's slot finds a box with your value (or the author's default) already in it, and may change it or leave it empty. | `signer_required` is always `false` on a text box: an empty one never blocks signing. Filled by can only change while the template is unlocked; locking it again publishes a new version, and a call that pins `version` keeps the contract of the version it names. ## Standard names Templates may name their inputs with the platform's **standard names**: English dotted keys anchored to international vocabularies (schema.org, ISO 3166-1, ITU-T E.164, ETSI EN 319 142 / PAdES, the Swiss UID and AHV registers): `person.first_name`, `person.last_name`, `person.full_name`, `person.date_of_birth`, `person.tax_id`, `person.ch_ahv_number`, `company.legal_name`, `company.uid`, `company.tax_id`, `company.vat_id`, `address.street`, `address.postal_code`, `address.city`, `address.country`, `address.full`, `contact.email`, `contact.phone`, `signing.place`, `signing.date`. - Map your data to them **once**. Every template that uses them then fills without a per-template mapping. The meaning, anchor, type and example of each are in [Fields & placeholders](https://www.wesign.now/docs/fields-placeholders#standard-field-names). - A second signer's copy carries the per-signer suffix (`signing.place_s2`) and is standard too. A template uses a copy only for a value that differs per signer; a value every signer shares has one key. - `signing.date` is stamped by the platform when a signer signs; you never send it. - **Custom keys are just as valid.** `standard: false` means only that the key is the template's own (`mandate_number`). It is never refused, and it is filled exactly like a standard one. ## Generate `POST /v1/templates/{id}/generate` takes `"validate_only": true` too and answers with `missing_optional` in the same way. It never requires a positioned text box of a PDF template, not even a Sender / API one, and never lists one, because `generate` does not print them. A value you send for one is still type-checked. Its dry-run `200` has no `problems`: `ok` is always `true` there, and every refusal is a `4xx`. ## For template authors The contract your integrators read is what you set in the editor: - **Name each field.** The key box suggests the standard names as you type, with a *Standard* badge, and fills in the label in your language. Any other valid key works too. - **Choose Filled by** on each text box of a PDF template, in the field inspector while you edit the template (on a phone: **Field settings**) or in the template's field list while it is a draft. **Sender / API** makes the value required for API callers unless you switch on **Optional for the sender** or give it a default. - **Place a shared fact once.** A Sender / API box shows to every signer, whichever slot carries it; add a per-signer copy only for a value that differs per signer. - **Lock** the template. Its contract is then fixed for that version, and `GET /v1/templates/{id}` serves it. When you send from the app instead of the API, **Quick send** asks you for the Sender / API values. A **Present** (form) link has no sending step: a Sender / API box prints its default there, and one without a default becomes a box the link's visitor fills in. --- # Integrator FAQ URL: https://www.wesign.now/docs/integrator-faq Summary: Short answers an integrating app, or its AI assistant, can quote — observers, AES by SMS, the SMS allowance, checking phone numbers, languages, signer names, parallel signing, field keys and dates, and how to reach us. The questions integrating apps send us most, answered in a few lines each, with a link to the page that holds the full contract. If you work with an AI assistant, give it [llms.txt](https://www.wesign.now/llms.txt) (every page with a one-line summary) or [llms-full.txt](https://www.wesign.now/llms-full.txt) (every page as plain Markdown) instead of pasting pages into a chat. **Note:** **Which release?** Every answer describes the API these docs were published with. Where the behaviour changed recently, the answer names the release and the changelog `id` (look it up in [`/api-changelog.json`](https://www.wesign.now/api-changelog.json)), and says what happened before it: that matters for documents created earlier. ## Observers ### How do I copy someone, such as a secretariat or a shared mailbox, on a signing request? Send `observer_emails` on [`POST /v1/signing-requests`](https://www.wesign.now/docs/signing-requests#create): ```json { "observer_emails": ["sekretariat@treuhand-muster.ch"] } ``` - An array of address **strings**: no objects, no names, no language per observer. At most **20** entries, counted before duplicates are removed; each address at most 254 characters. - A shared mailbox is fine. An observer is an address, not an account. - Addresses are stored lower-cased and without duplicates, and read back as `observers` in the 201 and on [`GET /v1/documents/{id}`](https://www.wesign.now/docs/documents#get-document). - Send plain ASCII addresses without surrounding spaces. A display-name form (`"Office" `), a leading or trailing space or a non-ASCII domain answers `400 invalid_request`; send an internationalised domain as punycode (`xn--…`). - In a multipart body, send the array as a JSON-encoded string. - The request key is `observer_emails`; `observers` is only the response key. A request key `observers` is ignored and named in `warnings[]` (`unknown_field`); from 2026-12-31 it answers `400 unknown_field`. - Only this endpoint takes observers: `instantiate` and `confirm` do not. The list cannot be changed after the create. Reference: [Observers](https://www.wesign.now/docs/observers). ### What does an observer receive, and when? Two emails at most: | | When | Attachments | Language | | --- | --- | --- | --- | | **Notice** ("X is collecting signatures on Y") | At the create, when the first signer is sent out. Not sent with `send_emails: false`. | None, and no link to the document | The `locale` of that first signer | | **Completed** | When the last signer signs and every signer has signed. Never after a decline, a withdrawal or an expiry. | The signed PDF and the audit certificate | The `locale` of the signer whose signature completed the document | - Observers never receive a signing link, a reminder, a note per signature, or a declined, withdrawn or expired notice. - `send_emails: false` stops the notice, not the "Completed" email. - An observer address that is also the completing signer, or the document's sender (the user who created the API key), receives that person's own completion email instead of the observer copy. - **Treat `document.completed`, or `GET /v1/documents/{id}` → `status: "signed"`, as the proof of completion — never the observer email.** Both require every signer to have signed. - Before the 2026-10-01 release (`observer-completed-every-signer`), when another signer had declined or been withdrawn, observers could receive "Completed" after the last remaining signature. ### Are observers on the audit trail? - Not by address: the audit certificate shows no observer addresses. - `GET /v1/signing-requests/{id}` → `auditEvents` has `observer_notified` (on the first signer's request) and `observer_completed` (on the completing signer's request), each with the addresses in `meta.observers`. It returns the first 50 events of that request. - There are no observer webhooks. ## AES signatures by SMS ### What makes a signature AES, and what backs it? Give the signer a `phone_e164` and `require_sms_verification: true`. Then: - The signer opens their own link and requests a code, which we text to that number; they enter it on the page. We never text a code on our own. - Our server refuses the signature (`403`) until **that signer's** code is verified. Only our server-side record counts, never the browser. - Every signer object of the create response and of `GET /v1/signing-requests/{id}` reads back `signature_level: "AES"` and, once the code is entered, `sms_verified_at`. - What the code proves: control of the number **you** supplied. We do not check that the number belongs to the named person. The verification is stored on the signing request, so any browser that has the link can then sign. - The signed PDF is sealed with our platform's PAdES certificate (the signer holds no key) and time-stamped under RFC 3161 when the time-stamp authority answers. The caption under the signature says "Electronic signature + SMS 2FA", and the audit certificate records the verification. - We classify this as an advanced electronic signature (AES). Whether AES meets your legal requirement is yours to assess. Reference: [SMS verification](https://www.wesign.now/docs/sms-verification), [Signer object](https://www.wesign.now/docs/signing-requests#signer-object). ### What must hold for an SMS signature to work? 1. `phone_e164` is E.164 (`^\+[1-9]\d{7,14}$`, e.g. `+41791234567`) and is sent with `require_sms_verification: true`. A missing or malformed number answers `400` before anything is created. 2. It is a mobile number in a country our SMS provider serves. Only the shape is checked at the create; a landline, blocked or unreachable number shows when the signer requests a code, as `signing_request.sms_failed`. 3. The workspace has a code left this month for every SMS signer. Otherwise the create answers `402 sms_allowance_exhausted` and nothing is created. 4. The request is still open when the signer asks for the code: a queued, withdrawn, declined, signed or expired request gets none. ### Can two signers both confirm by SMS in one parallel request? Yes; a request takes up to 20 signers. Each signer is a signing request of its own, with its own link, number and code: - Give each signer its own `role`. Fields are assigned by role, so two signers sharing a role would each get the other's fields. - Give each signer its own `phone_e164`. Codes are issued per number: when two signers share one, the code one of them enters makes the other request a new one. - Each SMS signer needs at least one code, so a two-signer request needs two codes left. - Read [Is parallel signing safe?](https://www.wesign.now/docs/integrator-faq#is-parallel-signing-safe) before you rely on two signatures that land within seconds of each other. ### How many SMS codes do we get, and what do they cost? | Plan | Codes included per month | | --- | --- | | Free | 25 | | Pro | 50 | | Branded | 200 | | Teams, Enterprise | 100 per paid seat | - **There is no price per SMS** and no price per AES signature: the codes are included in your subscription. API keys come with Enterprise (and legacy Teams) workspaces, so an API integration has 100 codes per paid seat. - Every code sent counts, resends included; a refused send does not. The counter resets at 00:00 UTC on the 1st of each month. - `GET /v1/me` → `sms_allowance: { monthly, used, remaining, resets_at }`. - When the allowance is used up, an SMS signer cannot sign until the reset. A request is never downgraded to a signature without SMS. - If your firm needs more, email support@wesign.now with your workspace ID and the number of codes you expect per month. We raise the workspace's monthly allowance, included in your subscription; `sms_allowance.monthly` then shows the plan's codes plus the raise (`sms-allowance-raise`, since the 2026-10-01 release). Reference: [SMS verification → the monthly allowance](https://www.wesign.now/docs/sms-verification#availability-and-the-monthly-allowance). ### Can we verify a phone number by SMS before we create a request? **No. It is not offered and not planned.** A code can only be requested by the signer, on their own signing link. Check numbers in your own interface before you create the request. After the create: - `signing_request.sms_failed` names the reason (`invalid_number`, `landline`, `blocked`, …) the moment the signer requests a code; - an `sms_sent` without a later `sms_verified` means our SMS provider accepted the code, not that the phone received it; - [`PATCH /v1/signing-requests/{id}`](https://www.wesign.now/docs/signing-requests#correct-the-phone-number) with `{ "phone_e164": "+41…" }` corrects the number while the request is `pending`, `viewed` or `queued` and no code has been verified. It sends nothing; the signer then requests a new code. Reference: [When no SMS arrives](https://www.wesign.now/docs/sms-verification#when-no-sms-arrives). ## Languages ### Can each signer have their own language? Yes: `locale` on each signer, one of `en`, `de`, `fr`, `it`, `nl`, `es`. Only the two-letter code is accepted (`de-CH` answers `400`). A signer without one takes the request's `locale`, which defaults to `en`. **Follows the signer's `locale`:** their invitation email, the signing page, reminders, the SMS code (if our SMS provider refuses the language, the number's country decides) and their own completion email. **Does not follow it:** | Item | Language | | --- | --- | | The caption under each signature | English, 24-hour clock | | The audit certificate from `GET /v1/documents/{id}/audit-trail` | English | | The audit certificate attached to completion emails | English (`certificate-always-english`, since the 2026-10-01 release; before, the language of the signer whose signature completed the document) | | The observer notice | The first signer's `locale` | | The observer "Completed" email | The completing signer's `locale` | | Error messages of the SMS step on the signing page | English | Values you send, such as names, print exactly as sent; only the labels around them are English. Reference: [Signer object](https://www.wesign.now/docs/signing-requests#signer-object), [The SMS itself](https://www.wesign.now/docs/sms-verification#the-sms-itself). ## Signer names ### Which name fields does a signer take? - `name` (at most 200 characters), **or** `first_name` and `last_name` (at most 100 each). If either `first_name` or `last_name` is non-blank, the stored name is "first last" and `name` is ignored, so send one form, not both. Put a title such as "Dr." where it should print. - The stored name prints in the caption under the signature, as the signer's name in the seal and on the audit certificate, and is read back as `name` on `GET /v1/signing-requests/{id}` and `GET /v1/documents/{id}`. - Signer objects are strict: an unknown key answers `400 unknown_signer_field`, naming it. ### Can a signer's company and function print under the signature? Yes, since the 2026-10-01 release (`signer-company-job-title`): each signer takes `company` (at most 200 characters) and `job_title` (at most 120). Both print in the caption under the signature, `Anna Muster · Geschäftsführerin · Muster AG · anna@muster-ag.ch`, and on the audit certificate, both the one attached to the completion emails and the one from `GET /v1/documents/{id}/audit-trail`, labelled "stated by the sender": we do not verify them. Template recipients and `confirm` take the same two fields. Before that release, both answered `400 unknown_signer_field`. Reference: [Company and function](https://www.wesign.now/docs/signing-requests#company-and-function). ## Parallel signing ### Is parallel signing safe? `signing_mode: "parallel"`, the default, invites every signer at once, and each signs whenever they like. - `document.completed` fires only when every signer has signed. It never fires when a signer declined, was withdrawn or expired. - The signatures of one document are sealed one at a time (`parallel-signing-chain`, since the 2026-10-01 release). A signer who presses **Sign** while another signature is being sealed waits a few seconds; the signing page retries by itself. The completed PDF carries every signature, however close together they land. - Before that release, a completed PDF could lack an earlier signature although every status read `signed`, whatever the `signing_mode`: when two signers signed within the same few seconds, or, rarely, when the earlier signed file could not be read at the next signature. Sequential signing (`signing_mode: "sequential"`) avoided only the first case. If a PDF completed before that release lacks a signature, email support@wesign.now with its `document_id`. - Deduplicate `document.completed` on `document_id` as well as on `event_id`: delivery is at least once. Reference: [What "completed" means](https://www.wesign.now/docs/documents#what-completed-means), [Webhooks](https://www.wesign.now/docs/webhooks). ## Templates, field keys and dates ### What do we have to send to fill a template? Read the template's contract with `GET /v1/templates/{id}`, ask your user for every input with `required: true`, dry-run the call with `"validate_only": true`, fix what `problems` lists, offer what `missing_optional` lists, then send the same body. Step by step: [Prepare a fill in your app](https://www.wesign.now/docs/prepare-a-fill). ### Who fills a text field, we or the signer? `owner` on each input of `GET /v1/templates/{id}` says so: - `"sender"`: you send the value. It prints as fixed document content that no signer can change. It is `required: true` unless the template's author made it optional or gave it a default. - `"signer"`: the signer types it while signing. It is never required; you may prefill it, and the signer may change it. An empty text field never blocks signing. Reference: [Who fills a text field](https://www.wesign.now/docs/fields-placeholders#who-fills-a-text-field-pdf-templates). ### What does a field key look like? Lowercase parts of letters, digits and underscores, each starting with a letter, joined by single dots; at most 64 characters: `client_name`, `person.date_of_birth`. A dotted key is one flat key of `field_values`, as in `{ "person.date_of_birth": "1990-03-15" }`, never a nested object. **Standard field names** such as `person.first_name`, `company.legal_name` or `signing.place` are keys we suggest for common data. An input that uses one reads `standard: true` and carries its name in six languages. Custom keys are just as valid. Reference: [Field keys and values](https://www.wesign.now/docs/api-overview#field-keys-and-values), [Standard field names](https://www.wesign.now/docs/fields-placeholders#standard-field-names). ### Which date and time formats do you accept? - **Dates:** `YYYY-MM-DD`, or day first: `DD.MM.YYYY`, `DD/MM/YYYY`, `DD-MM-YYYY` or `DD MM YYYY`. Month first is never read: `03/04/1990` is 3 April, and `03/15/1990` answers `422 invalid_date`. Stored and echoed as `YYYY-MM-DD`. - **Times:** `14:30`, `14.30` or `2:30 PM`. Stored and echoed as 24-hour `HH:MM`. - How a value **prints** is the template's choice, shown read-only as `date_format` and `time_format` on `GET /v1/templates/{id}`. Reference: [Dates and times](https://www.wesign.now/docs/fields-placeholders#dates-and-times). ## Asking us ### How do we ask something the docs do not answer? Email **support@wesign.now**. We reply within one working day. Include: - the method and path (`POST /v1/signing-requests`) and the UTC time of the call; - the HTTP status and the `code` and `error` of the answer; - the ids you have: `document_id`, `signing_request_id`, the webhook `event_id` (header `X-WeSign-Event-Id`), your `Idempotency-Key`, and the `x-vercel-id` response header, which finds the call in our logs; - your workspace (`GET /v1/me` → `workspace.slug`); - what you expected instead. Never send an API key, a webhook secret or a complete signing link. Reference: [Support](https://www.wesign.now/docs/support). --- # API overview URL: https://www.wesign.now/docs/api-overview Summary: Base URL, authentication, versioning policy, and the full map of v1 resources. The REST API is a single, versioned surface. Everything you integrate against lives under **`https://api.wesign.now/v1/`**. ## Base URL ``` https://api.wesign.now/v1 Authorization: Bearer wsk_live_… ``` There is one canonical host and one permanent alias. Both resolve to the same deployment, the same database and the same keys: | Host | Status | | --- | --- | | `https://api.wesign.now/v1` | **Canonical.** What every example on this site uses. Put this in new config. | | `https://api.letssign.now/v1` | **Alias, kept forever.** Identical routing, identical responses. A client written against it never has to change. | What is **not** the contract: path-style URLs on the web hosts — `https://letssign.now/api/v1/…`, `https://wesign.now/api/v1/…` and their `www.` forms. The apex hosts only answer with a redirect to `www`, and a redirected `POST` is exactly what HTTP clients handle inconsistently (some re-issue it as a `GET`, some drop the multipart body). If your client was written against `https://letssign.now/api/v1`, point it at `https://api.wesign.now/v1`: same paths, drop the `/api` prefix, nothing else changes. **Note:** **Which host carries which name.** The API host and our emails are `wesign`; the rest stays `letssign.now`: - **API responses** — the `signingUrl` / `signing_url` of each signer is on the workspace's signing host, `https://yourco.letssign.now/…/sign/…`. - **Emails we send** (invitations, reminders, completion) link to the same signing page on `https://yourco.wesign.now/…/sign/…`, wear the wesign.now design and reply to `support@wesign.now` unless the workspace set its own reply-to address. They are still sent from `noreply@send.letssign.now`. - **Unchanged:** signed PDFs, the PAdES issuer, the webhook `User-Agent` and the `X-LetsSign-*` headers. A signing link works on either host — the token decides, not the domain — so an emailed `wesign.now` link and the `letssign.now` URL from the API open the same page. ## Authentication & limits All requests authenticate with a workspace **API key** (`Authorization: Bearer wsk_live_…`; keys minted before the rename start with `lsk_live_` and are accepted forever). Mint and manage keys in **Settings → API** — see [Authentication](https://www.wesign.now/docs/authentication). Requests are rate-limited **per API key**: 60 requests per 60-second window, counted on the key's own row, so two keys in one workspace are two independent buckets. A `429` carries `Retry-After` in seconds. `GET /v1/me` is exempt. See [Rate limits](https://www.wesign.now/docs/rate-limits). ## Versioning - **`/v1`** is the stable path. Additive changes — new optional fields, new endpoints, new webhook events, new error codes — ship to it with a changelog entry and no notice period, so ignore fields and events you do not know. - A change that can break a working call is announced in the [changelog](https://www.wesign.now/docs/changelog) (and in the machine-readable [`/api-changelog.json`](https://www.wesign.now/api-changelog.json)) with its date, flagged `breaking`, at least **90 days** ahead; the one exception is refusing input that never had any effect. The whole policy, deprecation signals and the dated spec snapshots are on [Versioning](https://www.wesign.now/docs/versioning). - Always pin to the version in the path. - The names we introduced in September 2026 (host, key prefix, webhook header family) are additive: every old spelling keeps working next to the new one, permanently. ## Resources The machine-readable version of this map is the [OpenAPI 3.1 spec](https://www.wesign.now/docs/openapi) — every path below, request schema, status code and webhook event, importable into Postman, Insomnia or a client generator. | Resource | Endpoints | What it does | | --- | --- | --- | | **Signing requests** | `POST /signing-requests`, `GET /signing-requests/{id}`, `PATCH /signing-requests/{id}`, `POST …/{id}/remind`, `POST …/{id}/withdraw` | Send a PDF (multipart bytes or `file_url`) for signature with anchor / auto-append / explicit [placement](https://www.wesign.now/docs/placement-modes); read one signer, correct their phone number, remind or withdraw them. | | **Documents** | `GET /documents/{id}`, `GET …/{id}/signed`, `GET …/{id}/audit-trail`, `GET …/{id}/pdf`, `POST …/{id}/confirm`, `POST …/{id}/discard` | The whole document with every signer; the sealed PDF; the audit-trail PDF; the current un-signed file; confirm or discard a staged template instance (`confirm` needs Enterprise, `discard` does not). | | **Templates** | `GET /templates`, `GET /templates/{id}`, `POST /templates/{id}/instantiate`, `POST /templates/{id}/generate` | List templates, read a template's input schema (`instantiable: true` once it is locked), instantiate a locked one with `recipients` (optionally with the SMS second factor) and `field_values` (send, or stage for review), or generate a filled PDF with no signature step. `instantiate` and `generate` need Enterprise; the two reads do not. | | **Fields** | `GET /fields` | The workspace [field & API-object registry](https://www.wesign.now/docs/fields-placeholders) — keys, types, enum options, examples, max length. | | **Webhooks** | `GET /hooks`, `POST /hooks`, `DELETE /hooks/{id}`, `POST /hooks/{id}/rotate` | REST-hook subscriptions for connectors; `GET /hooks?include_disabled=true` also lists hooks switched off in the dashboard; rotate a signing secret with a 24-hour overlap. Dashboard hooks and per-request `callback_url` hooks share the same delivery — see [Webhooks](https://www.wesign.now/docs/webhooks). | | **Key check** | `GET /me` | Which workspace does this key belong to, and what may it do? Returns the workspace, the key's scopes, the `capabilities` booleans and this month's `sms_allowance` — see [Feature detection](https://www.wesign.now/docs/api-overview#feature-detection). Not rate-limited. | | **Embedded signing** | `POST /embedded/sign-sessions`, `GET`/`DELETE /embedded/sign-sessions/{id}` | Mint a short-lived, single-signer session and frame the signing view inside your own page, then read or revoke it. Minting needs Enterprise; any key with the `embedded` or `full` scope may call all three. See [Embedded signing](https://www.wesign.now/docs/embedded). | | **Embedded sessions** (retired) | `POST /embedded/sessions` → `410 embedded_sessions_retired` | Retired 2026-07-31 and replaced by `/embedded/sign-sessions` above. `GET`/`DELETE /embedded/sessions/{id}` still answer for rows minted before then. | Also retired on 2026-07-31 with the manual-placement flow: `POST /documents` answers `410 placement_retired`, and `placement="manual"` on `POST /signing-requests` is refused with `400 placement_retired` before any document is minted. Ingest-by-URL lives on as `file_url` on `POST /signing-requests`. **Note:** **Note:** template instantiation also responds at the legacy path `https://www.letssign.now/api/templates/{id}/instantiate` (and the same path on `www.wesign.now`) on the web origin. It runs the same code as the `/v1` route, so it answers identically, and it is deprecated with no sunset date yet — see [Versioning](https://www.wesign.now/docs/versioning#deprecated-fields). New integrations should use `https://api.wesign.now/v1/templates/{id}/instantiate`. ## Feature detection `GET /v1/me` answers with the workspace, the key, a `capabilities` object and the workspace's SMS allowance for the current month: ```json { "workspace": { "id": "6f1c2a9e-4b1d-4c3a-9f0e-2d8b7a6c5e41", "name": "Treuhand Muster AG", "slug": "treuhandmuster", "tier": "enterprise" }, "key": { "id": "0b9a7e3c-1f2d-4e5a-8b6c-9d0e1f2a3b4c", "scopes": ["full"] }, "capabilities": { "embedded_signing": true, "templates": true, "webhooks": true, "sms_verification": true, "ip_allowlist": true, "metadata": true }, "sms_allowance": { "monthly": 300, "used": 41, "remaining": 259, "resets_at": "2026-10-01T00:00:00.000Z" } } ``` That is the complete body — nothing else is returned. `workspace.name`, `slug` and `tier` are `null` only if the workspace row could not be read, and `sms_allowance` is then `null` too. `sms_allowance.monthly` follows the plan (Free 25, Pro 50, Branded 200, Teams and Enterprise 100 per paid seat); `remaining` is what the create calls check before they accept SMS signers — see [SMS verification](https://www.wesign.now/docs/sms-verification#availability-and-the-monthly-allowance). The route sends no `RateLimit-*` headers because it does not use the budget. Branch on the booleans, **never on `workspace.tier`**: tier names get renamed, and a comped or grandfathered workspace carries entitlements its tier string does not describe. The flags come from the same rows the gated endpoints enforce, so `/me` and a `402 enterprise-required` cannot disagree — `embedded_signing: false` means `POST /v1/embedded/sign-sessions` answers `402`, `templates: false` means `/templates/{id}/instantiate`, `/generate` and `/documents/{id}/confirm` do. `webhooks`, `sms_verification`, `ip_allowlist` and `metadata` are `true` for every workspace today and exist so a client detects every capability the same way. `sms_verification` stays `true` when the allowance is used up — "may I" and "how many are left" are different questions; read `sms_allowance` for the second. `capabilities` is **per workspace**; `key.scopes` is **per key**. A key scoped `embedded` may call `/v1/embedded/*` and nothing else — `GET /v1/me` included answers it `401 invalid_key` — so read `capabilities` with a `full` key (see [Key scopes](https://www.wesign.now/docs/authentication#key-scopes)). New keys may be added to the object over time — do not treat it as closed. ## Two ways to start a signature 1. **Direct** — `POST /v1/signing-requests` with a one-off PDF (`file` or `file_url`) and anchors or explicit field positions. Best for ad-hoc documents. 2. **Template** — `POST /v1/templates/{id}/instantiate` against a locked template, supplying `recipients` and `field_values`. Best for the same contract sent many times. Discover the fields to send via `GET /v1/templates/{id}` or `GET /v1/fields`. A recipient takes `slot`, `email`, `name`, the same SMS second factor as a signer (`phone_e164`, `require_sms_verification`, `sms_gate`), your own `reference`, and the `company` and `job_title` it signs with; the object is **strict**, so any other key — `phone`, say — is a `400 unknown_recipient_field` naming it, never dropped silently. ## Your own reference: `metadata` All three create calls — `POST /v1/signing-requests`, `POST /v1/templates/{id}/instantiate` and `POST /v1/templates/{id}/generate` — accept an optional `metadata` object: your case number, CRM id, tenant, anything you need to find the document again. ```json { "metadata": { "case_id": "ZT-2026-0142", "client": "zepf", "urgent": true } } ``` - **Flat only.** Up to 16 keys matching `^[A-Za-z0-9_.-]{1,40}$`; values are strings (≤ 500 characters), finite numbers, booleans or `null` — no nested objects or arrays — and the whole object is at most 4 KB as JSON. Anything else is a `400 invalid_metadata` with a top-level `problems: [{ path, message }]`, one entry per offending key, refused before anything is uploaded or created. - **Echoed everywhere.** It comes back as `metadata` on the create response, on `GET /v1/documents/{id}` and on [every webhook](https://www.wesign.now/docs/webhooks#envelope-and-sample-payload) that names the document — always present, `null` when you sent none. That is the fix for "lost the create response, cannot relink the document". - On multipart bodies send it JSON-encoded, like `signers`. It is part of the body hashed for `Idempotency-Key`, so the same key with different metadata is a `422 idempotency_key_reuse`. - On `/generate` it is validated in every mode but stored only with `review: true` — the streamed PDF has no document to carry it. `metadata` names the **document**. To name each **signer** too, give the signer (or template recipient) a `reference` — a string of up to 200 characters, stored on that signing request, read back on every signer of the create, instantiate and confirm responses and on `GET /v1/signing-requests/{id}`, and sent as `signer.reference` on `signing_request.sent`, `signing_request.signed`, `document.completed` and the [signer events](https://www.wesign.now/docs/webhooks#signer-events). ## Unknown keys A misspelt option used to vanish silently and the call ran with a default you never chose. Now nothing is dropped without a trace: | Where | Unknown key | Since | | --- | --- | --- | | A signer on `POST /v1/signing-requests` | `400 unknown_signer_field` — `meta.signers: [{ index, fields }]`, `meta.accepted` | 2026-09-24 | | A recipient on `POST /v1/templates/{id}/instantiate` | `400 unknown_recipient_field` — `meta.recipients: [{ index, fields }]`, `meta.accepted` | 2026-09-24 | | A recipient on `POST /v1/documents/{id}/confirm` | `400 unknown_recipient_field`, same shape | 2026-09-24 | | The body of `PATCH /v1/signing-requests/{id}` | `400 unknown_field` — `meta.fields`, `meta.accepted` | 2026-09-24 | | A top-level key of `POST /v1/signing-requests` (JSON key or multipart field other than `file`), `instantiate`, `generate` or `confirm` | Ignored and named in `warnings[]` on the success body (and on `validate_only` answers and the `422 template_input_invalid`). **From 2026-12-31: `400 unknown_field`.** | 2026-09-24 | | A key inside `theme` on `POST /v1/embedded/sign-sessions` | `400 invalid_request` | 2026-09-10 | | A top-level key of `POST /v1/embedded/sign-sessions` or `POST /v1/hooks` | Ignored, no warning | — | | A `field_values` key the template does not know | Not an error: an `unknown_field` warning, the value is not rendered. See [the field contract](https://www.wesign.now/docs/templates#the-field-contract). | — | Signer and recipient objects are strict at once because a dropped key there can be the SMS factor: the contract would go out weaker than you asked. A warning looks like this: ```json { "warnings": [ { "field": "send_email", "code": "unknown_field", "message": "\"send_email\" is not a parameter of this endpoint and was ignored. From 2026-12-31 an unknown top-level key is refused with 400 unknown_field." } ] } ``` The other warning code is `deprecated_field` — a spelling that still works until its sunset date (today: `recipients[].phone` on confirm, until 2026-12-31). The generate PDF stream has no JSON body; it names the ignored top-level keys in the `X-Unknown-Fields` response header instead (comma-separated, each key percent-encoded, at most 50 — for example `X-Unknown-Fields: send_email,sendEmails`), because from 2026-12-31 those keys are refused on that call too. ## Field keys and values The rules integrators trip over on the template routes, stated once: - **Keys** (`GET /v1/fields`, `field_values`) are lowercase letters, digits and underscores, with single dots between parts; every part starts with a letter, and a key is at most 64 characters — `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$`. `client_name`, `person.date_of_birth` and `company.uid` are all valid. A dot is part of the name, never nesting: `field_values` stays a flat map, and `person.date_of_birth` and `person_date_of_birth` are two different keys. - **Standard names** (`person.full_name`, `company.uid`, `address.postal_code`, `signing.place`, …) are keys the platform suggests, each anchored to an international vocabulary. An input that uses one says `standard: true` and carries its name in six languages (`labels`). Custom keys stay just as valid — see [Standard field names](https://www.wesign.now/docs/fields-placeholders#standard-field-names). - **Booleans are strings.** Every scalar in `field_values` is a string, including a `boolean` field: send `"true"`/`"false"`, `"yes"`/`"no"` or a UI-locale label (`"ja"`, `"nein"`, `"oui"`, `"non"`, `"sí"`, `"nee"`, …), case-insensitive — `true`/`yes` render as `Yes`, `false`/`no` as `No`, a label as sent. Any other value (`"X"`, `"1"`, `"on"`) is a `422 template_input_invalid` with problem code `invalid_boolean`; it is **never** silently left empty. A raw JSON `true`/`false` does not pass the body schema and answers `400 invalid_request`. - **Dates** are written day first — `DD.MM.YYYY`, `DD/MM/YYYY`, `DD-MM-YYYY` or `DD MM YYYY` (`15.03.1990`, `5.3.1990`) — or as `YYYY-MM-DD`, and must be a real calendar day with a four-digit year. They are stored and echoed as `YYYY-MM-DD`. Month-first is never tried — not even when the template prints `03/15/1990`: `03/04/1990` is 3 April, and `03/15/1990` — like `2026-02-30` — is `422 invalid_date`. - **Times** are `HH:MM` / `HH.MM` on a 24-hour clock (`14:30`) or `h:MM AM/PM` (`2:30 PM`), stored and echoed as 24-hour `HH:MM`; anything else is `422 invalid_time`. - **How a date or time prints** is the template's choice (`15.03.1990`, `15/03/1990`, `03/15/1990`, written out; `14:30` or `2:30 PM`), shown read-only as `date_format` / `time_format` on `GET /v1/templates/{id}` — see [Templates → how dates and times print](https://www.wesign.now/docs/templates#how-dates-and-times-print). The full contract — unknown and missing keys, signer-owned and auto-filled fields, what a dry run checks — is on [Templates → the field contract](https://www.wesign.now/docs/templates#the-field-contract). How an app asks its user for the right values before it calls is in [Prepare a fill in your app](https://www.wesign.now/docs/prepare-a-fill). --- # OpenAPI spec URL: https://www.wesign.now/docs/openapi Summary: The machine-readable v1 contract — import it into Postman, Insomnia or a client generator. Every v1 endpoint, request schema, response shape, status code and webhook event is described in one **OpenAPI 3.1** document: ``` https://www.wesign.now/openapi.json ``` The same file is served on the `letssign.now` alias host. It declares both API hosts as servers — `https://api.wesign.now/v1` (canonical) and `https://api.letssign.now/v1` (permanent alias) — so a generated client can target either. `info.version` is the date of the release the file describes (`2026-10-01` today). Each release's spec is also kept, frozen, at `/openapi/.json`, so you can diff two releases or pin a generator to one; what changed between them is in the [changelog](https://www.wesign.now/docs/changelog) and in [`/api-changelog.json`](https://www.wesign.now/api-changelog.json). See [Versioning](https://www.wesign.now/docs/versioning). ## What is in it - All 24 paths under `/v1` with their zod-derived request bodies, every documented error `code` per status, and the `Idempotency-Key` / `Idempotent-Replayed` / `Retry-After` / `RateLimit-*` headers — including `PATCH /signing-requests/{id}` for correcting a signer's phone. - `bearer` security scheme covering `wsk_live_…` keys and legacy `lsk_live_…` keys. - One shared `ApiMetadata` schema — the flat `metadata` object every create endpoint accepts (`POST /signing-requests`, `POST /templates/{id}/instantiate`, `POST /templates/{id}/generate`) and every create response, `GET /documents/{id}` and the webhook envelope echo — plus `MetadataInvalid`, the `400 invalid_metadata` body with its top-level `problems[]`. - `Me.capabilities` on `GET /me`: the booleans to feature-detect embedded signing, templates, webhooks, SMS verification, the IP allowlist and `metadata`, plus `Me.sms_allowance` — see [Feature detection](https://www.wesign.now/docs/api-overview#feature-detection). - The SMS contract: `sms_gate` and `reference` on every signer and recipient input, one `SignerReadback` merged into every signer of the create, instantiate, confirm and `GET /signing-requests/{id}` responses, and `SmsAllowanceExhausted`, the `402 sms_allowance_exhausted` body — see [SMS verification](https://www.wesign.now/docs/sms-verification). - `warnings[]` (`ApiWarning`: `unknown_field` / `deprecated_field`) on every JSON success body of the create and confirm calls. - Hooks: `enabled` on every `Hook`, the `include_disabled` query parameter on `GET /hooks`, and `POST /hooks/{id}/rotate` with its `HookSecretRotated` response. - A `webhooks` section with one entry per event (`signing_request.sent`, `.viewed`, `.signed`, `.declined`, `.withdrawn`, `.expired`, `.sms_sent`, `.sms_failed`, `.sms_verified`, `document.completed`, `template_instance.staged`, `template_instance.confirmed`, `template_instance.discarded`), the delivery envelope, and the signature scheme — both header families, `t=…,v1=…` (with a second `v1=` entry for 24 hours after a secret rotation), and the signed message `${t}.${rawBody}` — under the top-level `x-webhook-signature`, whose `rotation` block spells out the overlap window. - Signer identity: `SignerCompany` and `SignerJobTitle`, the optional `company` and `job_title` of a signer or recipient, read back on `SignerReadback` and `DocumentSigner`; `SigningRequestEventSigner`, the one `signer` block of `signing_request.signed`, `document.completed` and the signer events. - Strict objects (`additionalProperties: false`): the signer of `POST /signing-requests` (`400 unknown_signer_field`), the instantiate recipient (`slot`, `email`, `name`, `phone_e164`, `require_sms_verification`, `sms_gate`, `reference`, `company`, `job_title`) and the confirm recipient (`400 unknown_recipient_field`), where `phone` is marked `deprecated` in favour of `phone_e164`. - The field-key rule on `FieldDefinition.key` (`pattern` `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$` with `maxLength: 64` — lowercase snake_case parts joined by single dots, each starting with a letter), and the value rules on `FieldValues`: every scalar is a string, a boolean field takes `"true"`/`"false"`, `"yes"`/`"no"` or a UI-locale label, a date field takes a day-first date (`DD.MM.YYYY`, `DD/MM/YYYY`, `DD-MM-YYYY`, `DD MM YYYY`) or `YYYY-MM-DD`, a time field takes `HH:MM`, `HH.MM` or `h:MM AM/PM`, and anything else is `422 template_input_invalid`. How each date or time prints is on `TemplateInputField` as read-only `date_format`, `time_format` and `lang`. - The Enterprise gate: `/templates/{id}/generate`, `/templates/{id}/instantiate` and `/documents/{id}/confirm` carry a `402 enterprise-required` response. - The two retired endpoints (`POST /documents`, `POST /embedded/sessions`) marked `deprecated` with their stable `410`. ## Import it **Postman:** **Import → Link**, paste `https://www.wesign.now/openapi.json`, then set the collection variable `baseUrl` to `https://api.wesign.now/v1` and add your key under **Authorization → Bearer Token**. **Insomnia:** **Create → Import → URL**, paste the spec URL. Pick the canonical server when prompted and set the bearer token on the environment. **openapi-generator:** ```bash npx @openapitools/openapi-generator-cli generate \ -i https://www.wesign.now/openapi.json \ -g typescript-fetch \ -o ./wesign-client ``` Any generator target works (`python`, `go`, `java`, `csharp`, …). The spec uses `3.1.0` features — `webhooks`, type arrays for nullables, `const` — so use a generator release from 2023 or later. **Swagger UI / Redoc:** Point any hosted viewer at the URL, e.g. `https://redocly.github.io/redoc/?url=https://www.wesign.now/openapi.json`. **Note:** The spec is written **by hand** from the route handlers' zod schemas and response builders — it is not generated at build time. A unit test (`tests/openapi.test.ts`, run on every build) guards it against drift: every route directory must have a path, every documented method must be exported by its route file, every `status:` literal in a handler must be documented, Enterprise-gated routes must carry a `402`, and the webhook entries must match the event catalogue exactly. ## Reading the spec by hand - Paths are relative to the server URL, so `/documents/{id}` means `https://api.wesign.now/v1/documents/{id}`. - Error responses reference one `Error` schema (`{ error, code, meta? }`); the response description lists the `code` values that status can carry. The `400` on the three create endpoints is a `oneOf` of `Error` and `MetadataInvalid`, because `invalid_metadata` carries a top-level `problems: [{ path, message }]` array (the same placement `template_input_invalid` uses) rather than `meta`. See [Errors](https://www.wesign.now/docs/errors) for the prose version. - Binary responses (`/documents/{id}/signed`, `/documents/{id}/pdf`, `/documents/{id}/audit-trail`, and the default mode of `/templates/{id}/generate`) are `application/pdf`. - `POST /templates/{id}/generate` and `POST /templates/{id}/instantiate` return different shapes depending on `validate_only` and `review`; the `200` and `201` responses spell each one out. --- # Authentication URL: https://www.wesign.now/docs/authentication Summary: Bearer API keys — minting, rotating, revoking. The keys are workspace-scoped — no tenant header needed. Every request to `https://api.wesign.now/v1/*` (or its permanent alias `https://api.letssign.now/v1/*`) carries a Bearer token in the `Authorization` header. ```http Authorization: Bearer wsk_live_a1b2c3d4e5f6... ``` Keys are **workspace-scoped** — the bearer token alone tells the API which workspace, brand, hosting region, and reply-to address to use. There's no tenant header, no slug param, no signature on the request. Anything that has the key can act as the workspace. ## Key format | Prefix | Meaning | | --- | --- | | `wsk_live_` + 32 hex chars | Every key minted since the September 2026 rename. | | `lsk_live_` + 32 hex chars | Keys minted before it. **Accepted forever** — same lookup, same workspace, nothing to rotate. | We store only the SHA-256 of the full plaintext, plus its first 12 characters (`wsk_live_a1b…`) as a non-secret label. A bearer that starts with neither prefix is refused before we touch the database. Not sure which workspace a key belongs to? `GET /v1/me` answers with the workspace (`id`, `name`, `slug`, `tier`), the key (`id`, `scopes`), the `capabilities` booleans and this month's `sms_allowance` (`monthly`, `used`, `remaining`, `resets_at`) — the full example is under [Feature detection](https://www.wesign.now/docs/api-overview#feature-detection). Connectors use it as their "test authentication" step; it is exempt from the rate limit. It needs a `full` key — see [Key scopes](https://www.wesign.now/docs/authentication#key-scopes). ## Minting a key In your workspace dashboard, open [Developers → API keys](https://www.wesign.now/settings/api) and click **Create API key**. Only a workspace owner or admin can, and only on a plan with API access (Enterprise, and legacy Teams workspaces). - We show the `wsk_live_…` plaintext **once**, in a copy-and-confirm modal. Store it in your secret manager (1Password, Doppler, AWS Secrets Manager, GitHub Actions secrets) immediately — there's no recovery if you lose it. - The dashboard keeps the first 12 characters as a non-secret identifier (`wsk_live_a1b…`) so you can label rows in the audit log later. ## Rotating a key Rotation is "two keys live for a moment" rather than "regenerate": 1. **Create a new key.** Both old and new are now active. Update one deployment to use the new key. 2. **Revoke the old key.** From [Developers → API keys](https://www.wesign.now/settings/api), hit the **Revoke** button. Revocation is **immediate** — the next request with the old key returns `401 invalid_key`. This pattern means you can roll a key without a sub-second outage window. We don't support "scheduled rotation" or auto-expiry yet — ask if you need it. You do **not** need to rotate an `lsk_live_` key to a `wsk_live_` one; both stay valid. ## Revoking Same place: hit **Revoke** on a row. Effective immediately, and final — a revoked key cannot be restored. It moves to the list of revoked keys on the same page, which keeps its name, prefix and when it was revoked. ## Key scopes A key carries one of two scopes, chosen when it is created (the choice appears only when the workspace has embedded signing): | Scope | May call | | --- | --- | | `full` (the default) | Every `/v1` endpoint. | | `embedded` | Only `/v1/embedded/*`: mint, read and revoke [embedded signing](https://www.wesign.now/docs/embedded) sessions. | An `embedded` key is the one to deploy where all you need is to hand out signing frames: if it leaks, it cannot send documents, read them or change webhooks. Anything outside `/v1/embedded/*` answers it with the plain `401 invalid_key` — `GET /v1/me` included — not with a scope-specific error, so a stolen key learns nothing about what it may do. A `full` key satisfies the `embedded` scope too. The scope is fixed at creation; to change it, create a new key and revoke the old one. `GET /v1/me` returns the calling key's scopes as `key.scopes` (`["full"]`); an `embedded` key cannot call it, so name your keys by purpose when you create them. ## Rate limit is per key Every key has its own bucket of **60 requests per 60-second window**, tracked on the key's own row. Two keys in the same workspace are two buckets — a `429` on one says nothing about the other. Details and the recommended back-off in [Rate limits](https://www.wesign.now/docs/rate-limits). ## Restricting a key to your own IP addresses By default a key works from anywhere: whoever holds it can act as your workspace from any machine on the internet. If your integration calls us from a **fixed** address — an office NAT, a static egress IP on your VPC, a bastion host — you can pin the key to it, and calls from anywhere else are refused before the key is allowed to do anything. **Note:** **An empty list means unrestricted.** This is the opposite of the [embed-origin list](https://www.wesign.now/docs/embedded), and it is the half people get wrong: a key with **no** entries is not locked out — it is open to every address. That is the state every key is in until someone deliberately adds an entry. Only a **non-empty** list restricts anything. Manage it in [Settings → API](https://www.wesign.now/settings/api), on the key's own card, under **Allowed IP addresses**. Each key has its own list; two keys in the same workspace restrict independently. ### What goes in the list | Entry | Matches | | --- | --- | | `203.0.113.7` | that one IPv4 address | | `203.0.113.0/24` | every address in that IPv4 block | | `2001:db8::1` | that one IPv6 address | | `2001:db8::/32` | every address in that IPv6 block | - Up to **50 entries** per key. Mix IPv4 and IPv6 freely — and do list both if your backend can egress over either, or the day it picks IPv6 will look to us exactly like an attack. - We **canonicalise on save**, so `203.0.113.7/32` is stored as `203.0.113.7`, `203.0.113.7/24` as `203.0.113.0/24` (host bits are zeroed), and `2001:0DB8:0000::/32` as `2001:db8::/32`. What the card shows is what we match against — the UI, the database and the check can never disagree about spelling. - An IPv4-mapped IPv6 caller (`::ffff:203.0.113.7`) matches the plain `203.0.113.7` entry, and matches `203.0.113.0/24`. - Anything we can't parse is refused at the form rather than stored. Notably `::ffff:203.0.113.0/24` is rejected: read literally that is a quarter of the IPv6 address space, which is never what the typist meant. ### Which address we compare The address is the **public IP our platform observed the connection coming from** — your backend's egress address. Not a header you set, not a field in the body. In particular we ignore `X-Forwarded-For`: any caller can put any value in that header, so an allowlist that trusted it would be decoration. See [Security → Origin is not an access control for the REST API](https://www.wesign.now/docs/security#origin-is-not-an-access-control-for-the-rest-api). If the address can't be determined at all, a **restricted** key is refused rather than waved through. A key with an empty list is unaffected either way. ### The refusal A key refused by its allowlist answers `403`, **not** the generic `401 invalid_key` — your key is fine, your address isn't, and the two send you down completely different roads: ```http HTTP/1.1 403 Forbidden Content-Type: application/json { "error": "This API key does not allow calls from 203.0.113.9. Add that address to the key's IP allowlist in Settings → API, or empty the list to allow any address.", "code": "ip_not_allowed", "ip": "203.0.113.9" } ``` `ip` is the address we actually saw. Paste it into the allowlist and the next call goes through — no guessing what your egress address is. The key's card also shows the address **your browser** is on, with an **Add this IP** button next to it. Reach for that one only when your calls really do come from where you are sitting: a server almost always egresses from a different address than your laptop, and the address in the `403` body is the one that matters. (The button appears only when we can actually verify your browser's address — an unverified address would be the exact typo that locks you out.) When the caller isn't reaching us through our edge at all, the address is unknown and the same code comes back with `"ip": null`: ```json { "error": "This API key restricts which IP addresses may use it, and the caller's address could not be determined, so the call was refused. Empty the key's IP allowlist in Settings → API to allow any address.", "code": "ip_not_allowed", "ip": null } ``` ### It gates API keys only Worth spelling out, because this is where a control like this usually bites: - **Your workspace UI is never gated by it.** You can always sign in from any network, open [Settings → API](https://www.wesign.now/settings/api) and empty the list. A mistyped address locks out your integration — never you. - **Signing pages are never gated by it.** Your recipients can be anywhere, on any network. Nothing you put in this list can dead-end a signer. - It applies to `/v1/*` calls made with **that one key**. Other keys, the dashboard, webhooks we send you, and embedded signing sessions your signers open are all untouched. ### What it is and isn't worth - It **limits where a stolen key can be used from.** An attacker who lifts your key out of a CI log still has to call from an address you listed. - It **does not make a leaked key safe.** Anything running on an allowed address — including other code behind the same NAT, and whoever got into it — can still use the key. Rotate a leaked key; the allowlist is not a substitute. - It is **useless if your egress is dynamic.** Serverless without a NAT gateway, most PaaS dynos, a laptop on home broadband: those addresses change without warning and you will lock your own integration out. Give the caller a static egress address first, or leave the list empty. - It says nothing about *who* inside your company holds the key. One key per integration, plus revocation, still does that work. ## Error shape Bad, missing, or revoked keys — and an `embedded`-scoped key outside `/v1/embedded/*` — all return: ```http HTTP/1.1 401 Unauthorized Content-Type: application/json { "error": "Unauthorized", "code": "invalid_key" } ``` A key that is valid but refused by its [IP allowlist](https://www.wesign.now/docs/authentication#restricting-a-key-to-your-own-ip-addresses) returns `403` with `code: "ip_not_allowed"` and the observed address instead. Every other code is in [Errors](https://www.wesign.now/docs/errors). The `code` field is stable — pin your error handling to that, not the human-readable `error` message which we may improve over time. ## Security checklist **Warning:** **Never commit keys.** Ship them via your secrets manager, environment variable, or platform vault. Treat a leaked key the same as a leaked password — revoke immediately and audit recent activity. - All API calls go over **TLS 1.2+** on `api.wesign.now` and on the `api.letssign.now` alias. Pin certs if you're extra-paranoid; we use Let's Encrypt with normal renewal. - Keys are **bearer tokens** — there's no extra signing scheme on the request body. If you want HMAC-signed traffic, register a [webhook](https://www.wesign.now/docs/webhooks) — every delivery we send you is HMAC-SHA256 signed. - If your integration calls from a fixed egress address, pin the key to it with an [IP allowlist](https://www.wesign.now/docs/authentication#restricting-a-key-to-your-own-ip-addresses). It narrows where a stolen key can be used from; it does not replace rotating one that leaked. - Each key's card shows when it was last used. What a key did — a send, a reminder, a withdrawal, a phone correction — is on the affected document's audit trail, attributed to the key (never to a person). --- # Signing requests URL: https://www.wesign.now/docs/signing-requests Summary: Create, read, correct the phone, remind, and withdraw — the endpoints around the signing_request resource. A **signing request** is one signer's row on a document. A document with two signers has two signing requests; with five signers, five. Each carries a status, an expiry, an audit log, and a unique signing URL the signer clicks to sign. ## Create **POST** `https://api.wesign.now/v1/signing-requests` The big one — uploads the PDF, places the fields, emails the invitations (unless `send_emails` is `false`), and returns a per-signer URL list. It is all or nothing: nobody is emailed until everything is stored, and a call that fails creates nothing — see [If the call fails](https://www.wesign.now/docs/signing-requests#if-the-call-fails). Multipart when the PDF rides as a binary form field; JSON when you point us at a URL. This endpoint never sends an invitation by SMS: a signer who must verify by SMS asks for the code on the signing page — see [SMS verification](https://www.wesign.now/docs/sms-verification). ### Request body Send the PDF one of two ways: - **multipart/form-data** with a `file` part (the JSON fields ride as form fields), or - **application/json** with a **`file_url`** — we fetch the PDF server-side (SSRF-guarded: https/http only, no private hosts, no redirects, ≤ 25 MB). | Field | Type | Required | Description | | --------------------------- | --------------------------------------------- | :------: | ----------- | | `file` | multipart | one of | The PDF to sign. ≤ 25 MB. | | `file_url` | `string` (JSON) | one of | URL we fetch the PDF from. Alternative to `file`. | | `filename` | `string ≤ 200` | | The stored file name — what the invitation email and the signing page show. Honoured with `file` and with `file_url`; default: the uploaded file's name, or the last segment of `file_url`. Letters and digits of any script, `_`, `.` and `-` are kept; spaces and other characters become `_`. | | `signers` | `Signer[]` | ✓ | 1–20 entries. See [Signer object](https://www.wesign.now/docs/signing-requests#signer-object). | | `placement` | `"anchors" \| "auto_append" \| "explicit"` | | Default `anchors` with fallback to `auto_append`. See [Placement modes](https://www.wesign.now/docs/placement-modes). `"manual"` is still accepted by the parser but answers `400 placement_retired`. | | `fields` | `Field[]` | | Required when `placement="explicit"`. | | `observer_emails` | `string[]` | | 0–20 CC-style recipients, plain address strings without surrounding spaces. See [Observers](https://www.wesign.now/docs/observers). | | `callback_url` | `string` | | Per-request webhook, scoped to this document. HMAC-signed; the secret comes back once. See [Webhooks](https://www.wesign.now/docs/webhooks#registering-a-webhook). | | `signing_mode` | `"parallel" \| "sequential"` | | Default `parallel`. | | `locale` | `"en" \| "de" \| "fr" \| "it" \| "nl" \| "es"` | | Default email, page and SMS-code language. Default `en`. | | `expires_in_days` | `int 1..90` | | Default 14. Per-link TTL. | | `send_emails` | `boolean` | | Default `true`. `false`: no invitation email and no observer notice — not at the send, not when a sequential signer's turn comes — you distribute the `signingUrl`s yourself. See [`send_emails`](https://www.wesign.now/docs/signing-requests#send_emails). Multipart: `"true"` / `"false"`. Any other value (the JSON string `"false"`, a multipart `"no"`) is a `400 invalid_request`. | | `metadata` | `object` | | Your own reference for the document (case number, CRM id), echoed on every read and webhook. See [metadata](https://www.wesign.now/docs/api-overview#your-own-reference-metadata). Multipart: JSON-encoded. | | `placement_assignee_email` | `string` | | Belonged to the retired `manual` mode. Accepted and ignored so old request bodies keep validating. | Any other top-level key (or multipart field other than `file`) is ignored and named in the response's `warnings[]`; from **2026-12-31** it is refused with `400 unknown_field` — see [Unknown keys](https://www.wesign.now/docs/api-overview#unknown-keys). ### Field object (`placement="explicit"`) | Field | Type | Required | Description | | -------- | ------------------------------------------ | :------: | ----------- | | `page` | `int ≥ 0` | ✓ | 0-based page index. | | `x`,`y` | `0..1` | ✓ | Position as a fraction of page width/height. | | `w`,`h` | `0..1` | ✓ | Size as a fraction of the page. | | `kind` | `"signature" \| "initial" \| "date" \| "text"` | | Default `signature`. | | `role` | `string` | ✓ | Must match a signer's `role`. | | `origin` | `"top-left" \| "center"` | | Default `top-left`. With `center`, `x/y` is the field's **centre** — e.g. "centre the signature on page 3 at 70%/65%". | ```jsonc // "Point us at a PDF, place a signature by its centre, require an SMS code, send" — one JSON call: { "file_url": "https://your-dms.example.com/contracts/42.pdf", "placement": "explicit", "fields": [ { "page": 2, "x": 0.70, "y": 0.65, "w": 0.18, "h": 0.06, "kind": "signature", "role": "client", "origin": "center" } ], "signers": [ { "role": "client", "email": "sara@acme.ch", "name": "Sara Buyer", "phone_e164": "+41791112233", "require_sms_verification": true } ] } ``` ### Signer object | Field | Type | Required | Description | | -------------------------- | --------------------------------------------- | :------: | ----------- | | `email` | `string` | ✓ | Where the invite goes. | | `role` | `string` | ✓ | `[a-z][a-z0-9_-]{0,40}`. Must match an anchor placeholder when `placement="anchors"`. | | `name` | `string ≤ 200` | | Display name, printed as sent (put a title such as "Dr." where it should print). Auto-fills the typed-signature default. | | `first_name`, `last_name` | `string ≤ 100` each | | Split name, trimmed. If **either** is non-blank, the stored name is `"{first_name} {last_name}"` (a blank half left out) and `name` is ignored — send one form, not both. Only the stored name is read back, as `name`. | | `role_label` | `string \| { en, de }` | | Human-readable label for the signer's block on an auto-appended signature page (`placement` `auto_append`, or `anchors` without markers). Not shown in the invitation email. | | `locale` | `"en" \| "de" \| "fr" \| "it" \| "nl" \| "es"` | | Override request-level locale — the signer's email, page and SMS-code language. | | `signing_order` | `int 1..20` | | Only when `signing_mode="sequential"`. | | `recipient_color` | `"#RRGGBB"` | | Accent for this signer's fields on the sign view. | | `phone_e164` | `string` | see ↓ | The signer's mobile number in E.164 form (`+41791112233`). Required when `require_sms_verification` is `true`. | | `require_sms_verification` | `boolean` | | Default `false`. Signer must confirm a one-time SMS code before they can sign. See below. | | `sms_gate` | `"before_sign" \| "before_view"` | | Default `before_sign`: the code is asked at the Sign press. `before_view`: nothing of the document is shown until the code is verified. Needs `require_sms_verification: true`. | | `reference` | `string ≤ 200` | | Your own id for this signer (contact id, mandate number). Returned on every read of the request and as `signer.reference` on `signing_request.sent`, `signing_request.signed`, `document.completed` and the [signer events](https://www.wesign.now/docs/webhooks#signer-events). | | `company` | `string ≤ 200` | | The company this signer signs for ("Muster AG"), as you state it. Printed under the signature and on the audit certificate — see [Company and function](https://www.wesign.now/docs/signing-requests#company-and-function). | | `job_title` | `string ≤ 120` | | The signer's function as it should print ("Geschäftsführerin"). Same rules as `company`. | The signer object is **strict**. Any other key — `phone` instead of `phone_e164`, a typo — is refused before anything is created, naming every offender: ```json { "error": "Unknown signer field(s): signers[0].phone. Accepted: email, role, name, first_name, last_name, recipient_color, role_label, locale, signing_order, phone_e164, require_sms_verification, sms_gate, reference, company, job_title.", "code": "unknown_signer_field", "meta": { "signers": [{ "index": 0, "fields": ["phone"] }], "accepted": ["email", "role", "name", "first_name", "last_name", "recipient_color", "role_label", "locale", "signing_order", "phone_e164", "require_sms_verification", "sms_gate", "reference", "company", "job_title"] } } ``` ### Company and function Two optional signer fields say on whose behalf a person signs: ```jsonc { "role": "signatory_1", "email": "anna.muster@muster-ag.ch", "first_name": "Anna", "last_name": "Muster", "job_title": "Geschäftsführerin", // ≤ 120 characters "company": "Muster AG" // ≤ 200 characters } ``` - **Your statement, not ours.** We store and print what you send and never verify it; the certificate labels both "stated by the sender". - **Normalised.** Runs of whitespace collapse to one space and the ends are trimmed; a value that is blank after that means none. A longer value, or one with a line break, tab or other control character, is a `400 invalid_request` naming `signers[i].company` or `signers[i].job_title`, and nothing is created. - **Read back** as `company` and `job_title` (`null` when absent) on every signer of the 201, on `GET` and `PATCH /v1/signing-requests/{id}`, on `GET /v1/documents/{id}` → `signers[]`, and in the `signer` block of every signer webhook except `signing_request.expired`. They cannot be changed after the create. - The same two fields exist on [template recipients](https://www.wesign.now/docs/templates#recipients) and on [confirm](https://www.wesign.now/docs/templates#confirm-and-discard). ### What prints under each signature Under every signature and initials box, the sealed PDF carries a four-line caption, in English with a 24-hour clock: ```text Anna Muster · Geschäftsführerin · Muster AG · anna.muster@muster-ag.ch Signed 24/09/2026, 14:30 CEST Signed on Muster Treuhand · mustertreuhand.letssign.now IP 203.0.xxx.xxx · Electronic signature + SMS 2FA ``` 1. The stored name, then `job_title` and `company` when you sent them, then the email. Without a name the line starts with the email; without company and function it is `{name} · {email}`. With them, the line shrinks to fit the box (down to 6 pt). When it still does not fit, or would run past the page's right edge, the line stays `{name} · {email}` and function and company print on a line of their own under it (wrapped onto more lines if they need them); lines 2–4 move down by as much. Nothing is cut. 2. When the signature was sealed, in the time zone of the workspace owner's profile (else their country's, else UTC), not the signer's. 3. The workspace's name and signing host on a branded workspace, otherwise `Signed on letssign.now`. 4. The signer's masked IP address and the method: `Electronic signature`, or `Electronic signature + SMS 2FA` for an [SMS signer](https://www.wesign.now/docs/signing-requests#second-factor-by-sms). The caption starts about 12 pt and ends about 40 pt below the box, about 9 pt lower for each line function and company take of their own: leave that space, and some room to the right, free of your own text. Names, company and function print as values in the script they are written in; only the labels are English. The same name, function and company appear on the audit certificate, both the one attached to the completion emails and the one from [`GET /v1/documents/{id}/audit-trail`](https://www.wesign.now/docs/documents#download-the-audit-trail-pdf). ### Second factor by SMS Yes — a signer can be required to confirm an SMS code. Two fields on the signer, one rule: | Field | Value | | --- | --- | | `phone_e164` | The mobile number, E.164: a leading `+`, then 8–15 digits, no spaces (`^\+[1-9]\d{7,14}$`). | | `require_sms_verification` | `true` | | `sms_gate` | Optional: `"before_view"` to hide the document until the code is verified. | **Validation rule:** `require_sms_verification: true` without a `phone_e164` is rejected before anything is created — `400 invalid_request`, and the `error` text names the rule: `phone_e164 is required when require_sms_verification is true`. A number that isn't E.164 fails the same way (`phone must be E.164`), and so does `sms_gate: "before_view"` without the flag. A `phone_e164` on its own, without the flag, is stored on the request but no code is ever asked for. What the signer experiences depends on `sms_gate`: - **`before_sign`** (default): they open their signing link, read the document, place their signature — and when they press **Sign**, we text a one-time code to `phone_e164`. They enter it, and only then is the signature applied. - **`before_view`**: the link opens on the code step alone; the signer taps **Text me a code** (it is never sent on page load, so a link scanner cannot trigger it), and the document — title, pages, prefilled values — appears only after the code is verified. The invitation email does not name the document either. The check is recorded on the audit trail, and the resulting signature is classed as an **advanced electronic signature (AES)** rather than the simple (SES) email-link level — the same distinction you see in the dashboard, and the `signature_level` every response reads back. It is available on every plan, within the workspace's monthly SMS allowance: a call whose SMS signers the allowance cannot cover is refused with `402 sms_allowance_exhausted` and nothing is created. A request is never downgraded to SES. ```jsonc { "signers": [ { "role": "tenant", "email": "tenant@example.com", "name": "Ann Tenant", "phone_e164": "+41791112233", "require_sms_verification": true, "sms_gate": "before_view" } ] } ``` The whole contract — what the email and the page show before the code, the signer's side (resend, attempts, lifetime, lockout), undeliverable numbers, the allowance and the SMS events — is on [SMS verification](https://www.wesign.now/docs/sms-verification). The same fields exist on [template recipients](https://www.wesign.now/docs/templates#recipients) and on [`POST /v1/documents/{id}/confirm`](https://www.wesign.now/docs/templates#confirm-and-discard), where the phone field is also `phone_e164` (`phone` is a deprecated alias until 2026-12-31). ### `send_emails` `send_emails: false` creates everything — the document, the signing requests, the links, the `signing_request.sent` webhook — but sends **no invitation email and no observer notice**. Each signer comes back with `emailed: false`; you deliver the `signingUrl` yourself. In a **sequential** send that holds for the later signers too: the choice is stored on every signer, so when a signer's turn comes they are not emailed. Their release emits `signing_request.sent` with `"source": "sequential_release"` and `"emailed": false` — your cue to deliver the `signingUrl` this response gave you for them. See [Following a sequential send](https://www.wesign.now/docs/webhooks#following-a-sequential-send). It does not silence everything: - **The SMS code still arrives.** It is never part of the invitation: the signer asks for it on the signing page, so a signer who opens your link reaches the SMS step exactly as with an emailed invitation. - The workspace's **automatic reminders** (Settings → Signing, by default 7 and 14 days after the send) still email signers who have not signed. - **Completion emails** go out as usual. ### Response ```http HTTP/1.1 201 Created Content-Type: application/json { "documentId": "8a1e4f9a-…", "metadata": { "case_id": "ZT-2026-0142" }, "status": "pending", "placement": "anchors", "presentedSha256": "4f9a…d21c", "anchors": { "found": 4, "fields": 4 }, "signers": [ { "role": "tenant", "email": "tenant@example.com", "status": "pending", "signingUrl": "…", "signingRequestId": "…", "emailed": true, "require_sms_verification": true, "phone_masked": "•••• •••• 2233", "sms_verified_at": null, "sms_gate": "before_view", "signature_level": "AES", "reference": "crm-contact-4711", "company": null, "job_title": null }, { "role": "landlord", "email": "owner@example.com", "status": "pending", "signingUrl": "…", "signingRequestId": "…", "emailed": true, "require_sms_verification": false, "phone_masked": null, "sms_verified_at": null, "sms_gate": "before_sign", "signature_level": "SES", "reference": null, "company": "Owner Estates AG", "job_title": "Director" } ], "observers": [], "callback": { "url": "https://yourapp.com/wesign/callback", "secret": "whsec_…", "note": "Store this secret. We do not display it again. …" }, "warnings": [] } ``` Per signer: | Field | Meaning | | --- | --- | | `status` | `pending`, or `queued` for a sequential signer whose turn has not come. | | `emailed` | Whether the invitation email went out in this call. `false` with `send_emails: false`, for a `queued` signer (released when their turn comes — emailed then unless `send_emails` was `false`), and when the send failed — the link in `signingUrl` works either way. | | `require_sms_verification`, `phone_masked`, `sms_verified_at`, `sms_gate`, `signature_level`, `reference` | The SMS factor as **stored** — check it here rather than trusting your own request body. `phone_masked` shows the last four digits only; the full number is never returned. `signature_level` is `AES` with SMS, else `SES`. See [Reading it back](https://www.wesign.now/docs/sms-verification#reading-it-back). | | `company`, `job_title` | What was stored from your `company` and `job_title`, or `null`. See [Company and function](https://www.wesign.now/docs/signing-requests#company-and-function). | `callback` is only present when you passed `callback_url`. The `secret` is the HMAC key for that document's deliveries and is shown exactly once (an `Idempotency-Key` replay returns the same body, secret included). `warnings` names every top-level key the call ignored. Refusals specific to this endpoint, all before anything is stored or sent: | Status | Code | When | | --- | --- | --- | | 400 | `unknown_signer_field` | An unknown key in a signer object (see above). | | 400 | `invalid_request` | Any other body problem, including the SMS and `sms_gate` rules and a `company` or `job_title` that is too long or holds a control character; `error` names the path. | | 400 | `invalid_metadata` | `metadata` is not a flat object within the limits; `problems[]` per key. | | 402 | `tier_required` | The workspace's monthly document cap is reached (`meta.tier`, `meta.cap`, `meta.used`). | | 402 | `sms_allowance_exhausted` | The signers with `require_sms_verification: true` outnumber the SMS codes the workspace has left this month. Body: `{ error, code, sms_allowance: { monthly, used, remaining, resets_at }, required }`. See [the allowance](https://www.wesign.now/docs/sms-verification#availability-and-the-monthly-allowance). | A `file_url` that cannot be fetched is refused before anything is created too (`blocked_url`, `redirect_not_allowed`, `fetch_failed`, `fetch_timeout`, `file_too_large` — see [Fetching `file_url`](https://www.wesign.now/docs/errors#fetching-file_url)). The full list, placement codes included, is on [Errors](https://www.wesign.now/docs/errors). ### If the call fails The call writes everything first — the document, the `callback_url` webhook, every signing request and every field — and sends nothing until all of it is stored. Only then do the invitations go out, followed by the observer notice and the one `signing_request.sent`. If a write fails, the call undoes itself before anybody hears of it: the document, its signing requests, fields, audit rows and callback webhook are deleted, together with the PDF this call uploaded. It answers: ```json { "error": "Signing-request insert failed: canceling statement due to statement timeout. Nothing was created or sent; retry the request.", "code": "db_failed", "meta": { "role": "signatory_2", "rolled_back": true } } ``` | `meta` | Meaning | | --- | --- | | `rolled_back: true` | Nothing exists and nothing was sent. Retry the same body; the `Idempotency-Key` was released, so the same key works. | | `rolled_back: false` | Rare: the partly created document could not be deleted. `document_id` names it; nobody was invited, its open signing requests were withdrawn where possible, and we were alerted. Before you retry, withdraw any request that [`GET /v1/documents/{id}`](https://www.wesign.now/docs/documents#get-document) still shows as `pending`. | | `role` | The signer whose signing request or fields could not be written, when that was the failing step. | A failed `callback_url` webhook or a failed field write fails the call the same way; neither is ignored. `signingUrl` lives on the workspace's signing host (`https://yourco.letssign.now/en/sign/…`). The invitation email we send carries a link with the **same token** on the email host instead (`https://yourco.wesign.now/en/sign/…`); both open the same signing page, so either one works. Only the host differs — nothing else about the request does. **Note:** **Manual placement is retired.** `placement="manual"` — POST a file, get a `placementUrl`, let a human drop the fields — was retired on 2026-07-31 and now answers `400 placement_retired`. Keep the human in the loop *before* the send instead: place fields with anchors or explicit coordinates, or stage a [template instance for review](https://www.wesign.now/docs/templates#review-before-sending). ## Read one signer **GET** `https://api.wesign.now/v1/signing-requests/{id}` Status, signing URL, the signer's SMS factor, reference, company and function, and the **first 50** audit events (oldest first) for a single signer. Use this when you want a tight poll loop on one recipient without re-fetching the whole document. ```json { "id": "11111111-…", "documentId": "8a1e4f9a-…", "signer": { "email": "tenant@example.com", "name": "Ann Tenant", "role": "tenant", "order": null, "require_sms_verification": true, "phone_masked": "•••• •••• 2233", "sms_verified_at": "2026-04-29T10:31:40Z", "sms_gate": "before_view", "signature_level": "AES", "reference": "crm-contact-4711", "company": null, "job_title": null }, "status": "viewed", "locale": "en", "channel": "email", "expiresAt": "2026-05-13T10:30:00Z", "createdAt": "2026-04-29T10:30:00Z", "staged": false, "signingUrl": "https://yourco.letssign.now/en/sign/abc…", "auditEvents": [ { "type": "email_sent", "createdAt": "2026-04-29T10:30:01Z", "meta": { "to": "…", "source": "v1_api", "api_key_id": "…" } }, { "type": "sms_verify_sent", "createdAt": "2026-04-29T10:31:02Z", "meta": { "masked_phone": "•••• •••• 2233", "quota_used": 42, "quota_cap": 300 } }, { "type": "sms_verify_ok", "createdAt": "2026-04-29T10:31:40Z", "meta": {} }, { "type": "viewed", "createdAt": "2026-04-29T10:31:43Z", "meta": null } ] } ``` `status` is one of: | Status | Meaning | | --- | --- | | `queued` | A sequential signer whose turn has not come, or a recipient of a [staged template instance](https://www.wesign.now/docs/templates#review-before-sending). | | `pending` | Invited; the signer's browser has not loaded the document yet. | | `viewed` | The signer's browser loaded the document at least once (with `before_view`: after the SMS code). Opening the link alone does not count — a plain fetch of the link does not trigger it; a scanner that renders the page in a full browser can. Not guaranteed before `signed`. | | `signed` | Signed. | | `declined` | The signer declined; the reason is in `auditEvents[type=declined].meta.reason` and on `signing_request.declined`. | | `withdrawn` | Withdrawn by you or a user in the dashboard. | | `expired` | Passed `expiresAt` unsigned. | Treat `viewed` like `pending` wherever you branch on "still open". A recipient of a staged template instance reads `staged: true` and `signingUrl: null` until the instance is confirmed — the link does not work before then, so take it from the confirm response. `auditEvents` returns the oldest 50 events; a long-lived request's newest events can fall outside it — use the [webhooks](https://www.wesign.now/docs/webhooks) for a live view. For a doc-level view of all signers, see [GET document](https://www.wesign.now/docs/documents#get-document). ## Correct the phone number **PATCH** `https://api.wesign.now/v1/signing-requests/{id}` The signer says the code went to the wrong number? Correct it on the live request — the link, the status and everything else stay as they are. ```http PATCH https://api.wesign.now/v1/signing-requests/11111111-… Authorization: Bearer wsk_live_… Content-Type: application/json { "phone_e164": "+41797654321" } ``` `200` answers the [read-one shape](https://www.wesign.now/docs/signing-requests#read-one-signer) with the new `phone_masked`. Allowed while the request is `pending`, `viewed` or `queued` and no code has been verified yet: | Status | Code | When | | --- | --- | --- | | 400 | `invalid_phone` | Not E.164. | | 400 | `unknown_field` | Any key besides `phone_e164` (`meta.fields`, `meta.accepted`). | | 400 | `invalid_request` | `phone_e164` missing or not a string, or the body is not JSON. | | 404 | `not_found` | Unknown id, or another workspace's request. | | 409 | `not_editable` | Signed, declined, withdrawn or expired — or the signer already verified a code, which fixes the number as evidence (`meta.status`, `meta.sms_verified_at`). Withdraw and create a new request instead. | Nothing is sent by the PATCH; the signer taps "Send a new code" on the page, and the code goes to the new number. The audit trail records `phone_changed` with both numbers masked. Details: [Changing the phone number](https://www.wesign.now/docs/sms-verification#changing-the-phone-number). ## Remind **POST** `https://api.wesign.now/v1/signing-requests/{id}/remind` Re-send the original invite email. Allowed only while the request is `pending` or `viewed` and not yet expired. Empty request body. The audit trail records the API key as the actor. Every accepted call sends another email — this endpoint does not take an `Idempotency-Key`, so guard retries on your side. One signer gets **at most one reminder a minute**, however it is triggered: a call within 60 seconds of the last reminder to that signer — from your key, a user in the dashboard or the workspace's automatic reminders — is refused and sends nothing. A burst of near-identical mail to one inbox is what spam filters punish, so the cooldown protects your delivery too. ```http 200 OK { "ok": true } 404 Not Found { "error": "Not found", "code": "not_found" } 409 Conflict { "error": "Cannot remind a signed request", "code": "invalid_state" } 409 Conflict { "error": "Request has expired", "code": "expired" } 429 Too Many Requests Retry-After: 60 { "error": "This signer was reminded moments ago — give it a minute before sending another.", "code": "rate_limited" } 502 Bad Gateway { "error": "Reminder failed to send: …", "code": "email_failed" } 503 Service Unavailable { "error": "Email sending is not set up on this deployment yet. …", "code": "email_not_configured" } ``` | Status | Code | When | | --- | --- | --- | | 404 | `not_found` | Unknown id, or another workspace's request. | | 409 | `invalid_state` | Signed, declined, withdrawn — or `queued` (a sequential follower, or a recipient of a staged template instance). | | 409 | `expired` | The request's TTL has elapsed. | | 429 | `rate_limited` | The per-signer cooldown above. It carries `Retry-After: 60` (no `RateLimit-*` headers) and it is not the per-key [rate limit](https://www.wesign.now/docs/rate-limits): wait `Retry-After` seconds, then call again. | | 502 | `email_failed` | Our email provider failed the send. Nothing was recorded; retry later. | | 503 | `email_not_configured` | The deployment has no email provider (development only). | Every call that passes the key check spends one token of the per-key rate limit, whatever it answers — the cooldown `429` included. ## Withdraw **POST** `https://api.wesign.now/v1/signing-requests/{id}/withdraw` Cancel an in-flight signing request. Allowed while it is `pending` or `viewed`. Status flips to `withdrawn` — subsequent reminder/withdraw calls return 409, the signing URL no longer accepts signatures, and [`signing_request.withdrawn`](https://www.wesign.now/docs/webhooks#signer-events) fires with `withdrawn_by: "api"`. Empty request body. A `queued` sequential follower cannot be withdrawn here (`409 invalid_state`); once an earlier signer is withdrawn the chain stops, so the followers stay `queued` and are never invited. ```http 200 OK { "ok": true } 404 Not Found { "error": "Not found", "code": "not_found" } 409 Conflict { "error": "Cannot withdraw a signed request", "code": "invalid_state" } 409 Conflict { "error": "This request changed state while it was being withdrawn.", "code": "invalid_state" } 500 Internal Server Error { "error": "…", "code": "db_failed" } ``` The second `409` means a signature, a decline or another withdraw landed between our read and our write; read the request again to see which. A `500 db_failed` means the status update itself failed: nothing changed, the request is still in flight and no webhook fired. Retry the call. **Note:** Both remind and withdraw are also available on the in-app UI under **Documents → details**. The API endpoints share business rules with the UI flow — same status gates, same audit-event shape. --- # Placement modes URL: https://www.wesign.now/docs/placement-modes Summary: Where signature fields land on the PDF — anchors, auto-append, explicit. Visualised, with when-to-use guidance. Manual placement is retired. Pick a mode with the `placement` form field on [POST /v1/signing-requests](https://www.wesign.now/docs/signing-requests#create). The default is `anchors` with a fall-through to `auto_append` when no markers are found. Three live modes; every one of them places the fields at API call time, so the request that answers `201` is the request that went out. **Warning:** **`manual` is retired.** A fourth mode — `placement="manual"`: POST the PDF and the signers with no field information, get back a `placementUrl`, and let a person drop the fields in a hosted editor before anything was sent — was retired on 2026-07-31. It never dispatched a request in production, and the guest placement editor it opened went with the classic-editor retirement. The value is still parsed so that an old request body fails loudly instead of with a generic validation error: `POST /v1/signing-requests` answers `400 placement_retired` before any document is minted (`placement_assignee_email` is accepted and ignored), and its companion `POST /v1/documents` — ingest a PDF by URL, get the same placement link — answers `410 placement_retired`. Use one of the three live modes instead: `anchors` when your generator can drop `[[ls:signature:role]]` markers into the PDF, `explicit` when it already knows the coordinates, `auto_append` when a signature page on the end is enough. Ingest-by-URL lives on as `file_url` on [POST /v1/signing-requests](https://www.wesign.now/docs/signing-requests#create). To keep a person in the loop *before* the send, stage a template instance with `review: true` and [confirm or discard it](https://www.wesign.now/docs/templates#review-before-sending) — the fields come from the template, so there is nothing left to place. ## At a glance | Mode | When to pick it | Caller passes coordinates? | Field placement happens at | | ------------- | ---------------------------------------------------------------------------- | :------------------------: | -------------------------- | | `anchors` | You generated the PDF programmatically (HTML→PDF, LaTeX, Word) with markers. | No | API call time | | `auto_append` | Ad-hoc letters, no structure, just need a signature page on the end. | No | API call time | | `explicit` | You generated the PDF programmatically AND tracked field positions yourself. | Yes | API call time | ## anchors (default) Drop a marker like `[[ls:signature:tenant]]` in your PDF (HTML→PDF, LaTeX, Word, anywhere with a real text layer). We find it, mask the marker with a white rectangle, and place the signature field on top. - 1 — UPLOAD - PDF with markers in text layer ```text Tenant signature: [[ls:signature:tenant]] ───────────────── ``` - 2 — SCAN - pdfjs reads positions; we white-rect each marker - Markers are masked so they don't bleed through under the field. - 3 — PLACE - Signature field rendered on top of the masked region - Sign here **Best ergonomics for programmatically generated contracts** — HTML→PDF templates, document-assembly tools (Portant, DocAssemble), LaTeX, Pandoc. See [Anchor placeholders](https://www.wesign.now/docs/anchor-placeholders) for the full marker syntax. **Caveats:** - The PDF's **text layer** must contain the marker. Scanned-image PDFs don't qualify (run them through OCR first). - Markers wrapped across two lines are skipped silently — keep them on one line in the source template. - A marker can appear once per page per role for `signature` / `initial` / `date` / `name`. Duplicates → 400 `duplicate_anchor`. ## auto_append We add a fresh signature page after your PDF — one block per signer, role-labelled with a signature line and a date line. No coordinates needed from you. - 1 — UPLOAD - Original PDF (N pages) - 2 — APPEND - We add page N+1 with role-labelled blocks - 3 — SIGN - Signers sign on the appended page - TENANT - LANDLORD **Good for ad-hoc letters and quick-and-clean send-and-forget flows** where there's no existing signature area. Also the **fall-through** when `anchors` mode finds zero markers in the PDF: whether you passed `placement="anchors"` or left the default, we switch to `auto_append` so the document still goes out, and the response says so — `"placement": "auto_append"`, `anchors.found: 0`. Check those two fields if a PDF without markers must not be sent. **Caveats:** - Adds at least one extra page — six signer blocks fit on a page, so a seventh signer starts a second one. - Page size matches A4 regardless of your source PDF's size. ## explicit You pass a `fields[]` array with one tuple per field, in normalized [0..1] coordinates with a top-left origin: ```json { "placement": "explicit", "fields": [ { "page": 0, "x": 0.10, "y": 0.85, "w": 0.30, "h": 0.06, "kind": "signature", "role": "tenant" }, { "page": 0, "x": 0.45, "y": 0.85, "w": 0.30, "h": 0.06, "kind": "signature", "role": "landlord" }, { "page": 0, "x": 0.10, "y": 0.92, "w": 0.15, "h": 0.03, "kind": "date", "role": "tenant" } ] } ``` - 1 — UPLOAD - PDF + JSON coords from your generator ```text { "page": 0, "x": 0.10, "y": 0.85, ... ``` - 2 — VALIDATE - Each role must match a signer; coords must lie in [0..1] - 3 — PLACE - Field rendered exactly where you said **Use when you generated the PDF programmatically AND tracked field positions at generation time** — most often a templating system with inline metadata about where each field belongs. **Coordinate system:** - Origin **top-left** (matches our editor; PDF native is bottom-left but you don't need to think about that). Pass `"origin": "center"` on a field to give its centre instead. - `x`, `y`, `w` and `h` are in `[0, 1]` — fractions of page width / height. A value outside is a `400 invalid_request`, not clamped. `page` is 0-based. - `kind` is `signature` | `initial` | `date` | `text` (default `signature`). - `role` MUST match a `signers[].role` value (`400 unknown_role` otherwise), and every signer needs at least one `signature` or `initial` field (`422 signer_without_signature` otherwise). **Caveats:** - No safety net — coords land where you say. A bug in your generator puts the signature box on top of the legalese. - Page sizes vary; if your generator outputs Letter and the field table assumes A4, fields land where they shouldn't. --- # Anchor placeholders URL: https://www.wesign.now/docs/anchor-placeholders Summary: The [[ls:KIND:ROLE]] marker syntax used by placement="anchors". `placement="anchors"` (the default) on [`POST /v1/signing-requests`](https://www.wesign.now/docs/signing-requests#create) finds markers in your PDF's text layer and places fields on top. This page covers the marker syntax, what we do with the marker once we find it, and the rules that produce 400 errors. ## Syntax ``` [[ ls : KIND : ROLE (: FIELD_KEY)? ]] KIND — signature | initial | date | name | text ROLE — must match a signers[].role FIELD_KEY — optional third part: a letter, then letters, digits, _ - or . — up to 64 characters in all (e.g. [[ls:text:tenant:person.date_of_birth]]) ``` A `text` or `name` marker becomes a text box the signer fills in. Its optional third part is stored as the box's **field key**; a `name` marker without one is keyed `person.full_name`. A key that asks for the signer's own details opens the box with them: | Field key | The box opens with | | --- | --- | | `person.full_name` (or `full_name`) | The signer's full name | | `person.first_name`, `person.last_name` (or `first_name`, `last_name`) | That part of the signer's name | | `contact.email` (or `email`) | The signer's email address | | `company.legal_name` (or `company`) | The company of the workspace address-book contact with the signer's email, if there is one | | `role` | The role (job title) of that address-book contact, if there is one | Name and email come from the signing request (the address book can complete a missing half of the name). The signer can change what the box opens with, and an empty text box never blocks signing. Any other key only names the box. The `company` and `job_title` you send on a [signer](https://www.wesign.now/docs/signing-requests#company-and-function) print in the caption under the signature; they do not fill these boxes. On a `date` marker the third part is not a key. This endpoint ignores it. A `date` marker stamps the date that signer signs on, taken in the workspace owner's time zone, in your workspace's house format: `15.03.1990`, or `03/15/1990` when the workspace's country (Settings → Company) is the United States. Whitespace inside the brackets is tolerated (`[[ls:signature:tenant]]` and `[[ ls : signature : tenant ]]` parse the same). The marker must live on a single line in the PDF's text layer — line-wrapped markers are skipped silently, and so is a marker that breaks the syntax (a FIELD_KEY over 64 characters, say): it is ordinary text, neither placed nor masked. ## Example PDF excerpt ``` CLIENT — Acme GmbH [[ls:signature:client_principal]] [[ls:date:client_principal]] ───────────────────────────────── ────────────── [[ls:name:client_principal]] Date VENDOR — letssign.now Partners AG [[ls:signature:vendor_director]] [[ls:date:vendor_director]] ───────────────────────────────── ────────────── [[ls:name:vendor_director]] Date ``` When this contract is POSTed with two signers (`client_principal` and `vendor_director`), the response contains `"anchors": { "found": 6, "fields": 6 }` — two signatures, two dates, two names — and `"placement": "anchors"`. Each field belongs to the signer whose role its marker names, and each name box opens with its signer's name. ## Rules A marker can appear **once per page per role** for `signature`, `initial`, `date`, and `name` kinds. Multiple `text` markers per page per role are allowed (e.g. multiple inline fill-in-the-blanks). | Violation | Error | Status | | --- | --- | :---: | | Marker references a role not in `signers[]` | `unknown_role` (`meta.role`, `meta.page`) | 400 | | Same kind and role twice on the same page (`signature`, `initial`, `date`, `name`) | `duplicate_anchor` (`meta.role`, `meta.kind`, `meta.page`) | 400 | | The PDF has markers, but a signer in `signers[]` has none for their role | `signer_has_no_anchor` (`meta.role`) | 400 | A PDF with **no** markers at all is not an error, whether you passed `placement="anchors"` or left the default: the call falls through to `auto_append` and appends a signature page. The response tells you — `"placement": "auto_append"` and `anchors.found: 0` instead of `"placement": "anchors"`. By then the invitations are out (unless you sent `send_emails: false`), so if a PDF without markers must never go out, check your generator's output for markers before you send, and [withdraw](https://www.wesign.now/docs/signing-requests#withdraw) anything that slipped through. ## What happens to the marker **Note:** **The hash covers what the signer sees.** Before presenting the PDF we draw a white rectangle over each marker, so the signer sees their field on a clean page rather than the `[[ls:…]]` text underneath. The document's SHA-256 — `presentedSha256` in the response — is taken after that step: it is the hash of exactly the file the signers are shown. We don't keep the file you uploaded or its hash, so keep your own if you ever need to show the pre-masking original. The rectangle covers the marker; it does not delete it. The marker's text is still in the page's content stream, so text extraction (`pdftotext`, copy and paste, search in a PDF viewer) can still find `[[ls:…]]` in the presented and in the signed PDF. Apart from the rectangles the file keeps its content — a password-protected PDF is decrypted first, because it could not be sealed otherwise. --- # SMS verification URL: https://www.wesign.now/docs/sms-verification Summary: The SMS second factor end to end — the fields, the two gate modes, what the invitation and the page show before the code, the signer's side, the monthly allowance, and the events you receive. A signer can be required to enter a one-time code we text to their mobile phone. It binds the signature to a phone: the signature is an **advanced electronic signature (AES)** instead of the simple (SES) email-link level. This page is the whole contract; the endpoints link here. In one paragraph: you name the signer's email and mobile number, we send the invitation email, and the signer asks for the code on the signing page — we never text a code on our own. `sms_gate` decides whether that happens before the document is shown or at the Sign press. Every response reads the factor back, a call your monthly SMS allowance cannot cover is refused up front, and six webhook events tell you what happened to each code. ## The fields The same three fields on every create path: | Field | Value | Where | | --- | --- | --- | | `phone_e164` | The mobile number in E.164: `+`, then 8–15 digits, no spaces (`^\+[1-9]\d{7,14}$`), e.g. `+41791112233`. | `signers[]` on [`POST /v1/signing-requests`](https://www.wesign.now/docs/signing-requests#signer-object), `recipients[]` on [`instantiate`](https://www.wesign.now/docs/templates#recipients) and [`confirm`](https://www.wesign.now/docs/templates#confirm-and-discard). | | `require_sms_verification` | `true` | same | | `sms_gate` | `"before_sign"` (default) or `"before_view"` — [when the code is asked](https://www.wesign.now/docs/sms-verification#when-the-code-is-asked-sms_gate). | same | Rules, identical on the three endpoints: - `require_sms_verification: true` without `phone_e164` is refused before anything is created: `400 invalid_request`, message `phone_e164 is required when require_sms_verification is true`. On confirm, the rule is checked on the row as edited (a phone stored at instantiate counts) and fails as `422 invalid_recipients`. - `sms_gate: "before_view"` without `require_sms_verification: true` is refused the same way (`sms_gate "before_view" requires require_sms_verification: true`). - A `phone_e164` without the flag is stored, but no code is ever asked for. - Only the **shape** of the number is checked when you create the request. Whether it is a mobile, a landline or unreachable shows when the signer asks for a code — see [When no SMS arrives](https://www.wesign.now/docs/sms-verification#when-no-sms-arrives). - Signer and recipient objects are **strict**: a misspelt key such as `phone` is a `400 unknown_signer_field` / `unknown_recipient_field`, never dropped. On confirm, `phone` is still accepted as a deprecated alias of `phone_e164` until 2026-12-31 (each use adds a `deprecated_field` warning; both with different values are a `400 conflicting_fields`). - The code SMS follows the signer's `locale` — see [The SMS itself](https://www.wesign.now/docs/sms-verification#the-sms-itself). On `instantiate` there is one `locale` for all recipients. **Note:** **No invitation SMS on these paths.** `POST /v1/signing-requests` and a direct `instantiate` invite by email only; the SMS is the **code**, requested by the signer on the signing page. That is also why `send_emails: false` never stops the code: you distribute the link, the signer opens it and asks for the code at the gate. (The one invitation SMS is `channel: "sms"` or `"both"` on a [confirm](https://www.wesign.now/docs/templates#confirm-and-discard), which texts the signing link itself.) ## Availability and the monthly allowance SMS verification is available on **every plan**, and the codes are included in it: there is **no price per SMS**. Each workspace has a monthly allowance of SMS codes, which bounds abuse and cost: | Plan | Codes included per month | | --- | --- | | Free | 25 | | Pro | 50 | | Branded | 200 | | Teams, Enterprise | 100 per paid seat | **If your firm needs more, we raise your monthly allowance.** Email [support@wesign.now](mailto:support@wesign.now) with your workspace ID and the number of codes you expect per month. The raise is included in your subscription, is added to what your plan includes, and applies every month until it is changed. - **Every code sent counts once**, including a resend. A send that is refused (no usable number, a landline, a blocked number, the allowance used up) does not count. - The counter resets at **00:00 UTC on the 1st** of each month. A raise is not reset: it is part of your allowance, not a one-off credit. - `GET /v1/me` returns the current state as `sms_allowance: { monthly, used, remaining, resets_at }`. `monthly` is your total, the plan's codes plus any raise, and it is the number every check below and the `sms_allowance` of the SMS webhooks use. - **Checked up front.** `POST /v1/signing-requests`, a direct `POST /v1/templates/{id}/instantiate` and `POST /v1/documents/{id}/confirm` count the signers who must verify by SMS. If they outnumber the codes left, the call answers `402` and **nothing is created or sent**: ```json { "error": "This request has 2 signers who must verify by SMS, but the workspace has 1 of 300 SMS codes left this month (resets 2026-10-01T00:00:00.000Z). Nothing was created or sent.", "code": "sms_allowance_exhausted", "sms_allowance": { "monthly": 300, "used": 299, "remaining": 1, "resets_at": "2026-10-01T00:00:00.000Z" }, "required": 2 } ``` - A `validate_only` dry run of `instantiate` reports the same shortfall as `problems: [{ "code": "sms_allowance_exhausted", … }]` with `ok: false` and HTTP 200. A `review: true` instance is checked when it is confirmed. - The check guarantees **one** code per SMS signer. A signer who asks for a second code uses another one, so the allowance can still run out between the create call and the signing. - **Never downgraded.** When the allowance runs out after the request was created, a signer who asks for a code is refused, the [`signing_request.sms_failed`](https://www.wesign.now/docs/sms-verification#events) webhook fires with `reason: "allowance_exhausted"`, and the signer **cannot sign** until the counter resets. A request is never turned into a signature without SMS. ## When the code is asked: `sms_gate` ### `before_sign` (default) The signer opens the link and sees the whole document at once. The code is requested when they press **Sign**, after every other step has passed — signature fields placed, consent ticked, signature captured. The SMS protects the **signature**, not the reading: whoever holds the link can read the document. ### `before_view` The signer sees **nothing of the document** until the code is verified: no title, no file name, no pages, no prefilled values. The code is texted when the signer taps **Text me a code** on that page — never on page load, so a mail scanner that opens the link in a browser sends no SMS and uses up none of your allowance. After a reload in the same browser tab within 10 minutes the page shows the code field again instead of texting another code; the signer can always tap "Send a new code". After the code is verified the page reloads into the normal signing page, and **Sign** does not ask again. Use it when the document itself carries personal data — a name, a date of birth, an address, a social security number — and must not be readable by whoever holds the link. ```jsonc // POST /v1/signing-requests — one signer, SMS before the document is shown { "signers": [{ "role": "client", "email": "anna@example.ch", "name": "Anna Muster", "locale": "de", "phone_e164": "+41791112233", "require_sms_verification": true, "sms_gate": "before_view", "reference": "crm-contact-4711" }] } ``` Until the code is verified: - The signing page's own document endpoints (`/api/sign/{token}/pdf` and `/api/sign/{token}/fields`, used by the page — not part of the public API) answer `403 sms_verification_required` before anything about the file — its name included — leaves the server. The page never emits the page images. - Every invitation email for the request leaves the document unnamed: the first invite, [reminders](https://www.wesign.now/docs/signing-requests#remind) (manual and automatic) and the hand-over to a sequential signer. - The invitation **SMS** of a confirm with `channel` `sms` or `both` reads "… asks you to sign a document." instead of naming the file. - [`signing_request.viewed`](https://www.wesign.now/docs/webhooks#events) fires only after the code. - A signer who is logged in to an account with the same email address sees the request in their "awaiting your signature" list and notifications as "Document", without its name (logging in is an email code — the same factor as the link). Dashboard envelopes (several documents to one signer): an unverified `before_view` request is also shown when another request of the same envelope, for the same signer email and the **same phone**, is already verified. That opens it for viewing only — the Sign press still asks for that request's own code. A different phone never borrows a verification. **Warning:** **Embedded signing does not support `before_view` yet.** A frame minted for such a request shows the "session not available" card and posts `wesign.error` with `detail: "sms_required"`. Use the hosted signing link for these signers, or `before_sign` inside the frame. ## What the invitation email shows For an API send, `{sender}` is the **workspace name** (never a person: an API key has no human sender). `{document}` is the stored file name: from `POST /v1/signing-requests` the `filename` field, else the uploaded file's name or the name at the end of `file_url`; from a template, the template name plus `.pdf`. Either way letters and digits of every script are kept, as are `_`, `.` and `-`; spaces and any other character become `_` (`Vollmacht Müller` → `Vollmacht_Müller.pdf`). | Part | `before_sign` | `before_view` | | --- | --- | --- | | From | Branded, Teams, Enterprise: `"{workspace}" `. Free, Pro: `"{workspace}, wesign" `. | same | | Reply-To | The workspace's reply-to address (Settings → Branding, Branded and up), otherwise `support@wesign.now`. | same | | Subject | `{sender} asks for your signature on “{document}”` (all six languages below) | `Document to sign from {sender}` | | Inbox preview | `{sender} requested your signature on "{document}"` | `{sender} sent you a document to sign. You'll receive an access code by SMS when you open the link.` | | Header | Branded and up: the workspace logo on the brand colour (the wesign.now wordmark when no logo is set). Free, Pro: the wesign.now wordmark. The pill "SIGNATURE REQUESTED". | same | | Heading | `You have a document to sign.` | same | | Body | `{sender} has asked you to review and sign "{document}".` | `{sender} has sent you a document to sign. To protect its contents, it opens only after you enter a code we send you by SMS.` | | Document row | `{document}` | `Protected — opens after an SMS code` | | Your role | The template slot's role label, on `instantiate` and confirm when the template names one. Not on `POST /v1/signing-requests`. | same | | From row | The sender name, plus the workspace's reply-to address when one is set. | same | | Button and link | "Review and sign", plus the full link as text — on the email host: `https://{slug}.wesign.now/{locale}/sign/…` (Branded and up), otherwise `https://www.wesign.now/{locale}/sign/…`. The same token as the API's `signingUrl`, which is on `letssign.now`; both open the same page. | same | | Expiry | `This link expires on {date}.` | same | | Footer | "Do not share this email", the legal-evidence line, "Delivered via / Sent by wesign.now"; sender of record: the workspace name and postal address (Branded and up, when the address is set), otherwise NC Labs GmbH, Zurich; "not a marketing message". | same | | Never | Attachments, the phone number, the code, the signer's name, a personal message (the API has no message field). | same | Subjects in every language the email is sent in (the signer's `locale`): | `locale` | `before_sign` | `before_view` | | --- | --- | --- | | `en` | `{sender} asks for your signature on “{document}”` | `Document to sign from {sender}` | | `de` | `{sender} bittet um Ihre Unterschrift: {document}` | `Dokument zur Unterschrift von {sender}` | | `fr` | `{sender} demande votre signature sur « {document} »` | `Document à signer de la part de {sender}` | | `it` | `{sender} chiede la tua firma su «{document}»` | `Documento da firmare da parte di {sender}` | | `es` | `{sender} pide tu firma en "{document}"` | `Documento para firmar de {sender}` | | `nl` | `{sender} vraagt om je handtekening op "{document}"` | `Document om te ondertekenen van {sender}` | The plain-text part is derived from the HTML and follows the same rules. If the request cannot be read at the moment an invitation or reminder is sent (a database hiccup on our side), the email plays safe both ways: the subject is `Document to sign from {sender}`, the body says only that `{sender}` sent a document to sign, there is no document row, and no SMS code is announced. There is **no custom sending domain** on any plan, Enterprise included: the address is always `noreply@send.letssign.now` and only the display name changes. Replies go to Reply-To, never to the `noreply` address. The completion email a signer receives once every party has signed carries the signed PDF. The audit certificate is attached for signers only when the workspace's audit-trail setting includes them (the default sends it to the sender only). ## What the signing page shows before the code | | `before_sign` | `before_view` | | --- | --- | --- | | Browser tab and link preview (`og:title`) | `Sign {file name without .pdf} — from {workspace}` | `Sign document — from {workspace}` | | Brand header | The workspace brand (Branded and up), otherwise the platform's | same | | Document (pages, PDF) | Shown | Not sent to the browser | | Prefilled values, fields | Shown | Not sent | | Signer's name and email | Shown | Not sent | | "Invited by", other signers, quote tables | Shown | Not sent | | Decline button | Shown | Not shown | | Code step | At the Sign press: `Verify your phone`, the masked phone, a 6-digit field. | The only thing on the page: `From {workspace}`, `A document is waiting for your signature`, `We'll text a code to •••• •••• 1234. Enter it here to open the document.`, a `Text me a code` button, `Codes expire after 10 minutes.` After the tap: `Enter the code we sent to •••• •••• 1234 to open it.`, a 6-digit field, `Continue`, `Send a new code`. | The wording above is the English page. Search engines are told not to index either page. With `before_view` and no phone on file, the page says the document is protected and asks the signer to contact the sender. An expired, queued, withdrawn or declined request shows its usual notice, which never names the document. ## The SMS itself The code is sent through **Twilio Verify**, not as a message we compose. - **Language.** The signing request's `locale` (`en`, `de`, `fr`, `it`, `es`, `nl`) is passed to Twilio, so a French-speaking signer with a Swiss number gets French. Should Twilio refuse the language ([Twilio: 404, retry without it](https://www.twilio.com/docs/verify/supported-languages)), the code is sent once more without it and Twilio picks the language from the number's country — German for every +41 number, English for +31 and +423 ([Twilio's default languages](https://www.twilio.com/docs/verify/default-phone-verification-languages)). That country rule is what every code followed before the 2026-09-24 release. - **Wording.** Twilio's template, not ours. Its [documented English default](https://www.twilio.com/docs/verify/verification-templates) is `Your {service name} verification code is: {code}`; Twilio states the wording may change. The service name is our platform's Twilio setting, the same for every workspace. **Your workspace name and the document are never in the code SMS**: we send Twilio the number, the channel and the language, nothing else. - **Your workspace name in the SMS:** planned. It needs Twilio's per-request company name, which Twilio enables per account on request; no date until it is enabled. - **Sender ID.** Twilio sends Verify codes from its own shared senders. Which number or name a handset shows, per country, is **not documented by Twilio**. - **RCS.** Twilio may deliver a code over RCS instead of SMS where the handset supports it ([on by default at Twilio](https://www.twilio.com/docs/verify/rcs)). ## The signer's side | | | | --- | --- | | Code | 6 digits. | | Lifetime | 10 minutes (Twilio Verify's default; the page says so). After that: "Code expired — request a new one". | | Resend | "Send a new code" on the page. Within the 10 minutes Twilio re-sends the **same** code. Each send counts against the allowance and fires `sms_sent` (`attempt` counts them). | | Wrong code | "Wrong code"; the signer tries again. Recorded on the audit trail (`sms_verify_failed`, `meta.reason: "wrong-code"`); no webhook. | | Attempts | Twilio allows **5 checks per code**. After that the signer reads "Too many wrong codes. Wait 10 minutes, then request a new code." — the code stays locked until it expires, 10 minutes after it was sent. | | Too many sends | More than 5 sends without a successful check are refused by Twilio until the code expires (`sms_failed` with `reason: "rate_limited"`). | | Our own throttle | Per IP address: 4 code requests and 8 checks per minute. Refused clicks emit no event. | | After the code | The verification is recorded on the request (`sms_verified_at`, webhook `sms_verified`) and holds for that request from any browser that has the link. It is part of the audit trail and of the audit certificate. | | Not signable | A withdrawn, declined, queued, signed or expired request gets no code. | Every message above is shown in English whatever the signer's language today. ## When no SMS arrives Every refused send fires [`signing_request.sms_failed`](https://www.wesign.now/docs/webhooks#events) the moment the signer asks for a code — with `before_view` that is the moment they tap "Text me a code" on the page, with `before_sign` the moment they press Sign. | `reason` | Cause | What the signer sees | What you do | | --- | --- | --- | --- | | `invalid_number` | No usable E.164 number is stored, or Twilio rejected the number (Twilio 60200). | "No verifiable phone on file", or "Phone number not deliverable" | [Correct the number](https://www.wesign.now/docs/sms-verification#changing-the-phone-number). | | `landline` | Twilio identified a landline (Twilio 60205; see below). | "Phone number not deliverable" | Correct it to a mobile number. | | `blocked` | The destination country is not enabled for our SMS service (60605), is embargoed (60238), or Twilio's fraud protection blocked the number's prefix for 12 hours (60410). | "Phone number not deliverable" | Contact [support](https://www.wesign.now/docs/support) with the `signing_request_id`. | | `rate_limited` | More than 5 codes for this number without a completed check (60203), or Twilio throttling (60212, 20429). | "Try again in a moment" | Wait; the first case clears when the 10-minute code expires. | | `allowance_exhausted` | The workspace's monthly allowance is used up. | "Monthly SMS limit reached for this workspace" | Nothing until the reset on the 1st; the signer cannot sign before. | | `not_configured` | The deployment has no SMS provider. Never in production. | "SMS not configured on this deployment" | — | | `provider_error` | Anything else from Twilio. | Twilio's error message | Retry later; contact support if it persists. | The messages in the third column are shown in English on every locale today. - **Landlines.** Twilio refuses a landline at send time only when line-type lookup is enabled on its Verify service; for every other case Twilio does not document what happens, so a code to a landline can also be accepted (`sms_sent`) and simply never arrive. A line-type check when you **create** the request (so a landline is a `400` before anything is sent) is planned for **2026-10-31**. - **Fraud protection.** Twilio's fraud guard is on for every Verify account and can also block legitimate sends — Twilio names a first send to a country the account has never texted before as an example. It shows as `blocked`. - **Sent but not received.** `signing_request.sms_sent` means Twilio accepted the code, not that the handset received it. A signer who has a `sms_sent` but no `sms_verified` after a while has most likely not received it: check the number (`phone_masked`) and let them tap "Send a new code". ## Changing the phone number ```http PATCH https://api.wesign.now/v1/signing-requests/{id} Authorization: Bearer wsk_live_… Content-Type: application/json { "phone_e164": "+41791234567" } ``` - Allowed while the request is `pending`, `viewed` or `queued` **and** the signer has not verified a code yet. Otherwise `409 not_editable` with `meta.status` and `meta.sms_verified_at` — once verified, the number is part of the evidence for that request. To reach the signer on another number after that, [withdraw](https://www.wesign.now/docs/signing-requests#withdraw) the request and create a new one. - Changes the phone and nothing else: the flag, the gate, the link and the status stay. **Nothing is sent** — no email, no SMS: the signer requests the code on the page, which now goes to the new number. A code already sent to the old number cannot verify the new one. No webhook fires; the next `sms_sent` carries the new `phone_masked`. - The body is strict: `400 unknown_field` for any other key (`meta.fields`, `meta.accepted`), `400 invalid_phone` for a number that is not E.164, `400 invalid_request` for a missing `phone_e164` or a body that is not JSON. `null` is not accepted — removing the number would lock the signer out. The same number again answers `200` without a change. - Writes the audit event `phone_changed` (`meta.from` and `meta.to`, both masked) and answers the [`GET /v1/signing-requests/{id}`](https://www.wesign.now/docs/signing-requests#read-one-signer) shape. Same auth, workspace scope and rate limit as the GET; another workspace's id is a `404`. Before a staged template instance is confirmed, the phone can also be changed in the confirm body. The signer reports a wrong number but the request is already verified, signed or withdrawn? Then there is nothing to correct on it: create a new request. ## Reading it back Every signer object in the create response, both instantiate responses, the confirm response and `GET /v1/signing-requests/{id}` carries: | Field | Meaning | | --- | --- | | `require_sms_verification` | Whether the signer must enter a code. | | `phone_masked` | The stored phone, last four digits only (`•••• •••• 7037`); `null` without a phone. The full number is never returned. | | `sms_verified_at` | When the signer entered the correct code; `null` until then. | | `sms_gate` | `before_sign` or `before_view`. | | `signature_level` | `AES` when `require_sms_verification` is true, else `SES` — the level the request was created for. | | `reference` | Your own `reference` for this signer, or `null`. | | `company`, `job_title` | The company and function you [stated for the signer](https://www.wesign.now/docs/signing-requests#company-and-function), or `null`. | Check `require_sms_verification` and `signature_level` on the **create response**, not on your own request body: that is the proof the factor arrived. `GET /v1/documents/{id}` does not carry the SMS fields. ## Events | Webhook | When | Extra fields | | --- | --- | --- | | `signing_request.sms_sent` | Twilio accepted a code. One per code, resends included. | `phone_masked`, `attempt`, `sms_allowance` | | `signing_request.sms_failed` | A code could not be sent. | `reason`, `phone_masked`, `sms_allowance` | | `signing_request.sms_verified` | The signer entered the correct code. Once. | `phone_masked`, `sms_verified_at` | Payloads, the envelope and the full `reason` list are on [Webhooks](https://www.wesign.now/docs/webhooks#signer-events). The audit trail (`auditEvents` on `GET /v1/signing-requests/{id}`) records `sms_verify_sent` (`meta.masked_phone`, `meta.quota_used`, `meta.quota_cap`), `sms_verify_failed` (`meta.phase`: `send` or `check`, `meta.reason` — our internal code, e.g. `wrong-code`, `landline`, `quota-exceeded`), `sms_verify_ok`, and `phone_changed` for a PATCH. The invitation SMS of a confirm with `channel` `sms` or `both` is recorded as `sms_sent` / `sms_failed`, with the full number in `meta.to`. ## Countries Numbers are checked for their shape only; we keep no country list in the API. Which destination countries are enabled is a setting of our Twilio Verify service; a number in a country that is not enabled fails with `sms_failed` `reason: "blocked"` when the code is requested. Ask [support](https://www.wesign.now/docs/support) for the current list before you rely on a country. Twilio's own sender rules, for the countries our customers ask about most (Twilio's SMS guidelines; how they apply to Verify's shared senders is **not documented by Twilio**): | Country | Alphanumeric sender without registration | Registration | | --- | --- | --- | | Switzerland, Liechtenstein, France, Italy | supported | not required | | Germany | supported | not applicable | | Austria | supported until 2026-10-01 | required from 2026-10-01 | | Spain | not supported | required | ## Not offered **Verifying a number without a signing request** is not offered and not planned (the 2026-11-15 date once listed on this page is withdrawn). A code is only ever requested by the signer, on their own signing link. Check numbers in your own interface before you create a request; afterwards `signing_request.sms_failed` names an unusable number and [`PATCH /v1/signing-requests/{id}`](https://www.wesign.now/docs/signing-requests#correct-the-phone-number) corrects it. ## Not available yet - **A line-type check at create:** planned for **2026-10-31**. - **A sandbox** with simulated email and SMS visible through the API: planned for **2026-11-30**. - **Your workspace name in the SMS:** planned, date depends on Twilio. --- # Observers URL: https://www.wesign.now/docs/observers Summary: CC-style spectators on a signing request — notified when it goes out and when every signer has signed. They never sign. An **observer** is a CC-style recipient on a signing request. They never sign; they get at most two emails: 1. **On dispatch**: when the first signer is invited: *"X is collecting signatures on Y."* No attachment and no link to the document. Not sent with `send_emails: false`, which silences this notice along with the invitations. 2. **At completion**: when **every** signer has signed: the final tamper-evident PDF + the audit certificate, which is always in English. Nothing is sent when a signer declined, was withdrawn or expired: such a document never completes. Observers are workspace-scoped CCs, distinct from signers. They get no signing URL, no token, no field placement. ## Adding observers ```json { "observer_emails": [ "outside-counsel@law-firm.com", "compliance@acme.com" ] } ``` Pass on [POST /v1/signing-requests](https://www.wesign.now/docs/signing-requests#create), the only create call that takes observers (`instantiate` and `confirm` do not). Up to **20 observers** per document, counted before duplicates are removed; more entries are a `400 invalid_request`. Each entry is a plain address string, at most 254 characters; there is no name or language per observer. Send addresses without surrounding spaces: they are checked before anything else happens, so `" office@firm.ch"`, a display-name form (`"Office" `) or a non-ASCII domain (send its `xn--…` punycode form) is a `400 invalid_request`. Accepted addresses are lower-cased and de-duplicated, and the response's `observers` lists what was stored. The list cannot be changed after the create. The request key is `observer_emails`; `observers` is only the response key. In a multipart body, send the array as a JSON-encoded string. ### An observer who is also on the document An address in `observer_emails` stays on the list even when it is also a signer or the document's sender (the user who created the API key). That person gets the dispatch notice next to their own invitation, but only **one** email at completion: | The observer is also… | At completion they receive | | --- | --- | | The signer whose signature completed the document | Their own signer email, not the observer copy | | A signer who signed earlier | The observer copy only | | The sender | The sender's own completion email, not the observer copy | ## When are they emailed? | Event | Observer notification | | --- | --- | | First signer is invited | "X is collecting signatures…", to all observers in one message, in the language (`locale`) of that first signer | | Subsequent signers are invited | No notification (the dispatch note already went out) | | Each signer signs | No notification (saved for completion) | | Every signer has signed | "Completed" with the signed PDF + the audit certificate, to all observers in one message, in the language of the signer whose signature completed the document. The certificate is in English. | | A signer declines, is withdrawn or expires | No notification, then or later: the document never completes | Treat [`document.completed`](https://www.wesign.now/docs/webhooks), or [`GET /v1/documents/{id}`](https://www.wesign.now/docs/documents#get-document) → `status: "signed"`, as the proof of completion, never the observer email. All three follow one rule: every signer signed. Observers don't see real-time intermediate events, and there are no observer webhooks. If you want that level of detail, register a [webhook](https://www.wesign.now/docs/webhooks): the workspace webhook fires `signing_request.signed` per signer, plus `document.completed` at the end. Both observer emails are on the audit trail, with the addresses in `meta.observers`: `observer_notified` on the first invited signer's request and `observer_completed` on the completing signer's request (`GET /v1/signing-requests/{id}` → `auditEvents`). The audit certificate lists no observer addresses. ## Use cases - **Outside counsel** that needs the final signed contract for the firm's file. - **Compliance / risk** that wants the audit certificate for an archive without becoming a signer. - **Account exec** who needs a trigger to update the CRM but isn't party to the contract. - **A shared mailbox** such as a secretariat: an observer is an address, not an account. ## What observers don't do - Sign: they receive emails, not signing URLs. - Receive reminders, a note per signature, or a declined, withdrawn or expired notice. - Place fields: every field is placed by the create call itself (anchor markers, explicit coordinates or an appended signature page; see [Placement modes](https://www.wesign.now/docs/placement-modes)). - Edit observers: once a signing request is dispatched the observer list is fixed for that document. --- # Documents URL: https://www.wesign.now/docs/documents Summary: Holistic doc + signers, the signed PDF, the audit-trail PDF. A **document** is the contract — one PDF, one or more signers, plus metadata. Create one with [POST /v1/signing-requests](https://www.wesign.now/docs/signing-requests#create) (multipart bytes or a `file_url`) or by [instantiating a template](https://www.wesign.now/docs/templates#instantiate); the `documentId` you get back addresses the read endpoints below. **Note:** `POST /v1/documents` — "ingest a PDF by URL, get a placement link" — was retired on 2026-07-31 together with manual placement and now answers `410 placement_retired`. Ingest-by-URL lives on as `file_url` on [POST /v1/signing-requests](https://www.wesign.now/docs/signing-requests#create) with `placement="anchors"`, `"explicit"` or `"auto_append"`: one call places the fields and dispatches the invites. ## Get document **GET** `https://api.wesign.now/v1/documents/{id}` Holistic view: doc fields, every signer with their status, plus an **aggregate rollup status**. Use this when you want a single poll to get the whole picture. ```json { "id": "8a1e4f9a-…", "filename": "msa.pdf", "sha256": "4f9a…d21c", "observers": ["compliance@acme.com"], "callbackConfigured": true, "metadata": { "case_id": "ZT-2026-0142" }, "createdAt": "2026-04-29T10:30:00Z", "status": "partial", "signers": [ { "id": "11111111-…", "email": "tenant@example.com", "name": "Tina Tenant", "role": "tenant", "company": null, "job_title": null, "order": 1, "status": "signed", "locale": "en", "expiresAt": "2026-05-13T10:30:00Z", "staged": false, "signingUrl": "https://yourco.letssign.now/en/sign/abc…" }, { "id": "22222222-…", "email": "owner@example.com", "name": "Oscar Owner", "role": "landlord", "company": "Owner Estates AG", "job_title": "Director", "order": 2, "status": "viewed", "locale": "de", "expiresAt": "2026-05-13T10:30:00Z", "staged": false, "signingUrl": "https://yourco.letssign.now/de/sign/def…" } ], "review": null, "placement": null } ``` Signers are listed by `order` (the sequential position; `null` when everyone signs at once), then by creation. Each carries its `name` and `role` as given at create (`null` when none), the `company` and `job_title` you stated for it (`null` when none — see [Company and function](https://www.wesign.now/docs/signing-requests#company-and-function)), the `locale` of its emails and signing page, and `staged` — see [Staged template instances](https://www.wesign.now/docs/documents#staged-template-instances). `metadata` is what you passed at create, or `null`. `status` values: | Value | Meaning | | ---------------------- | ------- | | `staged` | A template instance awaiting review — nothing sent yet. See [Staged template instances](https://www.wesign.now/docs/documents#staged-template-instances). | | `pending` | No signer has signed, and neither `expired` nor `withdrawn` applies: signers still in flight — but also a document whose signers **all declined**, or ended in a **mix** of declined, withdrawn and expired. A document with no signers at all (a confirmed [generated file](https://www.wesign.now/docs/templates#generated-files-with-review)) reads `pending` too. | | `partial` | At least one signer has signed; the rest are still in flight — or declined, withdrawn or expired, in which case the document never reaches `signed`. | | `signed` | Every signer row is `signed` — the [one definition of "completed"](https://www.wesign.now/docs/documents#what-completed-means): `document.completed` fired, the signed PDF + audit-trail PDF are ready. | | `expired` | Every signer row expired. | | `withdrawn` | Every signer row was withdrawn — a discarded template instance included. | | `awaiting_placement` | Legacy — a manual placement created before 2026-07-31 that was never completed. New documents never show it. | The rollup is computed from the signer rows alone, so `pending` does not mean "still open". To tell a live document from a dead one, read `signers[].status`. Per-signer `status` uses the same enum less the rollups (`pending` | `viewed` | `signed` | `declined` | `expired` | `withdrawn` | `queued`). `viewed` means the signer's browser has loaded the document at least once; `queued` is a sequential signer whose turn has not come, or a staged one — see [the status list](https://www.wesign.now/docs/signing-requests#read-one-signer). The signers here do not carry the SMS read-back (`require_sms_verification`, `phone_masked`, `sms_verified_at`, `sms_gate`, `signature_level`) or your `reference`; read those per signer with [`GET /v1/signing-requests/{id}`](https://www.wesign.now/docs/signing-requests#read-one-signer). ### Staged template instances A template instance created with `review: true` reads `status: "staged"` until someone confirms or discards it. Its signers are `queued` with `"staged": true` and `"signingUrl": null` — their links only work once the instance is confirmed — and `review` describes the review: ```json { "id": "…", "filename": "Power_of_attorney__company_.pdf", "metadata": { "case_id": "ZT-2026-0142" }, "status": "staged", "signers": [ { "id": "…", "email": "anna@example.ch", "name": "Anna Muster", "role": null, "company": "Muster AG", "job_title": "Geschäftsführerin", "order": 1, "status": "queued", "locale": "de", "expiresAt": "2026-10-08T09:14:00Z", "staged": true, "signingUrl": null }, { "id": "…", "email": "ben@example.ch", "name": "Ben Keller", "role": null, "company": "Muster AG", "job_title": "Mitglied des Verwaltungsrates", "order": 2, "status": "queued", "locale": "de", "expiresAt": "2026-10-08T09:14:00Z", "staged": true, "signingUrl": null } ], "review": { "kind": "esign", "staged_at": "2026-09-24T09:14:00Z", "expires_at": "2026-10-08T09:14:00.000Z", "url": "https://www.letssign.now/en/documents/…/review" }, "placement": null } ``` (Other document fields omitted.) | `review` field | Meaning | | --- | --- | | `kind` | `esign` (a staged `instantiate`, with signers) or `file` (a staged `generate`, with none). | | `staged_at` | When the review window opened. | | `expires_at` | When it closes: 14 days later. After that the instance can only be discarded. | | `url` | The review page in the app — always under `/en/`, whatever the instance's locale. It needs someone signed in as a member of your workspace; your API key does not open it. | `review` is `null` on every other document, including an instance once it is confirmed or discarded. What a reviewer can change on that page is in [Templates → What a reviewer can change](https://www.wesign.now/docs/templates#what-a-reviewer-can-change). ## Download the signed PDF **GET** `https://api.wesign.now/v1/documents/{id}/signed` Streams the final PDF: every signer's signature and caption on the page, under one PAdES seal applied at the last signature and valid over the whole file. When our time-stamp authority answers, an RFC 3161 time stamp is taken over the sealed file and kept with our records — `tsa_provider` and `tsa_signed_at` on `document.completed`, and on the audit certificate; it is not embedded in the PDF. `Content-Type: application/pdf` with `Content-Disposition: inline; filename="msa.pdf"` so a browser tab opens it directly. ```bash curl -OJ -H "Authorization: Bearer $WSK_KEY" \ https://api.wesign.now/v1/documents/$DOC_ID/signed ``` | Status | Code | When | | :---: | --- | --- | | 409 | `not_complete` | At least one signer row is not `signed` — still in flight, or declined, withdrawn or expired. Wait for `document.completed`, then fetch; after a decline, withdrawal or expiry it never comes. | | 409 | `not_stored` | Every signer signed but the sealed file is not on storage yet. Retry shortly. | | 404 | `not_found` | Unknown id, **or** the document belongs to a different workspace than the key. | | 404 | `no_signers` | The document has no signers — a finalized file-only template instance. Use [`/pdf`](https://www.wesign.now/docs/documents#the-current-file) instead. | ### What "completed" means One rule, shared by every surface: a document is **completed when every signer row on it is `signed`**. - `GET /v1/documents/{id}` reports `status: "signed"`. - `document.completed` fires — once, right after the final signer's `signing_request.signed`. Only the signature that completed the document emits it. - `/signed` and `/audit-trail` serve `200`; before that they answer `409 not_complete`. A signer who **declined**, was **withdrawn** or **expired** is not `signed`, so such a document never completes: no `document.completed`, `status` stays `partial` (or `pending`), and both download endpoints keep answering `409 not_complete`. The signers who did sign still produced their own `signing_request.signed` events — but the contract was not concluded by every party, and the API does not pretend otherwise. Nobody is emailed "Completed" for it either: not the observers, not the signers, not the sender. ### Parallel signers With `signing_mode: "parallel"` (the default) every signer may sign at any moment, also within the same seconds. The signatures of one document are sealed one at a time, each onto the file that already carries every earlier signature: - The completed PDF carries every signature and caption, however close together they land. - A signer who presses **Sign** while another signature on the same document is being sealed waits a few seconds; the signing page, and an [embedded frame](https://www.wesign.now/docs/embedded), retries by itself. - `document.completed` and the "Completed" emails come from the one signature that completed the document. - Webhook delivery is at-least-once, and a database failure at the moment of completion can still repeat the event: deduplicate `document.completed` on `document_id` as well as on `event_id`. Use `signing_mode: "sequential"` when the signers must sign in a given order, not to protect the final PDF: each next signer is then invited only once the previous one has signed. ### `signed_pdf_url` in `document.completed` The `document.completed` webhook carries exactly these two URLs, as `signed_pdf_url` and `audit_trail_url`: ``` https://api.wesign.now/v1/documents/{document_id}/signed https://api.wesign.now/v1/documents/{document_id}/audit-trail ``` - **Host to pin:** `api.wesign.now`. The payload always names the canonical host. If you would rather call the alias, rewrite the host to `api.letssign.now` yourself — same path, same response — and pin that instead. Nothing else ever appears in either field. - **Authentication:** it is an API URL, not a share link. Send `Authorization: Bearer ` of the **same workspace** that owns the document; another workspace's key gets `404 not_found`. - **Timing:** the event fires under exactly the rule both endpoints check (every signer `signed`), so `409 not_complete` cannot follow it and the fetch normally succeeds on the first try. If you ever see `409 not_stored` — the sealed bytes are still being written — back off a few seconds and retry; do not treat it as a failure. - `audit_trail_url` follows the same rules as `signed_pdf_url`: same host, same key, same status codes. It is the [audit-trail PDF](https://www.wesign.now/docs/documents#download-the-audit-trail-pdf) below. ```ts // On document.completed — after verifying the signature, see /docs/webhooks async function fetchArtefact(href: string) { const url = new URL(href) if (url.host !== 'api.wesign.now') throw new Error('unexpected host') const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.WSK_KEY}` } }) if (res.status === 409) return retryLater() // not_stored if (!res.ok) throw new Error(`${href}: ${res.status}`) return Buffer.from(await res.arrayBuffer()) } await saveToDms(await fetchArtefact(event.signed_pdf_url), await fetchArtefact(event.audit_trail_url)) ``` ## Download the audit-trail PDF **GET** `https://api.wesign.now/v1/documents/{id}/audit-trail` Generated on demand, always in English, with times in UTC. Contains the document SHA-256, the cumulative cert serial, the TSA timestamp + provider, under each signer's signature the function and company you stated for them (labelled "stated by the sender"), and a chronological event log with masked IPs and user agents. Same `Content-Disposition` / content-type as `/signed` above, and the same rule: it serves once every signer row is `signed` and answers 409 `not_complete` otherwise — see [what "completed" means](https://www.wesign.now/docs/documents#what-completed-means). This is the URL `document.completed` carries as `audit_trail_url`. ```bash curl -OJ -H "Authorization: Bearer $WSK_KEY" \ https://api.wesign.now/v1/documents/$DOC_ID/audit-trail ``` The certificate attached to the completion emails is built from the same events, with the same function and company, and is always in English too, whatever the signers' languages. It prints its times in the time zone of the caption under each signature (the workspace owner's profile time zone, else their country's, else UTC). Which signers receive it follows the workspace's audit-trail setting; observers always do. ## The current file **GET** `https://api.wesign.now/v1/documents/{id}/pdf` Streams whatever file the document currently holds. For an e-sign document that is the **pre-signature original** — deliberately not the sealed chain, which only `/signed` serves. Its purpose is the file-only template instance: a document generated with `review: true` and then confirmed has no signers, so `/signed` answers `404 no_signers` and this is where the finalized bytes live. That is why `download_url` on a file-only confirm response is exactly this URL (`https://api.wesign.now/v1/documents/{id}/pdf`, same Bearer key). See [Generated files with review](https://www.wesign.now/docs/templates#generated-files-with-review). ## Confirm and discard `POST /v1/documents/{id}/confirm` and `POST /v1/documents/{id}/discard` release or throw away a **staged** template instance. They are documented with the flow that creates one: [Templates → Confirm and discard](https://www.wesign.now/docs/templates#confirm-and-discard). Confirm changes recipients, the deadline and whether invitation emails go out; it takes no field values — a value is corrected on the review page, or by discarding the instance and instantiating it again. **Note:** All three download endpoints are **rate-limited** at 60 requests per minute **per API key**, along with the rest of `/v1/*`. They're meant for occasional download-into-DMS, not high-frequency polling. If you're building a dashboard, fetch each document's signed PDF once on `document.completed` and cache it. --- # Embedded signing URL: https://www.wesign.now/docs/embedded Summary: Mint a short-lived, single-signer session and render the signing view inside your own page — iframe or mobile webview, your branding, postMessage events, no API key in the browser. An **embed session** is a short-lived permit for **one signer** to sign **one document** inside **your** page. Your backend exchanges a signing request for it; your frontend drops the returned URL into an ` ``` - **Give it height.** v1 has **no resize message**. The frame scrolls internally; give it a tall box (≥ 700 px, or a full-height modal on mobile) rather than expecting it to auto-fit. - **Don't sandbox it unless you must.** The signing UI needs scripts, forms and its own origin. If your policy requires the attribute, the minimum that works is `sandbox="allow-scripts allow-same-origin allow-forms allow-popups"` — and note that `allow-scripts` + `allow-same-origin` on a *cross-origin* frame is not a meaningful restriction anyway. The real boundary is `frame-ancestors`, which we enforce for you. - **One frame at a time.** A session is bound to one signer; rendering the same URL twice on one page is supported but pointless. There is a complete, copy-pasteable host page at [`/embed-example.html`](https://www.wesign.now/embed-example.html) — a single file, restrictive CSP, that frames a session and logs every v1 message. ## Content-Security-Policy ### What you publish The only directive you must relax is `frame-src`, naming the workspace's signing host on both roots: ``` Content-Security-Policy: frame-src https://acme.letssign.now https://acme.wesign.now; ``` That is genuinely all. A cross-origin iframe does **not** inherit your page's CSP: everything the signing UI loads — the PDF, the pdf.js worker, its CMaps, standard fonts and wasm, the typefaces, the field overlays — is fetched by the framed document against *our* origin and *our* headers, so it never touches your `script-src`, `font-src`, `img-src` or `connect-src`. If your policy has no `frame-src`, browsers fall back to `child-src` and then `default-src`; add `frame-src` explicitly rather than widening either of those. If you also filter **outbound network traffic** (a proxy allow-list, not CSP), here is the complete list of what leaves the frame. Everything the signer needs is same-origin on the signing host: the document (`/api/sign/…/pdf`), the pdf.js worker and its `/pdfjs/` CMaps, standard fonts and wasm, the typefaces under `/_next/static/`, the sign / decline / SMS endpoints, and our error reporter, which is tunnelled through `/monitoring` rather than sent to a third party. Exactly two requests can leave our origin, and neither is required to sign: - `https://unpkg.com` — only in the fallback where a deployed pdf.js worker version drifts from the bundled one. Blocking it leaves signing working. - `theme.logo_url`, if you set one — **your** image, from wherever you host it. Blocking it just means your logo does not appear. We run **no analytics and no third-party tags inside the frame**: the product analytics and the schema.org markup the rest of our site carries are suppressed on embed paths, so a signer in your product is not measured in our funnel and our entity is not published inside your document. ### What we send On embed paths only, the response carries: ``` Content-Security-Policy: frame-ancestors https://portal.example.com ``` — the session's registered origin, verbatim, and nothing else. A different site that gets hold of the URL cannot frame it. If the session id is malformed or unknown, or the lookup fails at all (the database is unreachable, the deployment is mid-migration), we send `frame-ancestors 'none'` and fail closed: one reload for a legitimate integrator beats a leaked URL framable anywhere. A session that *exists* but has expired or been revoked keeps its own origin in the header, so your page renders the frame's "this link is no longer valid" state instead of a silently blank box. Embed paths deliberately **do not** carry the app's global `X-Frame-Options: SAMEORIGIN`. Browsers ignore `X-Frame-Options` when a CSP `frame-ancestors` is present, but we don't rely on that: the header is excluded from these paths rather than contradicted. Belt and braces, for a client that ignores CSP: if the request carries an `Origin` or `Referer` header naming an origin that is neither the session's nor our own, the frame renders a short "blocked" notice instead of the document — and posts no message at all, because whoever is framing is not the host we were told to talk to. ## postMessage events The frame posts to `window.parent` with `targetOrigin` set to the session's registered origin — **never** `'*'`. Messages are a **UX signal only**: **Warning:** The [webhook](https://www.wesign.now/docs/webhooks) is the source of truth. A message can be lost — the viewer closes the tab, the browser kills the frame, the network drops mid-post — and no message is retried. Advance your UI on `wesign.signed`; record the case as signed on `signing_request.signed` / `document.completed`. The two must never contradict each other, and if they appear to, the webhook is right. ### Schema v1 ```jsonc { "v": 1, // schema version — branch on this "type": "wesign.signed", "session_id": "b7c2…", "signing_request_id": "3f1c9e2a-…", // the id your webhook carries too "at": "2026-09-10T14:31:02.881Z", // ISO-8601 UTC "detail": "…" // optional, ≤ 200 chars, absent when empty } ``` | `type` | Meaning | | --- | --- | | `wesign.ready` | The frame mounted and the signing view is usable. Hide your spinner. | | `wesign.viewed` | The signer has the document in front of them. A browser signal only; the server-side [`signing_request.viewed`](https://www.wesign.now/docs/webhooks#signer-events) webhook is the record. | | `wesign.signed` | This signer signed. Advance your case; confirm with the webhook. | | `wesign.declined` | The signer declined. No `detail`: the reason they typed is theirs and never crosses into your page. Your backend gets it as `reason` on the [`signing_request.declined`](https://www.wesign.now/docs/webhooks#signer-events) webhook (first 1000 characters), in full in `auditEvents[type=declined].meta.reason` on `GET /v1/signing-requests/{id}`, and in the sender's notification email. | | `wesign.expired` | The permit ran out or the signing request is no longer signable. `detail` is `expired` or `request_not_signable`. Mint a new session to try again. | | `wesign.error` | The frame could not be used, or signing failed. `detail` is always a short, stable code — `not_found`, `revoked`, `key_revoked`, `unavailable`, `withdrawn`, `queued` when the permit or the request itself is the problem, `sms_required` for a signer with `sms_gate: "before_view"` (see below); `sign_failed`, `sealing_in_progress`, `pades_failed`, `seal_persist_failed` or `http_` when a submitted signature failed. `sealing_in_progress` means the frame kept retrying for 30 seconds while a signature on the same document was still being sealed; the signer can press **Sign** again. Never a raw server message: `detail` crosses into your page, so we only ever hand over a value we control. Show your own retry affordance. | Order is not guaranteed beyond `wesign.ready` arriving first, and every type other than `ready` may legitimately never arrive. Ignore any message you don't recognise instead of erroring — that is how v2 will be introduced, and a future `v: 2` message must not break your page. ### Host-side listener ```js const FRAME_ORIGIN = 'https://acme.letssign.now' // the origin of embed_url window.addEventListener('message', (event) => { // 1. Always check the sender. Anyone can postMessage to your window. if (event.origin !== FRAME_ORIGIN) return const msg = event.data // 2. Validate the shape before trusting a single field. if (!msg || msg.v !== 1 || typeof msg.type !== 'string') return if (!msg.type.startsWith('wesign.')) return // 3. Bind it to the session you actually rendered. if (msg.session_id !== currentSessionId) return switch (msg.type) { case 'wesign.ready': hideSpinner() break case 'wesign.signed': // Optimistic UI only — the webhook confirms it server-side. showSignedState(msg.signing_request_id) break case 'wesign.declined': showDeclinedState() break case 'wesign.expired': offerRetry() // ask your backend for a fresh session break case 'wesign.error': showProblem(msg.detail) break } }) ``` Never `postMessage` back into the frame expecting it to act on it: the channel is one-way by design, and the frame ignores inbound messages. ## Theming and locale The frame carries **no vendor chrome** — no wordmark, no navigation, no footer, no feedback button. What you pass at mint time is applied as CSS variables on the embed root: | Field | Shape | Applied as | | --- | --- | --- | | `accent` | `#rrggbb` (lower-cased; `#rgb`, `rgba()` and colour names are refused) | the accent on buttons, focus rings and the active field | | `font` | a CSS font-family list, ≤ 60 chars, letters/digits/spaces/commas/quotes/hyphens only | the UI typeface (the *document* keeps its own fonts) | | `logo_url` | https URL, ≤ 512 chars | an `` at the top of the frame, loaded with `referrerPolicy="no-referrer"` | | `radius` | `none` \| `sm` \| `md` \| `lg` | the corner radius of controls | A value we cannot use fails the mint with `invalid_request` rather than being silently reinterpreted; a field you omit simply keeps our default. Self-host the logo you pass — we do not proxy, resize or cache it. `locale` accepts `en`, `de`, `fr`, `it`, `es`, `nl` and sets the frame's language: labels, buttons, the consent statement, the SMS step-up and every error state. The PDF itself is the file you sent — we never translate its content. Omit `locale` and the signing request's own is used, falling back to English. The audit trail stays English whatever the signer sees, by design: one legal record, one language. ## Lifetime and revocation A session dies in four ways, and every one of them fails the frame closed: 1. **Expiry** — `expires_at`, 60…900 seconds after minting (default 300). 2. **`DELETE`** — immediate, idempotent, and the safety valve if a URL leaked. 3. **The API key is revoked** — every session minted with it stops resolving. 4. **The signing request stops being signable** — signed, declined, withdrawn or expired. Within its lifetime the session is reusable: a reload inside the frame works, and we record `first_seen_at` / `last_seen_at` so you can tell "never opened" from "opened and abandoned". ```http GET https://api.wesign.now/v1/embedded/sign-sessions/{id} Authorization: Bearer wsk_live_… ``` ```jsonc { "id": "b7c2…", "status": "active", // active | expired | revoked "expires_at": "2026-09-10T14:35:12.004Z", "first_seen_at": "2026-09-10T14:30:41.220Z", "last_seen_at": "2026-09-10T14:31:02.910Z", "signing_request_id": "3f1c9e2a-…" } ``` ```http DELETE https://api.wesign.now/v1/embedded/sign-sessions/{id} Authorization: Bearer wsk_live_… → 200 { "ok": true } ``` `GET` and `DELETE` are workspace-scoped through the key: another tenant's session id answers `404 not_found`, never `403` (which would confirm it exists). Neither is Enterprise-gated — if a plan lapses with sessions still live, you can always see them and pull them. ## What happens after signing The frame shows the signer a confirmation in place and emits `wesign.signed`. By design it does not turn into a download surface: no sealed PDF is handed out inside your page, and nothing ever navigates out of the frame. Collect the result the way every other integration does: 1. Wait for `signing_request.signed`, and `document.completed` once *every* signer is done ([webhooks](https://www.wesign.now/docs/webhooks)). 2. Pull the sealed PDF from `signed_pdf_url` (`GET /v1/documents/{id}/signed`) and the audit trail from `GET /v1/documents/{id}/audit-trail`, both with your key, server-side. 3. Show it in your own UI, under your own auth. The signer separately receives the completion email linking to the sealed document, exactly as on the hosted flow — embedding changes nothing about who gets told what. For a multi-signer document, one signer finishing in your frame does not complete the document; mint a separate session per signer, or let the others use their emailed links. ## Accessibility A signature is a legal act, so the frame is the same accessible surface as the hosted page — nothing is stripped for embedding: - **Keyboard.** The fields, the consent checkbox and the sign action are real focusable controls — ``, `