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://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_url the API returns is the same link our emails carry: https://yourco.wesign.now/<locale>/sign/<token> for a branded workspace (Enterprise and Teams are), else https://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 to support@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), the User-Agent of a full webhook, the X-LetsSign-* headers, and links to pages that need a login (review_url, the docs link of a 422), 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

  • /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}/rotate, GET /hooks/{id}/deliveries, POST …/{id}/deliveries/{delivery_id}/redeliver, POST /hooks/{id}/testREST-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 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.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

  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.