Skip to content

Receiving webhooks

Rather than polling the API in a loop, you will register a URL and we will 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 URL must be https://, and point to a public address: a URL naming an internal or local address is refused.

Each endpoint gets a signing secret, shown only once when it is created: keep it then, it is what verifies every delivery.

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,
"paymentMethod": "qrcode",
"iban": "CH93 0076 2011 6238 5295 7",
"fileUrl": "https://files.example.com/invoices/F-2026-0042.pdf?token=test",
"appendixUrls": [],
"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, in ISO 8601, 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 have only one shape to parse 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: this nesting is what will let us add a sibling to it later — the previous values of the changed fields, for example — without moving what you already read.

Event names are camelCase, and use 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
On-site 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 authoritative: a name that is not in it is refused with a 400 listing the valid names.

POST /test/webhooks/send, with a tay_test_… key, sends the event of your choice to one of your test endpoints:

Fenêtre de terminal
curl -X POST https://app.tayo.pro/api/v1/test/webhooks/send \
-H "Authorization: Bearer tay_test_your_key" \
-H "Content-Type: application/json" \
-d '{ "endpointId": "…", "event": "invoiceSent" }'

The event follows exactly the same path as a real one: same queue, same signature, same retries. That is what makes it a real test of your endpoint.

A tay_live_… key is refused (403 test_key_required), as is an endpoint registered in live mode (409 endpoint_not_in_test_mode). The detail of the responses is in the reference.

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 takes four steps:

  1. Read the raw body, before any JSON parser.
  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: three attempts in all, a few seconds apart, then it is marked as failed. An attempt with no response after 10 seconds counts as a failure. A redirect (3xx) is never followed: it counts as a failure too.

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 changes, because it timestamps the attempt, not the event.

So you must:

  1. keep the ids you have already processed, in your 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. A network offers no guarantee of exactly-once delivery, and neither do we: we guarantee that the id is stable, which lets you build the guarantee you are missing.

A failed delivery can be retried by hand, with POST /webhook-deliveries/{deliveryId}/retry: it is queued again, with the same event id, and the response is a 202. A given delivery can be retried 10 times at most; beyond that, the response is 409 retry_limit_reached. The other refusals are listed in Getting started with the API.

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, in less than 10 seconds. 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 trigger pointless retries.
  • Never trust a payload whose signature has not been verified.