Skip to content
All guides

Integrations & API · Updated August 31, 2026

Webhooks

How outbound webhooks are registered, signed, delivered and retried, and exactly which events reach them today.


What a webhook is here

A webhook is an HTTPS endpoint you own that Bohari posts JSON to. You register the URL, list the event names you care about, and set a secret so you can prove each delivery came from us. Everything about a destination lives in one record: name, URL, events, secret, extra headers, a retry count and a timeout.

They come in two flavours. Company webhooks belong to one tenant and are managed under Settings > Webhooks by anyone holding manage_settings; the company also needs the WEBHOOKS module on its licence, or every call answers 403 with a "not licensed" message. Platform webhooks belong to the operator, have no company, and receive matching events from every tenant; only platform administrators see them, on a separate page backed by /api/platform/webhooks.

The settings form covers name, URL, events, retries, timeout and the active flag. It doesn't expose the signing secret or custom headers, so set those through the API (or ask the assistant, whose create_webhook tool accepts both). Read API Authentication first; every call below needs a bearer token and an X-Company-Id header.

Registering an endpoint

curl -X POST https://bohari.co.ke/api/webhooks \
  --header "Authorization: Bearer $ACCESS_TOKEN" \
  --header "X-Company-Id: $COMPANY_ID" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "Warehouse sync",
    "url": "https://hooks.example.co.ke/bohari",
    "events": ["*"],
    "secret": "a-long-random-string-you-generated",
    "retryCount": 3,
    "timeoutMs": 30000
  }'

Field rules, all enforced by the server:

  • url must parse, must be HTTPS in production, and can never point at anything private in any environment: localhost, RFC1918 ranges, link-local (the cloud metadata address included), .internal and .local names. The hostname is resolved again immediately before every delivery, so a public name that later resolves to a private address stops being delivered to.
  • events is a list of strings. "*" subscribes to everything.
  • secret is optional, at most 255 characters, encrypted at rest and never served back. Reads return hasSecret: true in its place. There is no reveal call; if you lose it, PATCH a new one.
  • headers is a map of extra request headers. Anything that would let you steer the request is dropped silently: Host, Content-Type, Content-Length, Transfer-Encoding, the X-Forwarded-* family, cloud metadata headers, and every X-Webhook-* name, which are ours.
  • retryCount accepts 0 to 10 (default 3); timeoutMs accepts 1000 to 120000 (default 30000).

The other endpoints are GET /api/webhooks, GET /api/webhooks/:id, PATCH /api/webhooks/:id, DELETE /api/webhooks/:id, POST /api/webhooks/:id/test and GET /api/webhooks/logs. All of them need manage_settings.

What a delivery looks like

Every delivery is a POST with a JSON body, serialised exactly once so the signature always covers the bytes on the wire. Redirects are never followed; a 3xx is logged as a failure. This is the test delivery, which is what POST /api/webhooks/:id/test and the play button on the settings page send:

POST /bohari HTTP/1.1
Content-Type: application/json
X-Webhook-Event: webhook.test
X-Webhook-Id: 6b0f2d0e-3c1a-4e7b-9a5d-2f8c1e4a7b90
X-Webhook-Timestamp: 1756631400
X-Webhook-Attempt: 1
X-Webhook-Signature: sha256=3f1c...
X-Webhook-Signature-V2: sha256=9a72...

{"event":"webhook.test","timestamp":"2026-08-31T09:10:00.000Z","data":{"message":"Test webhook delivery"}}

X-Webhook-Id is the webhook's own id, not a per-delivery id. X-Webhook-Timestamp is Unix seconds, stamped fresh on each attempt. The two signature headers only appear when the webhook has a secret. Domain events, once they flow (see below), carry the event's own payload plus a _companyId field naming the originating company, which is how a platform webhook tells tenants apart.

Verifying the signature

Both signatures are HMAC-SHA256 with your secret, hex encoded, prefixed sha256=. X-Webhook-Signature is over the raw body alone. X-Webhook-Signature-V2 is over "<timestamp>.<body>", which binds the timestamp so a captured delivery can't be replayed indefinitely. Verify v2, and verify it against the raw request bytes, not a re-serialised object:

const crypto = require('crypto');

function verify(headers, rawBody, secret) {
  const ts = headers['x-webhook-timestamp'];
  const given = headers['x-webhook-signature-v2'] || '';
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(`${ts}.${rawBody}`)
    .digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300; // your window, not ours
  return fresh
    && given.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}

The freshness window is your call; the server doesn't enforce one. Because the body is identical across retries while the timestamp changes, the v1 signature doubles as a dedupe key when the same delivery reaches you twice.

