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 API — POST /v1/hooks, built for connectors such as Zapier and Make. The secret comes back once, as secret in the create response; lost it, or rotating on a schedule? POST /v1/hooks/{id}/rotate mints a new one and keeps the old one valid for 24 hours. 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 signer is invited. Once per document when an API call puts it out for signature: for the first signer POST /v1/signing-requests or a direct POST /v1/templates/{id}/instantiate invites (source: "v1_api"), or for the first recipient a confirm releases (source: "template_instance") — not again for the other signers of a parallel send. And once per signer a sequential send releases (source: "sequential_release") — see Following a sequential send. Fires with send_emails: false too.signing_request_id, document_id, signer { email, name, role, slot, reference, company, job_title }, locale, source; from instantiate also template_id, template_version; a release adds occurred_at and emailed. Payloads.
signing_request.viewedThe signer's browser loaded the document for the first time (status pending → viewed). Details.base + nothing else
signing_request.sms_sentAn SMS verification code went out — one per code, resends included.base + phone_masked, attempt, sms_allowance
signing_request.sms_failedThe signer asked for a code and none went out.base + reason, phone_masked, sms_allowance
signing_request.sms_verifiedThe signer entered the correct code. Once per request.base + phone_masked, sms_verified_at
signing_request.signedA signer completes their signature.signing_request_id, document_id, signer (the signer block), sha256
signing_request.declinedThe signer declined. The document can no longer complete.base + reason
signing_request.withdrawnThe request was withdrawn by your key or by a user in the dashboard.base + withdrawn_by
document.completedThe final signer signs and every signer row on the document is signed. Once per document, from the signature that completed it, also when parallel signers sign within the same seconds. Never fires for a document with a declined, withdrawn or expired signer — see what "completed" means.signing_request_id, document_id, signer (the completing signer, signer block), 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 (esign | file), review_url, review_expires_at, source: "v1_api", api_key_id; e-sign adds signing_mode and recipients[{ signing_request_id, slot, email }], a file adds filename.
template_instance.confirmedA confirm released the invitations (or finalized a file).document_id, template_id, template_version, kind, actor (api_key | user); e-sign adds expires_at and recipients[{ signing_request_id, slot, email, status }].
template_instance.discardedDiscarded by your key or a user, or expired after the 14-day review window.document_id, template_id, kind, reason (discarded | expired), actor (api_key | user | system — system is the expiry).
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

"base" is the shared signer-event payload described in Signer events. Every payload carries document_id, which is how a document-scoped callback_url hook matches the template_instance.* and signer events too — and with it metadata, the object you passed when you created the document through the API (see below).

Which hooks receive new events

A hook whose events list is empty receives every event type — including types added later, such as the six signer events of the 2026-09-24 release. Every callback_url hook is such a hook. A hook with an explicit list receives exactly that list. So:

  • Answer 2xx to event types you do not handle. An unanswered or non-2xx delivery is retried for about 33 hours (see Retries) before it gives up.
  • Branch on event, never on the shape of the body.

Signer events

signing_request.viewed, .declined, .withdrawn, .sms_sent, .sms_failed and .sms_verified share one base, after the envelope and metadata:

FieldMeaning
signing_request_idThe signer's request — the id your create response returned per signer.
document_idThe document.
signer{ email, name, role, slot, reference, company, job_title } — name is the stored display name; role is the anchor role of a POST /v1/signing-requests signer (else null); slot the template slot, else the stored signing order (null in a parallel upload, even when a signing_order was passed — the same value on signing_request.sent and on every signer event); reference your own per-signer id; company and job_title what you stated for the signer (each null if none).
occurred_atWhen the transition happened. created_at in the envelope is when this delivery was queued.
{
  "event":              "signing_request.sms_failed",
  "event_id":           "evt_8c1d…",
  "created_at":         "2026-09-24T09:14:03.120Z",
  "workspace_id":       "3c1f…",
  "metadata":           { "mandate": "M-2026-0415" },
  "signing_request_id": "11111111-…",
  "document_id":        "8a1e4f9a-…",
  "signer":             { "email": "anna@example.ch", "name": "Anna Muster", "role": null, "slot": 1, "reference": "person-8812", "company": "Muster AG", "job_title": "Geschäftsführerin" },
  "occurred_at":        "2026-09-24T09:14:02.981Z",
  "reason":             "landline",
  "phone_masked":       "•••• •••• 2233",
  "sms_allowance":      { "monthly": 300, "used": 41, "remaining": 259, "resets_at": "2026-10-01T00:00:00.000Z" }
}

