Webhooks
Webhooks are how your integration learns about changes it did not trigger — an auto-issued invoice, a month-end accrual booking, a Dinero reconcile write. Poll-free, signed, at-least-once.
Registering an endpoint
Register a webhook endpoint in Settings → Developers → Webhooks. The signing secret is shown ONCE at creation — copy it into your integration's secret store.
Per endpoint you choose a subset of event types (or "all events").
Event catalog v1
invoice.issued— an invoice was booked into Dinero (whether by cron, by an API call, or by the UI).invoice.send_failed— the invoice booked but sending the PDF failed. Retry the send, not the issue.credit_note.issued— a credit note was booked.accrual.booked— a month-end accrual voucher was booked in Dinero.customer.updated— a customer was written (via UI, API, or Dinero reconcile).product.updated— a product was written.
Every payload carries the same wire shape as the corresponding
GET response — the same serializer output — so you can act on the
event without an extra round-trip.
Signature verification
Every delivery carries a Tabba-Signature header:
Tabba-Signature: t=1717000000,v1=e2b1c3…
The signature is HMAC-SHA256(secret, "{t}.{rawBody}"), hex-encoded.
Verify BEFORE trusting the payload:
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(header, rawBody, secret) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=').map((s) => s.trim())),
)
const t = parts.t
const v1 = parts.v1
if (!t || !v1) return false
// Reject payloads > 5 minutes old.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(v1, 'hex')
return a.length === b.length && timingSafeEqual(a, b)
}
The t timestamp prevents replay attacks; reject anything more than a
few minutes old.
Envelope
{
"id": "evt_01H…",
"type": "invoice.issued",
"created": "2026-07-06T10:00:00Z",
"data": {
"object": "invoice",
"id": "…",
"external_id": "clickup:proj_ACME_migration",
"status": "issued",
"dinero_invoice_guid": "…",
"lines": [ … ]
}
}
id— the event's stable id. Dedupe on this — deliveries are at-least-once (a retry can arrive after the original was already handled).type— one of the catalog above.data— the fat payload; the full resource, not a stub.
Retries
A failed delivery (non-2xx response or timeout) is retried with
exponential backoff for ~24 hours. After that it lands in the
delivery log as failed and stops retrying. You can inspect and
manually replay a delivery from Settings → Developers → Webhooks.
Best practice: return 200 fast (queue the work internally) and
verify the signature in the queue worker if the payload matters — this
way a slow database doesn't hold up the receiver.
Rotating the signing secret
Rotate the secret from Settings → Developers → Webhooks. The old secret keeps signing for a short overlap window so your receiver can accept both while you deploy the new value.