Retries, timeouts and the kill switch

A delivery is one dispatch of up to 1 + retryCount attempts, capped at six regardless of what you configured. Only outcomes worth repeating are repeated: a network error, a timeout (status: timeout in the log), any 5xx, 408 and 429. Any other 4xx ends the dispatch at once, because your endpoint has said the request itself is wrong. Between attempts the wait doubles from 500 ms (then 1 s, 2 s, 4 s, 8 s), with up to 250 ms of jitter so a fleet of instances doesn't retry in lockstep. Success is any 2xx.

Each attempt writes its own row to the delivery log with the attempt number, status (success, failed or timeout), HTTP code, up to 10,000 characters of your response body (we read at most 64 KB of it), any error message and the duration. The Delivery Logs tab shows the latest 50; GET /api/webhooks/logs pages through the rest and filters by webhookId, eventType and status.

Each webhook keeps a failureCount. A successful dispatch resets it to zero; a failed one increments it. At 25 consecutive failed dispatches the webhook is switched off (isActive: false) and receives nothing until someone re-enables it, on the edit form or with a PATCH. The Failures column on the settings page is that counter.

Which events you can subscribe to

The events list on the settings form is a picker, and everything in it is deliverable. That is not a formatting choice: the list comes from the server's projection allowlist, which is the same list the dispatcher checks when an event fires, so a name you can tick is a name that can arrive.

Today that covers:

EventFires when
inventory.stock.lowA movement takes an item's outlet balance across its minimum. Edge-triggered, so it fires on the crossing, not on every sale of an already-low item
inventory.approval.pendingA waste note or stock count lands in a state somebody has to decide on
inventory.approval.digestThe daily roll-up of what is waiting: pending waste notes, submitted counts, undecided invoices
inventory.variance.largeAn approved count's variance clears the company's threshold, or could not be fully priced
compliance.document.expiringA tracked document is inside its reminder window
compliance.filing.dueA statutory obligation is approaching its due date
billing.payment.initiated, billing.payment.completed, billing.payment.failedAn M-Pesa subscription payment moves
billing.plan.changedA completed payment changes the plan

Names follow the {domain}.{entity}.{action} taxonomy the platform uses for in-app notifications and email, and an existing name is never renamed. Subscribe to "*" and you receive every deliverable event, including ones added after you subscribed.

What a payload may contain

Every delivery is a projection, never the raw internal event. Each event type has an explicit list of fields it is allowed to carry, written down in the backend and reviewed like any other code, and an event with no such list is not delivered at all.

That default matters more than the field lists. Internal events are written for listeners inside the process and carry whatever the emitter had in hand: the M-Pesa payload includes the payer's phone number and Safaricom's correlation ids, and the compliance one names the member of staff a document belongs to. None of that crosses the network. Neither does a field somebody adds to an internal payload next year, because projections name their fields one at a time rather than forwarding whatever arrives.

So billing.payment.completed gives you the payment id, the plan and the amount, and no phone number. compliance.document.expiring gives you the document, its type and when it expires, and not whose licence it is. If you need something a projection withholds, fetch it with a token of your own through the API, where your permissions decide what you may see.

Accounting integration

Accounting is a separate integration, not a webhook. Under Settings > Accounting Integration (permission manage_settings or manage_integrations, module ACCOUNTING) a company creates one connection per provider from QuickBooks Online, Xero and Sage, with an OAuth authorisation step and a sync log per connection. The provider adapters are still stubs, so it runs in sandbox mode and doesn't yet post to a live ledger. If you need accounting data out today, pull it through the API rather than waiting on a push.

FAQ

How do I get the secret back? You don't. It's encrypted at rest and never leaves the server; every read returns hasSecret instead. Set a new one with PATCH /api/webhooks/:id and update your receiver.

My endpoint answers 302 to the real handler. Why is every delivery failing? Because redirects are never followed. The 3xx is logged as HTTP 302 (redirect not followed) and, not being a 5xx, 408 or 429, isn't retried. Point the webhook at the final URL.

Why did my webhook go inactive on its own? It failed 25 dispatches in a row. Check the Delivery Logs tab for the error message, fix the endpoint, then tick Active on the edit form. The counter resets on the first success.

Can I point a webhook at my laptop for testing? Not directly: loopback and private addresses are refused in every environment, and production also insists on HTTPS. Put a public tunnel in front of the local server and register the tunnel's URL.

Can a platform webhook see every company? Yes. It receives matching events from all tenants, each carrying _companyId. Its definition and logs have no company of their own, which is why they're managed from the platform page rather than a tenant's settings.