signing_request.signed and document.completed carry the same signer block (without occurred_at). If the signer row cannot be read at that moment, their block still carries email and name, with null for the rest, rather than holding the event back. signing_request.expired carries the short { email, name }.

The other five differ only in event and the fields after occurred_at:

// signing_request.viewed — nothing after occurred_at

// signing_request.declined
"reason": "Das Geburtsdatum ist falsch."          // or null

// signing_request.withdrawn
"withdrawn_by": "api"                             // or "sender"

// signing_request.sms_sent
"phone_masked": "•••• •••• 2233",
"attempt": 2,                                     // the second code for this request
"sms_allowance": { "monthly": 300, "used": 42, "remaining": 258, "resets_at": "2026-10-01T00:00:00.000Z" }

// signing_request.sms_verified
"phone_masked": "•••• •••• 2233",
"sms_verified_at": "2026-09-24T09:15:40.112Z"

What each adds and when it fires:

EventExtra fieldsFires
signing_request.viewed—Once, the first time the signer's browser loads the document — on the signing page or in an embedded frame. Opening the link alone does not count: a plain fetch of the link (what most mail scanners do) does not trigger it, but a scanner that renders the page in a full browser can. With sms_gate: "before_view" only after the code is verified. Not guaranteed before signed; queued signers never emit it.
signing_request.declinedreason: what the signer typed, trimmed, null if nothing; the first 1000 characters (the full text is in auditEvents[type=declined].meta.reason on GET /v1/signing-requests/{id}).Once, when the signer declines — hosted page or embedded frame.
signing_request.withdrawnwithdrawn_by: "api" (your key, POST /v1/signing-requests/{id}/withdraw) or "sender" (a user in the dashboard, one request or a whole envelope — one event per request pulled).Once per request. Not for cancelling a Send-Later request before it went out, nor for discarding a staged instance (that is template_instance.discarded).
signing_request.sms_sentphone_masked; attempt: codes sent for this request so far, this one included (null if the count could not be read); sms_allowance after this code.Each time Twilio accepts a code — sent in the request's locale. Not the invitation SMS of a confirm with channel sms/both.
signing_request.sms_failedreason (below); phone_masked (null when no usable number is stored); sms_allowance.Each time the signer asks for a code and none goes out — the moment they ask, so a landline or blocked number shows at once. Clicks our own throttle refuses (4 per minute per IP) do not emit.
signing_request.sms_verifiedphone_masked; sms_verified_at, the stamp the AES signature relies on.Once, when the correct code is entered.

sms_failed.reason:

reasonCauseWhat to do
invalid_numberNo usable E.164 number stored, or Twilio rejected the number.Correct the number.
landlineA landline cannot receive SMS.Correct it to a mobile number.
blockedDestination country not enabled for our SMS service, an embargoed destination, or Twilio's fraud guard blocked the number's prefix for 12 hours.Contact support with the signing_request_id.
rate_limitedMore than 5 codes without a completed check (clears when the 10-minute code expires), or Twilio throttling.Wait; the signer retries.
allowance_exhaustedThe workspace's monthly SMS allowance is used up. The signer cannot sign until the reset at 00:00 UTC on the 1st — never downgraded.Nothing until the reset; see SMS verification.
not_configuredThe deployment has no SMS provider — never in production.—
provider_errorAnything else from the SMS provider.Retry later; contact support if it persists.

A wrong code emits nothing (it is on the audit trail), and a phone change with PATCH /v1/signing-requests/{id} emits nothing either — the next sms_sent carries the new phone_masked. The whole SMS flow is on SMS verification.

Document and template payloads

The bodies below show the envelope and every field the event carries. The metadata echo is shown after the envelope for readability; key order is not part of the contract.

