> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vumasign.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Register an endpoint, verify every delivery, and deduplicate on the event id.

Register an endpoint with [`POST /api/v1/webhooks`](/api-reference/create-webhook). 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](/guides/embedded-signing) 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

| Event | What happened |
| - | - |
| `envelope.sent` | It left draft and the invitations are going out. |
| `envelope.completed` | Everybody who had to act has acted. ⚠️ THE EXECUTED DOCUMENT DOES NOT EXIST YET. Sealing is asynchronous and starts here — it takes about a minute — so a `GET /api/v1/envelopes/{envelopeId}/documents` on this event answers `sealed: false`, correctly. Use this to learn that signing finished; use `envelope.sealed` to fetch. |
| `envelope.declined` | Somebody refused. Terminal; no further signing happens. |
| `envelope.voided` | The sender withdrew it. Terminal. |
| `envelope.expired` | Its deadline passed before everybody acted. Terminal, and the one outcome NOBODY acted to produce — which is why it is published: there is no invitation, no click and nobody to ask. |
| `envelope.sealed` | ⚠️ THE ONE TO WAIT FOR BEFORE FETCHING. Every document this envelope holds has been sealed with its certificate of completion, and `GET /api/v1/envelopes/{envelopeId}/documents` now answers `sealed: true`. It fires about a minute after `envelope.completed`, in the transaction that records the last seal — or very much later than that for an envelope whose sealing failed and was requeued.<br /><br />⚠️ SO IT CAN ARRIVE AFTER AN `envelope.sealing_failed` FOR THE SAME ENVELOPE, and when it does it is the later truth: the document exists now. Do not reject it as impossible, and do not close an envelope against ever receiving it — see `envelope.sealing_failed` below.<br /><br />ONE PER ENVELOPE, NOT ONE PER DOCUMENT — a ten-document envelope emits this once, after the tenth. The body carries no document id and no url: fetch the documents endpoint on receipt, because the download credentials it mints are short-lived and one frozen into a webhook body would be expired before a redelivery reached you. |
| `envelope.sealing_failed` | ⚠️ THE EXECUTED DOCUMENT DOES NOT EXIST AND NOTHING FURTHER WILL BE TRIED AUTOMATICALLY. Sealing gave up on one of this envelope’s documents, so nothing on our side is still working towards `envelope.sealed` and waiting for it without acting is waiting for ever. The envelope itself stays `completed` — the signatures are real and are kept — and the sender is emailed separately.<br /><br />⚠️ IT IS NOT FINAL: A FAILED SEAL CAN BE REQUEUED BY OUR SUPPORT, AND ONE THAT THEN SUCCEEDS DELIVERS `envelope.sealed` FOR THIS SAME ENVELOPE. That is an ordinary route rather than a curiosity — it is how a cleared deployment fault is worked through — so treat this event as "stuck, tell us" and not as "gone". Concretely: raise it with us, tell whoever is waiting that the executed copy is delayed rather than lost, keep your `envelope.sealed` handler able to accept one for this envelope, and if you refund, archive or close a case on this event make that step reversible.<br /><br />ONLY ON THE PERMANENT GIVE-UP, never while a seal is still being retried. ONCE PER GIVE-UP — so once per document that failed, and again for a requeued job that fails again. Expect it to be rare; handle it anyway, because it is the only signal that distinguishes "not yet" from "not without us". |
| `recipient.completed` | One recipient finished their part — signed, or approved. ⚠️ ONE NAME FOR BOTH ACTS: what an integration does about it is identical, and two names would make every consumer write the same two-branch switch for ever. `recipient_id` says who. |

### 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`.

```text theme={null}
Vumasign-Signature: t=1786809335, v1=<hex>, v0=<hex, only during rotation>
```

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.
