Changelog

Public API changes. Reverse-chronological. Breaking changes flagged.

This page tracks visible API changes — new endpoints, response shape tweaks, error-code additions, deprecations. Internal refactors and UI changes don't make it here. The same releases, one entry per date, are published machine-readable at https://www.wesign.now/api-changelog.json; how much notice each kind of change gets is on Versioning.

2026-10-01 — Signer company and function, one seal at a time, all-or-nothing creates

Integrators asked how observers, AES signatures and signer identity work in detail. Answering them turned up defects our own review then confirmed; this release fixes them and adds what was missing. The spec is info.version 2026-10-01, frozen at /openapi/2026-10-01.json. Each item ends with its id in api-changelog.json.

Breaking

  • Anchor field keys are used. The third part of a text or name marker ([[ls:text:tenant:person.full_name]]) is stored as the box's key; before, it was accepted and ignored. A box keyed with one of the signer's own details (person.full_name, contact.email, company.legal_name, …) opens with that detail, and the signer can change it. A name marker without a third part is keyed person.full_name, so it opens with the signer's name; before, it was an empty box. See Anchor placeholders. (anchor-text-field-key)
  • Two write failures that were ignored now fail the create. A failed callback_url webhook on POST /v1/signing-requests used to be skipped: the 201 handed out the secret of a hook that was never stored, so no callback ever came. A failed field write on POST /v1/signing-requests or instantiate used to leave a signer with a link and nothing to sign. Both now answer 500 db_failed, and the call is rolled back (see below). (create-checked-writes)

Changed — additive, but check your client

  • Creates are all or nothing. POST /v1/signing-requests and instantiate write the document, its callback webhook, every signing request, its fields and its audit rows before anything is sent; then the invitations go out, then the observer notice (POST /v1/signing-requests only), then the one signing_request.sent. A failed write deletes everything the call created, the PDF it uploaded or rendered included, contacts nobody and answers 500 db_failed with meta.rolled_back: true and an error that ends "Nothing was created or sent; retry the request."; the Idempotency-Key is released. In the rare case that the clean-up fails as well, meta.rolled_back is false and meta.document_id names the leftover document. Before, a failure on signers[1] answered 500 after signers[0] had been invited and the observers notified, and the leftover one-signer document could complete on that one signature. See If the call fails. (create-all-or-nothing)
  • "Completed" only when every signer signed. When a co-signer declined, was withdrawn or expired, the last remaining signature no longer sends a "Completed" email or a certificate: observers and earlier signers receive nothing, and the signer and the sender each get a progress note without attachments. Before, observers and earlier signers could be emailed "Completed" with a PDF that lacked the missing signature. document.completed and GET /v1/documents/{id} already followed this rule. (observer-completed-every-signer)
  • The emailed certificate is always English. The audit certificate attached to completion emails used to be in the language of the signer whose signature completed the document. It is now English for everyone, like GET /v1/documents/{id}/audit-trail, with its times in the time zone of the caption under each signature. (certificate-always-english)

Added

  • Company and function per signer. Optional company (at most 200 characters) and job_title (at most 120) on signers[], instantiate recipients[] and confirm recipients[]. They are your statement; we never verify them. They print under each signature — Anna Muster · Geschäftsführerin · Muster AG · anna@muster-ag.ch, shrunk to fit the box down to 6 pt, else with function and company on a line of their own under the name, never cut and never off the page — and on the audit certificate, the one attached to the completion emails and the one from GET /v1/documents/{id}/audit-trail alike, labelled "stated by the sender". They are read back as company and job_title on every signer object and in the signer block of the webhooks. A line break or other control character, or a longer value, is a 400 invalid_request. See Company and function. (signer-company-job-title)
  • One signer block on every signer webhook. signing_request.signed and document.completed carry { email, name, role, slot, reference, company, job_title }, like the signer events; they carried { email, name }. signing_request.sent from the API or a sequential release and the six signer events add company and job_title. signing_request.expired is unchanged. (webhook-signer-block)
  • A higher SMS allowance on request. Email support@wesign.now with your workspace ID and the codes you expect per month; we raise the workspace's monthly allowance, included in your subscription. There is no price per SMS. GET /v1/me → sms_allowance.monthly then shows the plan's codes plus the raise, and every 402 sms_allowance_exhausted check uses that number. See the monthly allowance. (sms-allowance-raise)
  • Docs for AI assistants. /llms.txt lists every docs page with a one-line summary; /llms-full.txt is every page as plain Markdown. New page: the Integrator FAQ. (ai-assistant-docs)

