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:

  1. 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.
  2. Per document — pass callback_url on POST /v1/signing-requests. Scoped to that document only; a workspace hook still fires alongside it. The secret comes back once in the response under callback.secret; store it before discarding the response.
  3. REST hooks over the APIPOST /v1/hooks, built for connectors such as Zapier and Make. The secret comes back once, as secret in 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

EventFired whenPayload (in addition to the envelope)
signing_request.sentA 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.signedA signer completes their signature.signing_request_id, document_id, signer { email, name }, sha256
document.completedThe 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.stagedAn instantiate or generate with review: true created a staged instance.document_id, template_id, template_version, kind, review_url, review_expires_at, …
template_instance.confirmedA confirm released the invitations (or finalized a file).document_id, template_id, kind, actor, recipients[]
template_instance.discardedDiscarded by hand or expired.document_id, template_id, kind, reason: "discarded" | "expired"
signing_request.expiredA 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:

HeaderX-WeSign-Signature: t=<t>,v1=<hex>
tUnix 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.
v1hex(HMAC-SHA256(secret, message)) — 64 lowercase hex characters.
SecretThe 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 sentX-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 t is 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, read php://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 subscriptions

An 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/).