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://wesign.now/api/v1/…, its www. form and the same paths on
the old root. 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 such a URL, 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, signing links, our emails
and the certificates of new documents say wesign.now. A few names from
before the September 2026 rename are frozen, because integrations and signed
documents depend on them:
- Signing links — the
signingUrl/signing_urlthe API returns is the same link our emails carry:https://yourco.wesign.now/<locale>/sign/<token>for a branded workspace (Enterprise and Teams are), elsehttps://www.wesign.now/<locale>/sign/<token>. Links the API returned before late September 2026 keep their original host and keep working. - Emails we send (invitations, reminders, completion) wear the wesign.now
design and are still sent from
noreply@send.letssign.now. Invitations, reminders and completion emails reply to the workspace's own reply-to address when it set one, else tosupport@wesign.now. - Signed PDFs — the caption under each signature and the certificates of
a document first sealed since late September 2026 name
wesign.now; a document first sealed earlier keeps the name it was first sealed under, for all its signatures. - Unchanged: our seal certificate
(
CN=letssign.now Signing Authority), theUser-Agentof a full webhook, theX-LetsSign-*headers, and links to pages that need a login (review_url, thedocslink of a422), which stay on the web app's own host.
A signing link works on either root — the token decides, not the domain.
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.
Seeing your calls
Every call your keys make is logged — what was called, what we answered
and why — in Settings → API → API log, for 30 days, newest first,
filterable by key, endpoint and "failures only". Each row carries the
status, our error code and error message, the document or signing
request it concerns, and the request id. Request and response bodies
are never stored. Calls to an unknown endpoint, or with a method an
endpoint does not take (404 unknown_endpoint, 405 method_not_allowed),
appear in your log too, as long as they carry one of your keys — even a
revoked one.
Every answer under /v1 carries an X-Request-Id response header (the
same id as the log row). When you contact support about a call, quote
it together with the time of the call; it finds the exact row.
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, GET /hooks/{id}/deliveries, POST …/{id}/deliveries/{delivery_id}/redeliver, POST /hooks/{id}/test | REST-hook subscriptions for connectors and integrators (payload full or minimal; re-posting a target_url replaces its hook); GET /hooks?include_disabled=true also lists hooks switched off in the dashboard; rotate a signing secret with a 24-hour overlap; read the last 100 deliveries, redeliver one, or send a signed test ping. 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.wesign.now/api/templates/{id}/instantiate (and the same path
on the old root) 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.
