API - Webhooks
API - Webhooks
Les webhooks permettent à une application tierce de recevoir une notification dès qu'un enregistrement (record) ou un utilisateur (user) change côté Phyling, sans avoir à interroger l'API en boucle (polling). Cette page suppose une application OAuth 2.0 déjà créée — voir API - Authentification.
1. Principe
Un webhook Phyling n'envoie jamais la donnée elle-même. Il annonce qu'un objet a changé ; votre application rappelle ensuite notre API avec son access token OAuth2 pour lire l'objet à jour :
{
"event_id": "evt_3f9e2c1a...",
"event_type": "record.updated",
"event_time": "2026-09-03T10:15:30+00:00",
"subject": { "type": "record", "id": "12345" }
}event_id: identifiant stable de l'événement, à utiliser pour dédoublonner (voir § 5).event_type: voir le tableau ci-dessous pour la liste complète et ce qui déclenche chacun.event_time: horodatage ISO 8601 avec fuseau, au moment où l'événement a été produit (pas au moment de l'envoi).subject: type et identifiant de l'objet concerné. Pourrecord.*, relisez-le avecGET /records/{rec_id}; pouruser.*, avecGET /users/{id}. Sur undeleted, l'identifiant suffit — il n'y a plus rien à relire.
Aucune donnée métier ni personnelle ne transite dans le payload. Votre application relit toujours l'objet avec son propre access token, ce qui garantit un état frais et limite ce qu'une URL de webhook mal protégée pourrait révéler.
Événements disponibles
event_type | Se déclenche quand | Portée |
|---|---|---|
record.created |
| Un événement par utilisateur associé qui vous a autorisé. |
record.updated |
| Idem : un événement par utilisateur associé qui vous a autorisé. |
record.deleted |
| Un événement par utilisateur concerné. |
user.created | Un utilisateur est créé, ou réactivé après avoir été désactivé. | Applications utilisées par un Manager, un Revendeur ou un Admin du client de cet utilisateur. |
user.updated | Le prénom, le nom, les groupes, les rôles ou l'état actif/inactif d'un utilisateur changent. | Deux portées cumulées : les applications que l'utilisateur a lui-même autorisées (consentement), et celles utilisées par un Manager, un Revendeur ou un Admin de son client. |
user.deleted | Un utilisateur est supprimé, ou désactivé. | Applications utilisées par un Manager, un Revendeur ou un Admin du client de cet utilisateur. |
[!IMPORTANT] Le cas le plus fréquent est le second
record.created. Le flux courant est : téléversement, décodage, puis association de l'athlète. Au décodage l'enregistrement n'a encore aucun utilisateur, donc rien ne part ; c'est l'association qui déclenche lerecord.created. Ne comptez pas sur la fin du décodage pour être notifié.
[!NOTE] Ni
record.creatednirecord.updatedne se déclenchent sur un simple changement d'état interne (traitement en cours, erreur, etc.) : uniquement sur un décodage qui aboutit. Un enregistrement jamais décodé n'émet donc nirecord.creatednirecord.updated, même si on l'édite ; le retrait d'un utilisateur, lui, émet bien unrecord.deleted.Une désactivation d'utilisateur émet à la fois un
user.updated(le champ actif/inactif a changé) et unuser.deleted. Une réactivation émet unuser.updatedet unuser.created.
2. S'abonner
Vous déclarez vous-même vos abonnements, avec les identifiants de votre application OAuth (client_id / client_secret, les mêmes que pour /oauth/token) :
POST /webhooks/subscriptions
Content-Type: application/json
{
"client_id": "app_123",
"client_secret": "secret_456",
"url": "https://mon-serveur.example.com/webhooks/phyling",
"event_types": ["record.created", "record.updated", "record.deleted"],
"payload_format": "phyling",
"replay_history": true
}Réponse (le secret n'est montré qu'une seule fois, conservez-le) :
{
"id": 42,
"app_client_id": "app_123",
"url": "https://mon-serveur.example.com/webhooks/phyling",
"event_types": ["record.created", "record.updated", "record.deleted"],
"payload_format": "phyling",
"active": true,
"replay_history": true,
"created_at": "2026-09-03 10:00:00",
"secret": "whsec_9f3a...",
"prev_secret_expires_at": ""
}Une application peut poser plusieurs abonnements (une URL et une liste d'événements par abonnement, jusqu'à 10 par application), et gérer ses abonnements avec GET /webhooks/subscriptions, GET|PUT|DELETE /webhooks/subscriptions/{id}. Voir la référence Swagger pour le détail des routes.
[!IMPORTANT] Sur
GETetDELETE, l'en-têteAuthorization: Basicest obligatoire : ces méthodes n'ont pas de corps, et les identifiants ne sont jamais lus dans la query string — les y mettre les inscrirait dans les journaux d'accès.POSTetPUTacceptent les deux formes, en-tête ou corps.GET /webhooks/subscriptions Authorization: Basic <base64(client_id:client_secret)>Quand l'en-tête et le corps portent tous les deux un
client_id, l'en-tête gagne ; deuxclient_iddifférents donnent400 invalid_requestplutôt qu'un choix silencieux.
Vous pouvez aussi créer et gérer vos abonnements sans écrire de code, depuis l'interface Phyling : dans les paramètres avancés, section « Applications OAuth », en modifiant votre application.
Premier consentement : le rattrapage automatique
Au premier consentement d'un utilisateur à votre application, Phyling rejoue son historique récent sur vos abonnements : un record.created par enregistrement décodé dont la séance date des 30 derniers jours, 100 au maximum, les plus récents d'abord.
| Quand | Uniquement à la création du grant. Jamais à un renouvellement de token, et jamais de nouveau si l'utilisateur révoque puis reconsent. |
| Volume | Jusqu'à 100 record.created, sur une fenêtre de 30 jours de date de séance. |
| Portée | Les abonnements de votre application qui écoutent record.created, et eux seuls. |
| Rythme | Les événements passent par la même file d'envoi que les autres : ils arrivent étalés sur plusieurs passes, pas en une rafale. |
| Au-delà | Rien n'est rejoué au-delà de ces bornes. Le reste de l'historique se lit avec GET /records?since=… (§ 6). |
Ces événements sont des record.created ordinaires, avec un event_id neuf : si vous connaissiez déjà ces enregistrements, votre déduplication par event_id ne les reconnaîtra pas — traitez-les comme des créations idempotentes de votre côté (une insertion « si absent » sur l'identifiant de l'enregistrement, pas sur l'event_id).
Pour désactiver le rattrapage automatique, mettez replay_history à false — à la création de l'abonnement, ou après coup :
PUT /webhooks/subscriptions/{id}
Content-Type: application/json
{
"client_id": "app_123",
"client_secret": "secret_456",
"replay_history": false
}Le réglage existe aussi dans l'interface Phyling, sur le formulaire d'abonnement : « Rejouer l'historique au premier consentement ».
L'abonnement continue de recevoir tous les événements en direct : replay_history ne gouverne que ce rattrapage automatique. Modifier le champ ne rejoue rien et n'annule rien d'un rejeu déjà en file ; il ne change que le comportement des prochains premiers consentements.
Ce que vous recevez réellement
Vous ne recevez que les événements pour lesquels vous avez un droit :
record.*: uniquement pour les utilisateurs qui ont autorisé votre application (OAuth). Un utilisateur qui révoque son accès arrête le flux le concernant, sans action de votre part.user.updated: les deux régimes à la fois. Vous le recevez pour les utilisateurs qui ont autorisé votre application, et pour tous les utilisateurs du client si votre application est utilisée par un Manager, un Revendeur ou un Admin de ce client — dans ce second cas, sans consentement individuel de l'utilisateur concerné.user.createdetuser.deleted: réservés aux applications utilisées par un Manager, un Revendeur ou un Admin d'un client — un utilisateur qui vient d'être créé ou qui disparaît ne peut évidemment pas y avoir consenti lui-même.
3. Les en-têtes de la livraison
Chaque POST porte, en plus de la signature :
| En-tête | Contenu |
|---|---|
X-Phyling-Event-Id | L'event_id du corps, en double, pour dédoublonner sans parser le JSON. |
X-Phyling-Event-Type | L'event_type du corps, pour router la requête sans la parser. |
X-Phyling-Delivery-Attempt | Le numéro de la tentative, 1 au premier envoi. Une valeur supérieure à 1 signale une relivraison (§ 5). |
User-Agent | Phyling-Webhooks/1. |
Les valeurs du corps restent la référence : les en-têtes ne sont là que pour la commodité.
4. Vérifier la signature
Chaque livraison porte un en-tête X-Phyling-Signature :
X-Phyling-Signature: t=1725360930,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdv1 est HMAC-SHA256(secret, "<t>.<corps_de_la_requête>"), où <corps_de_la_requête> est le corps JSON exact reçu, secret votre secret d'abonnement, et t le timestamp Unix également présent dans l'en-tête. Le timestamp fait partie de la signature : sans cela, il suffirait de rejouer une ancienne requête en changeant juste l'horodatage affiché.
Vérification en Python :
import hashlib
import hmac
import time
def verify_signature(secret: str, header: str, body: bytes, tolerance_sec: int = 300) -> bool:
timestamp = None
signatures = []
for part in header.split(","):
key, _, value = part.partition("=")
if key == "t":
timestamp = int(value)
elif key == "v1":
signatures.append(value) # can appear twice during a secret rotation
if timestamp is None or abs(time.time() - timestamp) > tolerance_sec:
return False # missing or too old, reject as a possible replay
signed = f"{timestamp}.".encode() + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, v1) for v1 in signatures)Utilisez hmac.compare_digest (ou l'équivalent temps constant de votre langage) : une comparaison == classique fuite l'information de la position du premier octet différent.
Rotation de secret
POST /webhooks/subscriptions/{id}/rotate_secretémet un nouveau secret (montré une seule fois) tout en gardant l'ancien valide pendant une fenêtre de recouvrement (24 h par défaut, paramétrable). Pendant cette fenêtre, l'en-tête porte deux signatures v1 :
X-Phyling-Signature: t=1725360930,v1=<signature_ancien_secret>,v1=<signature_nouveau_secret>Il vous suffit qu'une seule des deux valide pour accepter la requête — ce qui vous permet de redéployer votre vérificateur avec le nouveau secret sans manquer une livraison pendant la transition.
Un seul ancien secret est mémorisé à la fois. Si vous faites de nouveau pivoter le secret avant d'avoir redéployé votre vérificateur avec le précédent, celui-ci devient immédiatement invalide — la nouvelle fenêtre de recouvrement ne couvre que le secret qui vient d'être remplacé, pas celui d'avant. Attendez d'avoir fini de redéployer avant de faire pivoter le secret à nouveau.
5. Doublons et ordre
La livraison est at-least-once : un même événement peut arriver plus d'une fois (perte de réponse réseau, retry après échec…). Votre application doit dédoublonner sur event_id — par exemple en gardant une table des event_id déjà traités pendant quelques jours.
Aucun ordre n'est garanti entre deux événements, y compris entre deux événements du même sujet. Si l'ordre relatif vous importe, fondez-vous sur event_time ou sur l'état lu via l'API, jamais sur l'ordre d'arrivée des requêtes HTTP.
Chaque livraison a un timeout de 10 secondes : répondez vite (2xx) et traitez l'événement de façon asynchrone si le traitement est long.
6. Retry et désactivation
Un événement non accepté (statut hors 2xx, timeout, connexion refusée…) est retenté en backoff exponentiel pendant environ 24 heures (8 tentatives). Une redirection n'est pas suivie : un 3xx compte comme un échec, votre URL d'abonnement doit être l'adresse finale. Passé ce délai sans succès, l'abonnement est désactivé (active: false) et Phyling en est alerté : réactivez-le (PUT /webhooks/subscriptions/{id} avec active: true) une fois votre endpoint de nouveau joignable.
Rattraper ce que vous avez manqué
Les webhooks notifient un changement, ils ne remplacent pas une resynchronisation. La route de rattrapage est GET /records?since=… : elle renvoie les enregistrements par ordre de date de modification croissante et vous rend un curseur pour la page suivante. C'est ce qui rend la désactivation d'un abonnement supportable — vous récupérez tout ce qui s'est passé pendant la coupure.
Elle s'appelle avec l'access token OAuth2 de l'utilisateur, comme le reste de l'API, et ne renvoie donc que ce que cet utilisateur vous a autorisé à lire.
GET /records?since=2026-09-01T00:00:00&pageSize=100
Authorization: Bearer <access_token>{
"total": 137,
"next_since": "2026-09-01T14:22:08.512000|8471",
"records": [ { "id": 8402, "…": "…" }, { "id": 8471, "…": "…" } ]
}since accepte deux formes, et deux seulement : une date ISO 8601 — naïve (2026-09-01T00:00:00) ou avec décalage (2026-09-01T00:00:00+02:00, suffixe Z accepté) — pour amorcer un rattrapage, ou un next_since que nous vous avons rendu, pour lire la page suivante. Tout le reste (un epoch en millisecondes, null, une chaîne quelconque) répond 400 Invalid modifiedSince: <valeur>. Un since vide n'est pas une erreur : il n'applique simplement aucun filtre, et la réponse ne porte alors pas de curseur.
La marche du curseur, et sa condition d'arrêt :
- Premier appel : passez une date ISO 8601 (
since=2026-09-01T00:00:00), la date à partir de laquelle vous voulez rattraper. - Traitez les enregistrements de la page.
- Si la réponse ne contient pas
next_since, c'est terminé : vous êtes à jour. C'est la seule condition d'arrêt. - Sinon, rappelez la route avec
since=<next_since>et reprenez à l'étape 2.
since = "2026-09-01T00:00:00" # first catch-up, then the stored cursor
while True:
answer = get("/records", params={"since": since, "pageSize": 100}).json()
for record in answer["records"]:
handle(record)
if "next_since" not in answer:
break # caught up: no cursor means no page left
since = answer["next_since"]
store(since) # so the next catch-up resumes here, not from a dateSept points qui évitent une boucle infinie ou un rejeu complet :
next_sinceest un curseur opaque. Ne l'interprétez pas comme une date, ne le tronquez pas, ne le reformatez pas : renvoyez-le verbatim. Sa forme peut changer sans préavis, et un curseur reçu est toujours accepté tel quel.pageIdest incompatible avecsinceet répond400: le curseur est la pagination, unpageIdpar-dessus sauterait une page entière — sur une route de rattrapage, ce serait une perte de données invisible. Réglez la taille de page avecpageSize, qui reste libre, et n'avancez que le curseur.- Un simple filtre
last_modified >= datepaginé parpageIdne suffit pas — c'est la toute première version de cette route, qui ne terminait pas proprement : un enregistrement modifié pendant que vous rattrapez peut se glisser avant votre position courante et se faire sauter, ou revenir dans une page déjà lue. Le curseur(last_modified, id)n'a pas ce problème, quel que soit ce qui change en base pendant que vous paginez : chaque page reprend exactement où la précédente s'est arrêtée, jamais à une position. - Persistez
next_sinceentre deux rattrapages. C'est lui qui évite de relire tout l'historique à chaque passage : sans lui, un poll périodique repartirait d'une date et rejouerait tout. next_sinceest absent quand la page est vide, et lui seul dit que c'est fini. Deux fausses conditions d'arrêt à ne pas utiliser : «next_sinceidentique au précédent » (ne peut plus arriver) et « moins depageSizeéléments » (faux quand le total est un multiple exact depageSize).totaln'est pas un total stable : il compte les enregistrements restants après le curseur, donc il décroît à chaque page et vaut0sur la dernière. C'est une jauge utile de votre retard à rattraper, jamais une condition d'arrêt.- Un curseur avance strictement : la dernière ligne d'une page ne revient jamais en tête de la suivante. Seul le premier appel, fait avec une date nue, inclut les enregistrements modifiés exactement à cette date — et il peut y en avoir plusieurs, pas un seul. Vous pouvez donc revoir ceux-là une fois. Comme pour les webhooks, traitez une relecture d'enregistrement de façon idempotente, sur l'identifiant de l'enregistrement.
Une valeur de since qui n'est ni une date ISO 8601 ni un curseur reçu de nous répond 400.
[!TIP]
POST /records/allaccepte le même mécanisme sous le nommodifiedSince— c'est la même route, un argument de plus à côté de vos filtres habituels (userIds,clientIds,sportIds…), pas un mode séparé. Utile pour ne rattraper qu'un utilisateur précis si votre intégration agit avec un rôle qui en voit plusieurs sans filtre (Manager, Coach, Revendeur voient par défaut tous les utilisateurs de leur périmètre). Un webhook, lui, tourne avec un token OAuth2 scopé à un seul utilisateur consentant : ce filtre y est déjà implicite, l'ajouter ne change rien.
Gardez ce rattrapage périodique même quand tout va bien : il couvre aussi bien une désactivation d'abonnement qu'une coupure passagère de votre endpoint.
Fichiers liés
- API - Authentification — créer l'application OAuth2 qui porte vos abonnements.
- Référence Swagger — détail complet des routes
/webhooks/subscriptions.