letssign.now docs

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:

  1. Workspace-wide — at Developers → Webhooks. Receives events for every document in the workspace.
  2. Per-request — pass callback_url on POST /v1/signing-requests. Scoped to that document only. The HMAC signing secret comes back once in the response under callback.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

EventFired when
signing_request.sentAn invite email goes out (first signer; subsequent signers in sequential mode).
signing_request.viewedA signer opens the document.
signing_request.signedA signer completes their signature.
signing_request.declinedA signer refuses to sign.
signing_request.expiredThe TTL elapses without a signature.
document.completedThe last pending signer signs. Payload carries signed_pdf_url + audit_trail_url.
placement_expiredOnly 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')
}