Skip to content

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.

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:

EventFires whenGating scope
invoice.paidAn invoice becomes fully paidinvoices:read
payment.receivedA customer payment is recordedpayments:read
customer.createdA customer is createdcustomers:read
app.uninstalledYour app is uninstalled from a business– (sent to you regardless of scopes)

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 because app.uninstalled fires exactly when there’s no active installation left.
  • privacy — customer.redact and supplier.redact follow the same double-gate as business events, 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.

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" }
}
HeaderValue
Content-Typeapplication/json
X-Webhook-IdDelivery ID (same as body id; stable across retries)
X-Webhook-EventEvent name, e.g. invoice.created
X-Webhook-Event-IdThe underlying event’s id (same as body eventId) — use this, not the delivery id, to correlate with the Events API
X-Webhook-Signaturet=<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.

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.

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.

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.

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:

AttemptDelay after previous failure
1immediate
21 minute
35 minutes
430 minutes
52 hours
66 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.

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:

  • type is the webhook’s first subscribed event (invoice.created if it has none), and data is that event’s sample payload from the Event catalog with "test": true added.
  • businessId is the literal string "test".
  • eventId is null and there’s no X-Webhook-Event-Id header — 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.