Webhooks

Real-time signer events delivered to your URL with HMAC-SHA256 signatures, full or minimal payloads, exponential-backoff retries and a delivery log with redelivery.

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. Over the API — POST /v1/hooks, for integrators and 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. Posting the same target_url again replaces the hook. "payload": "minimal" gives bodies without personal data. See Managing hooks over the API below.
  2. 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.
  3. 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. It gets the full body.

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. Each hook also has a payload mode: the full body (the default) or a minimal body without personal data.

Events

Every event we send, read from the webhooks of the OpenAPI spec, so this list always matches it. Each event has its own link: #event- and its name, dots and underscores as hyphens (#event-document-completed).

Every body starts with the envelope event, event_id, created_at, workspace_id, metadata. Each block lists what its event adds; ? marks a field that is not always present.

Every signing_request.* event also carries signing_request_id, document_id, signer, role, reference.

signing_request.sent

An invite went out. Sample

  • locale?
  • source?
  • template_id?
  • template_version?
  • occurred_at?
  • emailed?
  • test?
Details

Emitted when a signer is invited. Once per document for its first dispatched signer (from POST /signing-requests, POST /templates/{id}/instantiate, a dashboard send, a template dispatch or a confirmed review) — not again for the other signers of a parallel send. A dashboard send scheduled with Send Later emits when it goes out, not when it is scheduled, in the same body. And once per signer a sequential document releases when the previous signer has signed (source: "sequential_release", with occurred_at and emailed; an envelope emits one per document of the released signer), whatever sent the document. The release is emitted before the previous signer's signing_request.signed, so the two may arrive in either order. No payload carries a signing link: use the signing_url / signingUrl the create call returned, or GET /signing-requests/{id}.

