Skip to content

Webhooks

Webhooks push events to your endpoint instead of making you poll. You register an HTTPS URL per app in the developer console, choose which events to receive, and verify each delivery with the endpoint’s signing secret.

The secret (whsec_…) is shown once, when the webhook is created. Store it with your other credentials.

Event Fires when Gating scope
invoice.created An invoice is created invoices:read
invoice.updated An invoice changes invoices:read
payment.received A customer payment is recorded payments:read
customer.created A customer is created customers:read
sales_return.created A sales return (credit note) is created sales-returns:read

Deliveries are scope-gated twice: your app must hold the event’s gating scope, and the business the event happened in must have granted that scope to your app. You only ever receive events for businesses that connected your app.

Each delivery is an HTTP POST with a JSON body:

{
"id": "delivery-id",
"type": "invoice.created",
"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-Signature t=<unix-seconds>,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.

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 = Object.fromEntries(
header.split(',').map((kv) => kv.split('=', 2)),
);
const { t, v1 } = parts;
if (!t || !v1) 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 a = Buffer.from(v1, 'hex');
const b = Buffer.from(expected, 'hex');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
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.

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 recent deliveries per webhook — status, attempts, response code and last error — so you can diagnose a misbehaving endpoint.

From the console you can fire a test delivery at any webhook. It carries a synthetic payload (data is { "test": true, "message": "This is a test event" }) and is signed and delivered exactly like a real event — use it to verify your signature code end-to-end before going live. Pair it with a sandbox business to generate real events safely.