Template quickstart
From zero to a sealed PDF made from a template. A one-signer rich-text letter, or a two-signer PDF your system fills and two people sign in order.
This guide takes you from an empty workspace to a PAdES-sealed PDF made from a template: a contract you author once in the app and then send many times over the API, with only the values that change per case. It has two tracks. Pick one in any tab group; every tab group on the page switches with it.
- Rich-text template: "Mandate letter", written in the app's editor. One signer. Your system supplies the person's name and address.
- PDF template: "Power of attorney (company)", an existing PDF with boxes on it. Two representatives sign in order. Your system supplies the company's name and tax ID; each representative types where they sign.
Every URL uses the canonical host https://api.wesign.now/v1
(https://api.letssign.now/v1 is a permanent alias). Start with your key in
a variable:
export WSK_KEY="wsk_live_…"Prerequisites
-
An Enterprise workspace. Creating documents from templates is the Enterprise template entitlement. Without it the calls that create or send answer
402 enterprise-required; reading templates always works:Call Needs GET /v1/templates,GET /v1/templates/{id}A full-access key, on any plan with API keys POST /v1/templates/{id}/instantiateand/generate, dry runs includedThe template entitlement POST /v1/documents/{id}/confirmThe template entitlement POST /v1/documents/{id}/discardA full-access key, so you can clean up after a downgrade The Templates section of the app appears with the same entitlement. Check it over the API:
GET /v1/meanswers"capabilities": { "templates": true, … }. Branch on that flag, never on the tier name (see Feature detection). -
An owner or admin of the workspace to create the API key. Members do not see the developer settings.
-
Addresses you can check. There is no sandbox: every call that is not a dry run creates a real document. See Test without a sandbox.
1 · Create an API key
Open Developers → API keys, give the key a name, keep the
scope Full access (all API) and click Create API key. Copy the
wsk_live_… value into WSK_KEY: it is shown once. A key scoped
Embedded only is refused on every template call (401 invalid_key).
Key format, rotation and IP allowlists are in
Authentication.
Check the key and the entitlement:
curl -s https://api.wesign.now/v1/me \
-H "Authorization: Bearer $WSK_KEY" | jq '.capabilities.templates'
# true2 · Author the template in the app
- Open Templates → New template. Under Who fills it in? choose
Filled by your system, then under Start blank name it
Mandate letterand click Create & open editor. - Write the letter. Where the person's name goes, click Fill fields
and, under Custom field, type
person.full_name. The box suggests Standard names as you type; pick it and add it. Do the same foraddress.full. - Each placeholder you add this way to a template filled by your system has
owner: "api": your call must send it (required: true). The toolbar switch People fill / API fills changes who fills the values while the template is a draft. With People fill, the signer types them on a form before signing and your call may pre-fill them. - Add a Signature line where the person signs. A template has one signer until you add more in the Signers panel; each signature line names its Signer, and each signer needs at least one.
- Click Lock and confirm. The panel Locked · ready for API use shows the Template ID (Copy template ID) and the Fields a caller supplies.
-
Open Templates → New template and choose Upload a file. Pick the PDF. The editor opens on Add recipients.
-
Click Add recipient twice. A template stores signer slots, not people: the first row becomes slot 1 (Representative 1), the second slot 2 (Representative 2). Drag a row to change the order. No email is needed, and only the order is saved with the template.
-
Click Next: Place fields. Choose the recipient under Fields for and place their boxes:
- Representative 1: a Signature, a Date, and three Text boxes for the company name, the company's tax ID and the place of signing.
- Representative 2: a Signature, a Date and a Text box for the place of signing.
Every recipient needs a Signature box. The editor saves a template without one, but every call that sends it then fails with
422 signer_without_signature. -
Select each text box. In its Text field settings:
- Filled by: Sender / API for the company name and the tax ID: your system supplies them and they print as fixed text. Signer for the two places: the representative types it while signing. Every new box starts as Signer.
- API name: the key your API call uses. Type and pick from
Standard names (they carry a Standard badge):
company.legal_name,company.tax_id,signing.place. On Representative 2's box the same pick givessigning.place_s2: names are unique in a template, and_s2marks the second signer's own copy. - Leave Optional for the sender off, so the API requires both Sender / API values.
-
Click Save as template, name it
Power of attorney (company)and click Save template. The template page opens as a draft. Its field list shows every key with Filled by and Required, per signer; you can still rename keys there. -
Click Lock. This freezes the keys and gives the template a stable ID. Unlock later starts a new version; a call can pin the one it was built against with
version.
Use _s2 only for a value that differs per signer, like the place each
representative signs. A value both share, like the company name, is one
Sender / API box with one key.
A Sender / API value is document content. It prints as fixed text that
every signer sees from their first view, in any signing order: sent in
parallel, Representative 2 may sign first and still sees the company name.
Every seal covers it, and it is printed once. Nothing a signer submits can
change it. The box's slot only says whose field set it is copied into,
never who sees it. More in
Who fills a value.
3 · Read the template's contract
List the workspace's templates and take the id of the one with
instantiable: true (status: "locked"):
curl -s https://api.wesign.now/v1/templates \
-H "Authorization: Bearer $WSK_KEY" | jq .{
"templates": [
{
"id": "c7a41e2d-9b3f-4f60-8d15-6e2b0a9c7f48", "name": "Power of attorney (company)",
"description": null, "status": "locked", "version": 3, "pages": 1,
"content_kind": "pdf", "instantiable": true, "updated_at": "2026-09-22T14:05:11.402Z"
},
{
"id": "3b2f7c1e-5a4d-4e8b-9c61-0f2a7d9e4b13", "name": "Mandate letter",
"description": null, "status": "locked", "version": 2, "pages": 1,
"content_kind": "richtext", "instantiable": true, "updated_at": "2026-09-21T08:40:02.117Z"
}
]
}Then read what that template needs:
curl -s https://api.wesign.now/v1/templates/$TPL_ID \
-H "Authorization: Bearer $WSK_KEY" | jq .export TPL_ID=3b2f7c1e-5a4d-4e8b-9c61-0f2a7d9e4b13{
"template": { "id": "3b2f7c1e-5a4d-4e8b-9c61-0f2a7d9e4b13", "name": "Mandate letter",
"status": "locked", "version": 2, "content_kind": "richtext", "instantiable": true, … },
"recipients": [{ "slot": 1 }],
"field_values": [
{ "key": "person.full_name", "kind": "scalar", "owner": "api", "required": true,
"label": "Full name", "type": "text", "example": "Anna Muster", "standard": true, … },
{ "key": "address.full", "kind": "scalar", "owner": "api", "required": true,
"label": "Address", "type": "text", "example": "Bahnhofstrasse 1, 8001 Zürich", "standard": true, … }
],
"signing_fields": [{ "kind": "signature", "slot": 1, "required": true }]
}export TPL_ID=c7a41e2d-9b3f-4f60-8d15-6e2b0a9c7f48{
"template": { "id": "c7a41e2d-9b3f-4f60-8d15-6e2b0a9c7f48", "name": "Power of attorney (company)",
"status": "locked", "version": 3, "content_kind": "pdf", "instantiable": true, … },
"recipients": [{ "slot": 1 }, { "slot": 2 }],
"field_values": [
{ "key": "company.legal_name", "kind": "scalar", "owner": "sender", "required": true,
"signer_required": false, "has_default": false, "slot": 1,
"label": "Company name", "type": "text", "example": "Muster AG", "standard": true, … },
{ "key": "company.tax_id", "kind": "scalar", "owner": "sender", "required": true,
"signer_required": false, "has_default": false, "slot": 1,
"label": "Company tax ID", "type": "text", "example": "DE123456789-00001", "standard": true, … },
{ "key": "signing.place", "kind": "scalar", "owner": "signer", "required": false,
"signer_required": false, "has_default": false, "slot": 1,
"label": "Place of signing", "type": "text", "example": "Zürich", "standard": true, … },
{ "key": "signing.place_s2", "kind": "scalar", "owner": "signer", "required": false,
"signer_required": false, "has_default": false, "slot": 2,
"label": "Place of signing", "type": "text", "example": "Zürich", "standard": true, … }
],
"signing_fields": [
{ "kind": "signature", "slot": 1, "required": true },
{ "kind": "date", "slot": 1, "required": true },
{ "kind": "signature", "slot": 2, "required": true },
{ "kind": "date", "slot": 2, "required": true }
]
}Three properties drive your call (… marks what is left out here; every
property is in Get a template):
recipients: oneslotper signer. Your call assigns one recipient to each (400 missing_recipientsotherwise).required: send every entry that saystrue, or the call fails with422 template_input_invalid. Read the flag; never re-derive it fromowner.owner: who fills the entry.apiandsenderare your side;signeris the signer, and you may pre-fill it.sloton a PDF box says which signer's field set it belongs to.
4 · Dry run
Send the body you are about to send, plus "validate_only": true. Nothing is
created, nothing is sent, and no Idempotency-Key is read.
curl -s -X POST https://api.wesign.now/v1/templates/$TPL_ID/instantiate \
-H "Authorization: Bearer $WSK_KEY" \
-H "Content-Type: application/json" \
-d '{
"recipients": [{ "slot": 1, "email": "anna@example.ch", "name": "Anna Muster" }],
"field_values": {
"person.full_name": "Anna Muster",
"address.full": "Bahnhofstrasse 1, 8001 Zürich"
},
"metadata": { "case_id": "ZT-2026-0142" },
"validate_only": true
}' | jq .{
"ok": true,
"template_id": "3b2f7c1e-5a4d-4e8b-9c61-0f2a7d9e4b13",
"template_version": 2,
"warnings": [],
"problems": [],
"missing_optional": []
}This body forgets the tax ID:
curl -s -X POST https://api.wesign.now/v1/templates/$TPL_ID/instantiate \
-H "Authorization: Bearer $WSK_KEY" \
-H "Content-Type: application/json" \
-d '{
"recipients": [
{ "slot": 1, "email": "anna@example.ch", "name": "Anna Muster" },
{ "slot": 2, "email": "ben@example.ch", "name": "Ben Keller" }
],
"field_values": { "company.legal_name": "Muster AG" },
"signing_mode": "sequential",
"locale": "de",
"metadata": { "case_id": "ZT-2026-0142" },
"validate_only": true
}' | jq .// HTTP/1.1 422 Unprocessable Entity
{
"error": "1 problem with this request.",
"code": "template_input_invalid",
"template_id": "c7a41e2d-9b3f-4f60-8d15-6e2b0a9c7f48",
"template_name": "Power of attorney (company)",
"template_version": 3,
"problems": [
{ "field": "company.tax_id", "label": "Company tax ID", "code": "field_required",
"message": "\"Company tax ID\" is required by this template but was not supplied." }
],
"warnings": [],
"docs": "https://www.letssign.now/de/settings/api",
"missing_optional": [
{ "key": "signing.place", "label": "Place of signing", "owner": "signer", "type": "text",
"standard": true, "example": "Zürich", "labels": { … }, "slot": 1 },
{ "key": "signing.place_s2", "label": "Place of signing", "owner": "signer", "type": "text",
"standard": true, "example": "Zürich", "labels": { … }, "slot": 2 }
]
}Add "company.tax_id": "DE123456789-00001" and the dry run answers 200
with "ok": true. missing_optional still lists the two places: boxes you
may pre-fill, never a reason to refuse.
problemsblock the call, all of them in one answer. Fix and dry-run again.warningsnever block. Anunknown_fieldwarning is a key the template does not have, usually a spelling mismatch.- Read
ok, not only the status: a200withok: falsemeans the real call would be refused for the workspace's monthly document cap or its SMS allowance.
Everything a dry run checks, and what it does not, is under Dry run. Building a form for your own users from these answers is Prepare a fill in your app.
5 · Send
The same body without validate_only, with an
Idempotency-Key so a retry never sends twice:
curl -s -X POST https://api.wesign.now/v1/templates/$TPL_ID/instantiate \
-H "Authorization: Bearer $WSK_KEY" \
-H "Idempotency-Key: ZT-2026-0142-mandate" \
-H "Content-Type: application/json" \
-d '{
"recipients": [{ "slot": 1, "email": "anna@example.ch", "name": "Anna Muster" }],
"field_values": {
"person.full_name": "Anna Muster",
"address.full": "Bahnhofstrasse 1, 8001 Zürich"
},
"metadata": { "case_id": "ZT-2026-0142" }
}' | jq .{
"document_id": "5d8e2a41-7c3b-4f9e-a012-3b6c9d8e1f27",
"metadata": { "case_id": "ZT-2026-0142" },
"template_version": 2,
"signing_mode": "parallel",
"recipients": [
{
"slot": 1,
"email": "anna@example.ch",
"signing_request_id": "4c2e8a17-3b5d-4f6a-9e0c-1d7b3a5f9e82",
"signing_url": "https://yourco.letssign.now/en/sign/5b0e…",
"emailed": true,
"require_sms_verification": false,
"phone_masked": null,
"sms_verified_at": null,
"sms_gate": "before_sign",
"signature_level": "SES",
"reference": null,
"company": null,
"job_title": null
}
],
"field_values_echoed": {
"person.full_name": "Anna Muster",
"address.full": "Bahnhofstrasse 1, 8001 Zürich"
},
"warnings": []
}curl -s -X POST https://api.wesign.now/v1/templates/$TPL_ID/instantiate \
-H "Authorization: Bearer $WSK_KEY" \
-H "Idempotency-Key: ZT-2026-0142-poa" \
-H "Content-Type: application/json" \
-d '{
"recipients": [
{ "slot": 1, "email": "anna@example.ch", "name": "Anna Muster" },
{ "slot": 2, "email": "ben@example.ch", "name": "Ben Keller" }
],
"field_values": {
"company.legal_name": "Muster AG",
"company.tax_id": "DE123456789-00001"
},
"signing_mode": "sequential",
"locale": "de",
"metadata": { "case_id": "ZT-2026-0142" }
}' | jq .{
"document_id": "9f3c6b12-4e7a-4d85-b1c9-2a5e8f0d7c36",
"metadata": { "case_id": "ZT-2026-0142" },
"template_version": 3,
"signing_mode": "sequential",
"recipients": [
{
"slot": 1, "email": "anna@example.ch",
"signing_request_id": "6e1d9b34-8a2c-4e7f-b5d0-3c9a1e7f2b45",
"signing_url": "https://yourco.letssign.now/de/sign/3f9a…",
"emailed": true,
"require_sms_verification": false, "phone_masked": null, "sms_verified_at": null,
"sms_gate": "before_sign", "signature_level": "SES", "reference": null,
"company": null, "job_title": null
},
{
"slot": 2, "email": "ben@example.ch",
"signing_request_id": "7f2eac45-9b3d-4f80-a6e1-4dab2f803c56",
"signing_url": "https://yourco.letssign.now/de/sign/8c21…",
"emailed": false, // queued: invited once slot 1 has signed
"require_sms_verification": false, "phone_masked": null, "sms_verified_at": null,
"sms_gate": "before_sign", "signature_level": "SES", "reference": null,
"company": null, "job_title": null
}
],
"field_values_echoed": {
"company.legal_name": "Muster AG",
"company.tax_id": "DE123456789-00001"
},
"warnings": []
}"signing_mode": "sequential" signs in slot order. Slot 2 is created
queued; its link shows a "waiting for the previous signer" notice until slot 1
has signed. Then slot 2 is released: invited by email automatically, or, with
"send_emails": false, not emailed — the release's signing_request.sent is
your cue to deliver the signing_url you stored (step 6). The places are
left to the representatives; send signing.place or signing.place_s2 to
pre-fill a box they can still change.
A direct send answers 200. Store document_id and every
signing_request_id: the webhooks name them.
recipientsis[{ slot, email, name }], one per slot.nameis optional and fills{{recipient_name}}.phone_e164,require_sms_verification,sms_gate,reference,companyandjob_titleare optional too; any other key is a400 unknown_recipient_field. The whole body is in Templates → Instantiate.signing_urlsits on your workspace's host onletssign.now(https://yourco.letssign.now/…). The invitation email links the same token onwesign.now(https://yourco.wesign.now/…). Both open the same signing page.warnings: check it on every call. A misspelt key is a warning, not an error, and its value is not printed.
Or stage it for a colleague
Add "review": true and the call stages the document instead of
sending it: 201, "status": "staged", a review_url, and no
signing_url (the links only work once the instance is confirmed). Nothing
is emailed or texted. For the power of attorney:
// HTTP/1.1 201 Created
{
"document_id": "9f3c6b12-4e7a-4d85-b1c9-2a5e8f0d7c36",
"status": "staged",
"review_url": "https://www.letssign.now/de/documents/9f3c6b12-4e7a-4d85-b1c9-2a5e8f0d7c36/review",
"review_expires_at": "2026-10-08T09:14:00.000Z",
…
}-
A teammate opens
review_url. It needs a signed-in member of your workspace. The page shows the PDF exactly as it will go out, and the recipients, which they can correct. -
Before sending they can correct the values your system supplied: on a PDF template Edit values retypes the Sender / API boxes (never a signer's box); on a rich-text template Edit content opens the body and its values. See What a reviewer can change.
-
They click Send to 2 recipients, then Confirm. Or your system confirms over the API, with
DOC_IDset to thedocument_idof the201:curl -s -X POST https://api.wesign.now/v1/documents/$DOC_ID/confirm \ -H "Authorization: Bearer $WSK_KEY" \ -H "Content-Type: application/json" \ -d '{}' | jq .
An empty body sends what was staged. The confirm body takes recipients
corrections (addressed by signing_request_id), send_emails and
expires_in_days, and no field_values: values are corrected by a
person on the review page, or you discard the instance and create a new
one. The answer carries a signing_url per recipient; in a sequential
document the later ones come back "status": "queued". Unconfirmed
instances are discarded after 14 days. Details:
Review before sending and
Confirm and discard.
6 · Follow it with webhooks
instantiate has no callback_url: register a workspace hook once, in
Developers → Webhooks or over the API. Without events it
receives every event type:
curl -s -X POST https://api.wesign.now/v1/hooks \
-H "Authorization: Bearer $WSK_KEY" \
-H "Content-Type: application/json" \
-d '{ "target_url": "https://yourapp.example.com/wesign/webhooks" }' | jq .The answer carries the signing secret once. Store it,
verify every delivery, and answer
2xx to event types you do not handle.
For the two-signer PDF, in order:
| When | Event | What it tells you |
|---|---|---|
Instantiate answers 200 | signing_request.sent | The document went out, to slot 1. Once per document, with your metadata. |
| Anna opens her link | signing_request.viewed | signer.slot: 1 |
| Anna signs | signing_request.signed | Anna's signing_request_id. |
| Slot 2 is released | signing_request.sent | Ben's signing_request_id, signer.slot: 2, "source": "sequential_release", and emailed: false with send_emails: false — deliver his link now. Arrives around Anna's signed, in either order. |
| Ben opens his link | signing_request.viewed | signer.slot: 2 |
| Ben signs | signing_request.signed, then document.completed | Download the sealed PDF and the audit trail. |
The one-signer letter stops after Anna: her signing_request.signed is
followed by document.completed.
So signing_request.sent fires twice for this document: once when it goes
out ("source": "v1_api", slot 1) and once when slot 2's turn comes
("source": "sequential_release"). The release carries no link — match its
signing_request_id to the signing_url from your 200, or read it from
GET /v1/signing-requests/{id}.
Details: Following a sequential send.
// signing_request.sent
{
"event": "signing_request.sent",
"event_id": "evt_3f9c1a7b2d4e4c6f8a1b2c3d4e5f6a7b",
"created_at": "2026-09-24T09:14:03.120Z",
"workspace_id": "3c1f…",
"metadata": { "case_id": "ZT-2026-0142" },
"signing_request_id": "6e1d9b34-8a2c-4e7f-b5d0-3c9a1e7f2b45",
"document_id": "9f3c6b12-4e7a-4d85-b1c9-2a5e8f0d7c36",
"signer": { "email": "anna@example.ch", "name": "Anna Muster", "role": null, "slot": 1, "reference": null, "company": null, "job_title": null },
"locale": "de",
"template_id": "c7a41e2d-9b3f-4f60-8d15-6e2b0a9c7f48",
"template_version": 3,
"source": "v1_api"
}// document.completed
{
"event": "document.completed",
"event_id": "evt_9a0b…",
"created_at": "2026-09-25T16:02:41.508Z",
"workspace_id": "3c1f…",
"metadata": { "case_id": "ZT-2026-0142" },
"signing_request_id": "7f2eac45-9b3d-4f80-a6e1-4dab2f803c56",
"document_id": "9f3c6b12-4e7a-4d85-b1c9-2a5e8f0d7c36",
"signer": { "email": "ben@example.ch", "name": "Ben Keller", "role": null, "slot": 2, "reference": null, "company": null, "job_title": null },
"signed_pdf_url": "https://api.wesign.now/v1/documents/9f3c6b12-4e7a-4d85-b1c9-2a5e8f0d7c36/signed",
"audit_trail_url": "https://api.wesign.now/v1/documents/9f3c6b12-4e7a-4d85-b1c9-2a5e8f0d7c36/audit-trail",
"sha256": "4f9a…d21c",
"cert_serial": "1a2b…",
"tsa_provider": "freetsa",
"tsa_signed_at": "2026-09-25T16:02:39Z"
}On signing_request.sent, signer.role is the slot's role name when the
template has one (a rich-text template's Signers panel), else null; the
later events carry role: null for a template recipient, so match them on
slot or signing_request_id. A staged instance fires
template_instance.staged first; its confirm fires
template_instance.confirmed and then signing_request.sent with
"source": "template_instance". Every event and payload:
Webhooks.
7 · Download the sealed PDF and the audit trail
After document.completed, fetch both with the same key:
export DOC_ID=9f3c6b12-4e7a-4d85-b1c9-2a5e8f0d7c36
curl -OJ -H "Authorization: Bearer $WSK_KEY" \
https://api.wesign.now/v1/documents/$DOC_ID/signed
curl -OJ -H "Authorization: Bearer $WSK_KEY" \
https://api.wesign.now/v1/documents/$DOC_ID/audit-trail/signed is the PAdES-sealed PDF: every signature, an RFC 3161 timestamp,
and the Sender / API values printed once. Before every signer has signed,
both answer 409 not_complete. These are the signed_pdf_url and
audit_trail_url of the webhook: pin the fetch to api.wesign.now and send
the key (see
Documents).
Test without a sandbox
Every key is live: a real call creates a real document and sends real email (No sandbox yet). Three ways to test safely:
| Use | What happens | Watch out for |
|---|---|---|
"validate_only": true | Every check the real call makes, the SMS allowance included. Nothing is created, emailed or cached. | Needs the entitlement and costs one request of the rate limit. It does not render the PDF or deliver email. |
"send_emails": false | A real document; no invitation email, not even for a sequential signer when their turn comes. Open each signing_url yourself and sign. | Some mail still goes out: an SMS code when a signer asks for one, reminders and completion emails. Use addresses you control. |
"review": true, then discard | A staged instance: nothing is emailed or texted and the signing links do not work. Open review_url to see the exact PDF. | Clean up with POST /v1/documents/{id}/discard. The recipients are withdrawn and were never contacted; the document stays in the app's document list as withdrawn. |
# DOC_ID: the document_id of the staged instance's 201
curl -s -X POST https://api.wesign.now/v1/documents/$DOC_ID/discard \
-H "Authorization: Bearer $WSK_KEY" | jq .
# { "ok": true, "status": "discarded", "kind": "esign", "document_id": "…",
# "already_discarded": false, "withdrawn": 2, "deleted": false }What's next
- Prepare a fill in your app: build a form for your users from the template's contract.
- Templates: the full instantiate, review, confirm and generate reference.
- Fields & placeholders: who fills a value, and the standard field names.
- Recipes: the two-signer flow as a production pattern.
- Errors: every template error code.
