Recevoir les webhooks
Plutôt que d’interroger l’API en boucle, vous enregistrerez une URL et nous vous appellerons quand quelque chose change.
Un endpoint est enregistré pour un mode — live ou test — et abonné à une liste de noms d’événements. Un endpoint de test reçoit exactement la même forme de charge utile, signée de la même façon, qu’un endpoint live.
L’URL doit être en https://, et désigner une adresse publique : une URL
qui nomme une adresse interne ou locale est refusée.
Chaque endpoint reçoit un secret de signature, affiché une seule fois à sa création : conservez-le à ce moment-là, il sert à vérifier chaque livraison.
La forme d’un événement
Section intitulée « La forme d’un événement »Voici, octet pour octet à l’id près, le corps qu’un endpoint de test reçoit
pour invoiceSent — chaque livraison de test porte la même ressource
d’exemple, ce qui vous permet de comparer ce qui arrive à cette 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" } }}| Champ | Signification |
|---|---|
id |
L’identifiant de l’événement, evt_ suivi de 32 caractères hexadécimaux. Votre clé de dédoublonnage — voir plus bas. |
type |
Le nom de l’événement — voir Noms des événements. |
createdAt |
Le moment où l’événement métier s’est produit, en ISO 8601, en UTC. Pas le moment de l’envoi de cette tentative : une nouvelle tentative porte la même valeur. |
mode |
live ou test, le mode de l’endpoint auquel l’événement a été livré. |
data.object |
La ressource concernée par l’événement. |
data.object est construit sur les mêmes schémas que les réponses de lecture
de l’API. Vous n’avez donc qu’une seule forme à analyser par ressource, que vous
l’ayez obtenue par un GET ou reçue par un webhook. Tous les horodatages, sur
l’enveloppe comme dans la ressource, sont des chaînes ISO 8601.
Lisez la ressource dans data.object, et non dans data : cet emboîtement est
ce qui nous permettra d’y ajouter plus tard un voisin — les valeurs précédentes
des champs modifiés, par exemple — sans déplacer ce que vous lisez déjà.
Noms des événements
Section intitulée « Noms des événements »Les noms d’événements sont en camelCase, et reprennent le vocabulaire des
notifications de l’application : invoiceSent, quoteAccepted,
jobRescheduled. Un endpoint peut être abonné à n’importe lequel de ceux-ci :
| Ressource | Événements |
|---|---|
| Projet | projectCreated, projectUpdated, projectShared, projectClientValidationRequested, projectAccepted, projectDeclined, projectCanceled, projectDone, projectRated, projectReminderCreated, projectReminderUpdated, projectReminderDeleted, projectReminderSent |
| Intervention | jobCreated, jobUpdated, jobScheduled, jobRescheduled, jobDone, jobNoShow, jobCanceled |
| Message | messageCreated |
| Devis | quoteCreated, quoteUpdated, quoteDeleted, quoteSent, quoteAccepted, quoteDeclined |
| Facture | invoiceCreated, invoiceUpdated, invoiceDeleted, invoiceSent, invoiceAccepted, invoiceDeclined, invoiceAutoMarkPaidFailed, invoiceReminderCreated, invoiceReminderUpdated, invoiceReminderDeleted, invoiceReminderSent, invoiceReminderFailed |
La même liste est l’énumération event de POST /test/webhooks/send dans la
description OpenAPI de l’API, qui fait foi : un nom qui n’y
figure pas est refusé par un 400 listant les noms valides.
Envoyer un événement de test
Section intitulée « Envoyer un événement de test »POST /test/webhooks/send, avec une clé tay_test_…, envoie l’événement de
votre choix à l’un de vos endpoints de test :
curl -X POST https://app.tayo.pro/api/v1/test/webhooks/send \ -H "Authorization: Bearer tay_test_votre_cle" \ -H "Content-Type: application/json" \ -d '{ "endpointId": "…", "event": "invoiceSent" }'L’événement suit exactement le même chemin qu’un événement réel : même file d’attente, même signature, mêmes nouvelles tentatives. C’est ce qui en fait un vrai test de votre endpoint.
Une clé tay_live_… est refusée (403 test_key_required), tout comme un
endpoint enregistré en mode live (409 endpoint_not_in_test_mode). Le détail
des réponses est dans la référence.
Vérifier la signature
Section intitulée « Vérifier la signature »Chaque livraison porte un en-tête Tay-Signature :
Tay-Signature: t=1758186764,v1=5257a869e7b2cd0e2c...test l’horodatage Unix de la signature.v1est un HMAC-SHA256, en hexadécimal, de la chaîne${t}.${rawBody}, calculé avec le secret de signature de l’endpoint.
C’est le schéma de Stripe. Si vous avez déjà vérifié un webhook Stripe, vous avez déjà écrit ce code.
La vérification tient en quatre gestes :
- Lisez le corps brut, avant tout analyseur JSON.
- Extrayez
tetv1de l’en-tête. - Recalculez le HMAC sur
${t}.${rawBody}et comparez-le àv1en temps constant. - Refusez si
ts’écarte de l’heure courante de plus de 5 minutes.
import { createHmac, timingSafeEqual } from 'node:crypto';
/** La fenêtre de tolérance : cinq minutes, dans les deux sens. */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` exige deux tampons de même longueur. 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;}L’horodatage est inclus dans la chaîne signée précisément pour que vous puissiez détecter un rejeu : sans lui, une requête interceptée resterait valable indéfiniment.
Dédoublonnez par identifiant d’événement
Section intitulée « Dédoublonnez par identifiant d’événement »C’est le point le plus important de cette page.
Si votre endpoint ne répond pas un 2xx — parce qu’il est lent, indisponible,
ou qu’il a renvoyé une erreur — la livraison est réessayée : trois
tentatives au total, à quelques secondes d’intervalle, puis elle est marquée
en échec. Une tentative sans réponse au bout de 10 secondes compte comme un
échec. Une redirection (3xx) n’est jamais suivie : elle compte aussi comme un
échec.
Une tentative rejouée porte le même id et le même createdAt que la
première. Elle n’est pas un nouvel événement : c’est le même, présenté à
nouveau. Seul le t de l’en-tête de signature change, parce qu’il horodate la
tentative, et non l’événement.
Vous devez donc :
- conserver les
iddéjà traités, dans votre base ; - à la réception, vérifier si l’
idy figure déjà ; - si oui, répondre
200et ne rien faire d’autre.
Sans cela, un endpoint momentanément lent facturera deux fois, enverra deux e-mails, ou créera deux lignes. Un réseau n’offre aucune garantie de livraison unique, et nous n’en offrons pas non plus : nous garantissons que l’identifiant est stable, ce qui vous permet de construire la garantie qui vous manque.
Une livraison en échec peut être relancée à la main, avec
POST /webhook-deliveries/{deliveryId}/retry : elle est remise en file, avec le
même id d’événement, et la réponse est un 202. Une même livraison se
relance 10 fois au plus ; au-delà, la réponse est
409 retry_limit_reached. Les autres refus sont listés dans
Démarrer avec l’API.
Traitez aussi l’ordre comme non garanti. Deux événements proches peuvent vous
parvenir dans le désordre ; fiez-vous à createdAt, et non à l’ordre d’arrivée.
Ce que votre endpoint doit faire
Section intitulée « Ce que votre endpoint doit faire »- Répondre
2xxrapidement, en moins de 10 secondes. Accusez réception, mettez le travail en file, et traitez-le ensuite — un traitement long provoque un délai d’attente, donc une nouvelle tentative, donc un doublon. - Répondre
2xxmême pour untypeque vous ne connaissez pas. De nouveaux événements peuvent apparaître, et les refuser déclencherait des tentatives inutiles. - Ne jamais faire confiance à une charge utile dont la signature n’a pas été vérifiée.