Skip to main content
Register an endpoint with POST /api/v1/webhooks. The response carries the signing secret once; store it where your receiver can read it. A webhook is the authoritative signal that something happened to an envelope — a browser event from an embedded signer is not. A receiver does three things, in this order: verify the signature against the raw body, store the event and answer 2xx, then do the work. Everything below is generated from the x-webhooks extension at the root of the OpenAPI document, so it is what the running code does. ⚠️ Endpoints are not split into test and live. An envelope created with a vsk_test_ key emits the same events, to the same endpoints, as a live one, and the body does not say which it was. Keep your own record of which envelopes you created in the sandbox.

Events

Delivery

POST with Content-Type: application/json, and a 10-second timeout. Return any 2xx as soon as you have stored the event. ⚠️ DO THE WORK AFTERWARDS — we wait 10 seconds and then treat the delivery as failed, and a receiver that processes synchronously will start failing on the day its own dependencies get slower. Redirects are NOT followed: a 3xx is a failure, because following one would POST your data to a host you never registered.

Signature

HMAC-SHA256 in the Vumasign-Signature header, over t + "." + <the raw request body, exactly as received>, with a tolerance of 300 seconds on t.
Compute HMAC-SHA256(secret, t + "." + rawBody) and compare it to v1 IN CONSTANT TIME. ⚠️ RAW BODY MEANS THE BYTES AS RECEIVED. If your framework parses JSON and you re-serialise it before hashing, verification will fail on the first body containing a non-ASCII name or a different key order — and it will fail intermittently, which is worse than failing always. ⚠️ COMPARE IN CONSTANT TIME. a === b on a hex string stops at the first differing character, so how long it takes measures how many leading characters an attacker guessed right — which turns forging a 256-bit MAC into 64 sequential 16-way guesses against an endpoint that answers promptly. Use crypto.timingSafeEqual, hmac.compare_digest, or your language’s equivalent. ⚠️ CHECK THE TIMESTAMP IN BOTH DIRECTIONS, and check it FIRST. now - t > 300 alone leaves a replay window with no far edge: the MAC covers t, so a captured delivery presented with a timestamp far in the future is internally consistent for ever. Reject when abs(now - t) exceeds the tolerance, before computing any HMAC — otherwise an unauthenticated caller can make your server hash a body of their choosing. WHY BOTH HALVES ARE SIGNED: signing the timestamp WITHOUT the body means a genuine signature is valid for any body sharing that timestamp and event name, so a captured delivery replays with different content (Dropbox Sign does this). Signing the body WITHOUT a timestamp means a captured delivery is valid for ever (DocuSign does this). Both are real products; both are why the signed material is t + "." + body. v0 is present ONLY while a rotated secret is inside its grace window (24 hours). Accept a delivery if EITHER v1 verifies under your current secret or v0 verifies under your previous one. ⚠️ A verifier that ignores v0 drops every event between the rotation and your deploy — which is exactly the outage the window exists to prevent.

Retries

7 attempts: immediately, +1 minute, +15 minutes, +45 minutes, +1 hour, +8 hours, +24 hours. Every non-2xx is retried, INCLUDING 4xx. A 404 from a receiver mid-deploy and a 401 from one whose secret rotation has not landed are both transient, and the ladder is bounded anyway. Each interval is measured from the previous attempt. ⚠️ THESE ARE WHEN A DELIVERY BECOMES DUE, not when it will arrive. The queue is drained on an interval, so an attempt lands within one drain cycle of its due time. An endpoint with NO successful delivery for 7 days is deactivated: active goes false and disabled_reason says why, both readable from GET /api/v1/webhooks. The window is measured from the START of the current run of failures, not from a raw count — a threshold on the count alone disables a busy endpoint over a ten-minute outage and never disables a quiet one that has been dead for a month. ⚠️ RECOVERY IS PATCH {"active": true}, WHICH ALSO RESETS THE WINDOW. There is deliberately no automatic reactivation, and BoldSign — whose ladder this otherwise copies — does advertise one. We cannot honestly offer it: recovery is only observable by sending, and having stopped sending we would have to probe your URL with either an unsigned request (teaching receivers to accept those) or a fabricated event (a body describing something that did not happen). Neither is acceptable in a system whose value is that its records are true. ⚠️ AND NOBODY IS EMAILED WHEN THIS HAPPENS. There is no organisation-level notification channel in this product yet — every email it sends is to a recipient about an envelope. Poll your endpoint’s health, or watch for the absence of events. The gap is named rather than hidden.

Ordering

⚠️ ORDER IS NOT GUARANTEED AND YOU MUST NOT DEPEND ON IT. Deliveries are queued in the order events happen and drained in due-time order, so the common case is in order — but a recipient.completed that needed one retry arrives after the envelope.completed it preceded. Use created_at, and treat each event as independently meaningful.

Deduplication

Deduplicate on id. It is the EVENT’s identity: the same value at every endpoint subscribed to it and on every retry. retry_count is a convenience for logging and is 0 on a first delivery; it is not a substitute, because a delivery whose 200 we never received is retried with a higher count while your side has already processed it.