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:

    CallNeeds
    GET /v1/templates, GET /v1/templates/{id}A full-access key, on any plan with API keys
    POST /v1/templates/{id}/instantiate and /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/me answers "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'
# true

2 · Author the template in the app

  1. Open Templates → New template. Under Who fills it in? choose Filled by your system, then under Start blank name it Mandate letter and click Create & open editor.
  2. 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 for address.full.
  3. 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.
  4. 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.
  5. Click Lock and confirm. The panel Locked · ready for API use shows the Template ID (Copy template ID) and the Fields a caller supplies.
  1. Open Templates → New template and choose Upload a file. Pick the PDF. The editor opens on Add recipients.

  2. 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.

  3. 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.

  4. 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 gives signing.place_s2: names are unique in a template, and _s2 marks the second signer's own copy.
    • Leave Optional for the sender off, so the API requires both Sender / API values.
  5. 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.

  6. 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: one slot per signer. Your call assigns one recipient to each (400 missing_recipients otherwise).
  • required: send every entry that says true, or the call fails with 422 template_input_invalid. Read the flag; never re-derive it from owner.
  • owner: who fills the entry. api and sender are your side; signer is the signer, and you may pre-fill it. slot on 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.

  • problems block the call, all of them in one answer. Fix and dry-run again.
  • warnings never block. An unknown_field warning is a key the template does not have, usually a spelling mismatch.
  • Read ok, not only the status: a 200 with ok: false means 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.

  • recipients is [{ slot, email, name }], one per slot. name is optional and fills {{recipient_name}}. phone_e164, require_sms_verification, sms_gate, reference, company and job_title are optional too; any other key is a 400 unknown_recipient_field. The whole body is in Templates → Instantiate.
  • signing_url sits on your workspace's host on letssign.now (https://yourco.letssign.now/…). The invitation email links the same token on wesign.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",
  …
}
  1. 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.

  2. 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.

  3. They click Send to 2 recipients, then Confirm. Or your system confirms over the API, with DOC_ID set to the document_id of the 201:

    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:

WhenEventWhat it tells you
Instantiate answers 200signing_request.sentThe document went out, to slot 1. Once per document, with your metadata.
Anna opens her linksigning_request.viewedsigner.slot: 1
Anna signssigning_request.signedAnna's signing_request_id.
Slot 2 is releasedsigning_request.sentBen'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 linksigning_request.viewedsigner.slot: 2
Ben signssigning_request.signed, then document.completedDownload 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:

UseWhat happensWatch out for
"validate_only": trueEvery 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": falseA 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 discardA 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