Fixed

  • Parallel signers. The signatures of one document are sealed one at a time. Before, the completed PDF could lack an earlier signature while every status read signed, whatever the signing_mode: when two signatures landed within the same seconds, or, rarely, when the earlier signed file could not be read at the next signature (the seal then started again from the original file); and document.completed could fire twice. Now the completed PDF carries every signature, a signer who presses Sign while another signature is being sealed waits a few seconds (the signing page and an embedded frame retry by themselves), and only the signature that completed the document sends document.completed and the "Completed" emails. Keep deduplicating document.completed on document_id. If a PDF completed before this release lacks a signature, email support@wesign.now with its document_id. See Parallel signers. (parallel-signing-chain)
  • Chinese, Japanese and Korean text in a sealed PDF or a certificate renders in every PDF viewer. It used to show as empty boxes in Poppler-based viewers and as wrong characters in macOS Preview. This has been live since the 2026-09-25 deploy; the 2026-09-25 notes did not list it. PDFs sealed before that deploy keep the old font. (cjk-text-all-viewers)

Docs corrected

  • No standalone phone verification. Verifying a number by SMS without a signing request, listed on the SMS page as planned for 2026-11-15, is not offered and not planned. Check numbers in your own interface; after the create, signing_request.sms_failed names an unusable number and PATCH /v1/signing-requests/{id} corrects it. (no-standalone-phone-verification)
  • The stored signer name is "first last" as soon as either first_name or last_name is non-blank, and name is then ignored; the docs said "when both are given". (signer-name-rule-docs)
  • observer_emails addresses are checked before they would be trimmed: a leading or trailing space is a 400 invalid_request. The docs said they were trimmed. (observer-address-docs)
  • The RFC 3161 time stamp is kept with our records and reported as tsa_provider and tsa_signed_at; it is not embedded in the PDF, as the Documents page said. (tsa-token-docs)

Migration notes

  1. On 500 db_failed from POST /v1/signing-requests or instantiate, retry the same body (the same Idempotency-Key is fine). With meta.rolled_back: false, first withdraw any signing request of meta.document_id that GET /v1/documents/{id} still shows as pending.
  2. Read webhook signer blocks by key: signing_request.signed and document.completed now carry role, slot, reference, company and job_title too.
  3. Optional: send company and job_title for signers who sign on behalf of a company, and stop printing them yourself next to the box. Leave about 40 pt below each signature box (about 50 pt when function and company need a line of their own), and some room to its right, for the caption.
  4. If your PDFs carry name markers, or text markers with a third part, check what those boxes now open with.
  5. Keep deduplicating document.completed on document_id as well as event_id.

2026-09-25 — Monthly cap on instantiate, quiet sequential sends, who signs each slot, file names in every script

The spec is info.version 2026-09-25, frozen at /openapi/2026-09-25.json.

Breaking

  • send_emails: false holds for the later signers of a sequential send on POST /v1/signing-requests, instantiate and confirm. The choice is stored on every signer, so when a signer's turn comes they are not emailed either. Before, the next signer was emailed on release whatever the call said. If you relied on that email, send send_emails: true, or deliver the signer's link when the release's signing_request.sent arrives (see Added — signer events). Requests created before this release are still emailed on release.
  • 402 tier_required on instantiate: an instance counts against the workspace's monthly document cap exactly like POST /v1/signing-requests. At the cap nothing is created and the body is { error, code, meta: { tier, cap, used } }. This applies to review: true too — a staged instance counts from the moment it exists, and its confirm is not counted again. A validate_only dry run reports it in problems with ok: false. Enterprise, the only tier with template channels, has no cap, so no workspace meets this today.

Added

  • signing_request.sent also fires when a sequential document releases its next signer — once per released signer (once per document of an envelope), whatever sent the document — with "source": "sequential_release", occurred_at and emailed (false with send_emails: false: deliver the link yourself). It may arrive before the previous signer's signing_request.signed. Before, no event marked the release. A receiver that treats sent as "the document went out" should skip source: "sequential_release". See Following a sequential send.
  • Who signs each slot: recipients[] on GET /v1/templates/{id} carries the author's role and pin when set. pinned: "email" comes with pinned_email, the one address instantiate and confirm accept for that slot — send it instead of meeting 400 pinned-slot-mismatch. pinned: "sender" is whoever sends in the app; an API key does not have to match it. An unnamed, unpinned slot is still { "slot": n }. See Several signers.
  • The reminder cooldown's 429 rate_limited on POST /v1/signing-requests/{id}/remind carries Retry-After: 60, like every other 429. It still has no RateLimit-* headers: it is not the per-key window.

Fixed

  • POST /v1/signing-requests/{id}/withdraw answers 500 db_failed when the status update fails; it answered 400, which read as a fault in the request. Nothing changed — the request is still in flight and no webhook fired — so retry.
  • File names keep the letters of every script. A template called Vollmacht Müller makes Vollmacht_Müller.pdf, not Vollmacht_M_ller.pdf — and that is the name the invitation email shows. The same goes for a filename sent to POST /v1/signing-requests or generate. Digits, _, . and - are kept as before; spaces and any other character still become _.
  • generate's PDF answer names the file in RFC 5987 form (filename*=UTF-8''…) with an ASCII filename fallback, so the download keeps its real name.

