Docs
Connexion partenaire →

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 :

  1. votre application est active et l’organisation l’a connectée ;
  2. la connexion porte le droit webhooks et l’un des droits qui couvrent le type (colonne « Droits » ci-dessous) ;
  3. 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 » ;
  4. 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 ;
  5. une application de production (LIVE) ne reçoit rien d’une organisation de test : la livraison est écrite avec le statut MUTED et 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 est CANCELLED (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.

TypeQuand il partDroitsdata.objectRelire par
appointment.createdUn rendez-vous est créé.agenda:readappointmentGET /appointments/{id}
appointment.updatedUn 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:readappointmentGET /appointments/{id}
appointment.cancelledUn rendez-vous est annulé.agenda:readappointmentGET /appointments/{id}
appointment.no_showUn rendez-vous est marqué « non honoré ».agenda:readappointmentGET /appointments/{id}
appointment.completedUn rendez-vous passe à l’état terminé.agenda:readappointmentGET /appointments/{id}
patient.checked_inL’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:readappointmentGET /appointments/{id}

Types d’événement : clients, dossier, facturation, laboratoire

TypeQuand il partDroitsdata.objectRelire par
client.createdUne fiche client est créée, y compris par l’inscription d’un propriétaire lors d’une prise de rendez-vous en ligne.clients:readclientGET /clients/{id}
patient.createdUn animal est créé.patients:readpatientGET /patients/{id}
patient.deceasedUn animal est déclaré décédé.patients:readpatientGET /patients/{id}
consultation.startedUne consultation démarre.consultations:readconsultationGET /consultations/{id}
consultation.validatedUne consultation est validée.consultations:readconsultationGET /consultations/{id}
invoice.issuedUne facture est émise (validée et numérotée).billing:readinvoiceGET /invoices/{id}
invoice.paidUne facture devient entièrement réglée.billing:readinvoiceGET /invoices/{id}
payment.recordedUn règlement est enregistré sur une facture.billing:readpaymentGET /payments?updated_since=… (pas de lecture à l’unité ; le règlement porte invoiceId)
credit_note.issuedUn avoir est émis.billing:readcredit_noteGET /credit-notes/{id}
prescription.signedUne ordonnance est signée par le vétérinaire.prescriptions:readprescriptionGET /prescriptions/{id}
lab.result.receivedUn résultat d’analyses arrive et est rattaché à un animal (connecteur de laboratoire ou résultat envoyé par l’API).consultations:read ou lab:writelab_resultGET /lab/results/{id} (droit lab:write)
pingVous appelez POST /webhooks/test. Jamais produit autrement, pas d’abonnement.aucunpingrien à relire

Précisions sur les types

  • Une annulation publie deux événements : appointment.cancelled et appointment.updated. De même, « non honoré » publie appointment.no_show puis appointment.updated, et le passage à terminé appointment.completed puis appointment.updated. Abonnez-vous aux deux types ou à un seul, selon votre besoin.
  • L’enregistrement de l’arrivée publie patient.checked_in seul, pas appointment.updated. Un rendez-vous passé « en attente » en modifiant son état publie appointment.updated seul.
  • 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.created et patient.deceased portent le site d’origine du propriétaire (la règle de GET /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.recorded ne 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 de GET /payments).
  • lab.result.received ne 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 droit lab:write (un laboratoire) ne reçoit que les résultats de son laboratoire. Une application qui le reçoit par consultations:read sans lab:write les 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"
  }
}
ChampContenu
idIdentifiant 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.
typeLe type (tableau ci-dessus).
occurredAtInstant 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.
organizationIdOrganisation de l’événement.
siteIdSite de l’événement, ou null (ping, animal sans propriétaire). Toujours présent.
data.objectNature de la ressource : appointment, client, patient, consultation, invoice, payment, credit_note, prescription, lab_result, ou ping.
data.idIdentifiant de la ressource. Absent pour un ping.
data.publicIdIdentifiant 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êteValeur
Content-Typeapplication/json; charset=utf-8
User-AgentTreats-Webhooks/1.0
Treats-Event-IdL’id du corps.
Treats-Event-TypeLe type du corps.
Treats-AttemptNuméro de la tentative : 1, 2, … Repart à 1 après un rejeu. Il peut sauter un numéro.
Treats-Signaturet=<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 :

  1. Lisez le corps brut, en octets, avant tout décodage JSON. Une re-sérialisation change les octets et casse la signature.
  2. Découpez l’en-tête sur ,, puis chaque morceau sur le premier =. Gardez t et toutes les valeurs v1. Ignorez une clé inconnue.
  3. t est 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.
  4. Construisez la chaîne signée : t (les chiffres tels que reçus), un point ., puis le corps brut. Exemple : 1792029619.{"id":"7d0c…",…}.
  5. Calculez HMAC-SHA256 de cette chaîne, avec pour clé le secret entier en UTF-8, préfixe whsec_ compris. Le secret ne se décode pas en base64.
  6. Encodez le résultat en hexadécimal minuscule (64 caractères).
  7. Comparez-le, en temps constant, à chaque v1 reç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 2xx tout de suite, puis traitez l’événement en arrière-plan.

Relances

Un envoi en échec est retenté, six tentatives en tout :

TentativeQuand
1Dès que l’événement est produit, en général dans les secondes qui suivent.
2Environ 1 minute après l’échec de la tentative 1.
3Environ 5 minutes après l’échec de la tentative 2.
4Environ 30 minutes après l’échec de la tentative 3.
5Environ 2 heures après l’échec de la tentative 4.
6Environ 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.cancelled et appointment.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. occurredAt aide à 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 sur id (ou Treats-Event-Id), jamais sur Treats-Attempt. Gardez les id traité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 :

StatutSens
PENDINGEn file.
SENDINGEnvoi en cours.
DELIVEREDVotre endpoint a répondu 2xx.
FAILEDÉchec, nouvelle tentative à nextAttemptAt.
DEADSix tentatives en échec (ou ping en échec).
MUTEDOrganisation de test et application de production : rien n’est envoyé.
CANCELLEDRien 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

  1. Appelez POST /api/partner/v1/webhooks/endpoint/rotate-secret. La réponse donne le nouveau secret et previousSecretValidUntil, 24 heures plus tard.
  2. 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.
  3. 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.

esc
↵ ouvrir↑↓ naviguerrésultats limités à ce site