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
textornamemarker ([[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. Anamemarker without a third part is keyedperson.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_urlwebhook onPOST /v1/signing-requestsused 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 onPOST /v1/signing-requestsorinstantiateused to leave a signer with a link and nothing to sign. Both now answer500 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-requestsandinstantiatewrite 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-requestsonly), then the onesigning_request.sent. A failed write deletes everything the call created, the PDF it uploaded or rendered included, contacts nobody and answers500 db_failedwithmeta.rolled_back: trueand anerrorthat ends "Nothing was created or sent; retry the request."; theIdempotency-Keyis released. In the rare case that the clean-up fails as well,meta.rolled_backisfalseandmeta.document_idnames the leftover document. Before, a failure onsigners[1]answered500aftersigners[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.completedandGET /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) andjob_title(at most 120) onsigners[], instantiaterecipients[]and confirmrecipients[]. 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 fromGET /v1/documents/{id}/audit-trailalike, labelled "stated by the sender". They are read back ascompanyandjob_titleon every signer object and in the signer block of the webhooks. A line break or other control character, or a longer value, is a400 invalid_request. See Company and function. (signer-company-job-title) - One signer block on every signer webhook.
signing_request.signedanddocument.completedcarry{ email, name, role, slot, reference, company, job_title }, like the signer events; they carried{ email, name }.signing_request.sentfrom the API or a sequential release and the six signer events addcompanyandjob_title.signing_request.expiredis 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.monthlythen shows the plan's codes plus the raise, and every402 sms_allowance_exhaustedcheck uses that number. See the monthly allowance. (sms-allowance-raise) - Docs for AI assistants.
/llms.txtlists every docs page with a one-line summary;/llms-full.txtis 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 thesigning_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); anddocument.completedcould 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 sendsdocument.completedand the "Completed" emails. Keep deduplicatingdocument.completedondocument_id. If a PDF completed before this release lacks a signature, email support@wesign.now with itsdocument_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_failednames an unusable number andPATCH /v1/signing-requests/{id}corrects it. (no-standalone-phone-verification) - The stored signer name is "first last" as soon as either
first_nameorlast_nameis non-blank, andnameis then ignored; the docs said "when both are given". (signer-name-rule-docs) observer_emailsaddresses are checked before they would be trimmed: a leading or trailing space is a400 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_providerandtsa_signed_at; it is not embedded in the PDF, as the Documents page said. (tsa-token-docs)
Migration notes
- On
500 db_failedfromPOST /v1/signing-requestsorinstantiate, retry the same body (the sameIdempotency-Keyis fine). Withmeta.rolled_back: false, first withdraw any signing request ofmeta.document_idthatGET /v1/documents/{id}still shows aspending. - Read webhook signer blocks by key:
signing_request.signedanddocument.completednow carryrole,slot,reference,companyandjob_titletoo. - Optional: send
companyandjob_titlefor 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. - If your PDFs carry
namemarkers, ortextmarkers with a third part, check what those boxes now open with. - Keep deduplicating
document.completedondocument_idas well asevent_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: falseholds for the later signers of a sequential send onPOST /v1/signing-requests,instantiateandconfirm. 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, sendsend_emails: true, or deliver the signer's link when the release'ssigning_request.sentarrives (see Added — signer events). Requests created before this release are still emailed on release.402 tier_requiredoninstantiate: an instance counts against the workspace's monthly document cap exactly likePOST /v1/signing-requests. At the cap nothing is created and the body is{ error, code, meta: { tier, cap, used } }. This applies toreview: truetoo — a staged instance counts from the moment it exists, and its confirm is not counted again. Avalidate_onlydry run reports it inproblemswithok: false. Enterprise, the only tier with template channels, has no cap, so no workspace meets this today.
Added
signing_request.sentalso 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_atandemailed(falsewithsend_emails: false: deliver the link yourself). It may arrive before the previous signer'ssigning_request.signed. Before, no event marked the release. A receiver that treatssentas "the document went out" should skipsource: "sequential_release". See Following a sequential send.- Who signs each slot:
recipients[]onGET /v1/templates/{id}carries the author'sroleand pin when set.pinned: "email"comes withpinned_email, the one addressinstantiateandconfirmaccept for that slot — send it instead of meeting400 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_limitedonPOST /v1/signing-requests/{id}/remindcarriesRetry-After: 60, like every other 429. It still has noRateLimit-*headers: it is not the per-key window.
Fixed
POST /v1/signing-requests/{id}/withdrawanswers500 db_failedwhen the status update fails; it answered400, 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üllermakesVollmacht_Müller.pdf, notVollmacht_M_ller.pdf— and that is the name the invitation email shows. The same goes for afilenamesent toPOST /v1/signing-requestsorgenerate. 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 ASCIIfilenamefallback, so the download keeps its real name.
Docs corrected
- An archived template answers
409 template_unlockedoninstantiateandgenerate, with or without a version pin. The dashboard's archive dialog said such calls would return410; it now says409as well. - The reminder cooldown was documented without
Retry-After; it has one now (above).
Migration notes
- Handle
402 tier_requiredoninstantiateas you do onPOST /v1/signing-requests. - Retry a
500 db_failedfromwithdraw; it is not a request error. - Sequential sends with
send_emails: false: store every signer's link from the create answer and deliver the next one onsigning_request.sentwith"source": "sequential_release"— we no longer email it. A receiver that countssentas 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 —phoneinstead ofphone_e164, a typo — is a400 unknown_signer_fieldnaming every offender (meta.signers,meta.accepted). Before, it was dropped silently. - Template recipients are strict on
POST /v1/templates/{id}/instantiate, and they now takephone_e164,require_sms_verification,sms_gateandreference. 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 a400 unknown_recipient_field. - Confirm recipients are strict on
POST /v1/documents/{id}/confirm(400 unknown_recipient_field), a body that is not JSON is a400 invalid_requestinstead 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 answers409after the edits were saved, on an instance nobody can confirm any more. 402 sms_allowance_exhaustedonPOST /v1/signing-requests, a directinstantiateandconfirm: 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-30in adatefield is422 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 toauto_append— every signer then needs a marker (400 signer_has_no_anchor). metadatais validated onPOST /v1/signing-requests,instantiateandgenerate. 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 a400 invalid_metadata.send_emailsmust be a boolean onPOST /v1/signing-requests(JSONtrue/false, multipart"true"/"false"). Before, the key was ignored; a string such as"false"or"no"is now a400 invalid_request.- The multipart
filenameis read onPOST /v1/signing-requests(it was ignored), so a value over 200 characters is now a400. - A body that is not JSON is a
400 invalid_requestoninstantiateandgenerate, as onconfirm. Before, it was read as{}:instantiaterefused it with a message aboutrecipients, andgeneraterendered the template without your values — streaming it even when the lost body asked forreview: true. An empty body is still{}. signingUrlisnullwhile staged onGET /v1/signing-requests/{id}(and thePATCHanswer), next to a newstaged: true, asGET /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_failedoninstantiate: 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 theIdempotency-Keyis 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 print1990-03-15.{{date_today}}stays written out, now in the placeholder's language, else the document's, else the call'slocale(before: English, except on areview: trueinstance, which used the call'slocale). A stagedgeneratekeeps nolocale, so there it ends in English. The stamped signing date (adatefield or marker) prints in the field's format, else the workspace's house format —15.03.1990, or03/15/1990for a workspace in the United States — instead of24 Sept 2026, and the signing page shows it exactly as it will be stamped. Nothing you send and nothing echoed back changed. validate_onlyoninstantiatealways carriesproblemsand answersok: false(still HTTP 200) when the real call would be refused for the SMS allowance. Readok, not only the status.- Six new webhook events reach every hook whose
eventslist is empty — everycallback_urlhook included — without opting in. Answer2xxto event types you do not handle. viewedis now a real status. Nothing ever set it before; a request now movespending→viewedthe first time the signer's browser loads the document. Treat it likepending.- Idempotency: the hash of
POST /v1/signing-requestsnow includesmetadata,send_emailsand the multipartfilenamewhen you send them — a retry across this deploy with the same key and such a body answers422 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 inauditEvents. - Dotted field keys:
person.date_of_birth,company.legal_name,company.uidare 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_valuesstays 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_requiredisfalseon every positioned text field ofGET /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/…, orhttps://<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 confirmchannelsms/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,generateandconfirmis refused with400 unknown_field. Until then it is ignored and named inwarnings[]— on thegeneratePDF stream, which has no JSON body, in theX-Unknown-Fieldsresponse header. See Unknown keys. - 2026-12-31 —
recipients[].phoneon confirm stops being accepted; usephone_e164. Until then each use adds adeprecated_fieldwarning, and both with different values are a400 conflicting_fields.
Added — SMS
sms_gateper 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_editableotherwise). Sends nothing; audit eventphone_changed, both numbers masked.GET /v1/mereturnssms_allowance: { monthly, used, remaining, resets_at }next tocapabilities.- 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(withreason),.withdrawn(withwithdrawn_by),.sms_sent,.sms_failed(with areasoncode) 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.sentcarriessigner.reference, and a directinstantiate(withoutreview) now emits it. Itssigner.slotis the stored value like on every other event:nullon a parallelPOST /v1/signing-requestseven when asigning_orderwas passed (that ignored value was echoed before).
Added — references and options
reference(≤ 200 characters) per signer and recipient on create, instantiate and confirm.metadataonPOST /v1/signing-requests,instantiateandgenerate: a flat object stored on the document and echoed on the create response,GET /v1/documents/{id}and every webhook naming the document.send_emailsonPOST /v1/signing-requests(defaulttrue), withemailedper 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 directinstantiate200now also carries thefield_valueswarnings it used to drop.capabilitiesonGET /v1/me;enabledon every hook,GET /v1/hooks?include_disabled=true, andPOST /v1/hooks/{id}/rotate(new secret once, the old one valid for 24 hours, twov1=entries in the signature header meanwhile).
Added — day-first dates
- A
datevalue infield_valuesmay be written day first —DD.MM.YYYY,DD/MM/YYYY,DD-MM-YYYYorDD MM YYYY, day and month one or two digits (15.03.1990,5.3.1990,15 03 1990) — as well asYYYY-MM-DD, oninstantiateandgenerate. It is stored and echoed asYYYY-MM-DD. Month-first is never tried:03/04/1990is 3 April, and03/15/1990is422 invalid_date. Theinvalid_datemessage 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/1990or written out (24. September 2026, in a chosen language);14:30or2:30 PM.GET /v1/templates/{id}shows the pick read-only asdate_format,time_formatandlangonfield_values[]entries, anddate_format(+lang) ondateentries ofsigning_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
timefield type:HH:MMorHH.MMon a 24-hour clock, orh:MM AM/PM(14:30,9:05,2:30 PM,2:30pm), stored and echoed as 24-hourHH:MM; anything else is422 invalid_time.GET /v1/fieldscan listtimedefinitions, and the spec now enumerates everyFieldDefinition.type(a new type is an additive change: treat one you do not know liketext).
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"andrequired: trueunless 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.sloton 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: truecan have its Sender / API values corrected onreview_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 nofield_values: such a key is ignored and named inwarnings[]asunknown_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 carriesstandard: true,labels(its name in en, de, fr, it, es, nl), anexample, a hintpattern(never enforced) and an Englishdescription; every other input carriesstandard: false.GET /v1/fieldsmarks registry definitions the same way. Custom keys stay first-class. The list is in Fields & placeholders. missing_optionalon avalidate_onlyanswer ofinstantiateandgenerate, on the200and on the dry-run422: the inputs you may still supply and left empty, with what you need to ask your user for them. It never makesokfalse and is absent from a real call's422.generatenever 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
filenamefield onPOST /v1/signing-requestsis honoured (it was ignored); over 200 characters is a400(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 standardperson.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
(
generatewithreview: 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 for1990-03-25and25.03.1990(a day-first value was compared as text).15 03 1990now 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_e164works on confirm; confirm took onlyphoneand silently ignoredphone_e164. Both work now,phone_e164is 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/meis 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, direct200, staged201, dry runs,422), confirm and discard. Template.statusis an enum —draft,locked,archived; the list never shows an archived template,GET /v1/templates/{id}still answers for one.sourceon a template input is legacy: readownerandrequired.review_urland the422docslink 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
instantiate200carries notemplate_id; the staged201and the dry run do. - The dry-run note implied that
instantiatechecks a monthly document cap. It applies none, dry run or not. - The
400/402/422bodies that are either a specific error or the genericErrorare declaredanyOf, notoneOf: every specific body also matchedError, so a strictoneOfrejected them all. signing_request.sent→signer.slotis the recipient slot on every template send, in either signing mode; the spec saidnullin parallel mode.
Migration notes
- Send only documented keys in
signers[],recipients[]and confirmrecipients[]; watchwarnings[]and clear every top-levelunknown_fieldbefore 2026-12-31. - On confirm, rename
phonetophone_e164before 2026-12-31. - If you instantiate templates with SMS signers, move them onto
recipients[].phone_e164+require_sms_verificationand assertsignature_level: "AES"on the response. - Handle
402 sms_allowance_exhausted; readGET /v1/me→sms_allowanceto warn before sending. - Make your webhook receiver answer
2xxto unknown events, and add the six signer events you want. - Treat
viewedlikepending. - On
instantiatevalidate_only, checkok. - Send
datevalues day first or asYYYY-MM-DD, never month first — also when a template prints them03/15/1990. Sendtimevalues asHH:MMorh:MM AM/PM. - 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.
- Decide what to send from
requiredonGET /v1/templates/{id}, never fromowner: a template whose author sets a text field to Sender / API requires it from its next locked version on. Avalidate_onlydry run names anything you missed. - Optional: map your data to the standard field names once, and offer your
user what
missing_optionallists. - Read
signingUrlonGET /v1/signing-requests/{id}as nullable; a staged recipient's link comes from the confirm. - Retry a
502 render_failedfrominstantiate: nothing was created, so send the same body again (with the sameIdempotency-Key, if you use one). - 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-Foris 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 byOrigin— 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 generic401 invalid_key— a different problem deserves a different code.ipis the address we saw (nullwhen 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_secondsis 60…900 (default 300) and out-of-range is refused, not clamped.embed_urlis 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 theembeddedscope (afullkey 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 }withstatusone ofactive/expired/revoked.DELETErevokes, 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? }towindow.parentwithtargetOriginset 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 withContent-Security-Policy: frame-ancestors <the session's one origin>and omit the app's globalX-Frame-Optionsthere, defaulting toframe-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 answers410 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_urlis nowhttps://api.wesign.now/v1/documents/{document_id}/audit-trail— key-authenticated, same host, same Bearer key and same status rules assigned_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 ruleGET /v1/documents/{id}(status: signed),/signedand/audit-trailalready applied. A document with a declined, withdrawn or expired signer no longer receives it; before, it fired and both URLs answered409 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/hooksnever 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/viewedrequest toexpiredonce itsexpires_athas 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 thesigners[]onGET /v1/documents/{id}now reportstatus: "expired"for such rows (previously the row stayedpendingand expiry was only visible viaexpiresAt).remindon one answers409 expired,withdraw409 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 thecodefield 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/v1is 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-IdandX-WeSign-Eventand theX-LetsSign-*trio with byte-identical values. The scheme is unchanged:t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<rawBody>")>. TheUser-Agentstaysletssign.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_completeuntil every signer signed;404 not_foundfrom 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_urlmoved 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": trueonPOST /v1/templates/{id}/instantiateand/generatestages the instance instead of sending it; newPOST /v1/documents/{id}/confirmand/discard, newGET /v1/documents/{id}/pdffor a finalized file-only instance,"validate_only": truedry runs, and thetemplate_instance.staged/.confirmed/.discardedwebhook events — see Templates.POST /v1/templates/{id}/instantiatetakes anIdempotency-Key(same semantics asPOST /v1/signing-requests) and sits on the per-key rate limit; its409carriescode: "template_unlocked".- Breaking:
POST /v1/templates/{id}/generatesits behind the same Enterprise template entitlement asinstantiate(402 enterprise-required). - Fixed: documents created over the API are attributed to the key's
creator. Before,
POST /v1/signing-requestsandinstantiatefailed with500on the document insert.
2026-07-31 — Retirements (breaking, zero production usage)
placement="manual"onPOST /v1/signing-requests→400 placement_retired.POST /v1/documents(ingest-by-URL for manual placement) →410 placement_retired.file_urlonPOST /v1/signing-requestscovers 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
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/signing-requests | Create — multipart upload, four placement modes |
| GET | /v1/signing-requests/{id} | Read one signer + its first 50 audit events |
| POST | /v1/signing-requests/{id}/remind | Re-send invite |
| POST | /v1/signing-requests/{id}/withdraw | Cancel in-flight |
| GET | /v1/documents/{id} | Holistic doc + signers + rollup status |
| GET | /v1/documents/{id}/signed | Final PAdES-sealed PDF |
| GET | /v1/documents/{id}/audit-trail | Generated audit-trail PDF |
Concepts
Idempotency-Keyheader 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_urlon 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.
