Data webhooks
Webhooks let Bito tell you when a merchant's data changes, so you do not have to poll. A destination gets there in one of two ways:
- the integration's
webhook_uri— Bito subscribes it automatically on every install; webhook/subscribe— over the Integration API, per destination, with the events you choose.
webhook_uri: automatic subscription
If the integration has a webhook_uri, every time the integration is installed Bito subscribes that destination to create, update and delete on these collections: currencies, customers, trades, users, productCategories, products, unitsOfMeasure, saleOrders, invoices, transactions, purchases, purchaseReturns, purchaseOrders.
status_change is never sent this way — subscribe to it separately through webhook/subscribe if you need it. Uninstalling removes this subscription; reinstalling re-creates it.
Discover the events
POST /integration/api/v2/webhook/events lists everything you can subscribe to. Event names are <collection>.<action>.
Response (abridged)
{
"code": 0,
"data": [
{
"collection_name": "saleOrders",
"actions": ["create", "update", "delete", "status_change"],
"events": [
"saleOrders.create",
"saleOrders.update",
"saleOrders.delete",
"saleOrders.status_change"
]
},
{
"collection_name": "customers",
"actions": ["create", "update", "delete"],
"events": ["customers.create", "customers.update", "customers.delete"]
}
],
"time": "2025-01-15T10:30:00.000Z"
}| Action | Fires when |
|---|---|
create | A record is created |
update | A record is updated |
delete | A record is deleted |
status_change | A record's status changes. Only offered for collections that actually have a status — the events call tells you which. |
Subscribe
POST /integration/api/v2/webhook/subscribe. The call is an upsert scoped to one destination: re-sending it replaces that destination's event list and leaves your other destinations untouched.
Request
POST /integration/api/v2/webhook/subscribe
api-key: mycompany:9ad41c0e7b3f28d5610ac94e7f1b26d8
Content-Type: application/json
{
"destination": "https://your-app.example.com/bito/webhook",
"events": ["saleOrders.create", "saleOrders.status_change", "customers.update"]
}Response
{
"code": 0,
"data": {
"destination": "https://your-app.example.com/bito/webhook",
"events": ["saleOrders.create", "saleOrders.status_change", "customers.update"],
"secret": "d7c1904ae5b3268f04ad91c7e63b58f2019ce74db28a1650f3c9e7a84b2d6015"
},
"time": "2025-01-15T10:30:00.000Z"
}| Endpoint | Body | Who can call it |
|---|---|---|
POST webhook/events | - | Any authenticated integration user |
POST webhook/get-all | - | Any authenticated integration user |
POST webhook/subscribe | { destination, events[] } | Account owner or additional owner |
POST webhook/unsubscribe | { destination } | Account owner or additional owner |
subscribe and unsubscribe check who the request acts as: a call made as an ordinary employee through the user_id header is refused.
Limits: at most 100 distinct destinations per integration, at most 500 events in one subscribe call; subscribe and unsubscribe accept 1 request per second.
Receiving a delivery
What Bito sends you
POST https://your-app.example.com/bito/webhook
account-id: 507f1f77bcf86cd799439011
X-Bito-Signature: t=1736937000,v1=6b1f0a...c94e
Content-Type: application/json
{"collection_name":"saleOrders","action":"status_change","id":"6712f0a9c4d1e87b3a0f9911","organization_id":"6712f0a9c4d1e87b3a0f7788"}The body carries the identity of what changed, not the change itself. Fetch the current record from the regular v2 read endpoints using id, scoped to the account in the account-id header.
| Header | When present | Meaning |
|---|---|---|
account-id | Always | The account the event happened in |
X-Bito-Signature | Always | The signature — see below |
api-key | webhook_uri subscriptions only | A token Bito generated for that subscription. It is not your integration's api_key — do not use it to call the API. Subscriptions created with webhook/subscribe send no api-key header. |
Bito waits 10 seconds for your response and does not follow redirects (3xx). A response that does not arrive within 10 seconds, or is not 2xx, counts as a failed delivery. Queue heavy work and return 2xx immediately.
Verifying the signature
X-Bito-Signature is t=<unix seconds>,v1=<hex>, where v1 is HMAC_SHA256(secret, "{t}.{raw_body}"). The secret is your client secret (client_secret); subscribe and get-all return exactly that value. It is one value for every account and every destination, it does not change on reinstall, and it changes only when you rotate the secret.
Example verification
import { createHmac, timingSafeEqual } from 'crypto';
// `raw` MUST be the untouched request body bytes. Do NOT JSON.parse and
// re-stringify before verifying -- key order and number formatting will differ
// and the signature will never match.
export function verify(raw: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1) return false;
// Reject anything older than 5 minutes to blunt replay.
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${raw}`).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(v1, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}WARNING
Hash the raw body bytes. This is the single most common mistake with Bito webhooks. If you JSON.parse the body and re-serialize it before computing the HMAC, the bytes you hash will differ from the bytes Bito hashed the moment key order, whitespace or number formatting differs — and the signature will never match. Verify first, over the untouched body. Parse second.
What webhooks do not guarantee
WARNING
- One attempt, no retry. There is no retry, no backoff and no dead-letter queue. If your endpoint is down, that event is gone.
- No ordering. Deliveries can arrive out of order.
- Bulk operations emit nothing. Mass imports and bulk updates do not fire webhooks at all. Treat a webhook as a hint to re-fetch, never as the state itself, and reconcile periodically with a full read. An integration that assumes complete webhook coverage will silently drift.