{
  "name": "wesign.now API changelog",
  "api": "v1",
  "format": 1,
  "description": "Every visible change to the public v1 API, newest first. One entry per release date. `type` is added | changed | deprecated | removed | fixed | docs. `breaking: true` means a request that worked before can now fail or behave differently; `effective` is the day a scheduled change takes effect when it is not the release date; `sunset` is the last day a deprecated spelling is accepted. `action` is what an integrator has to do (null = nothing). `openapi` is the frozen spec of that release (null before snapshots existed). Policy: https://www.wesign.now/docs/versioning",
  "docs": "https://www.wesign.now/docs/changelog",
  "policy": "https://www.wesign.now/docs/versioning",
  "current_openapi": "/openapi/2026-10-01.json",
  "entries": [
    {
      "date": "2026-10-01",
      "title": "Signer company and function, one seal at a time, all-or-nothing creates",
      "openapi": "/openapi/2026-10-01.json",
      "changes": [
        {
          "id": "create-checked-writes",
          "type": "fixed",
          "breaking": true,
          "endpoints": [
            "POST /v1/signing-requests",
            "POST /v1/templates/{id}/instantiate"
          ],
          "summary": "Two writes whose failure used to be ignored now fail the call with 500 `db_failed`, and the call is rolled back (see `create-all-or-nothing`): the document-scoped webhook of `callback_url` on POST /v1/signing-requests (before, the 201 handed out the secret of a hook that was never stored, so no callback ever came), and the signing fields on both endpoints (before, a signer could receive a link with nothing to sign).",
          "action": "Treat 500 `db_failed` from these endpoints as retryable: nothing was sent."
        },
        {
          "id": "create-all-or-nothing",
          "type": "fixed",
          "breaking": false,
          "endpoints": [
            "POST /v1/signing-requests",
            "POST /v1/templates/{id}/instantiate"
          ],
          "summary": "A create is all or nothing. The document, its callback webhook, every signing request, its fields and its audit rows are written before anything is sent; only then go out the invitations, then (POST /v1/signing-requests only) the one observer notice, then the one `signing_request.sent`. A failed write deletes everything the call created, the PDF it uploaded or rendered included (never a template's own PDF), 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. If that clean-up fails too (rare), `meta.rolled_back` is false and `meta.document_id` names the leftover document; its open signing requests are withdrawn where possible and we are alerted. POST /v1/signing-requests keeps `meta.role` when a signer's write failed. 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.",
          "action": "On 500 `db_failed`, retry the same body; the same `Idempotency-Key` is fine. With `meta.rolled_back: false`, first withdraw every signing request of `meta.document_id` that GET /v1/documents/{id} still shows as `pending`."
        },
        {
          "id": "parallel-signing-chain",
          "type": "fixed",
          "breaking": false,
          "endpoints": [
            "GET /v1/documents/{id}/signed",
            "webhooks",
            "completion email",
            "signing page"
          ],
          "summary": "The signatures of one document are sealed one at a time. A signer who presses Sign while another signature on the same document is being sealed waits a few seconds; the signing page and an embedded frame retry by themselves (a frame whose 30 seconds of retries run out posts `wesign.error` with `detail: \"sealing_in_progress\"`). The completed PDF (GET /v1/documents/{id}/signed, `document.completed` `signed_pdf_url`) carries every signature however close together they land, and a document still out for signature whose file lost an earlier signature gets it back at its next signature. Only the signature that completes the document sends `document.completed` and the \"Completed\" emails. 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.",
          "action": "Keep deduplicating `document.completed` on `document_id` as well as `event_id`: delivery is at-least-once, and a database failure at the moment of completion can still repeat it. If a PDF completed before this release lacks a signature, email support@wesign.now with its `document_id`."
        },
        {
          "id": "signer-company-job-title",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "POST /v1/signing-requests",
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/documents/{id}/confirm",
            "GET /v1/signing-requests/{id}",
            "PATCH /v1/signing-requests/{id}",
            "GET /v1/documents/{id}",
            "GET /v1/documents/{id}/audit-trail",
            "completion email",
            "webhooks"
          ],
          "summary": "Optional `company` (at most 200 characters) and `job_title` (at most 120) on `signers[]` of POST /v1/signing-requests, `recipients[]` of instantiate and `recipients[]` of confirm, where null or a blank value clears the staged one. Both are your statement; we never verify them. Runs of whitespace collapse and the ends are trimmed, a blank value means none, and a longer value or one with a line break, tab or other control character answers 400 `invalid_request`; both keys are listed in `meta.accepted` of `unknown_signer_field` and `unknown_recipient_field`. They print on line 1 of the caption under every signature and initials box, \"{name} · {job_title} · {company} · {email}\", shrunk to fit the box down to 6 pt; when that is not enough, or the line would run past the page's right edge, function and company print on a line of their own under \"{name} · {email}\" (wrapped if needed) and the caption's other lines move down; never cut (without them the caption is unchanged), and on the audit certificate, both the one attached to the completion emails and the one from GET /v1/documents/{id}/audit-trail, as \"Function (stated by the sender)\" and \"Company (stated by the sender)\". Read back as `company` and `job_title`, null when absent, on every signer of the create 201, the direct instantiate 200, the staged instantiate 201 and the confirm 200, on GET and PATCH /v1/signing-requests/{id} (`signer`) and on GET /v1/documents/{id} (`signers[]`), and in the `signer` block of every signer webhook except `signing_request.expired`.",
          "action": null
        },
        {
          "id": "webhook-signer-block",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "webhooks"
          ],
          "summary": "`signing_request.signed` and `document.completed` carry the same `signer` block as the signer events, { email, name, role, slot, reference, company, job_title } (before: { email, name }). `signing_request.sent` from the API or a sequential release and the six signer events (viewed, declined, withdrawn, sms_sent, sms_failed, sms_verified) add `company` and `job_title`. `signing_request.expired` keeps { email, name }, and the `signing_request.sent` of a send from the app keeps { email, name, slot }.",
          "action": null
        },
        {
          "id": "anchor-text-field-key",
          "type": "changed",
          "breaking": true,
          "endpoints": [
            "POST /v1/signing-requests"
          ],
          "summary": "The optional third part of a `text` or `name` anchor marker (`[[ls:text:tenant:person.full_name]]`) is stored as the box's field key; before, it was accepted and ignored. A box keyed with one of the signer's own details, `person.full_name`, `person.first_name`, `person.last_name`, `contact.email`, `company.legal_name` or the short `full_name`, `first_name`, `last_name`, `email`, `company`, `role`, opens with that detail: name and email from the signing request, company and role from the workspace address book entry with the signer's email (the `company` you send on the signer does not fill it). The signer can change it, and an empty box never blocks signing. 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. The third part of a `date` marker is not a key and still has no effect on this endpoint.",
          "action": "If a PDF relies on a bare `[[ls:name:ROLE]]` box staying empty, give the marker a third part of your own (e.g. `[[ls:name:ROLE:witness_name]]`); otherwise nothing to do — the signer can still change the prefilled name."
        },
        {
          "id": "observer-completed-every-signer",
          "type": "changed",
          "breaking": false,
          "endpoints": [
            "completion email"
          ],
          "summary": "\"Completed\" emails go out only when every signer of the document signed, the rule `document.completed`, GET /v1/documents/{id} (`status: \"signed\"`) and the downloads already applied. When a co-signer declined, was withdrawn or expired, the last remaining signature sends no \"Completed\" email and no 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. No subject says \"waiting on 0 more\" any longer.",
          "action": null
        },
        {
          "id": "certificate-always-english",
          "type": "changed",
          "breaking": false,
          "endpoints": [
            "completion email"
          ],
          "summary": "The audit certificate attached to completion emails, to signers, the sender and observers alike, is always in English and prints its times in the time zone of the caption under each signature (the workspace owner's profile time zone, else their country's, else UTC). Before, it was in the language of the signer whose signature completed the document. GET /v1/documents/{id}/audit-trail is unchanged: English, times in UTC.",
          "action": null
        },
        {
          "id": "sms-allowance-raise",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "GET /v1/me",
            "POST /v1/signing-requests",
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/documents/{id}/confirm",
            "webhooks"
          ],
          "summary": "We raise a workspace's monthly SMS allowance on request: email support@wesign.now with your workspace ID and the number of codes you expect per month. The raise is included in your subscription (there is no price per SMS), adds to your plan's codes and applies every month until it is changed; the monthly reset clears the counter, not the raise. `GET /v1/me` → `sms_allowance.monthly`, every 402 `sms_allowance_exhausted` check and the `sms_allowance` of the SMS webhooks use the plan's codes plus the raise.",
          "action": null
        },
        {
          "id": "cjk-text-all-viewers",
          "type": "fixed",
          "breaking": false,
          "endpoints": [
            "GET /v1/documents/{id}/signed",
            "GET /v1/documents/{id}/audit-trail",
            "completion email"
          ],
          "summary": "Chinese, Japanese and Korean text in a sealed PDF or a certificate, such as a signer's name, company or function, renders in every PDF viewer. Before, it rendered in browsers (pdf.js) but showed as empty boxes in Poppler-based viewers and as wrong characters in macOS Preview and Apple Mail. 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, because rewriting them would break their seals.",
          "action": null
        },
        {
          "id": "no-standalone-phone-verification",
          "type": "docs",
          "breaking": false,
          "endpoints": [
            "POST /v1/signing-requests",
            "PATCH /v1/signing-requests/{id}",
            "webhooks"
          ],
          "summary": "Withdrawn: an endpoint to verify a phone number by SMS without a signing request, listed on the SMS verification page as planned for 2026-11-15, is not offered and not planned. A code is only ever requested by the signer on their own signing link. Check numbers in your own interface before you create a request; afterwards `signing_request.sms_failed` names an unusable number and PATCH /v1/signing-requests/{id} corrects it.",
          "action": "If you were waiting for that endpoint, keep checking numbers in your own interface."
        },
        {
          "id": "signer-name-rule-docs",
          "type": "docs",
          "breaking": false,
          "endpoints": [
            "POST /v1/signing-requests"
          ],
          "summary": "Corrected: the stored signer name is \"{first_name} {last_name}\" as soon as either `first_name` or `last_name` is non-blank, and `name` is then ignored. The docs said this happened only when both were given.",
          "action": "Send either `name` or `first_name` + `last_name`, not both."
        },
        {
          "id": "observer-address-docs",
          "type": "docs",
          "breaking": false,
          "endpoints": [
            "POST /v1/signing-requests"
          ],
          "summary": "Corrected: `observer_emails` addresses are checked before they are trimmed, so a leading or trailing space answers 400 `invalid_request`; the docs said they were trimmed. They are lower-cased and de-duplicated after the check.",
          "action": null
        },
        {
          "id": "tsa-token-docs",
          "type": "docs",
          "breaking": false,
          "endpoints": [
            "GET /v1/documents/{id}/signed",
            "webhooks"
          ],
          "summary": "Corrected: the RFC 3161 time stamp is taken over the sealed PDF when the time-stamp authority answers and kept with our records (`tsa_provider`, `tsa_signed_at` on `document.completed`, and on the audit certificate); it is not embedded in the PDF, as the Documents page said.",
          "action": null
        },
        {
          "id": "ai-assistant-docs",
          "type": "docs",
          "breaking": false,
          "endpoints": [
            "/llms.txt",
            "/llms-full.txt"
          ],
          "summary": "https://www.wesign.now/llms.txt lists every docs page with a one-line summary and the machine-readable sources; https://www.wesign.now/llms-full.txt is every page as plain Markdown in one file. Both are generated from the docs on every deploy. New page: Integrator FAQ (https://www.wesign.now/docs/integrator-faq), short answers with a marker for anything not live yet.",
          "action": null
        }
      ]
    },
    {
      "date": "2026-09-25",
      "title": "Monthly cap on instantiate, quiet sequential sends, who signs each slot, file names in every script",
      "openapi": "/openapi/2026-09-25.json",
      "changes": [
        {
          "id": "instantiate-monthly-cap",
          "type": "changed",
          "breaking": true,
          "endpoints": [
            "POST /v1/templates/{id}/instantiate"
          ],
          "summary": "Instantiate counts against the workspace's monthly document cap like POST /v1/signing-requests: at the cap it answers 402 `tier_required` with `meta: { tier, cap, used }` before anything is written, `review: true` included (a staged instance counts from the moment it exists; 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 it today.",
          "action": "Handle 402 `tier_required` on instantiate exactly as on POST /v1/signing-requests."
        },
        {
          "id": "remind-cooldown-retry-after",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "POST /v1/signing-requests/{id}/remind"
          ],
          "summary": "The per-signer reminder cooldown's 429 `rate_limited` carries `Retry-After: 60`, like every other 429. Still no `RateLimit-*` headers: it is not the per-key window.",
          "action": null
        },
        {
          "id": "withdraw-db-failed-500",
          "type": "fixed",
          "breaking": false,
          "endpoints": [
            "POST /v1/signing-requests/{id}/withdraw"
          ],
          "summary": "A failed status update answers 500 `db_failed`; it answered 400, which read as a fault in the request. Nothing changed: the request is still in flight and no webhook fired.",
          "action": "Retry a 500 `db_failed`; it is not a request error."
        },
        {
          "id": "unicode-filenames",
          "type": "fixed",
          "breaking": false,
          "endpoints": [
            "POST /v1/signing-requests",
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/templates/{id}/generate"
          ],
          "summary": "Stored 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. Digits, `_`, `.` and `-` are kept as before; spaces and any other character still become `_`. Reads, the staged 201 and `template_instance.staged` return the name as stored.",
          "action": null
        },
        {
          "id": "generate-content-disposition",
          "type": "fixed",
          "breaking": false,
          "endpoints": [
            "POST /v1/templates/{id}/generate"
          ],
          "summary": "The PDF answer's `Content-Disposition` names the file in RFC 5987 form (`filename*=UTF-8''…`) with an ASCII `filename` fallback (`Müller` → `Muller`), so the downloaded file keeps its real name.",
          "action": null
        },
        {
          "id": "archived-template-409",
          "type": "docs",
          "breaking": false,
          "endpoints": [
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/templates/{id}/generate"
          ],
          "summary": "Documented: an archived template answers 409 `template_unlocked`, with or without a version pin. The dashboard's archive dialog said such calls start returning 410; it now says 409 too. Behaviour unchanged.",
          "action": null
        },
        {
          "id": "send-emails-sequential-release",
          "type": "changed",
          "breaking": true,
          "endpoints": [
            "POST /v1/signing-requests",
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/documents/{id}/confirm"
          ],
          "summary": "`send_emails: false` holds for the later signers of a sequential send: the choice is stored on every signer (for a staged instance, the value its confirm settles on), so a signer released when their turn comes is not emailed either. Before, the next signer was emailed on release whatever the call said. Signing requests created before this release are still emailed on release.",
          "action": "If you relied on that email, send `send_emails: true`; otherwise deliver the stored signing link when the release's signing_request.sent (`source: \"sequential_release\"`) arrives."
        },
        {
          "id": "sent-on-sequential-release",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "webhooks"
          ],
          "summary": "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, or when the email failed). It is emitted before the previous signer's signing_request.signed, so the two may arrive in either order. It carries no signing link. Before, no event marked the release.",
          "action": "A receiver that treats signing_request.sent as once per document should skip `source: \"sequential_release\"`."
        },
        {
          "id": "template-recipient-roles-pins",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "GET /v1/templates/{id}"
          ],
          "summary": "Each `recipients[]` slot carries the author's `role` (\"Landlord\", \"Client\") and identity pin when set: `pinned: \"email\"` with `pinned_email`, the one address instantiate and confirm accept for that slot (any other is 400 `pinned-slot-mismatch`), or `pinned: \"sender\"`, which the app resolves to whoever sends and an API key does not have to match. An unnamed, unpinned slot is still exactly `{ slot }`. Roles and pins are the template's current ones, also for a pinned `version`. PDF templates now keep the roles set in the app's field editor, and a duplicated template keeps its roles and pins.",
          "action": "Send `pinned_email` as the recipient of every slot with `pinned: \"email\"` instead of learning it from a 400."
        }
      ]
    },
    {
      "date": "2026-09-24",
      "title": "SMS you can rely on: before-view gate, read-back, allowance up front, signer events",
      "openapi": "/openapi/2026-09-24.json",
      "changes": [
        {
          "id": "strict-signer-objects",
          "type": "changed",
          "breaking": true,
          "endpoints": [
            "POST /v1/signing-requests"
          ],
          "summary": "A signer object is strict: an unknown key (for example `phone` instead of `phone_e164`) answers 400 `unknown_signer_field` with `meta.signers` = [{ index, fields }] and `meta.accepted`. Before, the key was dropped silently.",
          "action": "Send only the documented signer keys: email, role, name, first_name, last_name, recipient_color, role_label, locale, signing_order, phone_e164, require_sms_verification, sms_gate, reference."
        },
        {
          "id": "strict-confirm-recipients",
          "type": "changed",
          "breaking": true,
          "endpoints": [
            "POST /v1/documents/{id}/confirm"
          ],
          "summary": "A confirm recipient object is strict: an unknown key answers 400 `unknown_recipient_field` (`meta.recipients`, `meta.accepted`). A malformed JSON body answers 400 `invalid_request` instead of confirming the instance unchanged; an empty body still confirms as staged. A refused confirm (400/402/409/422) 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 discarded/not_staged after the edits were saved, on an instance that can no longer be confirmed.",
          "action": "Send only signing_request_id, email, name, phone_e164, phone (deprecated), channel, require_sms_verification, signing_order, sms_gate, reference."
        },
        {
          "id": "strict-instantiate-recipients",
          "type": "changed",
          "breaking": true,
          "endpoints": [
            "POST /v1/templates/{id}/instantiate"
          ],
          "summary": "Recipients accept `phone_e164` and `require_sms_verification` (same rules as a signer on POST /v1/signing-requests), plus `sms_gate` and `reference`. The recipient object is strict: any other key answers 400 `unknown_recipient_field`. Before this release both SMS fields were dropped silently and a directly sent document went out without the SMS gate.",
          "action": "Move SMS signers onto these fields. Remove any other recipient keys."
        },
        {
          "id": "unknown-top-level-keys",
          "type": "changed",
          "breaking": false,
          "endpoints": [
            "POST /v1/signing-requests",
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/templates/{id}/generate",
            "POST /v1/documents/{id}/confirm"
          ],
          "summary": "An unknown top-level key (or multipart field other than `file`) is still ignored, but now named in `warnings[]` as { field, code: \"unknown_field\", message } on the success body, on validate_only answers, on the staged 201s and on the 422 template_input_invalid. The generate PDF stream has no JSON body; it names the same keys in the `X-Unknown-Fields` response header (comma-separated, percent-encoded, at most 50).",
          "action": "Log `warnings[]` (and `X-Unknown-Fields` on the generate PDF stream) and remove every top-level key they name before 2026-12-31."
        },
        {
          "id": "unknown-top-level-keys-400",
          "type": "changed",
          "breaking": true,
          "effective": "2026-12-31",
          "endpoints": [
            "POST /v1/signing-requests",
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/templates/{id}/generate",
            "POST /v1/documents/{id}/confirm"
          ],
          "summary": "Scheduled: from 2026-12-31 an unknown top-level key is refused with 400 `unknown_field`.",
          "action": "Make sure no call produces an `unknown_field` warning of the top-level kind (or an `X-Unknown-Fields` header on the generate PDF stream) before 2026-12-31."
        },
        {
          "id": "confirm-phone-e164",
          "type": "deprecated",
          "breaking": false,
          "sunset": "2026-12-31",
          "endpoints": [
            "POST /v1/documents/{id}/confirm"
          ],
          "summary": "`recipients[].phone_e164` is the confirm's phone field, spelled as everywhere else. `recipients[].phone` stays accepted as a deprecated alias until 2026-12-31; each use adds a `deprecated_field` warning. Both with different values answer 400 `conflicting_fields`. Before this release `phone_e164` was silently ignored on confirm.",
          "action": "Rename `phone` to `phone_e164` in confirm bodies before 2026-12-31."
        },
        {
          "id": "sms-gate",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "POST /v1/signing-requests",
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/documents/{id}/confirm"
          ],
          "summary": "Per signer `sms_gate`: `before_sign` (default, unchanged: the code is asked at the Sign press) or `before_view` (nothing of the document — title, file name, pages, prefilled values — is shown until the code is verified; the code is texted when the signer taps for it on that page, never on page load; every invitation email leaves the document unnamed; the signing page's document endpoints refuse until then). `before_view` needs `require_sms_verification: true` (400 on create and instantiate, 422 invalid_recipients on confirm). Not yet supported in embedded signing: the frame posts wesign.error with detail `sms_required`.",
          "action": "Set `sms_gate: \"before_view\"` on signers whose document must stay hidden until the code."
        },
        {
          "id": "signer-reference",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "POST /v1/signing-requests",
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/documents/{id}/confirm",
            "GET /v1/signing-requests/{id}"
          ],
          "summary": "Per signer `reference` (string ≤ 200), returned on every signer of the create, instantiate and confirm responses and on GET /v1/signing-requests/{id}, and as `signer.reference` on signing_request.sent and the six new signer events (not on signing_request.signed, signing_request.expired or document.completed).",
          "action": null
        },
        {
          "id": "signer-readback",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "POST /v1/signing-requests",
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/documents/{id}/confirm",
            "GET /v1/signing-requests/{id}"
          ],
          "summary": "Every signer in the create, instantiate (direct and staged), confirm and GET /v1/signing-requests/{id} responses carries require_sms_verification, phone_masked (last four digits only), sms_verified_at, sms_gate, signature_level (AES | SES) and reference. GET /v1/documents/{id} does not.",
          "action": "Assert `require_sms_verification` and `signature_level` on the create response instead of trusting the request you sent."
        },
        {
          "id": "send-emails-on-create",
          "type": "added",
          "breaking": true,
          "endpoints": [
            "POST /v1/signing-requests"
          ],
          "summary": "`send_emails` (default true; JSON boolean, multipart \"true\"/\"false\"; any other value, such as the JSON string \"false\" or a multipart \"no\", is a 400 invalid_request — breaking because the key was ignored before). false: no invitation email and no observer notice; `emailed: false` per signer; the document, links and signing_request.sent are created as usual. It does not stop the SMS code (the signer requests it on the signing page), the invitation of a queued sequential signer when its turn comes, automatic reminders or completion emails.",
          "action": "Send a JSON boolean (or leave the key out)."
        },
        {
          "id": "phone-patch",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "PATCH /v1/signing-requests/{id}"
          ],
          "summary": "Correct a signer's phone: body { phone_e164 } (strict: 400 unknown_field, 400 invalid_phone, 400 invalid_request). Allowed while pending, viewed or queued and before a code was verified; otherwise 409 `not_editable` with meta.status and meta.sms_verified_at. Answers the GET shape. Sends nothing, fires no webhook; audit event `phone_changed` with both numbers masked.",
          "action": null
        },
        {
          "id": "sms-allowance-402",
          "type": "added",
          "breaking": true,
          "endpoints": [
            "POST /v1/signing-requests",
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/documents/{id}/confirm"
          ],
          "summary": "When the signers who must verify by SMS outnumber the codes left in the workspace's monthly allowance (Free 25, Pro 50, Branded 200, Teams/Enterprise 100 per paid seat), POST /v1/signing-requests, a direct instantiate and confirm answer 402 `sms_allowance_exhausted` { error, code, sms_allowance, required } before anything is created. Before, the call succeeded and the signer could not sign later. instantiate validate_only reports it under `problems` with `ok: false`; a review:true instantiate is checked at confirm.",
          "action": "Handle 402 `sms_allowance_exhausted`; read `sms_allowance` on GET /v1/me to warn before sending."
        },
        {
          "id": "me-capabilities-allowance",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "GET /v1/me"
          ],
          "summary": "GET /v1/me returns { workspace { id, name, slug, tier }, key { id, scopes }, capabilities { embedded_signing, templates, webhooks, sms_verification, ip_allowlist, metadata }, sms_allowance { monthly, used, remaining, resets_at } } — sms_allowance is null if the workspace row cannot be read.",
          "action": null
        },
        {
          "id": "metadata",
          "type": "added",
          "breaking": true,
          "endpoints": [
            "POST /v1/signing-requests",
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/templates/{id}/generate",
            "GET /v1/documents/{id}"
          ],
          "summary": "`metadata`: a flat object (≤ 16 keys, string/number/boolean/null, ≤ 4 KB) stored on the document and echoed on the create response, GET /v1/documents/{id} and every webhook that names the document. Malformed: 400 `invalid_metadata` — breaking because before this release the key was dropped silently, so any value passed.",
          "action": "If you already send `metadata`, check it against the limits (flat, ≤ 16 keys, keys ^[A-Za-z0-9_.-]{1,40}$, values string ≤ 500 / number / boolean / null, ≤ 4 KB)."
        },
        {
          "id": "signer-webhook-events",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "webhooks"
          ],
          "summary": "Six events on one payload { signing_request_id, document_id, signer { email, name, role, slot, reference }, occurred_at }: signing_request.viewed; .declined (reason, first 1000 characters); .withdrawn (withdrawn_by: api | sender); .sms_sent (phone_masked, attempt, sms_allowance); .sms_failed (reason: invalid_number | landline | blocked | rate_limited | allowance_exhausted | not_configured | provider_error, phone_masked, sms_allowance); .sms_verified (phone_masked, sms_verified_at). Every hook with an empty `events` list receives them without opting in, including every callback_url hook.",
          "action": "Answer 2xx to event types you do not handle; an unanswered delivery is retried for about 33 hours."
        },
        {
          "id": "sent-event-reference",
          "type": "changed",
          "breaking": false,
          "endpoints": [
            "webhooks"
          ],
          "summary": "signing_request.sent: `signer.reference` added, and `signer.slot` is the stored value like on every other signing_request.* event (null on a parallel POST /v1/signing-requests even when a signing_order was passed; that ignored value was echoed before). A direct instantiate (without review) now emits signing_request.sent.",
          "action": null
        },
        {
          "id": "viewed-status",
          "type": "changed",
          "breaking": false,
          "endpoints": [
            "GET /v1/signing-requests/{id}",
            "GET /v1/documents/{id}"
          ],
          "summary": "Status `viewed` is now set: the first time the signer's browser loads the document (with before_view only after the code). Opening the link alone does not count; a scanner that renders the page in a full browser can trigger it. Before this release no request ever reported it. Audit event `viewed` is written with it.",
          "action": "Treat `viewed` like `pending` wherever you branch on status."
        },
        {
          "id": "sms-refusals-audited",
          "type": "changed",
          "breaking": false,
          "endpoints": [
            "GET /v1/signing-requests/{id}"
          ],
          "summary": "A code request refused because the SMS allowance is used up or no usable number is stored now writes audit event `sms_verify_failed` (before: nothing); every refusal also fires signing_request.sms_failed.",
          "action": null
        },
        {
          "id": "hooks-enabled-rotate",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "GET /v1/hooks",
            "POST /v1/hooks/{id}/rotate"
          ],
          "summary": "Every hook carries `enabled`; `include_disabled=true` also lists switched-off hooks. POST /v1/hooks/{id}/rotate returns a new secret once and keeps the old one valid for 24 hours (two v1= entries in the signature header meanwhile).",
          "action": "Verify every v1= entry of the signature header if you ever rotate."
        },
        {
          "id": "sms-locale",
          "type": "changed",
          "breaking": false,
          "endpoints": [
            "signing page"
          ],
          "summary": "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 (which gave every +41 number German and +31/+423 numbers English).",
          "action": "Set `locale` per signer (POST /v1/signing-requests) or per call (instantiate)."
        },
        {
          "id": "validate-only-problems",
          "type": "changed",
          "breaking": false,
          "endpoints": [
            "POST /v1/templates/{id}/instantiate"
          ],
          "summary": "validate_only answers 200 with `problems` (always present) and `ok: false` when the real call would be refused with 402 `sms_allowance_exhausted`. The direct 200 carries `warnings` (it used to drop the validator's warnings).",
          "action": "Check `ok`, not only the HTTP status."
        },
        {
          "id": "multipart-filename",
          "type": "fixed",
          "breaking": true,
          "endpoints": [
            "POST /v1/signing-requests"
          ],
          "summary": "The multipart `filename` field is honoured (it was ignored); over 200 characters is a 400. `metadata`, `send_emails` and `filename` are part of the Idempotency-Key hash when sent, so a retry across this release with the same key and such a body answers 422 idempotency_key_reuse (only within the key's 24 hours).",
          "action": "Keep a multipart `filename` at 200 characters or fewer; use a fresh Idempotency-Key for a retry that spans this release."
        },
        {
          "id": "anchor-dotted-field-key",
          "type": "fixed",
          "breaking": true,
          "endpoints": [
            "POST /v1/signing-requests"
          ],
          "summary": "An anchor marker whose field key contains a dot or is 42–64 characters long (`[[ls:text:tenant:person.date_of_birth]]`) is placed as a text field; before, the whole marker was silently skipped. Its role is now checked like any marker's (400 `unknown_role`), and a PDF whose only markers were such markers no longer falls through to `auto_append` (every signer needs a marker, 400 `signer_has_no_anchor`).",
          "action": "If your PDFs carry text markers with a dotted or 42–64-character field key, make sure their role is one of the request's signer roles."
        },
        {
          "id": "dates-calendar",
          "type": "changed",
          "breaking": true,
          "endpoints": [
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/templates/{id}/generate",
            "POST /v1/documents/{id}/confirm"
          ],
          "summary": "A `date` field value must be a real calendar day; `2026-02-30` is now 422 `invalid_date` (it passed the shape check before).",
          "action": null
        },
        {
          "id": "day-first-dates",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/templates/{id}/generate"
          ],
          "summary": "A `date` field value may be sent day first — `DD.MM.YYYY`, `DD/MM/YYYY`, `DD-MM-YYYY` or `DD MM YYYY`, day and month one or two digits — as well as `YYYY-MM-DD`; it is stored and echoed as `YYYY-MM-DD` and printed in the placeholder's format (see template-print-formats). Month-first input is never reinterpreted: `03/04/1990` is 3 April and `03/15/1990` is 422 `invalid_date`. The `invalid_date` message names the accepted formats.",
          "action": null
        },
        {
          "id": "time-field-type",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "GET /v1/fields",
            "GET /v1/templates/{id}",
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/templates/{id}/generate",
            "POST /v1/documents/{id}/confirm"
          ],
          "summary": "A `time` data 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`, `2.30 PM`), stored and echoed as 24-hour `HH:MM` (`2:30 PM` → `14:30`). `24:00`, `13:00 PM`, `2 PM`, seconds, `14h30` and a bare `1430` are 422 `template_input_invalid` with problem code `invalid_time`. GET /v1/fields can list `time` definitions; `FieldDefinition.type` is now an enum in the spec (a new value is additive: treat an unknown type like `text`).",
          "action": null
        },
        {
          "id": "template-print-formats",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "GET /v1/templates/{id}"
          ],
          "summary": "Read-only `date_format` (`dmy_dot` 15.03.1990 | `dmy_slash` 15/03/1990 | `mdy_slash` 03/15/1990 | `long` written out), `time_format` (`h24` 14:30 | `h12` 2:30 PM) and `lang` (en, de, fr, it, es, nl) on `field_values[]` entries, and `date_format` (+ `lang`) on `date` entries of `signing_fields[]`: how the template prints the value, as its author picked it; absent where the author never picked one. Input, validation, storage and echo never depend on them — month-first input stays 422 `invalid_date` even where a template prints 03/15/1990.",
          "action": null
        },
        {
          "id": "printed-date-formats",
          "type": "changed",
          "breaking": false,
          "endpoints": [
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/templates/{id}/generate",
            "POST /v1/documents/{id}/confirm",
            "POST /v1/signing-requests",
            "signing page"
          ],
          "summary": "Dates in rendered and signed PDFs print in the placeholder's format; a placeholder without one prints 15.03.1990 (an API-filled date printed 1990-03-15). `{{date_today}}` stays written out, in the placeholder's language, else the document's (its signature lines'), else the call's `locale` — a staged generate keeps none, so there English; before, it was English except on a review: true instance. The signing-date stamp (a `date` field or anchor marker) prints in the field's format, else the workspace's house format (United States 03/15/1990, else 15.03.1990), instead of \"24 Sept 2026\"; the signing page shows it exactly as it will be stamped. Nothing sent or echoed changed.",
          "action": null
        },
        {
          "id": "email-links-wesign",
          "type": "changed",
          "breaking": false,
          "endpoints": [
            "POST /v1/signing-requests",
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/documents/{id}/confirm",
            "POST /v1/signing-requests/{id}/remind"
          ],
          "summary": "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>`), and so does the link in the invitation SMS of confirm `channel` sms/both. Both hosts open the same signing page for the same token, and links already sent keep working.",
          "action": "None. If you filter or allowlist our emails by the domain of their links, add wesign.now."
        },
        {
          "id": "dotted-field-keys",
          "type": "changed",
          "breaking": false,
          "endpoints": [
            "GET /v1/fields",
            "GET /v1/templates/{id}",
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/templates/{id}/generate"
          ],
          "summary": "Field keys may contain dots and be up to 64 characters: `person.date_of_birth`, `company.legal_name`, `company.uid` (lowercase segments `[a-z][a-z0-9_]*` joined by single dots; before: `^[a-z][a-z0-9_]{0,39}$`). A dotted key is one flat `field_values` key, never a nested object; every existing key stays valid. Formulas resolve a dotted collection (`SUM(order.items.total)`), and a `{{person.date_of_birth}}` in a template body is filled instead of printed literally.",
          "action": null
        },
        {
          "id": "empty-text-fields",
          "type": "changed",
          "breaking": false,
          "endpoints": [
            "signing page"
          ],
          "summary": "An empty positioned text field no longer blocks the signer.",
          "action": null
        },
        {
          "id": "template-fill-by",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "GET /v1/templates/{id}",
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/documents/{id}/confirm",
            "signing page"
          ],
          "summary": "A PDF template's positioned text field says who fills it. The author picks \"Filled by\": Sender / API (`owner: \"sender\"`, `required: true` unless the author made it optional or gave it a default; the value you send prints as fixed document content the signer cannot change) or Signer (`owner: \"signer\"`, never required, you may prefill it and the signer may change it). Every existing field stays Signer, so no template's contract changes until its author chooses; the choice can only change while a template is unlocked, and a pinned `version` keeps the choice it was locked with. Such a value is document content: every signer sees it as fixed text from their first view, whichever slot its box sits on and in any signing order, and it is sealed exactly once. `slot` on the input only says which signer's field set stores it.",
          "action": "Read `owner` and `required` from GET /v1/templates/{id} and send every required `field_values` key; a dry run (`validate_only`) names any you missed. Step by step: https://www.wesign.now/docs/prepare-a-fill"
        },
        {
          "id": "text-signer-required",
          "type": "changed",
          "breaking": false,
          "endpoints": [
            "GET /v1/templates/{id}"
          ],
          "summary": "`signer_required` is always false on a positioned text field. It used to echo the field's stored flag, but an empty text field has not blocked signing since this release (see `empty-text-fields`).",
          "action": null
        },
        {
          "id": "standard-field-names",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "GET /v1/templates/{id}",
            "GET /v1/fields"
          ],
          "summary": "Every input of GET /v1/templates/{id} carries `standard`: true when its key is one of the platform's standard field names (`person.first_name`, `company.legal_name`, `company.uid`, `address.postal_code`, `contact.email`, `signing.place`, … — 19 English dotted keys anchored to schema.org, ISO 3166-1, E.164, ETSI EN 319 142 / PAdES and the Swiss UID / AHV registers; a per-signer copy such as `signing.place_s2` counts). A standard key also carries `labels` (the name in en, de, fr, it, es, nl), an `example` (unless the workspace registry has one), a hint `pattern` (never enforced) and an English `description`; a custom key carries `labels` only for its own label, under the language the template records it is written in. GET /v1/fields marks registry definitions the same way (`standard`, plus `labels` and `pattern` for a standard key). Custom keys stay first-class and are never refused. A standard key that neither the template nor the registry types takes the standard type (`contact.email` is validated as an email, `person.date_of_birth` as a date) — dotted keys ship in this same release, so no earlier request is affected.",
          "action": "Optional: map your data to the standard names once to fill every template that uses them."
        },
        {
          "id": "validate-only-missing-optional",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/templates/{id}/generate"
          ],
          "summary": "A `validate_only` answer lists `missing_optional`: the inputs you may still supply and left empty (a signer field you can prefill, a sender/api input the author made optional), each with key, label, owner, type, standard, example, pattern, labels, slot, has_default and the print format. It is on the 200 and on the dry-run 422 (next to `problems`), never makes `ok` false, and is absent from a real call's 422. Generate never lists positioned fields (it does not print them) and never requires one.",
          "action": null
        },
        {
          "id": "review-edit-sender-values",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/documents/{id}/confirm"
          ],
          "summary": "A PDF-template instance staged with `review: true` can have its Sender / API values (`owner: \"sender\"`) corrected before it is sent: on `review_url`, Edit values lets a signed-in member of the workspace retype them (never a Signer box or a platform value). Confirm checks the stored values again against the template version the instance was staged from (422 `template_input_invalid`) and copies each Sender / API value onto the signing fields its seal prints, so the signers sign what the reviewer approved. Before, the review page could not edit the values of a PDF-template instance. Confirm itself still takes no `field_values`: such a key is ignored and named in `warnings[]` (`unknown_field`).",
          "action": null
        },
        {
          "id": "instantiate-field-labels",
          "type": "fixed",
          "breaking": false,
          "endpoints": [
            "POST /v1/templates/{id}/instantiate",
            "signing page"
          ],
          "summary": "A positioned 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. The box shows its label, and a Signer box keyed with an auto-fill name (`full_name`, `first_name`, `last_name`, `email`, `company`, `role`, or the standard `person.full_name`, `person.first_name`, `person.last_name`, `contact.email`, `company.legal_name`) opens with the signer's own details when you sent no value and the author set no default. The signer can change it.",
          "action": null
        },
        {
          "id": "confirm-pinned-version",
          "type": "fixed",
          "breaking": false,
          "endpoints": [
            "POST /v1/documents/{id}/confirm"
          ],
          "summary": "Confirming a staged template instance checks its positioned text fields against the template version it was staged from, not the template's current version: a template re-locked while the instance waited for review can no longer block its confirm with a field the staging call never asked for. A file-only instance (generate `review: true`) never requires a positioned field at confirm, as generate never did.",
          "action": null
        },
        {
          "id": "staged-signing-url-null",
          "type": "fixed",
          "breaking": true,
          "endpoints": [
            "GET /v1/signing-requests/{id}",
            "PATCH /v1/signing-requests/{id}"
          ],
          "summary": "A signer of a staged template instance (`review: true`, not confirmed yet) reads `signingUrl: null` and the new `staged: true`, as `GET /v1/documents/{id}` already reported. Before, it read a link that opened only a waiting notice and could not be signed. Every other signer reads `staged: false` and its link as before.",
          "action": "Treat `signingUrl` as nullable; a staged recipient's link comes from the confirm response."
        },
        {
          "id": "malformed-json-400",
          "type": "fixed",
          "breaking": true,
          "endpoints": [
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/templates/{id}/generate"
          ],
          "summary": "A body that does not parse as JSON answers 400 `invalid_request` (\"Invalid JSON body\"), as confirm already did. Before, it was read as `{}`: instantiate refused it with a misleading zod message about `recipients`, and generate rendered the template without any of your values (streaming it even when the lost body asked for `review: true`). An empty body is still `{}`.",
          "action": "Send valid JSON."
        },
        {
          "id": "instantiate-render-failed",
          "type": "fixed",
          "breaking": true,
          "endpoints": [
            "POST /v1/templates/{id}/instantiate"
          ],
          "summary": "When the filled PDF of a rich-text template cannot be rendered or stored, instantiate answers 502 `render_failed` (`{ error, code }`, the confirm 502 body) and creates nothing: no document, no signing request, no email, no webhook, and the `Idempotency-Key` is released. Before, the call succeeded with the template's own PDF, so the signers received the contract with its `{{placeholders}}` unfilled. Applies to `review: true` too; a PDF template is never rendered and is unaffected.",
          "action": "Treat 502 `render_failed` on instantiate as retryable: nothing was created, so retry the same body (with the same `Idempotency-Key`, if you send one)."
        },
        {
          "id": "consolidated-pdf-one-seal",
          "type": "fixed",
          "breaking": false,
          "endpoints": [
            "GET /v1/documents/{id}/signed",
            "webhooks"
          ],
          "summary": "The completed PDF of a multi-signer document (GET /v1/documents/{id}/signed, `document.completed` `signed_pdf_url`) carries one PAdES seal, valid over the whole document. Before, each later seal was added on top of the earlier one, which validators such as Acrobat and pdfsig then reported as invalid (\"Digest Mismatch\") next to the valid last seal.",
          "action": "None for new documents. If a multi-signer PDF completed before this release shows an invalid earlier signature, ask support to re-seal it."
        },
        {
          "id": "earlier-signers-completed-pdf",
          "type": "fixed",
          "breaking": false,
          "endpoints": [
            "completion email"
          ],
          "summary": "When the last signer of a multi-signer document signs, every signer who signed earlier receives the completed, sealed PDF by email, in their own language. Before, that email went out only when the workspace sent signers the Certificate of Completion, which by default only the initiator receives, so whoever signed first never got the final PDF. The certificate still follows that setting.",
          "action": null
        },
        {
          "id": "formula-date-comparisons",
          "type": "fixed",
          "breaking": false,
          "endpoints": [
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/templates/{id}/generate",
            "POST /v1/documents/{id}/confirm",
            "signing page"
          ],
          "summary": "A formula comparison (`<`, `>`, `<=`, `>=`, `=`, `!=`) whose two sides are dates compares them as 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`. Before, a day-first value was compared as text. A value such as `15 03 1990` now reads as a date instead of the number 15031990. Numbers and plain text compare as before.",
          "action": null
        },
        {
          "id": "prepare-a-fill-guide",
          "type": "docs",
          "breaking": false,
          "endpoints": [
            "GET /v1/templates/{id}",
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/templates/{id}/generate"
          ],
          "summary": "New guide \"Prepare a fill in your app\" (https://www.wesign.now/docs/prepare-a-fill): read the template's contract, ask your user for every required input, dry-run with `validate_only`, offer `missing_optional`, then send the same body. Fields & placeholders now lists every standard field name with its meaning, anchor, type, example and pattern.",
          "action": null
        },
        {
          "id": "versioned-spec",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "/openapi.json",
            "/openapi/{date}.json",
            "/api-changelog.json"
          ],
          "summary": "`info.version` is the release date. Each release's spec is frozen at /openapi/<date>.json; /api-changelog.json is the machine-readable changelog. Policy: breaking changes at least 90 days ahead, except refusing input that never had an effect; deprecated fields answer a `deprecated_field` warning (Deprecation/Sunset headers are not sent yet).",
          "action": null
        },
        {
          "id": "docs-corrections",
          "type": "docs",
          "breaking": false,
          "endpoints": [
            "GET /v1/templates",
            "GET /v1/templates/{id}",
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/documents/{id}/confirm",
            "POST /v1/documents/{id}/discard",
            "webhooks"
          ],
          "summary": "Corrected: confirm's phone field (the docs said phone_e164, confirm took only phone); SMS availability (every plan, within the allowance; the pricing matrix showed none on Free); the embedded decline reason (no webhook carried it until signing_request.declined); the full shape of GET /v1/me; GET /v1/signing-requests/{id} returns the FIRST 50 audit events, not the last 50; the SMS code is asked at the Sign press by default, not before the document is shown. New pages: SMS verification, Versioning. In the OpenAPI spec: 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 archived, GET by id still answers it); `source` on a template input is marked legacy (read `owner` and `required`); `review_url` and the 422 `docs` link are pages in the web app for a signed-in workspace member; the direct instantiate 200 carries no `template_id` (the staged 201 and the dry run do); the dry-run note no longer implies that instantiate checks a monthly document cap (it applies none); the 400/402/422 bodies that are either a specific error or the generic Error are `anyOf`, not `oneOf` (every specific body also matched Error, so a strict oneOf rejected all of them); and `signing_request.sent` `signer.slot` is the recipient slot on every template send, in either signing mode (the spec said null in parallel mode).",
          "action": null
        }
      ]
    },
    {
      "date": "2026-09-10",
      "title": "IP allowlist per key, embedded signing, one-time hook secret, expiry events",
      "openapi": null,
      "changes": [
        {
          "id": "ip-allowlist",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "all authenticated /v1 routes"
          ],
          "summary": "An API key can carry an IP/CIDR allowlist (Settings → API, up to 50 entries). Empty = unrestricted. A call from another address answers 403 `ip_not_allowed` with the observed `ip`.",
          "action": null
        },
        {
          "id": "embedded-sign-sessions",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "POST /v1/embedded/sign-sessions",
            "GET /v1/embedded/sign-sessions/{id}",
            "DELETE /v1/embedded/sign-sessions/{id}"
          ],
          "summary": "Short-lived single-signer sessions for an iframe or webview on a registered origin; postMessage schema v1. Enterprise; `embedded` scope.",
          "action": null
        },
        {
          "id": "hook-secret-once",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "POST /v1/hooks"
          ],
          "summary": "The create response carries the hook's `secret` once.",
          "action": null
        },
        {
          "id": "expired-event",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "webhooks",
            "GET /v1/signing-requests/{id}",
            "GET /v1/documents/{id}"
          ],
          "summary": "signing_request.expired is emitted once per request by the hourly expiry cron, and such rows report status `expired`.",
          "action": null
        },
        {
          "id": "completed-every-signer",
          "type": "changed",
          "breaking": true,
          "endpoints": [
            "webhooks"
          ],
          "summary": "document.completed fires only when every signer row is `signed`; `audit_trail_url` is the key-authenticated GET /v1/documents/{id}/audit-trail.",
          "action": null
        },
        {
          "id": "instantiate-error-codes",
          "type": "changed",
          "breaking": false,
          "endpoints": [
            "POST /v1/templates/{id}/instantiate"
          ],
          "summary": "Every error carries a stable `code` (invalid_key, invalid_request, not_found, version_not_found, missing_recipients, db_failed).",
          "action": null
        }
      ]
    },
    {
      "date": "2026-09-09",
      "title": "api.wesign.now is the canonical host",
      "openapi": null,
      "changes": [
        {
          "id": "canonical-host",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "all /v1 routes"
          ],
          "summary": "Canonical base URL https://api.wesign.now/v1; https://api.letssign.now/v1 stays a permanent alias. New keys are wsk_live_…; lsk_live_… keys are accepted forever. Webhooks carry X-WeSign-* and the byte-identical X-LetsSign-* headers.",
          "action": null
        },
        {
          "id": "signed-pdf-url",
          "type": "changed",
          "breaking": true,
          "endpoints": [
            "webhooks"
          ],
          "summary": "document.completed `signed_pdf_url` is the key-authenticated GET /v1/documents/{id}/signed on the API host.",
          "action": "Send the Bearer key when fetching `signed_pdf_url`."
        }
      ]
    },
    {
      "date": "2026-09-06",
      "title": "Review before sending; template endpoints gated, rate-limited and idempotent",
      "openapi": null,
      "changes": [
        {
          "id": "review-before-sending",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "POST /v1/templates/{id}/instantiate",
            "POST /v1/templates/{id}/generate",
            "POST /v1/documents/{id}/confirm",
            "POST /v1/documents/{id}/discard",
            "GET /v1/documents/{id}/pdf"
          ],
          "summary": "`review: true` stages an instance; confirm or discard it; `validate_only: true` dry runs; events template_instance.staged / .confirmed / .discarded.",
          "action": null
        },
        {
          "id": "instantiate-idempotency",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "POST /v1/templates/{id}/instantiate"
          ],
          "summary": "Idempotency-Key support and the per-key rate limit (60 requests per minute); 409 carries `template_unlocked`.",
          "action": null
        },
        {
          "id": "generate-enterprise",
          "type": "changed",
          "breaking": true,
          "endpoints": [
            "POST /v1/templates/{id}/generate"
          ],
          "summary": "generate sits behind the same Enterprise template-channels entitlement as instantiate (402 enterprise-required).",
          "action": null
        },
        {
          "id": "api-document-owner",
          "type": "fixed",
          "breaking": false,
          "endpoints": [
            "POST /v1/signing-requests",
            "POST /v1/templates/{id}/instantiate"
          ],
          "summary": "Documents created over the API are attributed to the key's creator; before, these calls failed with 500.",
          "action": null
        }
      ]
    },
    {
      "date": "2026-07-31",
      "title": "Retirements",
      "openapi": null,
      "changes": [
        {
          "id": "manual-placement-retired",
          "type": "removed",
          "breaking": true,
          "endpoints": [
            "POST /v1/signing-requests",
            "POST /v1/documents",
            "POST /v1/embedded/sessions"
          ],
          "summary": "placement=\"manual\" answers 400 placement_retired; POST /v1/documents answers 410 placement_retired; POST /v1/embedded/sessions answers 410 embedded_sessions_retired. Zero production usage.",
          "action": null
        }
      ]
    },
    {
      "date": "2026-04-30",
      "title": "v1 launch",
      "openapi": null,
      "changes": [
        {
          "id": "v1-launch",
          "type": "added",
          "breaking": false,
          "endpoints": [
            "POST /v1/signing-requests",
            "GET /v1/signing-requests/{id}",
            "POST /v1/signing-requests/{id}/remind",
            "POST /v1/signing-requests/{id}/withdraw",
            "GET /v1/documents/{id}",
            "GET /v1/documents/{id}/signed",
            "GET /v1/documents/{id}/audit-trail"
          ],
          "summary": "The v1 public API: Bearer keys, Idempotency-Key, 60 requests per minute per key, document-scoped callback webhooks.",
          "action": null
        }
      ]
    }
  ]
}
