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.
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, "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
Section titled “Event names”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.
Sending a test event
Section titled “Sending a test event”POST /test/webhooks/send, with a tay_test_… key, sends the event of your
choice to one of your test endpoints:
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.
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 takes four steps:
- Read the raw body, before any JSON parser.
- 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: 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:
- keep the
ids you have already processed, in your 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. 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.
What your endpoint should do
Section titled “What your endpoint should do”- Answer
2xxquickly, in less than 10 seconds. 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 trigger pointless retries. - Never trust a payload whose signature has not been verified.