API overview
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.
Which host carries which name. The API host and our emails are
wesign; the rest stays letssign.now:
- API responses — the
signingUrl/signing_urlof 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 tosupport@wesign.nowunless the workspace set its own reply-to address. They are still sent fromnoreply@send.letssign.now. - Unchanged: signed PDFs, the PAdES issuer, the webhook
User-Agentand theX-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.
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.
Versioning
/v1is 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 (and in the machine-readable
/api-changelog.json) with its date, flaggedbreaking, 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. - 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 — 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; 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 — 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. |
| 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. 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. |
| 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: 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. 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:
{
"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.
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). New keys may
be added to the object over time — do not treat it as closed.
Two ways to start a signature
- Direct —
POST /v1/signing-requestswith a one-off PDF (fileorfile_url) and anchors or explicit field positions. Best for ad-hoc documents. - Template —
POST /v1/templates/{id}/instantiateagainst a locked template, supplyingrecipientsandfield_values. Best for the same contract sent many times. Discover the fields to send viaGET /v1/templates/{id}orGET /v1/fields. A recipient takesslot,email,name, the same SMS second factor as a signer (phone_e164,require_sms_verification,sms_gate), your ownreference, and thecompanyandjob_titleit signs with; the object is strict, so any other key —phone, say — is a400 unknown_recipient_fieldnaming 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.
{ "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 ornull— no nested objects or arrays — and the whole object is at most 4 KB as JSON. Anything else is a400 invalid_metadatawith a top-levelproblems: [{ path, message }], one entry per offending key, refused before anything is uploaded or created. - Echoed everywhere. It comes back as
metadataon the create response, onGET /v1/documents/{id}and on every webhook that names the document — always present,nullwhen 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 forIdempotency-Key, so the same key with different metadata is a422 idempotency_key_reuse. - On
/generateit is validated in every mode but stored only withreview: 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.
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. | — |
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:
{
"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_birthandcompany.uidare all valid. A dot is part of the name, never nesting:field_valuesstays a flat map, andperson.date_of_birthandperson_date_of_birthare 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 saysstandard: trueand carries its name in six languages (labels). Custom keys stay just as valid — see Standard field names. - Booleans are strings. Every scalar in
field_valuesis a string, including abooleanfield: send"true"/"false","yes"/"no"or a UI-locale label ("ja","nein","oui","non","sí","nee", …), case-insensitive —true/yesrender asYes,false/noasNo, a label as sent. Any other value ("X","1","on") is a422 template_input_invalidwith problem codeinvalid_boolean; it is never silently left empty. A raw JSONtrue/falsedoes not pass the body schema and answers400 invalid_request. - Dates are written day first —
DD.MM.YYYY,DD/MM/YYYY,DD-MM-YYYYorDD MM YYYY(15.03.1990,5.3.1990) — or asYYYY-MM-DD, and must be a real calendar day with a four-digit year. They are stored and echoed asYYYY-MM-DD. Month-first is never tried — not even when the template prints03/15/1990:03/04/1990is 3 April, and03/15/1990— like2026-02-30— is422 invalid_date. - Times are
HH:MM/HH.MMon a 24-hour clock (14:30) orh:MM AM/PM(2:30 PM), stored and echoed as 24-hourHH:MM; anything else is422 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:30or2:30 PM), shown read-only asdate_format/time_formatonGET /v1/templates/{id}— see 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. How an app asks its user for the right values before it calls is in Prepare a fill in your app.
