Signing requests
Create, read, remind, and withdraw — the four 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, dispatches the emails (and SMS, if asked), and returns a per-signer URL list. Multipart when the PDF rides as a binary form field; JSON when you point us at a URL.
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 | Override the stored filename when using file_url. | |
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. | |
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 + UI language. Default en. | |
expires_in_days | int 1..90 | Default 14. Per-link TTL. | |
placement_assignee_email | string | Belonged to the retired manual mode. Accepted and ignored so old request bodies keep validating. |
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 | Display name. Auto-fills the typed-signature default. | |
first_name, last_name | string | Split name. When both are given, name is recomputed as "{first_name} {last_name}". | |
role_label | string | { en, de } | Human-readable label shown in emails + UI. | |
locale | "en" | "de" | "fr" | "it" | "nl" | "es" | Override request-level locale. | |
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. |
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 |
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).
A phone_e164 on its own, without the flag, is stored on the request
but the invite still goes out by email and no code is sent.
What the signer experiences: they open their signing link as usual,
and before the document is shown we text a one-time code to
phone_e164. They enter it, and only then can they sign. 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. It is
available on every plan, subject to the workspace's monthly SMS
allowance.
{
"signers": [
{
"role": "tenant",
"email": "tenant@example.com",
"name": "Ann Tenant",
"phone_e164": "+41791112233",
"require_sms_verification": true
}
]
}The same two fields exist on
POST /v1/documents/{id}/confirm
for staged template instances.
Response
HTTP/1.1 201 Created
Content-Type: application/json
{
"documentId": "8a1e4f9a-…",
"status": "pending",
"placement": "anchors",
"presentedSha256": "4f9a…d21c",
"anchors": { "found": 4, "fields": 4 },
"signers": [
{ "role": "tenant", "email": "tenant@example.com", "status": "pending", "signingUrl": "…", "signingRequestId": "…" },
{ "role": "landlord", "email": "owner@example.com", "status": "pending", "signingUrl": "…", "signingRequestId": "…" }
],
"observers": [],
"callback": {
"url": "https://yourapp.com/wesign/callback",
"secret": "whsec_…",
"note": "Store this secret. We do not display it again. …"
}
}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.
signingUrl lives on the workspace's signing host
(https://yourco.letssign.now/en/sign/… for branded workspaces) —
only the API host is api.wesign.now.
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, and the last 50 audit events 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": null, "role": "tenant", "order": null },
"status": "viewed",
"locale": "en",
"channel": "email",
"expiresAt": "2026-05-13T10:30:00Z",
"createdAt": "2026-04-29T10:30:00Z",
"signingUrl": "https://yourco.letssign.now/en/sign/abc…",
"auditEvents": [
{ "type": "email_sent", "createdAt": "2026-04-29T10:30:01Z", "meta": { "to": "…" } },
{ "type": "viewed", "createdAt": "2026-04-29T10:32:08Z", "meta": null }
]
}For a doc-level view of all signers, see GET document.
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.
200 OK { "ok": true }
409 Conflict { "error": "Cannot remind a signed request", "code": "invalid_state" }
409 Conflict { "error": "Request has expired", "code": "expired" }Withdraw
Cancel an in-flight signing request. Status flips to withdrawn —
subsequent reminder/withdraw calls return 409, and the signing URL no
longer accepts signatures. In sequential mode, queued downstream
signers stay queued until you withdraw or resend them too.
200 OK { "ok": true }
409 Conflict { "error": "Cannot withdraw a signed request", "code": "invalid_state" }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.
