Notifications (webhooks) de l'API partenaires
Être prévenu en quelques secondes de ce qui change chez une clinique : types d'événement, corps envoyé, vérification de la signature HMAC en Python et Node.js, relances, doublons, rejeu, rotation du secret.
Notifications
Treats prévient votre application quand une ressource change, par un POST signé vers une adresse que vous choisissez (l’endpoint). Vous n’avez pas à interroger l’API en boucle.
Chaque application a un seul endpoint, commun à toutes les organisations qui l’ont connectée. Vous le créez par PUT /api/partner/v1/webhooks/endpoint, avec le jeton de n’importe quelle organisation connectée qui vous a accordé le droit webhooks. Chaque événement porte l’organisation (organizationId) : demandez le jeton de cette organisation pour relire la ressource.
Le corps envoyé est mince : le type, l’organisation, le site et l’identifiant de la ressource. Il ne porte aucune donnée personnelle (ni nom, ni téléphone, ni montant). Relisez la ressource sur l’API, avec vos droits du moment.
Qui reçoit quoi
Une organisation produit une livraison vers votre endpoint seulement si, au moment où l’événement se produit :
- votre application est active et l’organisation l’a connectée ;
- la connexion porte le droit
webhookset l’un des droits qui couvrent le type (colonne « Droits » ci-dessous) ; - les sites de la connexion couvrent le site de l’événement. Un événement sans site ne part que vers une connexion « tous les sites » ;
- votre endpoint existe, est actif et est abonné à ce type. Les événements antérieurs à l’enregistrement de l’endpoint (ou à l’abonnement au type) ne sont jamais envoyés après coup ;
- une application de production (
LIVE) ne reçoit rien d’une organisation de test : la livraison est écrite avec le statutMUTEDet rien n’est envoyé. Une application de recette (SANDBOX) ne reçoit que les événements d’une organisation de test : face à une organisation réelle, la livraison estCANCELLED(SANDBOX_ORG_LIVE).
Tout cela est revérifié avant chaque envoi. Une organisation qui vous déconnecte, un droit ou un site retiré, un type désabonné ou un endpoint désactivé arrêtent aussi les livraisons déjà en file : elles passent CANCELLED et lastError dit pourquoi.
Types d’événement
Rendez-vous et arrivée en salle d’attente ci-dessous ; les autres types dans la section suivante.
GET /api/partner/v1/webhooks/event-types rend cette liste. Les droits indiqués s’ajoutent à webhooks ; un seul suffit.
| Type | Quand il part | Droits | data.object | Relire par |
|---|---|---|---|---|
appointment.created | Un rendez-vous est créé. | agenda:read | appointment | GET /appointments/{id} |
appointment.updated | Un rendez-vous est modifié : horaire, durée, praticien, salle, site, animal, motif, type, état, annulation, « non honoré », passage à terminé. Aussi quand la consultation liée démarre ou est validée (le rendez-vous change d’état). | agenda:read | appointment | GET /appointments/{id} |
appointment.cancelled | Un rendez-vous est annulé. | agenda:read | appointment | GET /appointments/{id} |
appointment.no_show | Un rendez-vous est marqué « non honoré ». | agenda:read | appointment | GET /appointments/{id} |
appointment.completed | Un rendez-vous passe à l’état terminé. | agenda:read | appointment | GET /appointments/{id} |
patient.checked_in | L’arrivée de l’animal est enregistrée (pointage en salle d’attente). La ressource est le rendez-vous, qui porte l’heure d’arrivée. | agenda:read | appointment | GET /appointments/{id} |
Types d’événement : clients, dossier, facturation, laboratoire
| Type | Quand il part | Droits | data.object | Relire par |
|---|---|---|---|---|
client.created | Une fiche client est créée, y compris par l’inscription d’un propriétaire lors d’une prise de rendez-vous en ligne. | clients:read | client | GET /clients/{id} |
patient.created | Un animal est créé. | patients:read | patient | GET /patients/{id} |
patient.deceased | Un animal est déclaré décédé. | patients:read | patient | GET /patients/{id} |
consultation.started | Une consultation démarre. | consultations:read | consultation | GET /consultations/{id} |
consultation.validated | Une consultation est validée. | consultations:read | consultation | GET /consultations/{id} |
invoice.issued | Une facture est émise (validée et numérotée). | billing:read | invoice | GET /invoices/{id} |
invoice.paid | Une facture devient entièrement réglée. | billing:read | invoice | GET /invoices/{id} |
payment.recorded | Un règlement est enregistré sur une facture. | billing:read | payment | GET /payments?updated_since=… (pas de lecture à l’unité ; le règlement porte invoiceId) |
credit_note.issued | Un avoir est émis. | billing:read | credit_note | GET /credit-notes/{id} |
prescription.signed | Une ordonnance est signée par le vétérinaire. | prescriptions:read | prescription | GET /prescriptions/{id} |
lab.result.received | Un résultat d’analyses arrive et est rattaché à un animal (connecteur de laboratoire ou résultat envoyé par l’API). | consultations:read ou lab:write | lab_result | GET /lab/results/{id} (droit lab:write) |
ping | Vous appelez POST /webhooks/test. Jamais produit autrement, pas d’abonnement. | aucun | ping | rien à relire |
Précisions sur les types
- Une annulation publie deux événements :
appointment.cancelledetappointment.updated. De même, « non honoré » publieappointment.no_showpuisappointment.updated, et le passage à terminéappointment.completedpuisappointment.updated. Abonnez-vous aux deux types ou à un seul, selon votre besoin. - L’enregistrement de l’arrivée publie
patient.checked_inseul, pasappointment.updated. Un rendez-vous passé « en attente » en modifiant son état publieappointment.updatedseul. - L’appel de l’animal en salle de consultation depuis la salle d’attente et la suppression d’un rendez-vous saisi par erreur ne publient aucun événement.
patient.createdetpatient.deceasedportent le site d’origine du propriétaire (la règle deGET /patients), pas le site où l’animal a été créé ou déclaré décédé. Un animal sans propriétaire donne un événement sans site.payment.recordedne part pas pour un règlement sur un document repris d’un ancien logiciel qui n’est pas une facture (ces règlements sont absents deGET /payments).lab.result.receivedne part que pour un résultat rattaché à un animal dès son arrivée. Le complément d’un résultat déjà reçu ne publie pas de nouvel événement. Une application qui le reçoit par le seul droitlab:write(un laboratoire) ne reçoit que les résultats de son laboratoire. Une application qui le reçoit parconsultations:readsanslab:writeles reçoit tous, mais aucune route ne lui permet aujourd’hui de lire le résultat.- Il n’existe pas d’autre type. Une modification de fiche client ou animal, par exemple, ne publie rien : relisez ces ressources avec
updated_since.
Le corps envoyé
Sur le fil, le JSON est compact (sans espaces), champs dans cet ordre. Présenté ici indenté :
{
"id": "7d0c3a52-6f7e-4f43-9b0e-2b1f1c9d8e11",
"type": "appointment.created",
"occurredAt": "2026-10-14T08:30:00.123456Z",
"organizationId": "524612ac-1b7e-4a33-9c55-0d6c2f1e9a10",
"siteId": "d2b2b3de-55aa-4c1e-8f00-3b9a7c6d5e42",
"data": {
"object": "appointment",
"id": "0b6f1d2e-3c4a-4b5c-8d9e-0f1a2b3c4d5e",
"publicId": "apt_7XK2M9"
}
}
| Champ | Contenu |
|---|---|
id | Identifiant de l’événement pour cette connexion. Égal à l’en-tête Treats-Event-Id et à l’id de la livraison dans GET /webhooks/deliveries. Stable entre les tentatives et les rejeux. Un même fait vu par deux organisations connectées donne deux id différents. |
type | Le type (tableau ci-dessus). |
occurredAt | Instant où le fait s’est produit dans Treats, en UTC. Ce n’est pas l’instant de l’envoi. Précision variable, jusqu’à la nanoseconde. |
organizationId | Organisation de l’événement. |
siteId | Site de l’événement, ou null (ping, animal sans propriétaire). Toujours présent. |
data.object | Nature de la ressource : appointment, client, patient, consultation, invoice, payment, credit_note, prescription, lab_result, ou ping. |
data.id | Identifiant de la ressource. Absent pour un ping. |
data.publicId | Identifiant public (apt_…, cli_…, pat_…, cns_…). Présent seulement pour un rendez-vous, un client, un animal ou une consultation qui en a un. Il peut manquer à une tentative et apparaître à la suivante. |
Un ping :
{"id":"…","type":"ping","occurredAt":"2026-10-14T08:30:00.123456Z","organizationId":"…","siteId":null,"data":{"object":"ping"}}
En-têtes de la requête
Treats envoie une requête POST en HTTP/1.1 avec ces en-têtes :
| En-tête | Valeur |
|---|---|
Content-Type | application/json; charset=utf-8 |
User-Agent | Treats-Webhooks/1.0 |
Treats-Event-Id | L’id du corps. |
Treats-Event-Type | Le type du corps. |
Treats-Attempt | Numéro de la tentative : 1, 2, … Repart à 1 après un rejeu. Il peut sauter un numéro. |
Treats-Signature | t=<horodatage>,v1=<signature>, avec une seconde v1 pendant une rotation du secret. |
Vérifier la signature
Le secret de signature (whsec_…) vous est rendu une seule fois, à la création de l’endpoint (PUT, réponse 201) et à chaque rotation. L’en-tête a cette forme :
Treats-Signature: t=1792029619,v1=1ed88e84db3cf1dbf0f6e41180b7fe36f4bd37528d8d8bb165eacdc2b8680305
Pas à pas :
- Lisez le corps brut, en octets, avant tout décodage JSON. Une re-sérialisation change les octets et casse la signature.
- Découpez l’en-tête sur
,, puis chaque morceau sur le premier=. Gardeztet toutes les valeursv1. Ignorez une clé inconnue. test l’horodatage de la tentative, en secondes Unix (UTC). Il est recalculé à chaque tentative et à chaque rejeu. Treats n’impose pas de tolérance : nous conseillons de refuser un écart de plus de 5 minutes (300 secondes) avec votre horloge, synchronisée par NTP. Cela empêche de rejouer une requête interceptée.- Construisez la chaîne signée :
t(les chiffres tels que reçus), un point., puis le corps brut. Exemple :1792029619.{"id":"7d0c…",…}. - Calculez
HMAC-SHA256de cette chaîne, avec pour clé le secret entier en UTF-8, préfixewhsec_compris. Le secret ne se décode pas en base64. - Encodez le résultat en hexadécimal minuscule (64 caractères).
- Comparez-le, en temps constant, à chaque
v1reçue. La requête est authentique si l’une d’elles correspond à l’un de vos secrets valides.
Si la vérification échoue, répondez par un code d’erreur (401 par exemple) : Treats le compte comme un échec et réessaiera.
Tester votre vérification
Valeur de contrôle, pour tester votre code sans tolérance d’horodatage : avec le secret whsec_nouveauSecretDeTest0123456789abcdefghijklm, t = 1792029619 et le corps
{"id":"7d0c3a52-6f7e-4f43-9b0e-2b1f1c9d8e11","type":"appointment.created","occurredAt":"2026-10-14T08:30:00.123456Z","organizationId":"524612ac-1b7e-4a33-9c55-0d6c2f1e9a10","siteId":"d2b2b3de-55aa-4c1e-8f00-3b9a7c6d5e42","data":{"object":"appointment","id":"0b6f1d2e-3c4a-4b5c-8d9e-0f1a2b3c4d5e","publicId":"apt_7XK2M9"}}
la signature attendue est 1ed88e84db3cf1dbf0f6e41180b7fe36f4bd37528d8d8bb165eacdc2b8680305.
Code de vérification
Python
import hashlib
import hmac
import time
def verify_treats_signature(header: str, raw_body: bytes, secrets: list[str], tolerance: int = 300) -> bool:
"""header : valeur de Treats-Signature ; raw_body : corps brut reçu (octets) ; secrets : vos secrets whsec_… valides."""
timestamp = None
signatures = []
for part in header.split(","):
key, sep, value = part.strip().partition("=")
if not sep:
continue
if key == "t":
timestamp = value
elif key == "v1":
signatures.append(value)
if timestamp is None or not timestamp.isdigit() or not signatures:
return False
if abs(time.time() - int(timestamp)) > tolerance:
return False
message = timestamp.encode("ascii") + b"." + raw_body
for secret in secrets:
expected = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
if any(hmac.compare_digest(expected, s) for s in signatures):
return True
return False
Avec Flask, le corps brut est request.get_data() ; avec Django, request.body.
Node.js
const crypto = require("node:crypto");
// header : valeur de Treats-Signature ; rawBody : Buffer du corps brut reçu ; secrets : vos secrets whsec_… valides.
function verifyTreatsSignature(header, rawBody, secrets, toleranceSeconds = 300) {
let timestamp = null;
const signatures = [];
for (const part of String(header || "").split(",")) {
const i = part.indexOf("=");
if (i < 0) continue;
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === "t") timestamp = value;
else if (key === "v1") signatures.push(value);
}
if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const message = Buffer.concat([Buffer.from(timestamp + ".", "ascii"), rawBody]);
for (const secret of secrets) {
const expected = Buffer.from(
crypto.createHmac("sha256", Buffer.from(secret, "utf8")).update(message).digest("hex"), "ascii");
for (const s of signatures) {
const given = Buffer.from(s, "ascii");
if (given.length === expected.length && crypto.timingSafeEqual(given, expected)) return true;
}
}
return false;
}
module.exports = { verifyTreatsSignature };
Avec Express, lisez le corps brut sur la route du webhook : app.post("/webhooks/treats", express.raw({ type: "application/json" }), handler), puis req.body est un Buffer.
Ce que votre endpoint doit répondre
- Reçu = tout code
2xx(200 à 299). Tout autre code est un échec :3xx(les redirections ne sont jamais suivies),4xx,5xx. - Le corps de votre réponse n’est pas lu.
- Délai : 10 secondes pour tout l’échange (connexion comprise, et la connexion elle-même en 5 secondes au plus). Au-delà, l’envoi est coupé et compte comme un échec. Le ping a un délai de 5 secondes. Répondez
2xxtout de suite, puis traitez l’événement en arrière-plan.
Relances
Un envoi en échec est retenté, six tentatives en tout :
| Tentative | Quand |
|---|---|
| 1 | Dès que l’événement est produit, en général dans les secondes qui suivent. |
| 2 | Environ 1 minute après l’échec de la tentative 1. |
| 3 | Environ 5 minutes après l’échec de la tentative 2. |
| 4 | Environ 30 minutes après l’échec de la tentative 3. |
| 5 | Environ 2 heures après l’échec de la tentative 4. |
| 6 | Environ 12 heures après l’échec de la tentative 5. |
Entre deux tentatives, la livraison est FAILED et nextAttemptAt donne la prochaine. Après l’échec de la sixième, elle est DEAD : plus de reprise automatique, mais vous pouvez la rejouer. Rare : une erreur interne de Treats compte aussi une tentative sans qu’aucune requête ne parte. C’est pourquoi Treats-Attempt peut sauter un numéro.
Désactivation. Quand votre endpoint échoue sans interruption depuis trois jours (aucune livraison réussie, ping compris), Treats le désactive : active: false, disabledAt et disabledReason dans GET /webhooks/endpoint. Plus rien n’est produit pour lui et les livraisons en file passent CANCELLED (ENDPOINT_DISABLED). PUT /webhooks/endpoint le réactive ; rejouez ensuite les livraisons utiles une à une. Une rotation du secret ne le réactive pas.
Ordre et doublons
- L’ordre n’est pas garanti, ni entre deux types d’un même fait (
appointment.cancelledetappointment.updated), ni entre deux faits sur la même ressource. Ne déduisez pas l’état d’une suite d’événements : relisez la ressource, qui donne son état courant.occurredAtaide à ignorer un événement plus ancien qu’un autre déjà traité. - Une livraison peut arriver plus d’une fois (livraison « au moins une fois ») : une réponse perdue, un délai dépassé après votre traitement, un rejeu. Les doublons ont le même
id. Dédoublonnez surid(ouTreats-Event-Id), jamais surTreats-Attempt. Gardez lesidtraités au moins 30 jours : c’est la durée pendant laquelle une livraison terminée peut encore être rejouée.
Suivre et rejouer les livraisons
GET /api/partner/v1/webhooks/deliveries liste les livraisons de la connexion du jeton (filtres status, updated_since, pagination cursor, limit), pings compris. Statuts :
| Statut | Sens |
|---|---|
PENDING | En file. |
SENDING | Envoi en cours. |
DELIVERED | Votre endpoint a répondu 2xx. |
FAILED | Échec, nouvelle tentative à nextAttemptAt. |
DEAD | Six tentatives en échec (ou ping en échec). |
MUTED | Organisation de test et application de production : rien n’est envoyé. |
CANCELLED | Rien n’est envoyé : lastError dit pourquoi (GRANT_REVOKED, SCOPE_WITHDRAWN, SITE_NOT_COVERED, PROVIDER_NOT_LINKED, NO_ENDPOINT, ENDPOINT_DISABLED, TYPE_UNSUBSCRIBED, SANDBOX_ORG_LIVE, APP_SUSPENDED). |
POST /api/partner/v1/webhooks/deliveries/{id}/replay remet une livraison en file, quel que soit son état sauf pendant un envoi (409 WEBHOOK_DELIVERY_IN_PROGRESS). Elle repart dans les secondes qui suivent avec le même id et le même corps, six tentatives de nouveau. Les conditions d’envoi sont revérifiées : une livraison annulée dont la cause demeure est annulée de nouveau. Les livraisons terminées sont conservées 30 jours après leur production ; au-delà, le rejeu rend 404. Après une déconnexion puis une reconnexion de l’organisation, les livraisons de l’ancienne connexion ne sont plus visibles ni rejouables.
Rotation du secret
- Appelez
POST /api/partner/v1/webhooks/endpoint/rotate-secret. La réponse donne le nouveausecretetpreviousSecretValidUntil, 24 heures plus tard. - Pendant ces 24 heures, chaque envoi porte deux signatures
v1: avec le nouveau secret d’abord, puis avec l’ancien. Votre code accepte une requête si l’une des signatures correspond à l’un de vos secrets : déployez le nouveau secret à côté de l’ancien, sans coupure. - Après
previousSecretValidUntil, seul le nouveau secret signe. Retirez l’ancien.
Une seconde rotation dans les 24 heures remplace l’ancien secret par celui qui était en place : seuls les deux derniers secrets signent. Le secret n’est jamais conservé en clair : rejouer la rotation avec la même Idempotency-Key rend secret: null et secretRedacted: true. Si vous avez perdu la réponse, refaites une rotation avec une nouvelle clé.
Adresses refusées
L’adresse de l’endpoint est vérifiée à l’enregistrement (PUT, erreur 400) et avant chaque envoi, ce qui protège contre un nom qui se mettrait à désigner une adresse interne. Elle doit :
- commencer par
https://(http://est refusé) ; - nommer un hôte, sans identifiants (
user:pass@) ni fragment (#…), en 2 048 caractères au plus. Le port, le chemin et les paramètres sont libres ; - se résoudre, et toutes les adresses IP obtenues doivent être publiques.
Sont refusés localhost et les plages IPv4 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16 (dont l’adresse des métadonnées d’un cloud), 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4 et 240.0.0.0/4 (dont 255.255.255.255). En IPv6 : ::, ::1, fe80::/10, fec0::/10, fc00::/7, ff00::/8 et 2001:db8::/32. Une adresse IPv4 enveloppée en IPv6 (::ffff:a.b.c.d, ::a.b.c.d, 64:ff9b::/96) est jugée sur son adresse IPv4.
Codes à l’enregistrement : WEBHOOK_URL_INVALID (forme), WEBHOOK_URL_FORBIDDEN (adresse locale, privée ou réservée), WEBHOOK_URL_UNRESOLVABLE (nom qui ne se résout pas). Au moment d’un envoi, le même refus fait échouer la tentative, avec ce code dans lastError, et compte comme un échec.
Tester
POST /api/partner/v1/webhooks/test envoie tout de suite un ping signé à votre endpoint et rend le verdict : DELIVERED, ou DEAD avec lastStatusCode ou lastError. Un ping n’est pas retenté. Il suit les règles d’envoi : avec une organisation de test, une application de production obtient MUTED et rien n’est envoyé. Testez donc une application de production avec le jeton d’une organisation réelle, et une application de recette avec celui d’une organisation de test. Un seul ping à la fois par application : un second rend 503 WEBHOOK_TEST_IN_PROGRESS avec Retry-After: 5.