Docs corrected

  • An archived template answers 409 template_unlocked on instantiate and generate, with or without a version pin. The dashboard's archive dialog said such calls would return 410; it now says 409 as well.
  • The reminder cooldown was documented without Retry-After; it has one now (above).

Migration notes

  1. Handle 402 tier_required on instantiate as you do on POST /v1/signing-requests.
  2. Retry a 500 db_failed from withdraw; it is not a request error.
  3. Sequential sends with send_emails: false: store every signer's link from the create answer and deliver the next one on signing_request.sent with "source": "sequential_release" — we no longer email it. A receiver that counts sent as once per document should skip that source.

2026-09-24 — SMS you can rely on: before-view gate, read-back, allowance up front, signer events

An integrator that sends every contract with an SMS second factor reviewed the API and found the factor could go missing without a trace. This release closes every such gap and documents the rest. The spec is info.version 2026-09-24, frozen at /openapi/2026-09-24.json.

Breaking

  • Signer objects are strict on POST /v1/signing-requests: an unknown key — phone instead of phone_e164, a typo — is a 400 unknown_signer_field naming every offender (meta.signers, meta.accepted). Before, it was dropped silently.
  • Template recipients are strict on POST /v1/templates/{id}/instantiate, and they now take phone_e164, require_sms_verification, sms_gate and reference. Before, the SMS fields were dropped silently: a document sent directly from a template went out without the SMS gate. Any other recipient key is a 400 unknown_recipient_field.
  • Confirm recipients are strict on POST /v1/documents/{id}/confirm (400 unknown_recipient_field), a body that is not JSON is a 400 invalid_request instead of "confirm unchanged", and a refused confirm now writes nothing (before, the recipient edits were saved even when the confirm was refused). One exception: a discard or review expiry landing while the confirm renders answers 409 after the edits were saved, on an instance nobody can confirm any more.
  • 402 sms_allowance_exhausted on POST /v1/signing-requests, a direct instantiate and confirm: when the signers who must verify by SMS outnumber the codes left in the workspace's monthly allowance, nothing is created. Before, the call succeeded and the signer could not sign later. Body: { error, code, sms_allowance, required }.
  • Dates must be real calendar days: 2026-02-30 in a date field is 422 invalid_date (it passed the shape check before).
  • Anchor markers with a dotted or long field key are placed on POST /v1/signing-requests. A text marker whose FIELD_KEY contains a dot or has 42–64 characters ([[ls:text:tenant:person.date_of_birth]]) used to be skipped silently; it is now a text field. So its ROLE must be one of the signers' roles (400 unknown_role), and a PDF whose only markers were such markers no longer falls through to auto_append — every signer then needs a marker (400 signer_has_no_anchor).
  • metadata is validated on POST /v1/signing-requests, instantiate and generate. Before this release the key was dropped silently, so any value passed; a value that is not a flat object within the limits is now a 400 invalid_metadata.
  • send_emails must be a boolean on POST /v1/signing-requests (JSON true/false, multipart "true"/"false"). Before, the key was ignored; a string such as "false" or "no" is now a 400 invalid_request.
  • The multipart filename is read on POST /v1/signing-requests (it was ignored), so a value over 200 characters is now a 400.
  • A body that is not JSON is a 400 invalid_request on instantiate and generate, as on confirm. Before, it was read as {}: instantiate refused it with a message about recipients, and generate rendered the template without your values — streaming it even when the lost body asked for review: true. An empty body is still {}.
  • signingUrl is null while staged on GET /v1/signing-requests/{id} (and the PATCH answer), next to a new staged: true, as GET /v1/documents/{id} already reported. Before, a staged recipient read a link that opened only a waiting notice and could not be signed.
  • 502 render_failed on instantiate: when the filled PDF of a rich-text template cannot be rendered or stored, nothing is created — no document, no signing request, no email — and the Idempotency-Key is released. Before, the call succeeded with the template's own PDF: the signers received the contract with its {{placeholders}} unfilled. Body: { error, code }, as on confirm. A PDF template is never rendered.

