Recipes
End-to-end integration patterns — Portant, a two-signer template, invitations from your own email provider (Resend, Postmark, SendGrid, Amazon SES), Zapier, agent-generated PDFs, CRM sync.
Common shapes integrators ship. Each is a real call sequence with
the corner cases called out. Every URL is on the canonical host
https://api.wesign.now/v1; its alias
works identically.
Document automation tool → wesign.now
Pattern: Your Portant / DocAssemble / Pandoc / LaTeX pipeline generates the PDF; wesign.now sends it for signature.
Embed anchors at template authoring time
Drop [[ls:signature:role]] tokens in the template:
<!-- HTML→PDF template -->
<p>Tenant signature: [[ls:signature:tenant]]</p>
<p>Date: [[ls:date:tenant]]</p>The role is your business-domain identifier. Pick something stable;
you'll reuse it as signers[].role.
Render the PDF
Whatever your generator is, write the bytes to disk or memory. Don't flatten — the marker needs to remain in the text layer.
POST to the endpoint
curl -X POST https://api.wesign.now/v1/signing-requests \
-H "Authorization: Bearer $WSK_KEY" \
-H "Idempotency-Key: ${SOURCE_DOC_ID}" \
-F "file=@/tmp/contract.pdf" \
-F 'signers=[
{"email":"tenant@example.com", "role":"tenant"},
{"email":"landlord@example.com","role":"landlord", "phone_e164":"+41791112233", "require_sms_verification":true}
]' \
-F 'callback_url=https://yourapp.com/wesign/callback'Use your source-document ID as the Idempotency-Key. A retry with the
same key and the same bytes and fields within 24 hours replays the
first 201 instead of sending again. Retry with the bytes you already
sent, not a fresh render: most generators stamp a creation time into the
PDF, so a re-render under the same key is 422 idempotency_key_reuse.
Details in Idempotency. The landlord above
has to confirm an SMS code before signing — see
Second factor by SMS.
Receive the signed PDF
Verify the webhook signature (see Webhooks), then
fetch signed_pdf_url with your API key — it is
https://api.wesign.now/v1/documents/{id}/signed, so pin the fetch
to api.wesign.now — and fetch audit_trail_url the same way (same
host, same key: …/documents/{id}/audit-trail), check the PDF against
the event's sha256, then archive both into your DMS — see
Retrieve the signed document.
Two-signer power of attorney from a template
Pattern: A company grants a power of attorney that two representatives
sign, one after the other. The contract is a locked PDF template in your
workspace; your system supplies the company's details per case. Needs the
Enterprise template entitlement (GET /v1/me → capabilities.templates).
Author the template once in the app, as in the PDF track of the
Template quickstart: two recipient slots,
company.legal_name and company.tax_id as Sender / API boxes,
signing.place and signing.place_s2 as Signer boxes, a signature per
slot, locked.
Read the contract when you build, not per case
GET /v1/templates/{id} answers the slots (recipients) and every input
with its required flag. Build your mapping against that version and pin it
with "version" on every call, so an author who edits the template later
publishes a new version without changing yours.
While someone has the template unlocked for editing, every call answers
409 template_unlocked, pinned or not. Once it is locked again, a call
pinned to the old version keeps that version's contract.
Send one call per case
curl -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",
"job_title": "Geschäftsführerin", "company": "Muster AG" },
{ "slot": 2, "email": "ben@example.ch", "name": "Ben Keller",
"job_title": "Mitglied des Verwaltungsrates", "company": "Muster AG" }
],
"field_values": {
"company.legal_name": "Muster AG",
"company.tax_id": "DE123456789-00001"
},
"signing_mode": "sequential",
"version": 3,
"locale": "de",
"metadata": { "case_id": "ZT-2026-0142" }
}'recipientsis one{ slot, email, name }per slot; a slot without one is400 missing_recipients.job_titleandcompany(optional) say on whose behalf each representative signs. They print under each signature asAnna Muster · Geschäftsführerin · Muster AG · anna@example.chand on the audit certificate (emailed and fromGET /v1/documents/{id}/audit-trail), labelled "stated by the sender"; we do not verify them.- A missing Sender / API value is
422 template_input_invalid, with every problem listed at once. Run the same body with"validate_only": truefirst when the data comes from a person. - Read
warningson the200: anunknown_fieldthere is a misspelt key, and its value was not printed. - The same
Idempotency-Keywith the same body within 24 hours replays the200; it never sends twice.
Both representatives see the company name and tax ID from their first view, as fixed text; the sealed PDF prints them once.
Follow the chain
Slot 1 is invited at once. Slot 2 stays queued until slot 1 has signed,
then is released: invited by email automatically, or — with
send_emails: false — not emailed, and you deliver slot 2's signing_url.
| Event | Means |
|---|---|
signing_request.sent ("source": "v1_api") | Sent to slot 1. Carries your metadata. |
signing_request.signed (slot 1's signing_request_id) | Slot 1 signed. |
signing_request.sent ("source": "sequential_release", signer.slot: 2) | Slot 2 is released. emailed: false with send_emails: false: deliver its link now. May arrive before slot 1's signed. |
signing_request.viewed (signer.slot: 2) | Slot 2 opened the document. |
signing_request.signed, then document.completed | Done: fetch the files. |
signing_request.signed names the signer by signing_request_id (keep both
from the 200), not by slot. If slot 1 declines, or you withdraw it, the
chain stops: slot 2 is never invited and the document never completes.
Archive
On document.completed, verify the signature (see
Webhooks), then fetch
signed_pdf_url and audit_trail_url with your key, pinned to
api.wesign.now, check sha256, and store both with the case. There is one
document.completed, after the second representative signed. Details:
Retrieve the signed document.
Multi-tenant platform (one key, many clients)
Pattern: You run a platform for many firms and send on their behalf.
- One workspace API key covers every document the workspace sends;
hooks are per workspace, so one dashboard-registered webhook with
one stable secret receives everything. Route on
document_id(or on an id you keep in your own table keyed bydocumentIdfrom the201). - If a client needs its own brand on the signing page, that is a workspace of its own with its own key — brand, hosting region and reply-to all hang off the key.
- Pin outbound fetches to
api.wesign.now(or to the alias host if you deliberately use it); thesigned_pdf_urlfield only ever names the canonical host.
Invitations from your own email provider
Pattern: Your app already sends mail through Resend, Postmark, SendGrid or Amazon SES, and the signing invitation should come from your domain, in your own design. wesign.now creates the request, hosts the signing page and seals the PDF; you send the invitation.
Create the request without our invitation
Send "send_emails": false on
POST /v1/signing-requests, with an
Idempotency-Key made from your own business id — for an order,
order-123/sign:
{
"file_url": "https://files.example.com/orders/order-123.pdf",
"placement": "auto_append",
"signing_mode": "sequential",
"send_emails": false,
"signers": [
{ "email": "jane@example.com", "role": "client", "name": "Jane Doe" },
{ "email": "sam@example.com", "role": "countersigner", "name": "Sam Lee" }
],
"metadata": { "order_id": "order-123" }
}We create the document and every signing request, and email nothing: no
invitation, no observer notice. Each signer comes back with signingUrl,
signingRequestId, status — pending, or queued for a later signer of
a sequential send — and emailed: false. The same key with the same body
within 24 hours replays this 201, ids included
(Idempotency).
Send each invitation yourself
For each signer whose status is pending, send your email with their
signingUrl. Key the send on the same business id plus the signer —
order-123/invite/{signingRequestId} — so a retried job sends each
invitation once. What that takes depends on the provider:
- Resend takes the key as an
Idempotency-Keyheader (idempotencyKeyin the second argument ofresend.emails.sendin its Node SDK) and keeps it for 24 hours, like ours. Send the same email on a retry: Resend answers409 invalid_idempotent_requestto a reused key with a different payload. - Postmark (
POST /email/withTemplate,sendEmailWithTemplatein its Node client) takes no idempotency key: store the key of each invitation you sent and check it before a retry. Send through a transactional message stream (outboundis the default one); Postmark keeps broadcast streams for one-to-many mail. - SendGrid: pass the link in a dynamic template's
dynamic_template_dataand reference it with Handlebars, for example{{sign_url}}. Store the key of each invitation you sent, as for Postmark. - Amazon SES (
SendEmailCommandin the SES v2 client): a new account is in the sandbox in each AWS Region — it sends only to verified addresses, at most 200 messages in 24 hours — until you request production access. Name a configuration set (ConfigurationSetName) whose event destination records bounces and complaints.
Bounces are yours to watch: we did not send the invitation, so neither our
dashboard nor emailed tells you whether it arrived. The request turns
viewed once the signer's browser loads the document.
Invite a later signer on their turn
In a sequential send only the first signer is pending. The others are
queued: their link shows a waiting notice until their turn. When a signer
signs, the next one is released, and we emit signing_request.sent with
"source": "sequential_release" and "emailed": false — your cue to send
their invitation (Following a sequential
send).
- Register the hook with the default
fullpayload: aminimalbody carries neithersourcenoremailed, nor yourmetadata. - Webhooks never carry signing links. Use the
signingUrlthe create call gave you for that signer, or read it withGET /v1/signing-requests/{id}.
// Called after verifyWeSignSignature passed and the event id is new
// (Verifying a signature → Node.js). Throwing answers non-2xx: we redeliver.
import { Resend } from 'resend'
import { SignInvite } from './emails/sign-invite' // your React Email template
const resend = new Resend(process.env.RESEND_API_KEY!)
export async function onSigningRequestSent(event: {
signing_request_id: string
source?: string
emailed?: boolean
metadata: { order_id?: string } | null
}) {
// The first signer's invitation went out right after the create call.
if (event.source !== 'sequential_release' || event.emailed !== false) return
const res = await fetch(
`https://api.wesign.now/v1/signing-requests/${event.signing_request_id}`,
{ headers: { Authorization: `Bearer ${process.env.WSK_KEY}` } },
)
if (!res.ok) throw new Error(`wesign.now ${res.status}`)
const { status, signer, signingUrl } = await res.json()
if (status !== 'pending' && status !== 'viewed') return // withdrawn or expired meanwhile
const { error } = await resend.emails.send(
{
from: 'Acme <contracts@acme.example>',
to: signer.email,
subject: 'Your turn to sign',
react: SignInvite({ url: signingUrl }),
},
{ idempotencyKey: `${event.metadata?.order_id}/invite/${event.signing_request_id}` },
)
if (error) throw new Error(error.message)
}What still comes from us:
- The workspace's automatic reminders, from our sending address, by default 7 and 14 days after the request was created. A later signer in a sequential send is counted from that moment too, not from their turn, so they can get the first reminder within a day of your invitation. They follow the workspace's reminder settings (Settings → Signing); switching them off applies to every document of the workspace.
- The completion emails.
- The SMS code, when a signer has to verify by SMS: the signer asks for it on the signing page (Second factor by SMS).
- The signing page (on your workspace's subdomain) and the confirmation after signing. There is no redirect back to your app.
There is no sandbox: test with addresses you control, then withdraw the request (Testing without a sandbox).
Zapier / no-code triggers
Pattern: A Google Form / Typeform / Notion entry triggers a signing.
Without an SDK you can stitch this with Zapier's "Webhooks by Zapier" app, which Zapier does not offer on its free plan:
- Trigger: form submission.
- Action 1: pull the PDF (Google Drive download).
- Action 2: Webhooks by Zapier's POST action to
https://api.wesign.now/v1/signing-requests, with the PDF from action 1 in a field namedfileandsignersas a JSON string, which we parse as JSON. Not Custom Request: Zapier's help states that it cannot send file objects. Check the first run's response for a201withdocumentId. - Action 3: log the response in a Google Sheet for audit.
If the PDF already sits at a URL we can fetch directly (https or http,
no redirects, ≤ 25 MB), skip the binary step: send JSON with
file_url instead of a file part — Custom Request can send that. A
Google Drive download link answers with a redirect, which file_url
refuses (redirect_not_allowed), so a Drive file goes up as bytes.
Most no-code platforms throttle long-running steps — the multipart
upload may take >10s for large PDFs. Set an Idempotency-Key header
from the trigger (the form submission's id) and a retry from Zapier's
built-in error handler replays the first response instead of sending
twice. Without the header, every retry is a new send.
Agent-generated PDFs
Pattern: An AI agent (Claude tool-use, OpenAI function-calling, LangChain/LangGraph) produces a draft contract and ships it for signature without human-in-the-loop review.
Have the agent emit role-tagged anchors — [[ls:signature:client]] —
into the draft and send with placement="anchors". The anchor is
placed where the signature belongs by construction, so there is no
"signature box on page 3 paragraph 4" failure mode. For fully
programmatic layouts, placement="explicit" takes exact coordinates.
placement="manual"(a human-review placement URL) was retired on 2026-07-31 — requests using it receive400 placement_retired. Keep the human in the loop before the send instead: generate, review, then dispatch with anchors — or stage a template instance for review.
CRM sync (Salesforce / HubSpot / Attio)
Pattern: A Closed-Won opportunity triggers a contract send; contract status updates back to the CRM record.
- Webhook from CRM fires when stage flips to "Closed-Won".
- Your handler builds the PDF (template + opportunity field values).
- POST to
https://api.wesign.now/v1/signing-requestswithcallback_urlpointing back at your handler. - On
signing_request.signed, update the CRM custom fieldsigned_at. Ondocument.completed, archive the signed PDF + the audit trail in the CRM Notes / Files tab.
X-WeSign-Event-Id is your dedup key on the receiver side — at-
least-once delivery means retries occasionally re-fire the same event.
(The same value also arrives as X-LetsSign-Event-Id.) For
document.completed, deduplicate on document_id too: a database failure at
the moment of completion can repeat it with a new id.
Building your own
Three rules of thumb:
- Always pass an Idempotency-Key on the create calls —
POST /v1/signing-requestsandPOST /v1/templates/{id}/instantiate./reminddoes not take one and re-sends on every call, so debounce it yourself. - Verify webhook signatures. Skipping this is the #1 incident
pattern; an attacker can forge
signing_request.signedand your handler updates a CRM with bogus state. Node and PHP snippets are in Verifying a signature. - Store the signed PDF yourself. Pull it once on
document.completedand keep it: a workspace can have signed PDFs deleted after a set number of days, after which/signedanswers410 deleted_by_retentionfor good. Don't re-fetch it on every dashboard view either. See Retrieve the signed document.