signing_request_idstring (uuid)
document_idstring (uuid)
signerobject
localestringnot always present
The language requested for this signer, as locale on the create's response (Locale), never their own account language.
source"v1_api" | "template_instance" | "sequential_release"not always present
v1_api for API sends, template_instance for confirmed reviews, sequential_release when a sequential document releases its next signer (added in the 2026-09-25 release); absent for a dashboard send's first signers.
template_idstring (uuid)not always present
Template dispatches only.
template_versionintegernot always present
Template dispatches only.
occurred_atstring (date-time)not always present
sequential_release only: when the signer was released. Added in the 2026-09-25 release.
emailedbooleannot always present
sequential_release only: whether the invitation email went out. false when the document was created (or its review confirmed) with send_emails: false — deliver the signer's link yourself — when the email failed, or when the signer's address is on a domain that accepts no email (a null MX, no such domain, or no mail server — we do not send what can only bounce, and the document's sender is emailed that the address needs correcting); the link works either way. Added in the 2026-09-25 release.
testtruenot always present
Not sent since the 2026-10-02 release: the dashboard's Send test now sends a ping (PingEvent) to one hook instead. Before, it was set only on the synthetic delivery that button fired.
rolestring | null
Top level, for correlation: the signer's role — the role sent for this signer on POST /signing-requests, else the template slot's role label (for a template recipient, whose signer.role is null); null when there is neither.
referencestring | null
Top level, for correlation: your own reference for this signer (signers[].reference / recipients[].reference), or null — the same value as signer.reference.

Minimal body (a hook with payload: "minimal"): document_id, signing_request_id, role, reference.

signing_request.viewed

The signer opened the document for the first time. Sample

  • occurred_at
Details

The signer's browser loaded the document for the first time (status pending → viewed), on the signing page or in an embedded frame. Exactly once per request. 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 it fires only after the code is verified. Not guaranteed before signing_request.signed (a phone showing the fallback for documents without page images fires it only when the signer opens the PDF). Queued signers never emit it.

signing_request_idstring (uuid)
document_idstring (uuid)
signerSigningRequestEventSigner
The signer block of every signing_request.* event except a dashboard send's signing_request.sent, and of document.completed: signing_request.signed, signing_request.expired (until the 2026-10-02 release it carried only { email, name }), document.completed and the six signer events (viewed, declined, withdrawn, sms_sent, sms_failed, sms_verified). Every key is always present. name is the stored display name. If the signer row cannot be read when signing_request.signed or document.completed is built, their block carries email and name and null for the rest rather than holding the event back. Hooks with payload: "minimal" never receive it.
occurred_atstring (date-time)
When the transition happened. created_at is when this delivery was queued.
rolestring | null
Top level, for correlation: the signer's role — the role sent for this signer on POST /signing-requests, else the template slot's role label (for a template recipient, whose signer.role is null); null when there is neither.
referencestring | null
Top level, for correlation: your own reference for this signer (signers[].reference / recipients[].reference), or null — the same value as signer.reference.

Minimal body (a hook with payload: "minimal"): document_id, signing_request_id, role, reference.

signing_request.signed

A signer completed their signature. Sample

  • sha256
Details

A signer's signature was sealed. sha256 is the PDF after this seal. signer is the same block as on the signer events (SigningRequestEventSigner): email, stored name, role, slot, your reference and the company and function you stated.

signing_request_idstring (uuid)
document_idstring (uuid)
signerSigningRequestEventSigner
The signer block of every signing_request.* event except a dashboard send's signing_request.sent, and of document.completed: signing_request.signed, signing_request.expired (until the 2026-10-02 release it carried only { email, name }), document.completed and the six signer events (viewed, declined, withdrawn, sms_sent, sms_failed, sms_verified). Every key is always present. name is the stored display name. If the signer row cannot be read when signing_request.signed or document.completed is built, their block carries email and name and null for the rest rather than holding the event back. Hooks with payload: "minimal" never receive it.
sha256string
SHA-256 of the PDF after this signer's seal.
rolestring | null
Top level, for correlation: the signer's role — the role sent for this signer on POST /signing-requests, else the template slot's role label (for a template recipient, whose signer.role is null); null when there is neither.
referencestring | null
Top level, for correlation: your own reference for this signer (signers[].reference / recipients[].reference), or null — the same value as signer.reference.

Minimal body (a hook with payload: "minimal"): document_id, signing_request_id, role, reference.

signing_request.declined

The signer declined. Sample

  • occurred_at
  • reason
Details

Once per request, when the signer declines on the signing page (hosted or embedded). The document can no longer complete. reason is what the signer typed (first 1000 characters).

signing_request_idstring (uuid)
document_idstring (uuid)
signerSigningRequestEventSigner
The signer block of every signing_request.* event except a dashboard send's signing_request.sent, and of document.completed: signing_request.signed, signing_request.expired (until the 2026-10-02 release it carried only { email, name }), document.completed and the six signer events (viewed, declined, withdrawn, sms_sent, sms_failed, sms_verified). Every key is always present. name is the stored display name. If the signer row cannot be read when signing_request.signed or document.completed is built, their block carries email and name and null for the rest rather than holding the event back. Hooks with payload: "minimal" never receive it.
occurred_atstring (date-time)
When the transition happened. created_at is when this delivery was queued.
rolestring | null
Top level, for correlation: the signer's role — the role sent for this signer on POST /signing-requests, else the template slot's role label (for a template recipient, whose signer.role is null); null when there is neither.
referencestring | null
Top level, for correlation: your own reference for this signer (signers[].reference / recipients[].reference), or null — the same value as signer.reference.
reasonstring | null
What the signer typed, trimmed; null if they gave none. Cut at 1000 characters (the signing page accepts 2000); the full text is in GET /signing-requests/{id} auditEvents[type=declined].meta.reason.

Minimal body (a hook with payload: "minimal"): document_id, signing_request_id, role, reference.

signing_request.withdrawn

A signing request was withdrawn. Sample

  • occurred_at
  • withdrawn_by
Details

Once per request pulled out of flight: by your key (withdrawn_by: api), by a user in the dashboard (sender), or by a dashboard envelope withdraw, which emits one per pending, viewed or queued request of the envelope. Cancelling a Send-Later request before it went out and discarding a staged instance (template_instance.discarded) do not emit it.

signing_request_idstring (uuid)
document_idstring (uuid)
signerSigningRequestEventSigner
The signer block of every signing_request.* event except a dashboard send's signing_request.sent, and of document.completed: signing_request.signed, signing_request.expired (until the 2026-10-02 release it carried only { email, name }), document.completed and the six signer events (viewed, declined, withdrawn, sms_sent, sms_failed, sms_verified). Every key is always present. name is the stored display name. If the signer row cannot be read when signing_request.signed or document.completed is built, their block carries email and name and null for the rest rather than holding the event back. Hooks with payload: "minimal" never receive it.
occurred_atstring (date-time)
When the transition happened. created_at is when this delivery was queued.
rolestring | null
Top level, for correlation: the signer's role — the role sent for this signer on POST /signing-requests, else the template slot's role label (for a template recipient, whose signer.role is null); null when there is neither.
referencestring | null
Top level, for correlation: your own reference for this signer (signers[].reference / recipients[].reference), or null — the same value as signer.reference.
withdrawn_by"sender" | "api"
api = your key via POST /signing-requests/{id}/withdraw; sender = a user in the dashboard (one request, or a whole envelope).

Minimal body (a hook with payload: "minimal"): document_id, signing_request_id, role, reference.

signing_request.expired

A signing request passed its expires_at. Sample

  • expired_at
Details

Emitted by the hourly expiry cron the moment a pending / viewed request passes its expires_at and its status flips to expired — exactly once per request (the status flip is the guard: a row can make that transition only once). queued sequential followers behind an expired signer do not emit; they never held a live link. Requests found more than 7 days past expires_at (a backlog after an outage) are expired without a delivery. The document never completes: /signed and /audit-trail keep answering 409 not_complete.

signing_request_idstring (uuid)
document_idstring (uuid)
signerSigningRequestEventSigner
The signer block of every signing_request.* event except a dashboard send's signing_request.sent, and of document.completed: signing_request.signed, signing_request.expired (until the 2026-10-02 release it carried only { email, name }), document.completed and the six signer events (viewed, declined, withdrawn, sms_sent, sms_failed, sms_verified). Every key is always present. name is the stored display name. If the signer row cannot be read when signing_request.signed or document.completed is built, their block carries email and name and null for the rest rather than holding the event back. Hooks with payload: "minimal" never receive it.
expired_atstring (date-time)
The request's expires_at — the moment the link stopped working, not the cron tick that recorded it.
rolestring | null
Top level, for correlation: the signer's role — the role sent for this signer on POST /signing-requests, else the template slot's role label (for a template recipient, whose signer.role is null); null when there is neither.
referencestring | null
Top level, for correlation: your own reference for this signer (signers[].reference / recipients[].reference), or null — the same value as signer.reference.

Minimal body (a hook with payload: "minimal"): document_id, signing_request_id, role, reference.

signing_request.sms_sent

An SMS verification code was sent. Sample

  • occurred_at
  • phone_masked
  • attempt
  • sms_allowance
Details

The SMS provider accepted a verification code for this signer, sent in the signer's language (en, de, fr, it, es or nl; see Locale — for a signer without a set language, the language their signing page showed). One event per code, resends included (attempt counts them); a resend within the code's 10-minute lifetime delivers the same code and still counts against the allowance. Not the invitation SMS of channel sms/both.

signing_request_idstring (uuid)
document_idstring (uuid)
signerSigningRequestEventSigner
The signer block of every signing_request.* event except a dashboard send's signing_request.sent, and of document.completed: signing_request.signed, signing_request.expired (until the 2026-10-02 release it carried only { email, name }), document.completed and the six signer events (viewed, declined, withdrawn, sms_sent, sms_failed, sms_verified). Every key is always present. name is the stored display name. If the signer row cannot be read when signing_request.signed or document.completed is built, their block carries email and name and null for the rest rather than holding the event back. Hooks with payload: "minimal" never receive it.
occurred_atstring (date-time)
When the transition happened. created_at is when this delivery was queued.
rolestring | null
Top level, for correlation: the signer's role — the role sent for this signer on POST /signing-requests, else the template slot's role label (for a template recipient, whose signer.role is null); null when there is neither.
referencestring | null
Top level, for correlation: your own reference for this signer (signers[].reference / recipients[].reference), or null — the same value as signer.reference.
phone_maskedstring
attemptinteger | null
Codes sent for this signing request so far, this one included; null if the count could not be read.
sms_allowanceSmsAllowance
The workspace's allowance after this code.

Minimal body (a hook with payload: "minimal"): document_id, signing_request_id, role, reference.

signing_request.sms_failed

An SMS verification code could not be sent. Sample

  • occurred_at
  • reason
  • phone_masked
  • sms_allowance
Details

The signer asked for a code and none went out; one event per refused request, the moment it happens — so a landline, a blocked destination or a used-up allowance is visible at once. reason is a stable public code (see the schema).

signing_request_idstring (uuid)
document_idstring (uuid)
signerSigningRequestEventSigner
The signer block of every signing_request.* event except a dashboard send's signing_request.sent, and of document.completed: signing_request.signed, signing_request.expired (until the 2026-10-02 release it carried only { email, name }), document.completed and the six signer events (viewed, declined, withdrawn, sms_sent, sms_failed, sms_verified). Every key is always present. name is the stored display name. If the signer row cannot be read when signing_request.signed or document.completed is built, their block carries email and name and null for the rest rather than holding the event back. Hooks with payload: "minimal" never receive it.
occurred_atstring (date-time)
When the transition happened. created_at is when this delivery was queued.
rolestring | null
Top level, for correlation: the signer's role — the role sent for this signer on POST /signing-requests, else the template slot's role label (for a template recipient, whose signer.role is null); null when there is neither.
referencestring | null
Top level, for correlation: your own reference for this signer (signers[].reference / recipients[].reference), or null — the same value as signer.reference.
reason"invalid_number" | "landline" | "blocked" | "rate_limited" | "allowance_exhausted" | "not_configured" | "provider_error"
invalid_number — no usable E.164 number stored, or the SMS provider rejected the number as invalid. landline — the provider identified a landline. blocked — the destination is not enabled for our SMS service, is embargoed, or the provider's fraud protection blocked the number's prefix (temporarily, 12 h). rate_limited — too many codes for this number without a completed check (clears when the 10-minute code expires) or provider throttling. allowance_exhausted — the workspace's monthly SMS allowance is used up; the signer cannot sign until it resets. not_configured — no SMS provider on this deployment (never in production). provider_error — anything else. Clicks our own per-IP throttle refuses (4 code requests per minute) do not emit.
phone_maskedstring | null
null when no usable E.164 number is stored.
sms_allowanceSmsAllowance
The workspace's monthly SMS allowance. One code sent = one unit, resends included.

Minimal body (a hook with payload: "minimal"): document_id, signing_request_id, role, reference.

signing_request.sms_verified

The signer verified their phone. Sample

  • occurred_at
  • phone_masked
  • sms_verified_at
Details

The signer entered the correct code. sms_verified_at is the stamp the AES signature relies on. Once per request.

signing_request_idstring (uuid)
document_idstring (uuid)
signerSigningRequestEventSigner
The signer block of every signing_request.* event except a dashboard send's signing_request.sent, and of document.completed: signing_request.signed, signing_request.expired (until the 2026-10-02 release it carried only { email, name }), document.completed and the six signer events (viewed, declined, withdrawn, sms_sent, sms_failed, sms_verified). Every key is always present. name is the stored display name. If the signer row cannot be read when signing_request.signed or document.completed is built, their block carries email and name and null for the rest rather than holding the event back. Hooks with payload: "minimal" never receive it.
occurred_atstring (date-time)
When the transition happened. created_at is when this delivery was queued.
rolestring | null
Top level, for correlation: the signer's role — the role sent for this signer on POST /signing-requests, else the template slot's role label (for a template recipient, whose signer.role is null); null when there is neither.
referencestring | null
Top level, for correlation: your own reference for this signer (signers[].reference / recipients[].reference), or null — the same value as signer.reference.
phone_maskedstring
sms_verified_atstring (date-time)
The stamp the AES signature relies on — the same value as sms_verified_at on GET /signing-requests/{id}.

Minimal body (a hook with payload: "minimal"): document_id, signing_request_id, role, reference.

document.completed

Every signer has signed. Sample

  • signing_request_id
  • document_id
  • signer
  • signed_pdf_url
  • audit_trail_url
  • sha256
  • audit_trail_sha256?
  • cert_serial
  • tsa_provider
  • tsa_signed_at
Details

Fired once per document, right after the signing_request.signed event of the final signer, when EVERY signer row is signed — the same rule that makes GET /documents/{id} report status: signed and lets /signed + /audit-trail serve. A document with a declined, withdrawn or expired signer never fires it (its other signers' signing_request.signed events still do). signed_pdf_url and audit_trail_url are key-authenticated API URLs on the canonical host (GET /documents/{id}/signed and GET /documents/{id}/audit-trail) — fetch them with the owning workspace's Bearer key. Parallel signers who sign within the same seconds are sealed one after another, so signed_pdf_url carries every signature, and only the signature that completed the document emits this event. Delivery is at-least-once, and a database failure at the moment of completion can still repeat the event: deduplicate on document_id as well as event_id. signer is the completing signer, in the same block as the signer events (SigningRequestEventSigner). Fetch both URLs when this event arrives and store the files on your side: the owning workspace can have signed PDFs deleted after a set number of days, after which signed_pdf_url answers 410 deleted_by_retention (the audit trail stays). Compare sha256 with the PDF bytes. One event per document, after its last signer; each document of an envelope completes on its own, and there is no envelope-level event. Guide: /docs/documents#retrieve-the-signed-document.

signing_request_idstring (uuid)
The final signer's request.
document_idstring (uuid)
signerSigningRequestEventSigner
The signer block of every signing_request.* event except a dashboard send's signing_request.sent, and of document.completed: signing_request.signed, signing_request.expired (until the 2026-10-02 release it carried only { email, name }), document.completed and the six signer events (viewed, declined, withdrawn, sms_sent, sms_failed, sms_verified). Every key is always present. name is the stored display name. If the signer row cannot be read when signing_request.signed or document.completed is built, their block carries email and name and null for the rest rather than holding the event back. Hooks with payload: "minimal" never receive it.
signed_pdf_urlstring (uri)
https://api.wesign.now/v1/documents/{document_id}/signed — key-authenticated (the owning workspace's Bearer key). 409 not_complete until every signer signed; 404 across workspaces; 410 deleted_by_retention once the workspace's retention period has passed, so fetch it on this event and keep your own copy.
audit_trail_urlstring (uri)
https://api.wesign.now/v1/documents/{document_id}/audit-trail — the audit-trail PDF (Certificate of Completion), issued once at completion — its SHA-256 is audit_trail_sha256; same host, same Bearer key and same 409/404 rules as signed_pdf_url, but never 410: it stays available after retention.
sha256string
Lowercase hex SHA-256 of exactly the bytes served at signed_pdf_url (the same value as the sha256 of the last signer's full-body signing_request.signed). Not the sha256 of GET /documents/{id}, which hashes the PDF as it was sent for signature.
audit_trail_sha256string | nullnot always present
Lowercase hex SHA-256 of the certificate at audit_trail_url, issued once at completion — every fetch returns those exact bytes (also the ETag of the download). Null when it could not be issued at that moment (the first download issues it); absent on events queued before 2026-10-15.
cert_serialstring
Serial of the seal certificate that made the last seal. From 2026-10-22, 00:00 UTC, new seals are made with a new certificate, CN=wesign.now Signing Authority (serial 6a5258df714ab427831c680e605d8231); seals made before carry 0062c9cfa7868e51c7ed510fece02d96 (the previous seal certificate). For a document signed across that date this is the new serial (the last seal's); each signer's signed audit event (auditEvents[].meta.cert_serial on GET /signing-requests/{id}) names the certificate that sealed that signature. Every certificate, with its SHA-256 fingerprint: /seal-certificates.json.
tsa_providerstring | null
Name of the RFC 3161 timestamp authority that stamped the sealed PDF, e.g. "DigiCert" (a fallback authority such as "freetsa.org" when the primary is unavailable). Informational: do not branch on it. Null when no timestamp could be obtained.
tsa_signed_atstring (date-time) | null
The time inside the timestamp token (TSTInfo genTime), in whole seconds UTC. Every token is verified before it is kept: it must cover the sealed PDF's SHA-256 and our request nonce, and be signed by the authority's timestamping certificate under a pinned root.

Minimal body (a hook with payload: "minimal"): document_id, signed_pdf_url, sha256, audit_trail_url, audit_trail_sha256.

Every template_instance.* event also carries document_id, template_id, kind.

template_instance.staged

A template instance was staged for review. Sample

  • template_version
  • review_url
  • review_expires_at
  • filename?
  • signing_mode?
  • recipients?
  • source
  • api_key_id
Details

Emitted by /templates/{id}/instantiate (kind esign) and /templates/{id}/generate (kind file) when called with review: true.

document_idstring (uuid)
template_idstring (uuid)
template_versioninteger | null
kind"esign" | "file"
review_urlstring (uri)
The review page in the web app for a signed-in workspace member; see InstantiateStaged.review_url.
review_expires_atstring (date-time)
filenamestringnot always present
kind file only.
signing_mode"parallel" | "sequential"not always present
kind esign only.
recipientsobject[]not always present
kind esign only.
source"v1_api"
api_key_idstring (uuid)

Minimal body (a hook with payload: "minimal"): document_id.

template_instance.confirmed

A staged instance was confirmed. Sample

  • template_version
  • actor
  • expires_at?
  • recipients?
Details

An e-sign confirm additionally emits one ordinary signing_request.sent for the first promoted recipient.

document_idstring (uuid)
template_idstring (uuid) | null
template_versioninteger | null
kind"esign" | "file"
actor"user" | "api_key" | "system"
expires_atstring (date-time)not always present
kind esign only.
recipientsobject[]not always present
kind esign only.

Minimal body (a hook with payload: "minimal"): document_id.

template_instance.discarded

A staged instance was discarded or expired. Sample

  • reason
  • actor
Details
document_idstring (uuid)
template_idstring (uuid) | null
kind"esign" | "file"
reason"discarded" | "expired"
expired when the 14-day review window lapsed (cron).
actor"user" | "api_key" | "system"

Minimal body (a hook with payload: "minimal"): document_id.

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). The six signer events share one base, described in Signer events.

Every signing_request.* event also carries role and reference at the top level (since the 2026-10-02 release, webhook-top-level-role-reference), so a receiver can correlate without reading the signer block: role is the role you sent for the signer on POST /v1/signing-requests, else, for a template recipient, the template slot's role label (null when there is neither); reference is your per-signer reference (null when none). document.completed and template_instance.* do not carry them.

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, ping included. An unanswered or non-2xx delivery is retried for about 45 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",
  "role":               "Client",
  "reference":          "person-8812",
  "reason":             "landline",
  "phone_masked":       "•••• •••• 2233",
  "sms_allowance":      { "monthly": 300, "used": 41, "remaining": 259, "resets_at": "2026-10-01T00:00:00.000Z" }
}

(Anna is a template recipient here: signer.role is null, and the top-level role is her template slot's label.)

signing_request.signed, signing_request.expired and document.completed carry the same signer block (without occurred_at). If the signer row cannot be read at that moment, the block of signed and completed still carries email and name, with null for the rest, rather than holding the event back. Before the 2026-10-02 release signing_request.expired carried only { 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 signer's language (Language per signer). 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; locale is the language requested for the signer, as on the create's response, never their own account language (Language per signer):

{
  "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.wesign.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 — when our email failed, and when the signer's address is on a domain that accepts no email (we do not send what can only bounce, and the document's sender is told); 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

The editor's Send (one file or an envelope, a document made from a template included) emits one signing_request.sent per document, for its first signer, once the invitations went out; not again for the other signers of a parallel send. It has no source; signer carries email, name and slot, the signing order in a sequential send, else null. Before the 2026-10-05 release the editor's Send emitted none. The template quick-send emits one per invited recipient, also without source. A Send Later emits the same events in the same body when it goes out, not when it is scheduled; before the 2026-10-06 release it emitted 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. That is the full body, the default; a hook with payload: "minimal" receives the minimal body instead.

POST https://yourapp.com/wesign/callback
Content-Type: application/json
X-WeSign-Signature:   t=1757404800,v1=9c3b…a2f1
X-WeSign-Event-Id:    evt_3f9c1a7b2d4e4c6f8a1b2c3d4e5f6a7b
X-WeSign-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",
  "audit_trail_sha256": "7b5e…90c1",
  "cert_serial":        "1a2b…",
  "tsa_provider":       "DigiCert",
  "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. Fetch them when the event arrives and keep your own copy: a workspace can have signed PDFs deleted after a set number of days, after which signed_pdf_url answers 410 deleted_by_retention. The whole flow — the hash check, which status codes to retry, retention, and what to do when the event did not come — is in Documents → Retrieve the signed document.

audit_trail_sha256 is the SHA-256 of the audit trail at audit_trail_url, which is issued once at completion and stored: every fetch returns those bytes. It is null when the certificate could not be issued at that moment — the first download then issues it — and absent on events queued before 2026-10-15. cert_serial names the seal certificate that made the last seal; see Our seal certificates.

The User-Agent names our webhook sender and is not part of the contract: never filter deliveries on it — verify X-WeSign-Signature instead.

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), on the test ping, which names no document, and on every delivery to a hook with payload: "minimal". A minimal hook therefore relinks by document_id: store it from your create call's response, or read metadata back with GET /v1/documents/{id}.

Payload modes: full and minimal

Each hook has a payload mode, chosen with payload on POST /v1/hooks or switched per hook in the dashboard (the secret stays):

ModeBody
full (default)Everything documented above: the envelope with workspace_id, metadata, the signer block (names, emails, company, function), and every event field. The mode of every hook that does not ask for minimal.
minimalIdentifiers only — no names, emails, phone numbers, decline reason, metadata, workspace_id, seal-certificate or time-stamp fields (cert_serial, tsa_*).

A document-scoped callback_url hook is created with the full body, and the API cannot make it minimal: POST /v1/hooks with the same URL creates a separate workspace hook and leaves the callback_url hook as it is. For identifiers only, register a workspace hook with POST /v1/hooks and "payload": "minimal", and do not pass callback_url.

A minimal body has exactly these keys:

EventKeys
every eventevent, event_id, created_at, document_id
signing_request.* (signed, declined, expired, …)+ signing_request_id, role, reference
document.completed+ signed_pdf_url, sha256, audit_trail_url, audit_trail_sha256
template_instance.*nothing more

signing_request.signed:

{"event":"signing_request.signed","event_id":"evt_3f9c1a7b2d4e4c6f8a1b2c3d4e5f6a7b","created_at":"2026-10-02T09:14:03.120Z","document_id":"8a1e4f9a-5b6c-4d7e-8f90-a1b2c3d4e5f6","signing_request_id":"11111111-2222-4333-8444-555555555555","role":"tenant","reference":"person-8812"}

document.completed:

{"event":"document.completed","event_id":"evt_9b1d2c3e4f5a6b7c8d9e0f1a2b3c4d5e","created_at":"2026-10-02T09:14:03.410Z","document_id":"8a1e4f9a-5b6c-4d7e-8f90-a1b2c3d4e5f6","signed_pdf_url":"https://api.wesign.now/v1/documents/8a1e4f9a-5b6c-4d7e-8f90-a1b2c3d4e5f6/signed","sha256":"d21c…","audit_trail_url":"https://api.wesign.now/v1/documents/8a1e4f9a-5b6c-4d7e-8f90-a1b2c3d4e5f6/audit-trail","audit_trail_sha256":"7b5e…"}

signing_request.expired and signing_request.declined have the shape of signed (a decline carries no reason in this mode):

{"event":"signing_request.expired","event_id":"evt_77c0a1b2c3d4e5f6a7b8c9d0e1f2a3b4","created_at":"2026-10-16T10:00:03.114Z","document_id":"8a1e4f9a-5b6c-4d7e-8f90-a1b2c3d4e5f6","signing_request_id":"11111111-2222-4333-8444-555555555555","role":"tenant","reference":"person-8812"}
  • document_id is the documentId of the POST /v1/signing-requests response (document_id of instantiate); signing_request_id is that signer's signers[].signingRequestId.
  • role and reference are the same top-level values a full body carries (see Events); null when the signer has none.
  • sha256 is the lowercase hex SHA-256 of exactly the bytes GET signed_pdf_url serves. The audit trail is a separate PDF at audit_trail_url, issued once at completion; sha256 does not cover it, audit_trail_sha256 does — a hash, no personal data (null when the certificate could not be issued at that moment; the first download issues it). sha256 is not the sha256 of GET /v1/documents/{id}, which hashes the file as it was sent for signature — see Check the hash.
  • An event emitted while a hook is minimal is stored for it only as the minimal body, so the personal data is not kept on our side for that hook either.
  • Switching a hook to minimal applies to its deliveries still being retried too: they are cut down to the minimal body when they are sent. Switching back to full applies to new events only.

Data you need beyond identifiers (a signer's name, metadata) is available with your API key: GET /v1/documents/{id} and GET /v1/signing-requests/{id}.

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. The HMAC key is that whole string, whsec_ prefix included, as UTF-8 bytes.

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')
  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'] ?? 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 = 1790000000 and the 38-byte body {"event":"ping","event_id":"evt_test"}, the message is the string 1790000000.{"event":"ping","event_id":"evt_test"} and the header is:

X-WeSign-Signature: t=1790000000,v1=281995437b0317b37dd656119dd670cded7fc52a8b1379711d663c146ba78dd1

Check it with createHmac('sha256', 'whsec_test').update(message).digest('hex') in Node, or printf '%s' "$message" | openssl dgst -sha256 -hmac whsec_test in a shell. 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

Any 2xx answer is a delivery; we never retry it. Anything else — another status, no response within 10 seconds, a connection error — is a failure, and the delivery is retried on this schedule:

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   +12 hours
attempt 8   +24 hours    (final)

That is eight attempts over about 45 hours (the 12-hour step was added in the 2026-10-02 release; before, seven attempts over about 33 hours). Retries are fired by a scheduler that runs every 5 minutes, so a step can fire up to 5 minutes after its due time. Every attempt is re-signed with a fresh t; the event_id stays the same.

After the last attempt fails, the delivery is marked giving_up and is not sent again on its own. The delivery log shows it; once your endpoint is fixed, Redeliver it there or with POST /v1/hooks/{id}/deliveries/{delivery_id}/redeliver.

Acknowledge first, work after: answer 2xx as soon as the signature checks out and do the processing afterwards, so a slow step on your side never turns into a timeout and a retry.

Redirects are not followed: a 3xx from your endpoint is a failure and is retried, and the delivery log records where it pointed (redirect not followed: HTTP 308, Location: …). Point the hook at the final URL — a trailing slash counts.

We never switch a hook off because its deliveries fail; only you can, in the dashboard or with DELETE /v1/hooks/{id}.

Delivery log and redelivery

Every hook keeps a log of its last 100 deliveries — in the dashboard under Developers → Webhooks, and over the API:

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

→ 200 { "deliveries": [
  { "id": "5d0c…", "event": "document.completed", "event_id": "evt_9b1d…",
    "document_id": "8a1e4f9a-…", "attempts": 2, "response_status": 200,
    "response_body": "ok", "latency_ms": 143, "status": "success",
    "created_at": "2026-10-02T09:14:03.410Z", "next_retry_at": null,
    "completed_at": "2026-10-02T09:15:12.006Z" },
  …
] }

Newest first. attempts counts the attempts so far; response_status and response_body (the first 500 characters) are what your endpoint answered on the last one — or our note when no answer came: network: … for a timeout or connection error, redirect not followed: HTTP 308, Location: … for a redirect. latency_ms is how long that attempt took. status is pending (waiting for next_retry_at), success or giving_up. The body we sent is not in the log. A replaced or switched-off hook keeps its log; a deleted hook's log goes with it.

To send one again — typically one that gave up while your endpoint was down:

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

→ 200 { "delivery_id": "5d0c…", "event_id": "evt_9b1d…", "status": "success",
        "response_status": 200, "latency_ms": 97 }

It is the same delivery: the same event_id and body, in the hook's current payload mode, signed afresh. One attempt is made at once; status is success, pending (it failed and carries on along the retry schedule) or giving_up (it failed with no retries left). A switched-off hook answers 409 hook_disabled — a hook replaced by a re-POST too, and its deliveries cannot be redelivered on the new hook either, so redeliver given-up deliveries before you re-POST a target_url. The dashboard's Redeliver button does the same.

Test pings and redeliveries go to https hooks only: a hook registered over http before the 2026-10-02 release still receives its events, but answers 409 https_required here. Each hook takes at most 10 test pings and redeliveries a minute, together; the next answers 429 rate_limited with Retry-After.

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.

A redelivery carries the event_id of the original delivery, so the same check covers it.

Order and reconciliation

Order. Each delivery is its own request and its retries run on their own, so events can arrive in any order: document.completed before a signing_request.signed, or a released signer's signing_request.sent before the previous signer's signing_request.signed. Act on event and the ids, never on arrival order; when order matters, read the current state with GET /v1/documents/{id}.

Reconcile. Rarely, an event cannot be recorded for delivery (a database failure at that moment) and is never sent: no delivery row, so no retry and nothing to redeliver. We are alerted. If every signer's signing_request.signed has arrived and no document.completed follows within a few minutes, read GET /v1/documents/{id} (status: "signed") or fetch the signed PDF with GET /v1/documents/{id}/signed. The same read settles any other event you expected and did not get. No endpoint lists documents, so reconcile from the document_ids you stored; the delivery log names the document_id of each of the hook's last 100 deliveries. See If the event did not come.

Testing a hook

POST /v1/hooks/{id}/test — or Send test on the hook in the dashboard — sends that one hook a signed ping, whatever events it subscribes to:

{ "event": "ping", "event_id": "evt_0f3c…", "created_at": "2026-10-02T08:00:00.000Z" }

It carries the usual headers and signature, is attempted once and never retried, and the response tells you how it went:

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

→ 200 { "delivery_id": "…", "event_id": "evt_0f3c…", "status": "success",
        "response_status": 200, "latency_ms": 84 }

status is success when your endpoint answered 2xx, else giving_up (with your response_status, or null when no answer came). ping is not an event type you can subscribe to; answer it 2xx like any event you do not handle. A switched-off hook answers 409 hook_disabled; the https rule and the limit of 10 a minute per hook are the same as for redelivery. Before the 2026-10-02 release, Send test sent a synthetic signing_request.sent with "test": true instead.

REST hooks (for connectors)

Integrators and no-code tools (Zapier, Make) manage subscriptions over the API instead of in the dashboard, with a full API key (Key scopes):

POST https://api.wesign.now/v1/hooks
Authorization: Bearer wsk_live_…
Content-Type: application/json

{ "target_url": "https://yourapp.example/wesign",
  "events": ["signing_request.signed", "signing_request.declined", "signing_request.expired", "document.completed"],
  "payload": "minimal" }

→ 200 { "id": "…", "target_url": "https://yourapp.example/wesign",
        "events": ["signing_request.signed", "signing_request.declined", "signing_request.expired", "document.completed"],
        "enabled": true, "created_at": "…", "payload": "minimal",
        "secret": "whsec_…", "replaced_hook_ids": [] }
GET    https://api.wesign.now/v1/hooks                            # your active hooks
GET    https://api.wesign.now/v1/hooks?include_disabled=true      # … plus the switched-off ones
DELETE https://api.wesign.now/v1/hooks/{id}                       # unsubscribe → 204, no body
POST   https://api.wesign.now/v1/hooks/{id}/rotate                # new signing secret, 24 h overlap
GET    https://api.wesign.now/v1/hooks/{id}/deliveries            # the delivery log
POST   https://api.wesign.now/v1/hooks/{id}/deliveries/{delivery_id}/redeliver
POST   https://api.wesign.now/v1/hooks/{id}/test                  # a signed ping

target_url must be https: anything else answers 400 https_required. A host that is, or resolves to, a private, loopback, link-local (such as 169.254.169.254), CGNAT or reserved address — or localhost, *.local, *.internal, or a name that does not resolve — answers 400 url_not_public. The address is checked again at every delivery.

events: an empty or omitted list subscribes to all event types, including ones added later; up to ten named events otherwise. An unknown name answers 400 unknown_event and lists the valid ones:

{ "error": "Unknown event name: signing_request.signedd. Valid events: signing_request.sent, …",
  "code": "unknown_event",
  "meta": { "unknown": ["signing_request.signedd"],
            "valid_events": ["signing_request.sent", "signing_request.viewed", "…"] } }

payload: "full" (default) or "minimal" — see Payload modes. On a re-POST that replaces a minimal hook, leaving payload out keeps the new hook minimal, so a request body written before this field existed never turns personal data back on; send "full" to switch back. events is not carried over: left out, it means every event.

The same target_url again replaces the hook. When an active workspace-wide hook of your workspace already targets the URL, it is switched off — it drops out of GET /v1/hooks and receives nothing more — and the call returns the new hook with a new id and a new secret, naming the old one in replaced_hook_ids. That is how you change a hook's events or payload mode. Deliveries of the old hook still being retried, for events the new hook subscribes to, move to the new hook with their event_id, so nothing is lost in the switch; switch your receiver to the new secret right away. Deliveries that had already given up stay in the old hook's log, which stays readable but cannot redeliver: redeliver them before you re-POST. URLs are compared parsed: the case of scheme and host and a default port do not matter, but the path does, a trailing slash and the query string included. Two registrations that race leave the newest active. A workspace-wide hook created in the dashboard on the same URL is replaced too; a document-scoped callback_url hook never is, and creating a hook in the dashboard replaces nothing.

The create answers 200 (not 201), and secret is in that response only.

Listing. GET /v1/hooks lists active hooks only, newest first, each with id, target_url, events, enabled, created_at and payload, never the secret. A hook missing from the default list is inactive: deleted, replaced, or switched off in the dashboard. Pass exactly include_disabled=true to also get the switched-off ones (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. We never switch a hook off on our own because its deliveries fail.

Unsubscribing. DELETE /v1/hooks/{id} answers 204 with no body — also for an unknown id or another workspace's hook, where nothing is deleted. The delivery log goes with the hook.

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. Re-posting the same target_url also gives you a new secret, but with no overlap: the old hook stops at once. 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>

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.