Aller au contenu

Recevoir les webhooks

Plutôt que d’interroger l’API en boucle, vous enregistrez une URL et nous vous appelons 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.

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

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 un nombre borné de fois, puis marquée en é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.

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