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 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).
FieldTypeRequiredDescription
filemultipartone ofThe PDF to sign. ≤ 25 MB.
file_urlstring (JSON)one ofURL we fetch the PDF from. Alternative to file.
filenamestring ≤ 200The 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 _.
signersSigner[]✓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.
fieldsField[]Required when placement="explicit".
observer_emailsstring[]0–20 CC-style recipients, plain address strings without surrounding spaces. See Observers.
callback_urlstringPer-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_daysint 1..90Default 14. Per-link TTL.
send_emailsbooleanDefault 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.
metadataobjectYour own reference for the document (case number, CRM id), echoed on every read and webhook. See metadata. Multipart: JSON-encoded.
placement_assignee_emailstringBelonged 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")

FieldTypeRequiredDescription
pageint ≥ 0✓0-based page index.
x,y0..1✓Position as a fraction of page width/height.
w,h0..1✓Size as a fraction of the page.
kind"signature" | "initial" | "date" | "text"Default signature.
rolestring✓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

FieldTypeRequiredDescription
emailstring✓Where the invite goes.
rolestring✓[a-z][a-z0-9_-]{0,40}. Must match an anchor placeholder when placement="anchors".
namestring ≤ 200Display name, printed as sent (put a title such as "Dr." where it should print). Auto-fills the typed-signature default.
first_name, last_namestring ≤ 100 eachSplit 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_labelstring | { 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_orderint 1..20Only when signing_mode="sequential".
recipient_color"#RRGGBB"Accent for this signer's fields on the sign view.
phone_e164stringsee ↓The signer's mobile number in E.164 form (+41791112233). Required when require_sms_verification is true.
require_sms_verificationbooleanDefault 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.
referencestring ≤ 200Your 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.
companystring ≤ 200The 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_titlestring ≤ 120The 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_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 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
  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.

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:

FieldValue
phone_e164The mobile number, E.164: a leading +, then 8–15 digits, no spaces (^\+[1-9]\d{7,14}$).
require_sms_verificationtrue
sms_gateOptional: "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.

{
  "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:

FieldMeaning
statuspending, or queued for a sequential signer whose turn has not come.
emailedWhether 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, referenceThe 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_titleWhat 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:

StatusCodeWhen
400unknown_signer_fieldAn unknown key in a signer object (see above).
400invalid_requestAny 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.
400invalid_metadatametadata is not a flat object within the limits; problems[] per key.
402tier_requiredThe workspace's monthly document cap is reached (meta.tier, meta.cap, meta.used).
402sms_allowance_exhaustedThe 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 }
}
metaMeaning
rolled_back: trueNothing exists and nothing was sent. Retry the same body; the Idempotency-Key was released, so the same key works.
rolled_back: falseRare: 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.
roleThe 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:

StatusMeaning
queuedA sequential signer whose turn has not come, or a recipient of a staged template instance.
pendingInvited; the signer's browser has not loaded the document yet.
viewedThe 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.
signedSigned.
declinedThe signer declined; the reason is in auditEvents[type=declined].meta.reason and on signing_request.declined.
withdrawnWithdrawn by you or a user in the dashboard.
expiredPassed 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:

StatusCodeWhen
400invalid_phoneNot E.164.
400unknown_fieldAny key besides phone_e164 (meta.fields, meta.accepted).
400invalid_requestphone_e164 missing or not a string, or the body is not JSON.
404not_foundUnknown id, or another workspace's request.
409not_editableSigned, 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" }
StatusCodeWhen
404not_foundUnknown id, or another workspace's request.
409invalid_stateSigned, declined, withdrawn — or queued (a sequential follower, or a recipient of a staged template instance).
409expiredThe request's TTL has elapsed.
429rate_limitedThe 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.
502email_failedOur email provider failed the send. Nothing was recorded; retry later.
503email_not_configuredThe 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.