signing_request.sent from POST /v1/signing-requests. name is the name you sent (null when you sent only first_name and last_name; the later events carry the stored name); role is the signer's anchor role; slot is the stored signing order, null in a parallel send:

{
  "event":              "signing_request.sent",
  "event_id":           "evt_5b0e…",
  "created_at":         "2026-09-24T09:00:01.412Z",
  "workspace_id":       "3c1f…",
  "metadata":           { "case_id": "ZT-2026-0142" },
  "signing_request_id": "11111111-…",
  "document_id":        "8a1e4f9a-…",
  "signer":             { "email": "tenant@example.com", "name": "Ann Tenant", "role": "tenant", "slot": null, "reference": "crm-contact-4711", "company": null, "job_title": null },
  "locale":             "en",
  "source":             "v1_api"
}

From a direct instantiate the same event also names the template, and role is the slot's role label from the template (null if the slot has none):

  "signer":           { "email": "anna@example.ch", "name": "Anna Muster", "role": "Client", "slot": 1, "reference": "person-8812", "company": "Muster AG", "job_title": "Geschäftsführerin" },
  "locale":           "de",
  "template_id":      "c0ffee00-…",
  "template_version": 4,
  "source":           "v1_api"

From a confirm it carries "source": "template_instance" and no template_id — read that from template_instance.confirmed, which fires just before it.

From a sequential release it carries "source": "sequential_release", the moment of the release as occurred_at, and emailed — see Following a sequential send.

template_instance.staged, an e-sign instance (instantiate with review: true):

{
  "event":             "template_instance.staged",
  "event_id":          "evt_9d21…",
  "created_at":        "2026-09-24T09:00:02.030Z",
  "workspace_id":      "3c1f…",
  "metadata":          { "case_id": "ZT-2026-0142" },
  "document_id":       "8a1e4f9a-…",
  "template_id":       "c0ffee00-…",
  "template_version":  4,
  "kind":              "esign",
  "review_url":        "https://www.letssign.now/en/documents/8a1e4f9a-…/review",
  "review_expires_at": "2026-10-08T09:00:01.977Z",
  "signing_mode":      "sequential",
  "recipients": [
    { "signing_request_id": "11111111-…", "slot": 1, "email": "anna@example.ch" },
    { "signing_request_id": "22222222-…", "slot": 2, "email": "partner@example.ch" }
  ],
  "source":            "v1_api",
  "api_key_id":        "0b9a7e3c-…"
}

A file-only instance (generate with review: true) has "kind": "file" and a filename instead of signing_mode and recipients.

template_instance.confirmed, e-sign. recipients[].status is pending for the recipients invited now and queued for the later ones of a sequential send; expires_at is the signing links' new expiry, counted from the confirm:

{
  "event":            "template_instance.confirmed",
  "event_id":         "evt_1f7a…",
  "created_at":       "2026-09-25T13:12:44.508Z",
  "workspace_id":     "3c1f…",
  "metadata":         { "case_id": "ZT-2026-0142" },
  "document_id":      "8a1e4f9a-…",
  "template_id":      "c0ffee00-…",
  "template_version": 4,
  "kind":             "esign",
  "actor":            "api_key",
  "expires_at":       "2026-10-09T13:12:43.901Z",
  "recipients": [
    { "signing_request_id": "11111111-…", "slot": 1, "email": "anna@example.ch", "status": "pending" },
    { "signing_request_id": "22222222-…", "slot": 2, "email": "partner@example.ch", "status": "queued" }
  ]
}

A file-only confirm carries document_id, template_id, template_version, "kind": "file" and actor — no expires_at, no recipients.

template_instance.discarded:

{
  "event":        "template_instance.discarded",
  "event_id":     "evt_77c0…",
  "created_at":   "2026-10-08T10:00:03.114Z",
  "workspace_id": "3c1f…",
  "metadata":     { "case_id": "ZT-2026-0142" },
  "document_id":  "8a1e4f9a-…",
  "template_id":  "c0ffee00-…",
  "kind":         "esign",
  "reason":       "expired",
  "actor":        "system"
}

A file-only instance is deleted when it is discarded, before the event is sent, so its discarded event carries "metadata": null — match it on document_id, which the staged event gave you together with your metadata.

