Signing requests
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
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.
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.
Request body
Send the PDF one of two ways:
- multipart/form-data with a
filepart (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. |
placement | "anchors" | "auto_append" | "explicit" | Default anchors with fallback to auto_append. See 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. | |
callback_url | string | Per-request webhook, scoped to this document. HMAC-signed; the secret comes back once. See Webhooks. | |
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 signingUrls yourself. See 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. 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.
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%". |
// "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. | |
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. | |
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:
{
"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:
{
"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_requestnamingsigners[i].companyorsigners[i].job_title, and nothing is created. - Read back as
companyandjob_title(nullwhen absent) on every signer of the 201, onGETandPATCH /v1/signing-requests/{id}, onGET /v1/documents/{id}→signers[], and in thesignerblock of every signer webhook exceptsigning_request.expired. They cannot be changed after the create. - The same two fields exist on template recipients and on confirm.
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:
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- The stored name, then
job_titleandcompanywhen 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. - 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.
- The workspace's name and signing host on a branded workspace,
otherwise
Signed on letssign.now. - The signer's masked IP address and the method:
Electronic signature, orElectronic signature + SMS 2FAfor an SMS signer.
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.
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 tophone_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.
{
"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. The same fields exist on
template recipients and on
POST /v1/documents/{id}/confirm,
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.
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/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. |
company, job_title | What was stored from your company and job_title, or null. See 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. |
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). The full list,
placement codes included, is on 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:
{
"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} 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.
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.
Read one signer
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.
{
"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. |
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 for a live
view. For a doc-level view of all signers, see
GET document.
Correct the phone number
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.
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 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.
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.
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: 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
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 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.
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.
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.
