# Webhooks

> Signed at-least-once events for changes you did not trigger.

Source: https://invoia.io/developers/api/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 [#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 [#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 [#signature-verification]

Every delivery carries a `Invoia-Signature` header:

```
Invoia-Signature: t=1717000000,v1=e2b1c3…
```

The signature is `HMAC-SHA256(secret, "{t}.{rawBody}")`, hex-encoded.
Verify BEFORE trusting the payload:

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

```json
{
  "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 [#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 [#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.