Following a sequential send

In a sequential send only the first signer is invited at once; the others stay queued and emit nothing. When a signer signs, the next one is promoted to pending — their link works from that moment — and the release emits a signing_request.sent for them:

{
  "event":              "signing_request.sent",
  "event_id":           "evt_8d4f…",
  "created_at":         "2026-09-25T09:41:07.512Z",
  "workspace_id":       "3c1f…",
  "metadata":           { "case_id": "ZT-2026-0142" },
  "signing_request_id": "7f2eac45-…",
  "document_id":        "8a1e4f9a-…",
  "signer":             { "email": "ben@example.com", "name": "Ben Beispiel", "role": "Tenant", "slot": 2, "reference": null, "company": null, "job_title": null },
  "occurred_at":        "2026-09-25T09:41:07.498Z",
  "locale":             "de",
  "source":             "sequential_release",
  "emailed":            false
}
  • source: "sequential_release" tells it apart from the document's first sent (v1_api or template_instance). A receiver that only records "the document went out" can skip it.
  • emailed says whether we emailed the invitation. It is false when the document was created with send_emails: false — we send nothing and you deliver the link — and when our email failed; the link works either way.
  • The link is the one the create call already gave you for that signer: signing_url from instantiate or a confirm, signingUrl from POST /v1/signing-requests, or signingUrl on GET /v1/signing-requests/{id}. Webhooks never carry signing links — a link is the signer's credential.
  • The release happens while the previous signer's signature is sealed, before that signer's signing_request.signed is emitted, so the two can arrive in either order. Match on signing_request_id and signer.slot, never on arrival order.
  • An envelope emits one sent per document of the released signer; one email covers all of them.
  • signing_request.viewed fires when the released signer opens the document, document.completed when the last one has signed. GET /v1/documents/{id} shows each signer's status: queued → pending → viewed → signed.

send_emails: false is stored on every signer the call creates, so it holds for a signer released days later. For a staged instance, the value its confirm settles on counts (the confirm body may override the instantiate call's). Requests created before the 2026-09-25 release do not carry it: their later signers are still emailed, and their releases emit sent with "emailed": true.

Documents sent from the dashboard

signing_request.sent for a document's first signers is an API event. From the dashboard, the template quick-send emits one per invited recipient, without source; the editor's Send and a Send Later emit none. A sequential release emits its sent whatever sent the document. Every other event — viewed, signed, declined, the SMS events, document.completed, expired — fires for a document whatever sent it.

Envelope and sample payload

Every body is a single flat JSON object: four envelope fields (event, event_id, created_at, workspace_id), the event's own fields at the same level, and metadata when the event names a document. Read fields by name; their order is not part of the contract.

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…",
  "metadata":           { "case_id": "ZT-2026-0142", "client": "zepf" },
  "signing_request_id": "11111111-…",
  "document_id":        "8a1e4f9a-…",
  "signer":             { "email": "owner@example.com", "name": "Olivia Owner", "role": "landlord", "slot": null, "reference": null, "company": "Owner Estates AG", "job_title": "Director" },
  "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.

metadata: relinking a delivery to your own record

Every event that carries a document_id also carries metadata — the object you passed as metadata when you created the document through the API (POST /v1/signing-requests, POST /v1/templates/{id}/instantiate or /generate with review: true), or null when the document has none, including every document created from the dashboard. Limits and shape are in API overview → Your own reference.

This is how a connector that lost the HTTP response to its create call finds the document again: put your case number in metadata, and the first webhook for the document (signing_request.sent, fired on dispatch — or template_instance.staged for a review: true instance) hands you the document_id next to it.

The key is omitted only when the lookup failed at send time (the event still arrives, without it) and on the dashboard's synthetic Send test delivery, whose document_id is a placeholder.

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>[,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. Normally one entry.
RotationFor 24 hours after POST /v1/hooks/{id}/rotate there are two v1= entries, new secret first. Accept the delivery if any entry matches (the Stripe convention).
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 — or the new secret from a rotate.
Also sentX-LetsSign-Signature, X-LetsSign-Event-Id, X-LetsSign-Event — the pre-rename names, byte-identical values (including the second v1= entry), 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.
  • Try every v1= entry. Split the header on ,, take t from the first part and compare your HMAC against each v1= part. A receiver that only reads the first entry still holds the old secret during a rotation window while the first entry is signed with the new one — if yours is written that way, switch to the new secret immediately after rotating.

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
  // t=<t>,v1=<hex>[,v1=<hex>] — two entries for 24 h after a secret rotation.
  const parts = header.split(',')
  const t = parts[0]?.startsWith('t=') ? parts[0].slice(2) : null
  if (!t || !/^\d+$/.test(t)) return false
  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 b = Buffer.from(expected, 'hex')
  return parts
    .filter((p) => p.startsWith('v1='))
    .some((p) => {
      const a = Buffer.from(p.slice(3), '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) {
        return false;
    }
    // t=<t>,v1=<hex>[,v1=<hex>] — two entries for 24 h after a secret rotation.
    $parts = explode(',', $header);
    if (!preg_match('/^t=(\d+)$/', $parts[0] ?? '', $m)) {
        return false;
    }
    $t = $m[1];
    if (abs(time() - (int) $t) > $toleranceSec) {
        return false; // replay protection
    }
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    foreach (array_slice($parts, 1) as $part) {
        if (str_starts_with($part, 'v1=') && hash_equals($expected, substr($part, 3))) {
            return true; // constant-time, any entry may match
        }
    }
    return false;
}

$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)). During a rotation window the same delivery is signed twice over the same message:

X-WeSign-Signature: t=1757404800,v1=<hex with the NEW secret>,v1=<hex with the OLD secret>

Both snippets above accept it with either secret; after the window the header is back to the single v1=.

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.

For document.completed, also deduplicate on document_id: the event is emitted once per document, but a database failure at the moment of completion can still repeat it with a new event_id.

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) and no metadata key — 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"], "enabled": true,
    "created_at": "…", "secret": "whsec_…" }
