Skip to content

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.

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 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.

Every delivery carries a Tay-Signature header:

Tay-Signature: t=1758186764,v1=5257a869e7b2cd0e2c...
  • t is the Unix timestamp of the signature.
  • v1 is 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:

  1. Read the raw body, before any JSON parser touches it.
  2. Extract t and v1 from the header.
  3. Recompute the HMAC over ${t}.${rawBody} and compare it to v1 in constant time.
  4. Reject if t is 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.

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:

  1. keep the ids you have already processed, in your own database;
  2. on receipt, check whether the id is already there;
  3. if it is, answer 200 and 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.

  • Answer 2xx quickly. Acknowledge, queue the work, then process it — slow processing causes a timeout, therefore a retry, therefore a duplicate.
  • Answer 2xx even for a type you 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.