# Webhooks

> Signed, at-least-once events for changes your integration did not make.

Source: https://www.tabba.io/developers/api/webhooks

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 [#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 [#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 [#the-event]

```json
{
  "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 its `GET` endpoint answers it, so you can act
  on the event without a second request. An invoice is as
  `GET /invoices/{ref}` answers it, a customer as `GET /customers/{ref}`, a
  product as `GET /products/{ref}`, and an accrual entry as `GET /accruals`.

### What a customer or a product event adds [#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. `null` while it offers it.
* `deleted_at`: when the accounting system stopped holding the record. `null`
  while 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 [#verifying-the-signature]

Every delivery carries two headers:

```
Tabba-Signature: t=1782892800,v1=e2b1c3…
Tabba-Event-Id: evt_3f9c1e0a7b2d4c5e8f6a1b0c9d8e7f6a
```

`t` 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:

```js
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 [#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 [#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.