DELETE https://api.wesign.now/v1/hooks/{id}                       # unsubscribe
GET    https://api.wesign.now/v1/hooks                            # list your enabled subscriptions
GET    https://api.wesign.now/v1/hooks?include_disabled=true      # … including hooks switched off in the dashboard
POST   https://api.wesign.now/v1/hooks/{id}/rotate                # new signing secret, 24 h overlap

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.

Every hook in a listing carries enabled. The default listing is enabled hooks only, as connectors have always seen it; pass exactly include_disabled=true to also get hooks someone switched off at Developers → Webhooks (enabled: false), so an "is my hook set up?" check can answer it exists but is disabled instead of creating a duplicate. There is no GET /v1/hooks/{id} — list and filter by id.

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? Rotate it: the new secret comes back once and the old one keeps working for 24 hours, so nothing is missed in between. Deliveries are signed either way; a connector that trusts its transport may ignore the field.

Rotating a secret

POST https://api.wesign.now/v1/hooks/{id}/rotate
Authorization: Bearer wsk_live_…

→ 200 {
  "id": "…",
  "secret": "whsec_…",
  "secret_prefix": "whsec_ab12cd34",
  "previous_secret_valid_until": "2026-09-17T12:00:00.000Z"
}

No body. The response carries the new secret once. Until previous_secret_valid_until — a fixed 24 hours — every delivery is signed with both secrets, new first:

X-WeSign-Signature:   t=1757404800,v1=<hmac with new secret>,v1=<hmac with old secret>
X-LetsSign-Signature: t=1757404800,v1=<hmac with new secret>,v1=<hmac with old secret>

A receiver that tries every v1= entry keeps verifying with the old secret throughout the window and switches whenever it likes. After the window the header is back to a single v1=. Rotating again inside the window replaces the previous secret — the older one stops verifying immediately, so at most two secrets are ever live.

Rotation works on any workspace-wide hook, whether it was created here or in the dashboard, enabled or disabled. 404 not_found for an unknown id, a hook of another workspace, or a document-scoped callback_url hook; 409 rotation_conflict if two rotations raced — retry, the other call's secret is the live one.

Our official Zapier app is built on these endpoints (source: connectors/zapier/).