Webhooks
Signed, at-least-once events for changes your integration did not make.
Webhooks are how your integration learns of changes it did not make: an invoice the daily run issued, a month-end accrual entry the close booked, a customer changed in the accounting system. Tabba posts a signed event to an HTTPS address of yours, and tries again when your server does not take it.
Registering an endpoint
An admin or a bookkeeper of the company registers a webhook endpoint in the
company's settings, under Developers (/workspace/settings/company/developers).
An endpoint is bound to that company, and
subscribes to one or more event types. It can be disabled and enabled again.
The endpoint's signing secret, whsec_ and 32 characters, is shown once,
when the endpoint is registered. Copy it into your integration's secret
store.
The address must be a public HTTPS host name. An address that is not https,
that carries a user name or a password, that is an IP address, or whose host
name resolves only inside a network (localhost, .local, .internal and
the like) is refused. A host name that resolves to an address that is not
public is never connected to.
Events
| Type | Sent when |
|---|---|
invoice.issued | The accounting system booked an invoice: by the daily run, by an admin in the workspace, or by a call to the issue endpoint. Sent once per invoice |
invoice.send_failed | An invoice was booked, and its delivery to the customer is not known to have succeeded. Sent beside invoice.issued, never instead of it |
credit_note.issued | The accounting system booked a credit note: one an admin or a key asked for, or one the end of a recurring series queued. Sent once per credit note |
accrual.booked | The month-end close posted a customer's accrual entry. Not sent for a month the accounting system had closed, which a later month carries |
customer.updated | A customer was created, changed, switched off or deleted, in Tabba or in the accounting system |
product.updated | A product was created, changed, switched off or deleted, in Tabba or in the accounting system |
A change made in the accounting system is seen at the next import, and the event is sent then. A change your own key made is announced like any other.
The event
{
"object": "event",
"id": "evt_3f9c1e0a7b2d4c5e8f6a1b0c9d8e7f6a",
"type": "invoice.issued",
"created_at": "2026-07-01T08:00:00.000Z",
"data": {
"object": "invoice",
"id": "…",
"external_id": "crm:acme-migration",
"status": "issued",
"issued_at": "2026-07-01T08:00:00.000Z",
"platform_ref": "…",
"platform_number": 1042,
"send_error": null,
"lines": [ … ]
}
}id:evt_and 32 hex digits. It is the same for every endpoint the event went to, and for every retry and replay of it. Tell a repeat by this id.type: one of the six above.created_at: when the event was announced.data: the whole record, as itsGETendpoint answers it, so you can act on the event without a second request. An invoice is asGET /invoices/{ref}answers it, a customer asGET /customers/{ref}, a product asGET /products/{ref}, and an accrual entry asGET /accruals.
What a customer or a product event adds
The API lists and reads only records the accounting system still offers, so
the event is how you learn that one was switched off or deleted. In
customer.updated and product.updated, data carries two more fields:
archived_at: when the accounting system stopped offering the record, switched off or deleted.nullwhile it offers it.deleted_at: when the accounting system stopped holding the record.nullwhile it holds it.
A deleted record has external_id: null: the id is free for another record.
Name it by its id. Where Tabba holds nothing of a deleted record but its
id, data is only object, id, external_id, archived_at and
deleted_at.
Two changes to one customer in quick succession may both carry the later state.
Verifying the signature
Every delivery carries two headers:
Tabba-Signature: t=1782892800,v1=e2b1c3…
Tabba-Event-Id: evt_3f9c1e0a7b2d4c5e8f6a1b0c9d8e7f6at is when the attempt was signed, in unix seconds. v1 is the
HMAC-SHA256, in hex, of the timestamp, a period and the request body, under
the endpoint's signing secret. Compute it over the body byte for byte as it
arrived, not over a body you parsed and serialised again. Verify before you
trust the payload:
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(header, rawBody, secret) {
const parts = Object.fromEntries(
header.split(',').map((part) => part.trim().split('=', 2)),
)
const t = parts.t
const v1 = parts.v1
if (!t || !v1 || !/^\d+$/.test(t)) return false
// Refuse a timestamp more than 300 seconds from your own clock.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest()
const presented = Buffer.from(v1, 'hex')
return expected.length === presented.length && timingSafeEqual(expected, presented)
}The timestamp is what stops an old request from being replayed against you: refuse one more than 300 seconds from your own clock. The event type is in the body only. There is no header for it.
Delivery and retries
Answer with a 2xx within ten seconds. What your server answers decides what
happens next:
| Your answer | What Tabba does |
|---|---|
2xx | The delivery is delivered |
5xx, 408, 429, or no answer in time | Tries again later |
| Any other status, a redirect among them | Fails the delivery at once |
A delivery is tried again eight times, after waits of 30 seconds, 2 minutes,
10 minutes, 30 minutes, 1 hour, 2 hours, 4 hours and 8 hours: about sixteen
hours in all. After the last one the delivery is failed. A 4xx such as
400, 401 or 404 is read as the endpoint's own configuration, so another
post would change nothing and none is made. A redirect is not followed.
Delivery is at least once. A server that took the post and did not answer in time is posted to again, under the same event id. A retry carries the same body, byte for byte, under a new signature.
An admin reads an endpoint's deliveries, with the status each was answered with, on the Developers page, and replays one from there. A replay posts the same body under the same event id, with a new signature.
Take the event, answer 200, and do the work afterwards: queue it inside
your own system so that a slow database cannot run past the ten seconds.
Verify the signature before you queue.
Rotating the signing secret
An admin rotates the secret on the Developers page. The new secret is shown once. There is no overlap: the old secret signs nothing from the next delivery on.
A delivery signed with the new secret before your receiver holds it fails
your verification. If the receiver answers such a delivery with a 4xx, the
delivery is failed at once, and an admin replays it once the new secret is
deployed.