Webhooks
Receive real-time events — envelope sent, viewed, signed, completed, and more — pushed to an HTTPS endpoint you control, so you never have to poll.
Webhooks push events to an HTTPS endpoint you control the moment something happens, so you don't have to poll for status. This is the intended way to track envelopes through to completion.
Endpoints are managed in the app under Settings → Webhooks (owner or admin), or
programmatically through the /v1/webhooks API — which is
what iPaaS connectors (Zapier, Make) use to register their instant triggers.
1. Register an endpoint
Provide an HTTPS URL, pick the events you want (or leave it empty for everything your plan allows), and optionally set a shared secret plus the header it should arrive in.
You're shown a signing secret exactly once. Store it — it can't be retrieved again, only rotated.
2. Verify ownership
A new endpoint starts as pending_verification and receives no events at all until it proves it
belongs to you. We POST a webhook.verification event:
{
"event_id": "018f2c3d-…",
"event": "webhook.verification",
"occurred_at": "2026-07-22T10:00:00.000Z",
"company_id": "018f0a1b-…",
"data": { "challenge": "3f9a…", "instructions": "…" }
}Respond 200 echoing the challenge, either as the raw body or as JSON:
{ "challenge": "3f9a…" }The endpoint then becomes active.
Changing the URL resets verification
This is intentional — otherwise an endpoint could be verified at an address you control and then repointed elsewhere.
3. Verify each request is really from us
Two independent mechanisms. Prefer the signature; the shared secret is the quick option.
Signature (recommended)
Every request carries these headers:
X-BunnyDoc-Signature: t=1800000000,v1=5257a869e7…
X-BunnyDoc-Event: envelope.completed
X-BunnyDoc-Delivery: 018f…
X-BunnyDoc-Timestamp: 1800000000v1 is HMAC-SHA256(signing_secret, "<t>.<raw request body>"), hex-encoded.
const crypto = require('crypto');
function verify(rawBody, header, signingSecret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
const expected = crypto
.createHmac('sha256', signingSecret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
// Constant-time compare, and reject stale timestamps to block replays.
const a = Buffer.from(expected), b = Buffer.from(parts.v1);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
return Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t)) <= toleranceSeconds;
}Sign the raw body bytes
Compute the HMAC over the raw request body, before any JSON parsing. Re-serialising changes whitespace and key order, and the signature won't match.
Shared secret (quick)
If you set one, it arrives verbatim in your chosen header (default X-Webhook-Secret). Compare it to
your stored value. It travels on every request, so prefer the signature where you can — the signature
never puts a key on the wire and also proves the payload wasn't altered or replayed.
4. Payload envelope
Every event has the same outer shape; only data differs.
{
"event_id": "018f2c3d-…",
"event": "envelope.completed",
"occurred_at": "2026-07-22T10:00:00.000Z",
"company_id": "018f0a1b-…",
"data": { "envelope_id": "018f…", "title": "MSA — Acme Corp" }
}5. Delivery semantics
- At-least-once. You may receive the same event twice. Dedupe on
event_id. - Unordered.
envelope.completedcan arrive beforeenvelope.signer_signed. Order byoccurred_at, not by arrival. - Respond fast. Return
2xxwithin 10 seconds; queue your own work rather than doing it inline. We do not follow redirects — point the endpoint at its final URL. - Retries. Any
5xx,408,429, timeout, or network error is retried at 1m → 5m → 30m → 2h → 6h (5 attempts). Other4xxare not retried — they mean the request reached you and was rejected. - Auto-disable. After 20 consecutive failures the endpoint is disabled and the account owner is emailed. Re-enable it in Settings.
- Delivery log is kept for 7 days — successes and failures. After that a delivery can't be inspected or replayed.
6. E-Signing events
Which events you can receive depends on your plan's tier. Advanced includes everything in standard.
Standard — envelope lifecycle
| Event | Fires when |
|---|---|
envelope.sent | The envelope was sent to its recipients. |
envelope.viewed | A recipient opened it for the first time. |
envelope.signer_signed | A recipient completed their signing. |
envelope.partially_signed | Some, but not all, signers have completed. |
envelope.completed | All signers finished; the envelope is complete. |
envelope.declined | A recipient declined to sign. |
envelope.voided | The sender voided or cancelled the envelope. |
envelope.awaiting_payment | The envelope is blocked pending a document payment. |
envelope.reminder_sent | An expiry reminder was emailed to a recipient (one event per recipient). |
Advanced — data, documents, payments, bulk send, forms
| Event | Fires when |
|---|---|
envelope.document_ready | The finished signed document exists and is downloadable — carries a time-limited CDN link. |
envelope.field_values | On completion — carries every captured field value (the data-extraction event). |
payment.pending | A document payment was initiated and is awaiting confirmation. |
payment.succeeded | A document payment succeeded. |
payment.failed | A document payment failed. |
payment.refunded | A document payment was refunded (fires for partial refunds too — check fully_refunded). |
bulk_send.download_ready | A bulk-send download bundle finished building. |
form.submitted | A form response was submitted. |
No envelope.expired event — by design
Envelopes carry an expiry date and a reminder sweep, but never transition to an "expired" state, so
no such event could ever fire. Track outstanding envelopes with envelope.sent /
envelope.reminder_sent and your own timer instead.
Lifecycle payloads
Every lifecycle event carries the envelope's id and title. The ones triggered by a specific person —
envelope.viewed, envelope.signer_signed, envelope.declined — add that recipient, when we know
who they are.
{
"event_id": "018f2c3d-…",
"event": "envelope.declined",
"occurred_at": "2026-07-22T10:00:00.000Z",
"company_id": "018f0a1b-…",
"data": {
"envelope_id": "018f…",
"title": "MSA — Acme Corp",
"recipient_id": "018f…",
"recipient_name": "Jane Doe",
"recipient_email": "jane@acme.com"
}
}A decline is terminal for the whole envelope — nobody else can sign it afterwards, and no
envelope.completed or envelope.document_ready will follow. To act on it you need the recipient,
which is why they're on the payload; the reason they typed is recorded on the audit trail, not the
event.
envelope.voided is sender-initiated, so there is no recipient on it:
{
"event": "envelope.voided",
"data": { "envelope_id": "018f…", "title": "MSA — Acme Corp" }
}Treat envelope.declined and envelope.voided as the two ways an envelope can end without a
signed document. If you're waiting on an envelope, subscribe to both alongside
envelope.completed — otherwise a declined or voided envelope simply goes quiet on you.
| Event | data fields |
|---|---|
envelope.sent | envelope_id, title |
envelope.viewed | envelope_id, title, recipient_id, recipient_name, recipient_email |
envelope.signer_signed | envelope_id, title, recipient_id, recipient_name, recipient_email |
envelope.partially_signed | envelope_id, title |
envelope.completed | envelope_id, title |
envelope.declined | envelope_id, title, recipient_id, recipient_name, recipient_email |
envelope.voided | envelope_id, title |
envelope.awaiting_payment | envelope_id |
envelope.reminder_sent | envelope_id, title, recipient_id, recipient_email, reminder_type |
Recipient fields are best-effort
recipient_name and recipient_email are omitted when we don't hold them for that recipient. Key
your own records off recipient_id.
envelope.field_values — what's included
{
"event": "envelope.field_values",
"data": {
"envelope_id": "018f…",
"title": "MSA — Acme Corp",
"completed_at": "2026-07-22T10:00:00.000Z",
"fields": [
{
"field_id": "018f…",
"type": "text",
"label": "Company name",
"value": "Acme Ltd",
"filled": true,
"recipient": { "id": "018f…", "role": "Client", "name": "Jane Doe", "email": "jane@acme.com" }
}
]
}
}Always excluded:
signatureandinitials— captured marks, not data. (date_signedanddateare included.)- Masked fields — omitted entirely, not redacted. They're encrypted at rest, so sensitive values never leave the platform.
- Layout-only blocks (
text_block,image_block,divider).
Unfilled fields appear with "value": null and "filled": false, so you can tell "asked but
unanswered" from "not asked".
7. Get the completed file by URL — envelope.document_ready
This is the event that hands you the finished document. It fires once the merged, stamped and AATL-signed file actually exists, and its payload carries a time-limited CDN download URL — so you can archive a signed PDF without a second authenticated round trip.
Don't fetch the document on envelope.completed
envelope.completed fires the instant the last signer finishes — before we've merged, stamped and
signed the PDF. There is nothing to download yet, and an integration that reacts to it by fetching
races our pipeline and loses. Wait for envelope.document_ready.
You must subscribe to this event by name
It's the one event an empty events list ("send me everything") does not cover — because it
carries a credential, we require a deliberate tick rather than inheriting consent you gave to a
catalogue that had none. Tick it in Settings → Webhooks, or name it in events when you
create the subscription over the API.
GET /v1/webhooks/events returns it in both events and explicit_subscription_only.
{
"event_id": "018f2c3d-…",
"event": "envelope.document_ready",
"occurred_at": "2026-07-22T10:00:00.000Z",
"company_id": "018f0a1b-…",
"data": {
"envelope_id": "018f…",
"title": "MSA — Acme Corp",
"completed_at": "2026-07-22T10:00:00.000Z",
"document": {
"filename": "MSA_Acme_Corp.pdf",
"content_type": "application/pdf",
"size_bytes": 284119,
"aatl_signed": true,
"download_url": "https://cdn.bunnydoc.com/…?Expires=…&Signature=…",
"expires_at": "2026-07-23T10:00:00.000Z"
}
}
}| Field | Notes |
|---|---|
document.filename | Suggested filename. |
document.content_type | application/pdf normally; application/zip if the envelope is configured to keep its documents as separate files. Don't assume PDF — read this. |
document.size_bytes | May be null if the size lookup failed. The link is still good. |
document.aatl_signed | Whether the file carries the AATL long-term signature. |
document.download_url | Time-limited link — GET it, no auth header. |
document.expires_at | When that link dies. Always check it before fetching. |
download_url is a bearer credential
Anyone holding it can fetch the document until expires_at — there is no other authentication.
Treat it like a password: don't log it, don't forward it, don't put it in a system with a wider
audience than the document itself.
The 24-hour clock starts when we build the event, not when you receive it. That covers the full
retry window with room to spare, but a replay from the delivery log days later will contain a dead
link. If expires_at has passed, don't retry the URL — fetch the document through the
authenticated API instead.
Two more things that surprise people:
- Individual envelopes only. Envelopes sent as part of a bulk send do not fire this — you'd get
one per recipient. Subscribe to
bulk_send.download_readyfor batches. - Once per envelope. Unlike every other event, this one is de-duplicated on our side, so a
retried internal job won't re-publish it. (Your endpoint can still see the same delivery twice from
an HTTP retry — keep deduping on
event_id.)
8. Manage subscriptions over the API
Instead of the app, an integration can manage its own endpoints with an API key. This is what powers iPaaS connectors (Zapier, Make) as instant triggers.
Both routes need webhooks in your plan, plus the matching scope on the key.
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /v1/webhooks/events | webhooks:read | The events you may subscribe to right now |
GET | /v1/webhooks | webhooks:read | List your subscriptions |
POST | /v1/webhooks | webhooks:write | Subscribe a target_url to events |
DELETE | /v1/webhooks/{id} | webhooks:write | Unsubscribe |
curl -X POST "https://api2.bunnydoc.com/v1/webhooks" \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{
"target_url": "https://example.com/hooks/bunnydoc",
"events": ["envelope.document_ready", "envelope.declined", "envelope.voided"]
}'{
"id": "018f…",
"url": "https://example.com/hooks/bunnydoc",
"events": ["envelope.document_ready", "envelope.declined", "envelope.voided"],
"status": "pending_verification",
"created_at": "2026-07-22T10:00:00.000Z",
"signing_secret": "whsec_…"
}signing_secret is returned only on this create call — store it now. Everything else (payload
shape, signature verification, retries, auto-disable) is exactly as described above: a subscription
created here is an ordinary endpoint.
Verification for API-created endpoints
A subscription normally starts pending_verification and must echo the challenge
(§2) before anything is delivered — the same rule as the app. The one
exception: if target_url belongs to a vetted integration platform (Zapier, Make), it's created
active immediately, because those platforms' catch-hooks can't echo a challenge and their
per-account URL was already issued to you during an authenticated connection. Any other URL —
including your own server — still starts pending_verification; complete the challenge from your
endpoint, and check status on the create response to see which you got.
An empty events array means "everything my plan allows" — except envelope.document_ready,
which you must name explicitly (see §7).