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:

HostStatus
https://api.wesign.now/v1Canonical. What every example on this site uses. Put this in new config.
https://api.letssign.now/v1Alias, 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_url of 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 to support@wesign.now unless the workspace set its own reply-to address. They are still sent from noreply@send.letssign.now.
  • Unchanged: signed PDFs, the PAdES issuer, the webhook User-Agent and the X-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

  • /v1 is 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, flagged breaking, 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.

ResourceEndpointsWhat it does
Signing requestsPOST /signing-requests, GET /signing-requests/{id}, PATCH /signing-requests/{id}, POST …/{id}/remind, POST …/{id}/withdrawSend 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.
DocumentsGET /documents/{id}, GET …/{id}/signed, GET …/{id}/audit-trail, GET …/{id}/pdf, POST …/{id}/confirm, POST …/{id}/discardThe 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).
TemplatesGET /templates, GET /templates/{id}, POST /templates/{id}/instantiate, POST /templates/{id}/generateList 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.
FieldsGET /fieldsThe workspace field & API-object registry — keys, types, enum options, examples, max length.
WebhooksGET /hooks, POST /hooks, DELETE /hooks/{id}, POST /hooks/{id}/rotateREST-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 checkGET /meWhich 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 signingPOST /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_retiredRetired 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

  1. Direct — POST /v1/signing-requests with a one-off PDF (file or file_url) and anchors or explicit field positions. Best for ad-hoc documents.
  2. Template — POST /v1/templates/{id}/instantiate against a locked template, supplying recipients and field_values. Best for the same contract sent many times. Discover the fields to send via GET /v1/templates/{id} or GET /v1/fields. A recipient takes slot, email, name, the same SMS second factor as a signer (phone_e164, require_sms_verification, sms_gate), your own reference, and the company and job_title it signs with; the object is strict, so any other key — phone, say — is a 400 unknown_recipient_field naming 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 or null — no nested objects or arrays — and the whole object is at most 4 KB as JSON. Anything else is a 400 invalid_metadata with a top-level problems: [{ path, message }], one entry per offending key, refused before anything is uploaded or created.
  • Echoed everywhere. It comes back as metadata on the create response, on GET /v1/documents/{id} and on every webhook that names the document — always present, null when 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 for Idempotency-Key, so the same key with different metadata is a 422 idempotency_key_reuse.
  • On /generate it is validated in every mode but stored only with review: 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:

WhereUnknown keySince
A signer on POST /v1/signing-requests400 unknown_signer_field — meta.signers: [{ index, fields }], meta.accepted2026-09-24
A recipient on POST /v1/templates/{id}/instantiate400 unknown_recipient_field — meta.recipients: [{ index, fields }], meta.accepted2026-09-24
A recipient on POST /v1/documents/{id}/confirm400 unknown_recipient_field, same shape2026-09-24
The body of PATCH /v1/signing-requests/{id}400 unknown_field — meta.fields, meta.accepted2026-09-24
A top-level key of POST /v1/signing-requests (JSON key or multipart field other than file), instantiate, generate or confirmIgnored 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-sessions400 invalid_request2026-09-10
A top-level key of POST /v1/embedded/sign-sessions or POST /v1/hooksIgnored, no warning—
A field_values key the template does not knowNot 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_birth and company.uid are all valid. A dot is part of the name, never nesting: field_values stays a flat map, and person.date_of_birth and person_date_of_birth are 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 says standard: true and carries its name in six languages (labels). Custom keys stay just as valid — see Standard field names.
  • Booleans are strings. Every scalar in field_values is a string, including a boolean field: send "true"/"false", "yes"/"no" or a UI-locale label ("ja", "nein", "oui", "non", "sí", "nee", …), case-insensitive — true/yes render as Yes, false/no as No, a label as sent. Any other value ("X", "1", "on") is a 422 template_input_invalid with problem code invalid_boolean; it is never silently left empty. A raw JSON true/false does not pass the body schema and answers 400 invalid_request.
  • Dates are written day first — DD.MM.YYYY, DD/MM/YYYY, DD-MM-YYYY or DD MM YYYY (15.03.1990, 5.3.1990) — or as YYYY-MM-DD, and must be a real calendar day with a four-digit year. They are stored and echoed as YYYY-MM-DD. Month-first is never tried — not even when the template prints 03/15/1990: 03/04/1990 is 3 April, and 03/15/1990 — like 2026-02-30 — is 422 invalid_date.
  • Times are HH:MM / HH.MM on a 24-hour clock (14:30) or h:MM AM/PM (2:30 PM), stored and echoed as 24-hour HH:MM; anything else is 422 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:30 or 2:30 PM), shown read-only as date_format / time_format on GET /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.