Changed — additive, but check your client

  • Dates print in the template's format. In a rendered or signed document, a date placeholder prints as its template picked; one placed before formats existed prints 15.03.1990 — an API-filled date used to print 1990-03-15. {{date_today}} stays written out, now in the placeholder's language, else the document's, else the call's locale (before: English, except on a review: true instance, which used the call's locale). A staged generate keeps no locale, so there it ends in English. The stamped signing date (a date field or marker) prints in the field's format, else the workspace's house format — 15.03.1990, or 03/15/1990 for a workspace in the United States — instead of 24 Sept 2026, and the signing page shows it exactly as it will be stamped. Nothing you send and nothing echoed back changed.
  • validate_only on instantiate always carries problems and answers ok: false (still HTTP 200) when the real call would be refused for the SMS allowance. Read ok, not only the status.
  • Six new webhook events reach every hook whose events list is empty — every callback_url hook included — without opting in. Answer 2xx to event types you do not handle.
  • viewed is now a real status. Nothing ever set it before; a request now moves pending → viewed the first time the signer's browser loads the document. Treat it like pending.
  • Idempotency: the hash of POST /v1/signing-requests now includes metadata, send_emails and the multipart filename when you send them — a retry across this deploy with the same key and such a body answers 422 idempotency_key_reuse (only inside the key's 24 hours).
  • SMS refusals are on the audit trail. A code request refused because the allowance is used up or no usable number is stored now writes sms_verify_failed (before: nothing), visible in auditEvents.
  • Dotted field keys: person.date_of_birth, company.legal_name, company.uid are valid keys — lowercase [a-z][a-z0-9_]* parts joined by single dots, up to 64 characters (before: no dots, 40 characters) — in the field registry (GET /v1/fields), templates (GET /v1/templates/{id}), field_values, formulas and anchor markers. A dot is part of the name, never nesting: field_values stays a flat map. Every existing key is still valid; if your client checks keys against the old ^[a-z][a-z0-9_]{0,39}$, widen it. A formula reads a dotted table ({{= SUM(order.items.total) }}), and a {{person.date_of_birth}} in a template body is filled — it used to print as literal text.
  • signer_required is false on every positioned text field of GET /v1/templates/{id}. It used to echo the field's stored flag, but an empty text field no longer blocks signing (see Fixed).
  • Email links point at wesign.now. Invitation, reminder and completion emails carry the wesign.now design and link to https://www.wesign.now/…, or https://<slug>.wesign.now/… for a branded workspace. Replies go to the workspace's own reply-to address when it has one, else to support@wesign.now. The sending address (noreply@send.letssign.now) did not change, and neither did any URL the API returns: the signing links on create, instantiate, confirm and the GETs stay on letssign.now (https://<slug>.letssign.now/<locale>/sign/<token>), as does the link in the invitation SMS of confirm channel sms/both. Both hosts open the same signing page for the same token; links already sent keep working. If you filter or allowlist our emails by the domain of their links, add wesign.now.

Scheduled

  • 2026-12-31 — an unknown top-level key on POST /v1/signing-requests, instantiate, generate and confirm is refused with 400 unknown_field. Until then it is ignored and named in warnings[] — on the generate PDF stream, which has no JSON body, in the X-Unknown-Fields response header. See Unknown keys.
  • 2026-12-31 — recipients[].phone on confirm stops being accepted; use phone_e164. Until then each use adds a deprecated_field warning, and both with different values are a 400 conflicting_fields.

Added — SMS

  • sms_gate per signer and recipient: "before_sign" (default, unchanged) or "before_view" — nothing of the document (title, file name, pages, prefilled values) is shown until the code is verified, and every invitation email leaves it unnamed. The code is texted when the signer taps for it on that page, never on page load. Not yet in embedded signing. See SMS verification.
  • A per-signer read-back on every signer of the create, instantiate, confirm and GET /v1/signing-requests/{id} responses: require_sms_verification, phone_masked, sms_verified_at, sms_gate, signature_level (AES | SES), reference.
  • PATCH /v1/signing-requests/{id} { "phone_e164": … } corrects a signer's phone while the request is open and unverified (409 not_editable otherwise). Sends nothing; audit event phone_changed, both numbers masked.
  • GET /v1/me returns sms_allowance: { monthly, used, remaining, resets_at } next to capabilities.
  • The SMS code is sent in the signing request's locale (en, de, fr, it, es, nl) instead of the language Twilio derives from the number's country (every Swiss number used to get German, Dutch and Liechtenstein numbers English).

Added — signer events

  • signing_request.viewed, .declined (with reason), .withdrawn (with withdrawn_by), .sms_sent, .sms_failed (with a reason code) and .sms_verified, on one shared payload { signing_request_id, document_id, signer { email, name, role, slot, reference }, occurred_at }. See Signer events.
  • signing_request.sent carries signer.reference, and a direct instantiate (without review) now emits it. Its signer.slot is the stored value like on every other event: null on a parallel POST /v1/signing-requests even when a signing_order was passed (that ignored value was echoed before).

Added — references and options

  • reference (≤ 200 characters) per signer and recipient on create, instantiate and confirm.
  • metadata on POST /v1/signing-requests, instantiate and generate: a flat object stored on the document and echoed on the create response, GET /v1/documents/{id} and every webhook naming the document.
  • send_emails on POST /v1/signing-requests (default true), with emailed per signer. It never stops the SMS code, which the signer requests on the page.
  • warnings[] on the JSON success bodies of create, instantiate, generate and confirm; the direct instantiate 200 now also carries the field_values warnings it used to drop.
  • capabilities on GET /v1/me; enabled on every hook, GET /v1/hooks?include_disabled=true, and POST /v1/hooks/{id}/rotate (new secret once, the old one valid for 24 hours, two v1= entries in the signature header meanwhile).

Added — day-first dates

  • A date value in field_values may be written day first — DD.MM.YYYY, DD/MM/YYYY, DD-MM-YYYY or DD MM YYYY, day and month one or two digits (15.03.1990, 5.3.1990, 15 03 1990) — as well as YYYY-MM-DD, on instantiate and generate. It is stored and echoed as YYYY-MM-DD. Month-first is never tried: 03/04/1990 is 3 April, and 03/15/1990 is 422 invalid_date. The invalid_date message now names the accepted formats.

Added — date and time formats, and a time type

  • The template author picks how each date or time placeholder prints: 15.03.1990, 15/03/1990, 03/15/1990 or written out (24. September 2026, in a chosen language); 14:30 or 2:30 PM. GET /v1/templates/{id} shows the pick read-only as date_format, time_format and lang on field_values[] entries, and date_format (+ lang) on date entries of signing_fields[]. Absent means the default. It never changes what you send, what is stored or what is echoed (see How dates and times print).
  • A time field type: HH:MM or HH.MM on a 24-hour clock, or h:MM AM/PM (14:30, 9:05, 2:30 PM, 2:30pm), stored and echoed as 24-hour HH:MM; anything else is 422 invalid_time. GET /v1/fields can list time definitions, and the spec now enumerates every FieldDefinition.type (a new type is an additive change: treat one you do not know like text).

Added — who fills a field, standard names, what is still missing

  • Filled by on each text box of a PDF template. Sender / API: the input is owner: "sender" and required: true unless the author made it optional or gave it a default; the value you send prints as fixed text the signer cannot change. It is document content: every signer sees it from their first view, whichever slot its box sits on and in any signing order, and it is sealed once. slot on such an input only says which signer's field set stores it. Signer: owner: "signer", never required; you may pre-fill it and the signer may change it. Every existing field stays Signer, so no contract changes until a template's author chooses and locks a new version. See Which fields you must supply.
  • Correct values before sending. A PDF-template instance staged with review: true can have its Sender / API values corrected on review_url (Edit values, for a signed-in member of your workspace; a Signer box or a platform value cannot be edited there). Confirm checks the values again against the template version the instance was staged from and copies each one onto the signing fields its seal prints, so the signers sign what the reviewer approved. Confirm itself takes no field_values: such a key is ignored and named in warnings[] as unknown_field.
  • Standard field names: 19 English dotted keys anchored to international vocabularies (person.full_name, person.date_of_birth, company.legal_name, company.uid, address.postal_code, contact.email, signing.place, …). An input using one carries standard: true, labels (its name in en, de, fr, it, es, nl), an example, a hint pattern (never enforced) and an English description; every other input carries standard: false. GET /v1/fields marks registry definitions the same way. Custom keys stay first-class. The list is in Fields & placeholders.
  • missing_optional on a validate_only answer of instantiate and generate, on the 200 and on the dry-run 422: the inputs you may still supply and left empty, with what you need to ask your user for them. It never makes ok false and is absent from a real call's 422. generate never requires or lists a positioned field.
  • A new guide, Prepare a fill in your app: read the contract, ask your user, dry-run, send.

Fixed

  • The multipart filename field on POST /v1/signing-requests is honoured (it was ignored); over 200 characters is a 400 (see Breaking).
  • An empty text field no longer blocks the signer.
  • An anchor marker whose field key contains a dot or is longer than 41 characters is placed; it was skipped silently (see Breaking).
  • A text box of an API-instantiated document now carries its key and label onto the signing page, as a quick-send from the app always did. A Signer box keyed with an auto-fill name (full_name, email, …, or the standard person.full_name, contact.email, …) now opens with the signer's own details when you sent no value and the author set no default.
  • Confirming a staged instance checks its positioned text fields against the template version it was staged from, not the current one, so re-locking the template meanwhile can no longer block the confirm. A generated file (generate with review: true) never requires a positioned field at confirm.
  • A rich-text template whose filled PDF could not be rendered is no longer sent unfilled from instantiate (see Breaking).
  • One valid seal on a multi-signer PDF. The completed file (GET /v1/documents/{id}/signed, document.completed → signed_pdf_url) carries one PAdES seal, valid over the whole document. Each later seal used to be added on top of the earlier one, which Acrobat and pdfsig then reported as invalid ("Digest Mismatch") next to the valid last seal. If a multi-signer PDF completed before this release shows an invalid earlier signature, ask support to re-seal it.
  • Earlier signers get the completed PDF. When the last signer signs, everyone who signed earlier receives the sealed PDF by email, in their own language. Before, that email went out only when the workspace sent signers the Certificate of Completion (by default only the initiator gets it), so whoever signed first never received the final document. The certificate still follows that setting.
  • Formulas compare dates as days. A comparison (<, >, <=, >=, =, !=) whose two sides are dates compares the days, whatever accepted spelling each side uses, as conditional sections do: {{= IF(person.date_of_birth < "2008-09-24", "adult", "minor") }} now gives the same answer for 1990-03-25 and 25.03.1990 (a day-first value was compared as text). 15 03 1990 now reads as a date, not the number 15031990. Numbers and plain text compare as before.

Docs corrected

  • Confirm's phone field: the docs said the signing-request spelling phone_e164 works on confirm; confirm took only phone and silently ignored phone_e164. Both work now, phone_e164 is canonical.
  • SMS availability: every plan, within a monthly allowance (Free 25, Pro 50, Branded 200, Teams/Enterprise 100 per paid seat) — the pricing matrix showed none on Free.
  • The embedded page said a decline reason reaches you by webhook; no event carried it until signing_request.declined.
  • GET /v1/me is documented with its full body; GET /v1/signing-requests/{id} returns the first 50 audit events (oldest first), not the last.
  • The code is asked at the Sign press by default, not "before the document is shown" — that is now sms_gate: "before_view".
  • The OpenAPI spec carries worked examples for GET /v1/templates, GET /v1/templates/{id}, instantiate (request, direct 200, staged 201, dry runs, 422), confirm and discard.
  • Template.status is an enum — draft, locked, archived; the list never shows an archived template, GET /v1/templates/{id} still answers for one. source on a template input is legacy: read owner and required.
  • review_url and the 422 docs link are pages in the web app (on letssign.now, not the API host) that only a signed-in member of your workspace can open.
  • The direct instantiate 200 carries no template_id; the staged 201 and the dry run do.
  • The dry-run note implied that instantiate checks a monthly document cap. It applies none, dry run or not.
  • The 400/402/422 bodies that are either a specific error or the generic Error are declared anyOf, not oneOf: every specific body also matched Error, so a strict oneOf rejected them all.
  • signing_request.sent → signer.slot is the recipient slot on every template send, in either signing mode; the spec said null in parallel mode.

Migration notes

  1. Send only documented keys in signers[], recipients[] and confirm recipients[]; watch warnings[] and clear every top-level unknown_field before 2026-12-31.
  2. On confirm, rename phone to phone_e164 before 2026-12-31.
  3. If you instantiate templates with SMS signers, move them onto recipients[].phone_e164 + require_sms_verification and assert signature_level: "AES" on the response.
  4. Handle 402 sms_allowance_exhausted; read GET /v1/me → sms_allowance to warn before sending.
  5. Make your webhook receiver answer 2xx to unknown events, and add the six signer events you want.
  6. Treat viewed like pending.
  7. On instantiate validate_only, check ok.
  8. Send date values day first or as YYYY-MM-DD, never month first — also when a template prints them 03/15/1990. Send time values as HH:MM or h:MM AM/PM.
  9. If a PDF carries text anchor markers with a dotted or 42–64-character field key, check that their role is one of your signers' roles.
  10. Decide what to send from required on GET /v1/templates/{id}, never from owner: a template whose author sets a text field to Sender / API requires it from its next locked version on. A validate_only dry run names anything you missed.
  11. Optional: map your data to the standard field names once, and offer your user what missing_optional lists.
  12. Read signingUrl on GET /v1/signing-requests/{id} as nullable; a staged recipient's link comes from the confirm.
  13. Retry a 502 render_failed from instantiate: nothing was created, so send the same body again (with the same Idempotency-Key, if you use one).
  14. If you filter or allowlist our emails by the domain of their links, add wesign.now. The signing links the API returns stay on letssign.now.

2026-09-10 — Pin an API key to your own IP addresses

Added — a per-key IP allowlist

  • Every API key now carries a list of IPv4/IPv6 addresses and CIDR blocks (203.0.113.7, 203.0.113.0/24, 2001:db8::1, 2001:db8::/32), managed on the key's card in Settings → API, up to 50 entries. Entries are canonicalised on save, so what the card shows is what we match.
  • An empty list means unrestricted. Nothing changes for any existing key, and nothing changes for you until you deliberately add an entry. This is the opposite of the embed-origin list, where empty means "cannot embed" — worth reading twice, it is the half people get wrong.
  • The address we compare is the one our platform observed the call arriving from. X-Forwarded-For is not used for the decision: a caller can write that header itself, so trusting it would be decoration. For the same reason we do not and will not authorise REST calls by Origin — see Security.

Added — 403 ip_not_allowed

  • A key that is valid but called from an address its allowlist does not cover answers 403 { error, code: "ip_not_allowed", ip } instead of the generic 401 invalid_key — a different problem deserves a different code. ip is the address we saw (null when it could not be determined, in which case a restricted key fails closed). Documented on every key-authenticated operation in the OpenAPI spec.
  • The control gates API keys only: never the workspace UI, never the signing pages. A mistyped address locks out an integration, never a person — sign in from anywhere and empty the list.

Full contract: Restricting a key to your own IP addresses.

2026-09-10 — Embedded signing: sign inside your own page

Your signer can now sign in an <iframe> or a mobile webview on your domain, with your branding around it. Everything here is additive; no existing endpoint changed shape.

Added — POST /v1/embedded/sign-sessions

  • Exchanges an existing signing request for a short-lived, single-signer session: { signing_request_id, origin, ttl_seconds?, locale?, theme? } → 201 { id, embed_url, expires_at, signing_request_id }. ttl_seconds is 60…900 (default 300) and out-of-range is refused, not clamped. embed_url is on the workspace's own signing host, so the signer never sees a domain their firm did not put in front of them. Enterprise only (402 enterprise-required); needs a key with the embedded scope (a full key satisfies it); on the standard 60/minute per-key bucket.
  • GET /v1/embedded/sign-sessions/{id} → { id, status, expires_at, first_seen_at, last_seen_at, signing_request_id } with status one of active / expired / revoked. DELETE revokes, idempotently, and answers { "ok": true }. Neither is Enterprise-gated: revocation is a safety valve and never depends on billing.
  • New error codes: invalid_origin (400), origin_not_allowed (400), request_not_signable (409), embedding_unavailable (503, while a deployment has not applied the migration).

Added — ancestor origins are registered per API key

  • Embedding is a permission, not a default. The origins allowed to frame a session are listed on the key in Settings → API (up to 20, exact https://host[:port] match, no wildcards). A key with an empty list cannot mint a session.

Added — postMessage schema v1 and a documented CSP

  • The frame posts { v: 1, type, session_id, signing_request_id, at, detail? } to window.parent with targetOrigin set to the registered origin, never '*'. Types: wesign.ready / viewed / signed / declined / expired / error. They are a UX signal only — webhooks remain the source of truth.
  • Hosts allow exactly one directive: frame-src https://<slug>.letssign.now https://<slug>.wesign.now. We answer embed paths with Content-Security-Policy: frame-ancestors <the session's one origin> and omit the app's global X-Frame-Options there, defaulting to frame-ancestors 'none' whenever the session cannot be read.

Full contract, a host-side listener and a copy-pasteable example page: Embedded signing.

Unchanged — the retired endpoint

  • POST /v1/embedded/sessions — no hyphen, the 2026 guest-placement flow — still answers 410 embedded_sessions_retired. The two share nothing but a word.

2026-09-10 — One-time hook secret, honest errors, expiry events

Changed — document.completed

  • audit_trail_url is now https://api.wesign.now/v1/documents/{document_id}/audit-trail — key-authenticated, same host, same Bearer key and same status rules as signed_pdf_url. Previously a token link on the web origin whose route no longer existed (it answered 404).
  • The event fires only when every signer row on the document is signed — the same rule GET /v1/documents/{id} (status: signed), /signed and /audit-trail already applied. A document with a declined, withdrawn or expired signer no longer receives it; before, it fired and both URLs answered 409 not_complete. See what "completed" means.

Added — POST /v1/hooks returns the secret once

  • The create response now carries secret (whsec_…), shown once. GET /v1/hooks never returns it; lose it and you delete + resubscribe. Additive — the response keeps every existing field.

Added — signing_request.expired is emitted

  • An hourly cron flips a pending / viewed request to expired once its expires_at has passed and emits the event exactly once per request: signing_request_id, document_id, signer { email, name }, expired_at. Queued sequential followers behind an expired signer do not emit. GET /v1/signing-requests/{id} and the signers[] on GET /v1/documents/{id} now report status: "expired" for such rows (previously the row stayed pending and expiry was only visible via expiresAt). remind on one answers 409 expired, withdraw 409 invalid_state — both as documented.

Changed — POST /v1/templates/{id}/instantiate error bodies

  • Every error now carries a stable code. New: invalid_key (401), invalid_request (400 body validation), not_found (404), version_not_found (404, meta.version + meta.current_version), missing_recipients (400, meta.slots), db_failed (500). Messages are unchanged; only the code field was added.

2026-09-09 — api.wesign.now is the canonical host

letssign.now now also runs as wesign.now, and the public API takes the new name first. Everything here is additive: every old spelling keeps working, nothing is removed.

Base URL

  • Canonical: https://api.wesign.now/v1. Every example on this site now uses it.
  • https://api.letssign.now/v1 is a permanent alias — identical routing and responses. Do not migrate a working client on our account.
  • Path-style URLs on the web hosts (https://letssign.now/api/v1/…) were never the contract; the apex only redirects. See Base URL.

API keys

  • New keys are minted as wsk_live_<32 hex>; the dashboard shows the first 12 characters (wsk_live_a1b…).
  • Existing lsk_live_ keys are accepted forever. No rotation needed.

Webhook headers

  • Every delivery now carries X-WeSign-Signature, X-WeSign-Event-Id and X-WeSign-Event and the X-LetsSign-* trio with byte-identical values. The scheme is unchanged: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<rawBody>")>. The User-Agent stays letssign.now-webhooks/1.0.
  • The docs now spell out the exact message format, both header families, a 300 s replay tolerance, event-id idempotency, and give Node and PHP verifiers — Verifying a signature.

Changed — document.completed.signed_pdf_url

  • Now https://api.wesign.now/v1/documents/{document_id}/signed: a key-authenticated API URL on the canonical host (Bearer key of the owning workspace; 409 not_complete until every signer signed; 404 not_found from another workspace).
  • Previously it was an unauthenticated token link on the web origin. A receiver that fetched it without a key must now send the bearer; one that pins the origin should pin api.wesign.now. No integrator was live on the old shape. audit_trail_url moved the same way one day later — see the entry above.

Docs

  • Second factor by SMS (phone_e164 + require_sms_verification) is documented.
  • Embedded signing states what exists today and what the iframe phase will be.
  • The events table lists only events that are actually emitted.

2026-09-06 — Review before sending

  • "review": true on POST /v1/templates/{id}/instantiate and /generate stages the instance instead of sending it; new POST /v1/documents/{id}/confirm and /discard, new GET /v1/documents/{id}/pdf for a finalized file-only instance, "validate_only": true dry runs, and the template_instance.staged / .confirmed / .discarded webhook events — see Templates.
  • POST /v1/templates/{id}/instantiate takes an Idempotency-Key (same semantics as POST /v1/signing-requests) and sits on the per-key rate limit; its 409 carries code: "template_unlocked".
  • Breaking: POST /v1/templates/{id}/generate sits behind the same Enterprise template entitlement as instantiate (402 enterprise-required).
  • Fixed: documents created over the API are attributed to the key's creator. Before, POST /v1/signing-requests and instantiate failed with 500 on the document insert.

2026-07-31 — Retirements (breaking, zero production usage)

  • placement="manual" on POST /v1/signing-requests → 400 placement_retired.
  • POST /v1/documents (ingest-by-URL for manual placement) → 410 placement_retired. file_url on POST /v1/signing-requests covers ingest-by-URL.
  • POST /v1/embedded/sessions → 410 embedded_sessions_retired. GET/DELETE /v1/embedded/sessions/{id} still answer for existing rows.

All three answer a stable, descriptive 410 rather than a 404, so an old integration fails loud.

2026-04-30 — v1 launch

The v1 public API launches with the full reference live.

New endpoints

MethodPathPurpose
POST/v1/signing-requestsCreate — multipart upload, four placement modes
GET/v1/signing-requests/{id}Read one signer + its first 50 audit events
POST/v1/signing-requests/{id}/remindRe-send invite
POST/v1/signing-requests/{id}/withdrawCancel in-flight
GET/v1/documents/{id}Holistic doc + signers + rollup status
GET/v1/documents/{id}/signedFinal PAdES-sealed PDF
GET/v1/documents/{id}/audit-trailGenerated audit-trail PDF

Concepts

  • Idempotency-Key header with a Stripe-style pending lock for concurrent retries — see Idempotency.
  • 60 req/min/key rate limiter with Retry-After — see Rate limits.
  • Workspace-scoped Bearer keys; rotation via mint-then-revoke.
  • Multipart placement modes: anchors / auto_append / explicit / manual — see Placement modes. Manual mode collapses the previous "magic-link" mode into a single URL that works with or without a workspace login.
  • Doc-scoped callback webhooks via callback_url on POST. HMAC-SHA256 signed events, 7-attempt exponential-backoff retries.

Subdomain branding

Branded + Teams tier workspaces get <slug>.letssign.now/sign/... for signer-facing pages. Wildcard cert provisions automatically; no DNS work per-customer.

Migration from /docs/api

If you bookmarked the old monolithic https://letssign.now/docs/api page: it now permanently redirects to /docs. The content has been split into the reference pages listed in the sidebar.

If you used a deep section anchor like /docs/api#errors, it redirects to /docs (apex). Direct links to specific topics are now e.g. /docs/errors, /docs/webhooks.

Versioning policy

Additive changes ship to /v1 with a changelog entry. Breaking changes are announced here at least 90 days ahead, with the one exception of refusing input that never had an effect; a change that cannot be announced that way would ship as /v2, with /v1 kept for at least 12 months after /v2 GA. The full policy, deprecation signals and the dated spec snapshots: Versioning.