Webhooks
Webhooks push events to your endpoint instead of making you poll. You register an HTTPS URL per app on the app’s Webhooks tab in the developer console, choose which events to receive, and verify each delivery with the endpoint’s signing secret. An app can have several webhooks, each with its own URL, events and secret.
The secret (whsec_…) is shown once, when the webhook is created. Store it
with your other credentials.
Events
Section titled “Events”The full list — 45 events across every module, app-lifecycle and privacy notices included — is the Event catalog: the same set the console’s event picker offers. A sample:
| Event | Fires when | Gating scope |
|---|---|---|
invoice.paid | An invoice becomes fully paid | invoices:read |
payment.received | A customer payment is recorded | payments:read |
customer.created | A customer is created | customers:read |
app.uninstalled | Your app is uninstalled from a business | – (sent to you regardless of scopes) |
Audiences: who receives what
Section titled “Audiences: who receives what”Most events are audience business and matched twice: your app must hold
the event’s gating scope, and the business the event happened in must have
an ACTIVE installation of your app whose granted scopes include it. You only
ever receive business events for businesses that installed your app and
granted the relevant scope.
Two other audiences work differently:
app— app-lifecycle events (app.installed,app.uninstalled,app.scopes_updated,business.redact,app_data.updated) go only to your app’s subscribed webhooks, regardless of installation state — precisely becauseapp.uninstalledfires exactly when there’s no active installation left.privacy—customer.redactandsupplier.redactfollow the same double-gate asbusinessevents, but they’re compliance notices, not just data updates; see Privacy & data handling.
Webhook deliveries are created from events recorded in the business, and the
same events can be pulled from the Events API (with the
events:read scope) — use it to reconcile if you suspect you missed
something.
Delivery format
Section titled “Delivery format”Each delivery is an HTTP POST with a JSON body:
{ "id": "delivery-id", "eventId": "evt_…", "type": "invoice.created", "apiVersion": "2026-09-22", "businessId": "business-id", "created": 1765465600, "data": { "…": "event-specific payload" }}| Header | Value |
|---|---|
Content-Type | application/json |
X-Webhook-Id | Delivery ID (same as body id; stable across retries) |
X-Webhook-Event | Event name, e.g. invoice.created |
X-Webhook-Event-Id | The underlying event’s id (same as body eventId) — use this, not the delivery id, to correlate with the Events API |
X-Webhook-Signature | t=<unix-seconds>,v1=<hex HMAC-SHA256>[,v1=<hex HMAC-SHA256>] |
businessId tells you which connected business the event belongs to — this is
the one place the platform hands you a business identifier, since a single
webhook endpoint serves all businesses connected to your app. apiVersion is
the API version this payload shape corresponds to —
your app’s pinned version, or the current version if you haven’t pinned one.
data is the event’s compact payload, as listed in the
Event catalog.
Verifying signatures
Section titled “Verifying signatures”The signature is HMAC-SHA256(secret, "<t>.<raw body>"), hex-encoded, where
t is the timestamp from the header. Verify against the raw request bytes
(before any JSON parsing), and use a constant-time comparison.
import crypto from 'node:crypto';import express from 'express';
const app = express();const WEBHOOK_SECRET = process.env.ONEBOOKS_WEBHOOK_SECRET; // whsec_…const TOLERANCE_SECONDS = 300;
app.post('/webhooks/onebooks', express.raw({ type: 'application/json' }), // keep the raw body (req, res) => { const header = req.get('X-Webhook-Signature') ?? ''; const parts = header.split(',').map((kv) => kv.split('=')); const t = parts.find(([k]) => k === 't')?.[1]; const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v); if (!t || signatures.length === 0) { return res.status(400).send('malformed signature'); }
// Reject stale timestamps to blunt replay attacks. if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) { return res.status(400).send('timestamp out of tolerance'); }
const expected = crypto .createHmac('sha256', WEBHOOK_SECRET) .update(`${t}.${req.body}`) // req.body is a Buffer here .digest('hex'); const expectedBuf = Buffer.from(expected, 'hex');
// Accept if ANY v1 value verifies — during a secret rotation, one of the // two carried signatures is signed with your (still-valid) old secret. const verified = signatures.some((sig) => { const a = Buffer.from(sig, 'hex'); return a.length === expectedBuf.length && crypto.timingSafeEqual(a, expectedBuf); }); if (!verified) return res.status(400).send('bad signature');
const event = JSON.parse(req.body.toString('utf8')); // Acknowledge fast; process asynchronously. res.status(200).end(); queueForProcessing(event); });Each retry is re-signed with a fresh timestamp, so a delivery that arrives late still verifies.
Secret rotation
Section titled “Secret rotation”Rotate a webhook’s signing secret from the console (or
POST /developer/apps/:appId/webhooks/:webhookId/rotate-secret) at any time —
compromise, routine hygiene, whatever the reason:
{ "secret": "whsec_…", "previousSecretExpiresAt": "2026-09-23T10:00:00.000Z" }The old secret keeps signing deliveries (as the second v1= value) for
24 hours, so you can roll your receiver’s WEBHOOK_SECRET without
dropping any in-flight or retrying deliveries. After the overlap window, only
the new secret signs.
Redelivery
Section titled “Redelivery”Failed deliveries (see retries below) can be manually
re-queued with Redeliver in the console once you’ve fixed whatever broke
your endpoint —
POST /developer/apps/:appId/webhooks/:webhookId/deliveries/:deliveryId/redeliver.
Redelivery reuses the same delivery row: the same X-Webhook-Id and
eventId come back, with a fresh attempt count and a fresh signature. A
receiver that records a delivery id only once it has processed it successfully
therefore handles the redelivery normally (and ignores one you redeliver after
it already succeeded). A delivery that’s waiting for its next automatic retry
can’t be redelivered until that attempt has run.
Redelivery is refused with 400 whenever the delivery couldn’t be sent
anyway: your app is suspended or deactivated, or — for a business or privacy
event — your app is no longer installed in that business, or it (or its
installation) no longer holds the event’s scope. The same checks run before
every send; see Retries and timeouts.
Delivery history is kept for 30 days: a delivery — delivered or failed —
is deleted 30 days after it was created (the cleanup runs hourly), and
redelivering one older than that is refused with 400. Replay older changes
from your own records or the current API state instead.
Retries and timeouts
Section titled “Retries and timeouts”A delivery succeeds on any 2xx response. Your endpoint has 10 seconds to
respond; redirects are not followed. Anything else — non-2xx, timeout,
connection error — schedules a retry:
| Attempt | Delay after previous failure |
|---|---|
| 1 | immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
After the 6th failed attempt the delivery is marked failed and not retried. The console shows each webhook’s 100 most recent deliveries — event, status, attempts, response code and last error — so you can diagnose a misbehaving endpoint.
Every attempt is re-checked at send time. A queued or retrying delivery is dropped — marked failed, not sent — if, before it goes out:
- the webhook was deleted or switched off;
- OneBooks suspended your app, or it was deactivated; or
- for a business or privacy event, the business uninstalled your app, or your app or its installation no longer holds the event’s gating scope.
App-lifecycle notices (app.uninstalled, business.redact, …) are never
dropped for lack of an installation — they’re sent precisely when there isn’t
one. Deliveries never outlive the access they were created under.
Testing
Section titled “Testing”From the console you can fire a test delivery at any webhook. It’s signed, retried and logged exactly like a real delivery, with a realistic body:
typeis the webhook’s first subscribed event (invoice.createdif it has none), anddatais that event’s sample payload from the Event catalog with"test": trueadded.businessIdis the literal string"test".eventIdisnulland there’s noX-Webhook-Event-Idheader — no real event stands behind it.
Use it to verify your signature code and your parsing end to end before going
live — and make sure your handler recognizes "test": true (or the "test"
business id) rather than acting on it. Pair it with a
sandbox business to generate real events safely.