Webhooks
Bona Fide sends an HTTPS POST to your endpoint for every envelope event you subscribe to. Deliveries follow the
Standard Webhooks specification, so any of its libraries can verify them.
Registering an endpoint
Section titled “Registering an endpoint”In Settings › Webhooks, or with an API key that has the org:manage
scope:
curl https://bonafide-api.republic.global/v1/webhook-endpoints \ -H "Authorization: Bearer $BONA_FIDE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://app.example.com/hooks/bona-fide", "description": "Production", "events": ["envelope.completed", "envelope.sealed", "envelope.declined"] }'The response includes secret (whsec_…), only this once. An empty events list subscribes to every event.
The URL must be public https://. An endpoint belongs to one mode: endpoints created with a test key receive only
test-mode envelopes’ events, live ones only live events.
PATCH /v1/webhook-endpoints/{id} changes the URL, events or description and pauses or resumes it
(enabled). POST /v1/webhook-endpoints/{id}/test sends a webhook.test ping.
Events
Section titled “Events”| Type | When |
|---|---|
envelope.created |
The envelope was created |
envelope.ready |
The document is rendered and the envelope is ready for its recipients |
recipient.sent |
It became a recipient’s turn (and, with email delivery, their link was sent) |
recipient.viewed |
A recipient opened the signing page for the first time |
recipient.signed |
A recipient submitted (or was countersigned automatically) |
recipient.declined |
A recipient declined |
envelope.completed |
Every recipient finished. data.values has the variables and every typed value |
envelope.sealed |
The sealed PDF is ready to download |
envelope.declined |
The envelope ended because a recipient declined |
envelope.voided |
You voided the envelope |
envelope.expired |
expiresAt passed before completion |
signing_link.used |
A visitor started an envelope from an always-on signing link |
acceptance.created is reserved in the schema and not emitted yet.
Payload
Section titled “Payload”{ "id": "evt_9sKq2Lm4Tz", "type": "envelope.completed", "createdAt": "2026-10-13T14:09:52.004Z", "testMode": false, "data": { "envelope": { "id": "env_8Gm2kQ4rT1", "status": "completed", "seal": "pending", "recipients": [{ "id": "rcp_5Jw0pLs9Xe", "role": "client", "status": "completed", "…": "…" }], "externalId": "user_8841", "metadata": {}, "…": "…" }, "values": { "variables": { "client": { "name": "Acme Robotics, Inc." }, "effective_date": "2026-10-13" }, "recipients": { "client": { "client_title": "CEO", "start_date": "2026-11-01" } } } }}data.envelope is the full envelope at the time of the event. data.values is only on
envelope.completed: the variables you sent, and each role’s field values by field id.
Headers and signature
Section titled “Headers and signature”Each delivery carries:
| Header | Value |
|---|---|
webhook-id |
The event id (evt_…); the same on every retry. Use it to de-duplicate |
webhook-timestamp |
Unix seconds when this attempt was signed |
webhook-signature |
v1,<base64 HMAC-SHA256>, space-separated when more than one secret signs |
user-agent |
BonaFide-Webhooks/1 |
The signature is HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw body}, keyed with the base64-decoded part
of the secret after whsec_. Verify the raw body (before JSON parsing), compare in constant time, and reject
timestamps more than five minutes away from your clock.
import express from 'express';import { Webhook } from 'standardwebhooks'; // npm i standardwebhooks
const wh = new Webhook(process.env.BONA_FIDE_WEBHOOK_SECRET!); // whsec_…const app = express();
app.post('/hooks/bona-fide', express.raw({ type: 'application/json' }), (req, res) => { let event; try { event = wh.verify(req.body, req.headers as Record<string, string>); // throws if forged or too old } catch { return res.sendStatus(400); } queue.add(event); // do the work asynchronously and answer quickly res.sendStatus(204);});# pip install standardwebhooks flaskimport osfrom flask import Flask, requestfrom standardwebhooks.webhooks import Webhook
wh = Webhook(os.environ["BONA_FIDE_WEBHOOK_SECRET"]) # whsec_…app = Flask(__name__)
@app.post("/hooks/bona-fide")def bona_fide(): try: event = wh.verify(request.get_data(), dict(request.headers)) except Exception: return "", 400 enqueue(event) return "", 204import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(secret: string, headers: Record<string, string>, rawBody: string) { const id = headers['webhook-id']; const ts = Number(headers['webhook-timestamp']); if (!id || !ts || Math.abs(Date.now() / 1000 - ts) > 300) throw new Error('stale or missing headers'); const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const expected = createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest(); const ok = (headers['webhook-signature'] ?? '').split(' ').some((part) => { const [version, sig] = part.split(','); const given = Buffer.from(sig ?? '', 'base64'); return version === 'v1' && given.length === expected.length && timingSafeEqual(given, expected); }); if (!ok) throw new Error('bad signature'); return JSON.parse(rawBody);}Retries
Section titled “Retries”A delivery succeeds on any 2xx within 10 seconds. Redirects are not followed. Anything else is retried with
backoff, after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 14 hours, 20 hours and 24 hours,
for at most three days after the event. Then the delivery is marked failed.
- Answer quickly and process asynchronously; de-duplicate on
webhook-id. - Deliveries can arrive out of order: check
data.envelope.statusrather than assuming a sequence. GET /v1/webhook-endpoints/{id}/deliverieslists recent attempts with status codes, errors and timings, and each endpoint shows its 24-hour success rate (health).POST /v1/events/{id}/redeliversends an event to an endpoint again, for example after an outage.
Rotating the secret
Section titled “Rotating the secret”POST /v1/webhook-endpoints/{id}/secret/rotate returns a new secret immediately. For the next 24 hours every
delivery is signed with both the new and the old secret (two entries in webhook-signature), so you can
deploy the new secret without dropping events.