BunnyDoc
APIE-Signing API

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:

webhook.verification
{
  "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:

Response body
{ "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.

Every request carries these headers:

X-BunnyDoc-Signature: t=1800000000,v1=5257a869e7…
X-BunnyDoc-Event: envelope.completed
X-BunnyDoc-Delivery: 018f…
X-BunnyDoc-Timestamp: 1800000000

v1 is HMAC-SHA256(signing_secret, "<t>.<raw request body>"), hex-encoded.

verify.js
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 envelope
{
  "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.completed can arrive before envelope.signer_signed. Order by occurred_at, not by arrival.
  • Respond fast. Return 2xx within 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). Other 4xx are 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

EventFires when
envelope.sentThe envelope was sent to its recipients.
envelope.viewedA recipient opened it for the first time.
envelope.signer_signedA recipient completed their signing.
envelope.partially_signedSome, but not all, signers have completed.
envelope.completedAll signers finished; the envelope is complete.
envelope.declinedA recipient declined to sign.
envelope.voidedThe sender voided or cancelled the envelope.
envelope.awaiting_paymentThe envelope is blocked pending a document payment.
envelope.reminder_sentAn expiry reminder was emailed to a recipient (one event per recipient).

Advanced — data, documents, payments, bulk send, forms

EventFires when
envelope.document_readyThe finished signed document exists and is downloadable — carries a time-limited CDN link.
envelope.field_valuesOn completion — carries every captured field value (the data-extraction event).
payment.pendingA document payment was initiated and is awaiting confirmation.
payment.succeededA document payment succeeded.
payment.failedA document payment failed.
payment.refundedA document payment was refunded (fires for partial refunds too — check fully_refunded).
bulk_send.download_readyA bulk-send download bundle finished building.
form.submittedA 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.

envelope.declined
{
  "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:

envelope.voided
{
  "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.

Eventdata fields
envelope.sentenvelope_id, title
envelope.viewedenvelope_id, title, recipient_id, recipient_name, recipient_email
envelope.signer_signedenvelope_id, title, recipient_id, recipient_name, recipient_email
envelope.partially_signedenvelope_id, title
envelope.completedenvelope_id, title
envelope.declinedenvelope_id, title, recipient_id, recipient_name, recipient_email
envelope.voidedenvelope_id, title
envelope.awaiting_paymentenvelope_id
envelope.reminder_sentenvelope_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

envelope.field_values
{
  "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:

  • signature and initials — captured marks, not data. (date_signed and date are 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.

envelope.document_ready
{
  "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"
    }
  }
}
FieldNotes
document.filenameSuggested filename.
document.content_typeapplication/pdf normally; application/zip if the envelope is configured to keep its documents as separate files. Don't assume PDF — read this.
document.size_bytesMay be null if the size lookup failed. The link is still good.
document.aatl_signedWhether the file carries the AATL long-term signature.
document.download_urlTime-limited link — GET it, no auth header.
document.expires_atWhen 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_ready for 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.

MethodPathScopePurpose
GET/v1/webhooks/eventswebhooks:readThe events you may subscribe to right now
GET/v1/webhookswebhooks:readList your subscriptions
POST/v1/webhookswebhooks:writeSubscribe a target_url to events
DELETE/v1/webhooks/{id}webhooks:writeUnsubscribe
Subscribe to the completed-document event
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"]
      }'
201 Created
{
  "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).

On this page