Aller au contenu

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.

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

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.

POST /test/webhooks/send, avec une clé tay_test_…, envoie l’événement de votre choix à l’un de vos endpoints de test :

Fenêtre de terminal
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.

Chaque livraison porte un en-tête Tay-Signature :

Tay-Signature: t=1758186764,v1=5257a869e7b2cd0e2c...
  • t est l’horodatage Unix de la signature.
  • v1 est 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 :

  1. Lisez le corps brut, avant tout analyseur JSON.
  2. Extrayez t et v1 de l’en-tête.
  3. Recalculez le HMAC sur ${t}.${rawBody} et comparez-le à v1 en temps constant.
  4. Refusez si t s’é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.

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 :

  1. conserver les id déjà traités, dans votre base ;
  2. à la réception, vérifier si l’id y figure déjà ;
  3. si oui, répondre 200 et 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.

  • Répondre 2xx rapidement, 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 2xx même pour un type que 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.