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
Two ways:
- Workspace-wide — at Developers → Webhooks. Receives events for every document in the workspace.
- Per-request — pass
callback_urlon POST /v1/signing-requests. Scoped to that document only. The HMAC signing secret comes back once in the response undercallback.secret; store it before discarding the response.
Both register a row in our webhooks table; both share the same
delivery + retry mechanism documented below.
Events
| Event | Fired when |
|---|---|
signing_request.sent | An invite email goes out (first signer; subsequent signers in sequential mode). |
signing_request.viewed | A signer opens the document. |
signing_request.signed | A signer completes their signature. |
signing_request.declined | A signer refuses to sign. |
signing_request.expired | The TTL elapses without a signature. |
document.completed | The last pending signer signs. Payload carries signed_pdf_url + audit_trail_url. |
placement_expired | Only for placement="manual" — placement token elapsed before the placer reviewed. |
Sample payload
POST https://yourapp.com/letssign/callback
X-LetsSign-Signature: t=1714399812,v1=9c3b…a2f1
X-LetsSign-Event-Id: evt_01JK7M2N3R…
X-LetsSign-Event: document.completed
Content-Type: application/json
{
"event": "document.completed",
"event_id": "evt_01JK7M2N3R…",
"request_id": "req_…",
"document_id": "doc_8a1e…",
"signed_pdf_url": "https://blob.letssign.now/…/signed.pdf?token=…",
"audit_trail_url": "https://blob.letssign.now/…/audit.pdf?token=…",
"presented_sha256": "0x4f9a…d21c",
"tsa_provider": "freetsa",
"tsa_signed_at": "2026-04-28T15:48:13Z"
}Verifying the signature
Your endpoint MUST verify the X-LetsSign-Signature header before
trusting the body. Pattern is Stripe-style: t=<unix>,v1=<hmac>
where hmac = HMAC-SHA256(secret, "<unix>.<rawBody>").
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyLetssignSignature(
rawBody: string,
header: string | null,
secret: string,
): boolean {
if (!header) return false
const m = header.match(/t=(\d+),v1=([0-9a-f]+)/)
if (!m) return false
const [, ts, sig] = m
// Reject signatures older than 5 minutes — replay protection.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
const expected = createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('hex')
return timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
}Use the raw request body, not a re-serialized JSON object. Express
- body-parser default to JSON-parsing — register a raw-body middleware on your webhook route so the bytes you HMAC-verify are byte-identical to what we signed.
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.
Idempotency on your side
The X-LetsSign-Event-Id header 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.
async function handle(req) {
const eventId = req.headers['x-letssign-event-id']
if (await alreadyProcessed(eventId)) return new Response('ok')
// … do the work …
await markProcessed(eventId)
return new Response('ok')
}