Webhooks
Real-time signer events delivered to your URL with HMAC-SHA256 signatures and exponential-backoff retries.
Optional. Without webhooks, integrators get email notifications and can poll the API. With one, we POST events to your endpoint with HMAC-signed bodies and retry on failure.
Registering a webhook
Hooks are per workspace. An API key belongs to exactly one workspace, so "a webhook per API key with a stable secret" is the same thing as a workspace hook: register it once, keep its secret, and every document the workspace sends — over the API or from the dashboard — reaches it. Three ways to register:
- Workspace-wide, from the dashboard — at
Developers → Webhooks. Pick the events (or all).
The signing secret is shown once on creation; the dashboard keeps
only its
whsec_…prefix afterwards. This is the one to use when you verify signatures. - Per document — pass
callback_urlon POST /v1/signing-requests. Scoped to that document only; a workspace hook still fires alongside it. The secret comes back once in the response undercallback.secret; store it before discarding the response. - REST hooks over the API —
POST /v1/hooks, built for connectors such as Zapier and Make. The secret comes back once, assecretin the create response. See REST hooks below.
All three register a row in the same webhooks table and share the
delivery, signing and retry mechanism documented here. Every delivery
is signed with that row's own secret.
Events
| Event | Fired when | Payload (in addition to the envelope) |
|---|---|---|
signing_request.sent | A create call dispatches invites — one event per signer named on POST /v1/signing-requests (source: "v1_api"), or one for the first recipient a template-instance confirm releases (source: "template_instance"). Not re-emitted when sequential mode later releases a queued signer — watch signing_request.signed for progress. | signing_request_id, document_id, signer { email, role, name, slot }, locale, source |
signing_request.signed | A signer completes their signature. | signing_request_id, document_id, signer { email, name }, sha256 |
document.completed | The final signer signs and every signer row on the document is signed. Never fires for a document with a declined, withdrawn or expired signer — see what "completed" means. | signing_request_id, document_id, signer, signed_pdf_url, audit_trail_url, sha256, cert_serial, tsa_provider, tsa_signed_at |
template_instance.staged | An instantiate or generate with review: true created a staged instance. | document_id, template_id, template_version, kind, review_url, review_expires_at, … |
template_instance.confirmed | A confirm released the invitations (or finalized a file). | document_id, template_id, kind, actor, recipients[] |
template_instance.discarded | Discarded by hand or expired. | document_id, template_id, kind, reason: "discarded" | "expired" |
signing_request.expired | A pending / viewed request passes its expires_at — the hourly expiry cron flips its status to expired and emits this once per request. Queued sequential followers behind it do not emit. The document never completes; expired_at is the row's expires_at. | signing_request_id, document_id, signer { email, name }, expired_at |
Every payload carries document_id, which is how a document-scoped
callback_url hook matches the template_instance.* events too.
Envelope and sample payload
Every body is a single JSON object: four envelope fields, then the event's own fields at the same level.
POST https://yourapp.com/wesign/callback
Content-Type: application/json
User-Agent: letssign.now-webhooks/1.0
X-WeSign-Signature: t=1757404800,v1=9c3b…a2f1
X-WeSign-Event-Id: evt_3f9c1a7b2d4e4c6f8a1b2c3d4e5f6a7b
X-WeSign-Event: document.completed
X-LetsSign-Signature: t=1757404800,v1=9c3b…a2f1
X-LetsSign-Event-Id: evt_3f9c1a7b2d4e4c6f8a1b2c3d4e5f6a7b
X-LetsSign-Event: document.completed
{
"event": "document.completed",
"event_id": "evt_3f9c1a7b2d4e4c6f8a1b2c3d4e5f6a7b",
"created_at": "2026-09-09T08:00:00.000Z",
"workspace_id": "3c1f…",
"signing_request_id": "11111111-…",
"document_id": "8a1e4f9a-…",
"signer": { "email": "owner@example.com", "name": "Olivia Owner" },
"signed_pdf_url": "https://api.wesign.now/v1/documents/8a1e4f9a-…/signed",
"audit_trail_url": "https://api.wesign.now/v1/documents/8a1e4f9a-…/audit-trail",
"sha256": "4f9a…d21c",
"cert_serial": "1a2b…",
"tsa_provider": "freetsa",
"tsa_signed_at": "2026-09-09T07:59:58Z"
}signed_pdf_url and audit_trail_url are API URLs on the same
host — GET /v1/documents/{id}/signed and
GET /v1/documents/{id}/audit-trail for the document in document_id.
Fetch either with your workspace's Bearer key, and pin the fetch to
api.wesign.now — neither field ever names any other host. Details,
status codes and a fetch snippet in
Documents → the URLs in document.completed.
The User-Agent is letssign.now-webhooks/1.0 and will stay so —
allow-list it if you filter on UA.
Verifying a signature
Your endpoint must verify the signature before trusting the body. This is the exact scheme — nothing is left to guess:
| Header | X-WeSign-Signature: t=<t>,v1=<hex> |
t | Unix time in seconds at send time (a retry is re-signed with a fresh t). |
| Message | `${t}.${rawBody}` — the decimal t, a literal ., then the request body byte for byte. |
v1 | hex(HMAC-SHA256(secret, message)) — 64 lowercase hex characters. |
| Secret | The whsec_… string shown once when the hook was created: secret in the POST /v1/hooks response, callback.secret on POST /v1/signing-requests, or the dashboard's one-time display. |
| Also sent | X-LetsSign-Signature, X-LetsSign-Event-Id, X-LetsSign-Event — the pre-rename names, byte-identical values, kept forever. Read whichever family you like; a receiver written against either verifies. |
Recommended receiver rules:
- Replay tolerance: 300 seconds. Reject a delivery whose
tis more than five minutes from your clock. Our retry schedule re-signs every attempt, so a legitimate late delivery never fails this check. - Idempotency on
X-WeSign-Event-Id. Delivery is at-least-once; the id (evt_+ 32 hex) is stable across retries of the same event. Store it and short-circuit repeats after the signature check. - Compare in constant time, and hash the raw body — a re-serialised JSON object will not match.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyWeSignSignature(
rawBody: string | Buffer,
header: string | null | undefined,
secret: string,
toleranceSec = 300,
): boolean {
if (!header) return false
const m = /t=(\d+),v1=([0-9a-f]{64})/.exec(header)
if (!m) return false
const [, t, sig] = m
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSec) return false
const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8')
const expected = createHmac('sha256', secret).update(`${t}.${body}`).digest('hex')
const a = Buffer.from(sig, 'hex')
const b = Buffer.from(expected, 'hex')
return a.length === b.length && timingSafeEqual(a, b)
}
// Express: keep the raw bytes on this one route.
import express from 'express'
const app = express()
app.post('/wesign/callback', express.raw({ type: 'application/json' }), async (req, res) => {
const header = req.get('x-wesign-signature') ?? req.get('x-letssign-signature')
if (!verifyWeSignSignature(req.body, header, process.env.WESIGN_WEBHOOK_SECRET!)) {
return res.status(401).end()
}
const eventId = req.get('x-wesign-event-id')!
if (await alreadyProcessed(eventId)) return res.status(200).end()
const event = JSON.parse(req.body.toString('utf8'))
// … handle event.event …
await markProcessed(eventId)
res.status(200).end()
})PHP
<?php
function verifyWeSignSignature(string $rawBody, ?string $header, string $secret, int $toleranceSec = 300): bool
{
if ($header === null || !preg_match('/t=(\d+),v1=([0-9a-f]{64})/', $header, $m)) {
return false;
}
[, $t, $sig] = $m;
if (abs(time() - (int) $t) > $toleranceSec) {
return false; // replay protection
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $sig); // constant-time
}
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_WESIGN_SIGNATURE'] ?? $_SERVER['HTTP_X_LETSSIGN_SIGNATURE'] ?? null;
if (!verifyWeSignSignature($rawBody, $header, getenv('WESIGN_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
$eventId = $_SERVER['HTTP_X_WESIGN_EVENT_ID'] ?? '';
if (alreadyProcessed($eventId)) {
http_response_code(200);
exit;
}
$event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
// … handle $event['event'] …
markProcessed($eventId);
http_response_code(200);Use the raw request body, not a re-serialized JSON object. Express
- body-parser default to JSON-parsing — register
express.raw()on your webhook route so the bytes you HMAC-verify are byte-identical to what we signed. In PHP, readphp://input; in Laravel use$request->getContent().
A worked example, so you can pin a unit test: with secret whsec_test,
t = 1757404800 and body {"event":"ping"}, the message is the
string 1757404800.{"event":"ping"} and v1 is
hex(HMAC-SHA256("whsec_test", that string)).
Retries
We treat any non-2xx response (or no response within 15 s) as a failure. The retry schedule is exponential:
attempt 1 immediate
attempt 2 +1 minute
attempt 3 +5 minutes
attempt 4 +30 minutes
attempt 5 +2 hours
attempt 6 +6 hours
attempt 7 +24 hours (final)After the seventh attempt fails, the row is marked giving_up and
your endpoint won't see that event again. The dashboard shows
the failure history per webhook so you can replay from the UI when
you've fixed the receiver. Every attempt is re-signed with a fresh
t; the event_id stays the same.
Redirects are not followed: a 3xx from your endpoint counts as a
failure. Point the hook at the final URL.
Idempotency on your side
The X-WeSign-Event-Id header (and the event_id field in the body)
is a stable, unique string per event. Use it as a deduplication key in
your handler — at-least-once delivery means you'll occasionally see the
same event twice during retry overlaps. The snippets above show where
the check goes: after the signature, before the work.
Testing a hook
Send test on a dashboard webhook fires a synthetic
signing_request.sent at it, signed with the hook's real secret. The
body carries "test": true and placeholder ids
(req_test_synthetic, doc_test_synthetic) — skip it in handlers that
write to production systems.
REST hooks (for connectors)
No-code tools (Zapier, Make) manage subscriptions programmatically instead of in the dashboard. Subscribe and unsubscribe with your API key:
POST https://api.wesign.now/v1/hooks
Authorization: Bearer wsk_live_…
Content-Type: application/json
{ "target_url": "https://hooks.zapier.com/…", "events": ["document.completed"] }
→ { "id": "…", "target_url": "…", "events": ["document.completed"], "created_at": "…",
"secret": "whsec_…" }DELETE https://api.wesign.now/v1/hooks/{id} # unsubscribe
GET https://api.wesign.now/v1/hooks # list your subscriptionsAn empty events array subscribes to all event types; up to ten
named events otherwise. A REST hook is a workspace-wide row like a
dashboard hook, and its deliveries carry the same signature headers.
The secret is shown once, in the POST /v1/hooks response. Store
secret before discarding the response — GET /v1/hooks lists hooks
without it and no call returns it again. Lost it? DELETE the hook
and subscribe again for a fresh one. Deliveries are signed either
way; a connector that trusts its transport may ignore the field.
Our official Zapier app is built on these endpoints (source:
connectors/zapier/).
