Receiving webhooks
Rather than polling the API in a loop, you register a URL and we call you when something changes.
An endpoint is registered for one mode — live or test — and subscribed to a list of event names. A test endpoint receives exactly the same payload shape, signed the same way, as a live one.
The shape of an event
Section titled “The shape of an event”This is, byte for byte apart from the id, the body a test endpoint receives
for invoiceSent — every test delivery carries the same sample resource, so
you can compare what arrives against this page:
{ "id": "evt_3f9c2b7d1e8a4c6f9b0d2e5a7c1f4b8d", "type": "invoiceSent", "createdAt": "2026-01-15T09:30:00.000Z", "mode": "test", "data": { "object": { "id": "inv_0000000000000001", "projectId": "prj_0000000000000001", "status": "sent", "refNumber": "F-2026-0042", "isFinalInvoice": true, "amount": 1850.5, "clientRef": { "id": "cli_0000000000000001", "name": "Acme AG", "email": "billing@example.com" }, "sentAt": "2026-01-15T09:30:00.000Z", "createdAt": "2026-01-15T09:30:00.000Z", "updatedAt": "2026-01-15T09:30:00.000Z" } }}| Field | Meaning |
|---|---|
id |
The event’s identifier, evt_ followed by 32 hex characters. Your deduplication key — see below. |
type |
The event name — see Event names. |
createdAt |
When the business event happened, as an ISO 8601 string in UTC. Not when this attempt was sent: a retry carries the same value. |
mode |
live or test, the mode of the endpoint the event was delivered to. |
data.object |
The resource the event is about. |
data.object is built from the same schemas as the API’s read responses. You
therefore parse one shape per resource, whether you obtained it from a GET or
received it through a webhook. Every timestamp, on the envelope as inside the
resource, is an ISO 8601 string.
Read the resource from data.object, not from data: the nesting is what lets
us add a sibling to it later — the previous values of the changed fields, say —
without moving what you already read.
Event names
Section titled “Event names”Event names are camelCase, and are the same vocabulary as the notifications in
the app: invoiceSent, quoteAccepted, jobRescheduled. An endpoint can be
subscribed to any of these:
| Resource | Events |
|---|---|
| Project | projectCreated, projectUpdated, projectShared, projectClientValidationRequested, projectAccepted, projectDeclined, projectCanceled, projectDone, projectRated, projectReminderCreated, projectReminderUpdated, projectReminderDeleted, projectReminderSent |
| Job | jobCreated, jobUpdated, jobScheduled, jobRescheduled, jobDone, jobNoShow, jobCanceled |
| Message | messageCreated |
| Quote | quoteCreated, quoteUpdated, quoteDeleted, quoteSent, quoteAccepted, quoteDeclined |
| Invoice | invoiceCreated, invoiceUpdated, invoiceDeleted, invoiceSent, invoiceAccepted, invoiceDeclined, invoiceAutoMarkPaidFailed, invoiceReminderCreated, invoiceReminderUpdated, invoiceReminderDeleted, invoiceReminderSent, invoiceReminderFailed |
The same list is the event enum of POST /test/webhooks/send in the
OpenAPI description of the API, which is the authoritative
copy: a name that is not in it is refused with a 400 listing the valid ones.
Verifying the signature
Section titled “Verifying the signature”Every delivery carries a Tay-Signature header:
Tay-Signature: t=1758186764,v1=5257a869e7b2cd0e2c...tis the Unix timestamp of the signature.v1is a hex HMAC-SHA256 of the string${t}.${rawBody}, computed with the endpoint’s signing secret.
This is Stripe’s scheme. If you have ever verified a Stripe webhook, you have already written this code.
Verification is four steps:
- Read the raw body, before any JSON parser touches it.
- Extract
tandv1from the header. - Recompute the HMAC over
${t}.${rawBody}and compare it tov1in constant time. - Reject if
tis more than 5 minutes away from the current time.
import { createHmac, timingSafeEqual } from 'node:crypto';
/** The tolerance window: five minutes, in either direction. */const TOLERANCE_SECONDS = 300;
export function verify(rawBody, header, secret) { const parts = Object.fromEntries( header.split(',').map((part) => part.split('=')), );
const expected = createHmac('sha256', secret) .update(`${parts.t}.${rawBody}`) .digest('hex');
const received = Buffer.from(parts.v1, 'hex'); const computed = Buffer.from(expected, 'hex');
// `timingSafeEqual` requires two buffers of equal length. if (received.length !== computed.length) return false; if (!timingSafeEqual(received, computed)) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t)); return age <= TOLERANCE_SECONDS;}The timestamp is part of the signed string precisely so that you can detect a replay: without it, an intercepted request would stay valid forever.
Deduplicate by event id
Section titled “Deduplicate by event id”This is the most important point on this page.
If your endpoint does not answer 2xx — because it is slow, unavailable, or
returned an error — the delivery is retried a bounded number of times, then
marked as failed.
A retried attempt carries the same id and the same createdAt as the
first one. It is not a new event: it is the same event, presented again. Only
the t in the signature header is new, because it is the timestamp of the
attempt, not of the event.
So you must:
- keep the
ids you have already processed, in your own database; - on receipt, check whether the
idis already there; - if it is, answer
200and do nothing else.
Without this, a briefly slow endpoint will bill twice, send two emails, or create two rows. No network guarantees exactly-once delivery, and neither do we: what we guarantee is that the id is stable, which lets you build the guarantee you need.
Treat ordering as unguaranteed too. Two events close together may reach you out
of order; rely on createdAt, not on arrival order.
What your endpoint should do
Section titled “What your endpoint should do”- Answer
2xxquickly. Acknowledge, queue the work, then process it — slow processing causes a timeout, therefore a retry, therefore a duplicate. - Answer
2xxeven for atypeyou do not recognise. New events may appear, and refusing them would only trigger pointless retries. - Never trust a payload whose signature you have not verified.