Webhooks
Recevoir les événements de ton compte sur ton serveur : créer un webhook, vérifier la signature, répondre vite, les nouveaux essais et GET /events pour rattraper.
Un webhook prévient ton outil dès qu'il se passe quelque chose dans ton compte : un élève s'inscrit, termine une séance, envoie un bilan. Fit'Distance envoie un POST signé à l'adresse que tu choisis. Tu n'as plus à relire les listes à intervalle régulier pour voir ce qui a changé.
Créer un webhook
Un webhook, c'est une adresse HTTPS et la liste des événements qu'elle reçoit. Tu en as 5 au plus.
- Dans l'app : Paramètres › Intégrations › Clés API, onglet Webhooks, bouton Nouveau webhook.
- Par l'API :
POST /operations/webhooks.create, avec le périmètrewebhooks:write.
curl -X POST https://api.fitdistance.io/api/partner/v1/operations/webhooks.create \
-H "Authorization: Bearer $FITDISTANCE_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://hooks.example.com/fitdistance", "events": ["workout.completed", "bilan.submitted"], "description": "Mon zap"}'url est une adresse HTTPS sur le port 443, sans identifiants, vers un nom d'hôte public : ni adresse IP, ni nom local, 2 048 caractères au plus. events liste les types à recevoir, ou vaut ["*"] seul pour tous. description est facultative, 120 caractères au plus.
Neuf opérations gèrent tes webhooks : list, get, create, update, rotate_secret, ping, deliveries, redeliver et delete, chacune préfixée par webhooks.. La référence les décrit.
Une clé ne gère que les webhooks dont elle peut lire tous les événements. Avec webhooks:read, elle les lit tous. Sans lui, chaque type demande le périmètre de lecture de sa famille, indiqué sous le tableau des événements. Les périmètres détaillent ces règles.
Le secret de signature
La création répond avec le secret du webhook dans data.secret. Il commence par whsec_.
{
"data": {
"endpoint": { "uid": "…", "url": "https://hooks.example.com/fitdistance", "events": ["workout.completed", "bilan.submitted"] },
"secret": "whsec_…"
},
"message": "…",
"dry_run": false
}Ce secret n'est montré qu'une seule fois : garde-le tout de suite là où ton serveur le lit. Aucune lecture ne le rend. S'il est perdu ou s'il a fuité, webhooks.rotate_secret en crée un nouveau, montré une seule fois lui aussi. L'ancien cesse aussitôt de vérifier les envois.
La requête reçue
Chaque événement arrive en POST sur ton adresse :
POST /fitdistance HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: FitDistance-Webhooks/1
X-FitDistance-Event: workout.completed
X-FitDistance-Delivery: 01M6PG9E6AC4D6F8H0J2K4M6N8
X-FitDistance-Signature: t=1793904130,v1=<hex>X-FitDistance-Event donne le type de l'événement, X-FitDistance-Delivery l'identifiant de l'envoi et X-FitDistance-Signature la signature à vérifier.
Le corps est l'enveloppe de l'événement, la même que liste GET /events :
{
"id": "01M6PG9DYGM8Z4K6N3P5R7T9V1",
"type": "workout.completed",
"api_version": "2026-10",
"occurred_at": "2026-11-05T18:42:10.000Z",
"coach_id": 7,
"data": {
"workout": {
"id": "3f2b9c1e-5a7d-4e8f-9b0a-1c2d3e4f5a6b",
"program_id": "8d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6",
"student_id": 42,
"coach_id": 7,
"name": "Haut du corps",
"status": "done",
"scheduled_at": "2026-11-05T17:00:00.000Z",
"started_at": "2026-11-05T17:58:02.000Z",
"ended_at": "2026-11-05T18:42:10.000Z",
"day_in_program": 3,
"updated_at": "2026-11-05T18:42:10.000Z"
},
"student_id": 42
}
}id identifie l'événement (un ULID, qui croît avec le temps). data change selon le type : voir les événements.
Vérifier la signature
X-FitDistance-Signature vaut t=<timestamp>,v1=<hex>. <timestamp> est l'heure de l'envoi, en secondes. <hex> est le HMAC-SHA256, avec le secret du webhook, de <timestamp>.<corps> : le timestamp, un point, puis le corps brut.
Pour vérifier un envoi :
- Lis le corps brut, octet pour octet, avant tout décodage JSON. Un corps décodé puis réécrit ne donne plus la même signature.
- Refuse un
tà plus de 5 minutes de ton horloge : c'est la protection contre le rejeu d'un ancien envoi. - Recalcule le HMAC et compare-le à chaque
v1en temps constant. Unv1qui correspond suffit.
En Node, avec le seul module node:crypto :
const crypto = require("node:crypto");
// rawBody : le corps reçu, octet pour octet, avant tout JSON.parse.
function verifyFitDistance(secret, header, rawBody, nowS = Math.floor(Date.now() / 1000)) {
let t = null;
const signatures = [];
for (const item of header.split(",")) {
const eq = item.indexOf("=");
if (eq < 0) continue;
const name = item.slice(0, eq).trim();
const value = item.slice(eq + 1).trim();
if (name === "t" && /^[0-9]{1,12}$/.test(value)) t = Number(value);
if (name === "v1") signatures.push(value);
}
if (t === null || signatures.length === 0) return false;
if (Math.abs(nowS - t) > 300) return false; // 5 minutes
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
return signatures.some(
(v1) => /^[0-9a-f]{64}$/.test(v1) && crypto.timingSafeEqual(Buffer.from(v1, "hex"), expected)
);
}En Python, avec la bibliothèque standard :
import hashlib
import hmac
import re
import time
def verify_fitdistance(secret, header, raw_body, now_s=None):
# raw_body : les octets reçus, avant tout json.loads.
now_s = int(time.time()) if now_s is None else now_s
t = None
signatures = []
for item in header.split(","):
name, eq, value = item.partition("=")
if not eq:
continue
name, value = name.strip(), value.strip()
if name == "t" and re.fullmatch(r"[0-9]{1,12}", value):
t = int(value)
if name == "v1":
signatures.append(value)
if t is None or not signatures:
return False
if abs(now_s - t) > 300: # 5 minutes
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(
re.fullmatch(r"[0-9a-f]{64}", v1) and hmac.compare_digest(v1, expected)
for v1 in signatures
)Un en-tête mal formé donne False, jamais une exception.
Répondre vite et dédoublonner
Ton serveur a 10 secondes pour répondre un 2xx, réponse complète comprise. Le corps de ta réponse est ignoré. Réponds d'abord, puis fais le travail : une tâche longue avant la réponse dépasse le délai, et l'envoi repart.
Un même événement peut arriver deux fois, par exemple quand ta réponse se perd. Garde les id déjà traités et ignore un id déjà vu : il ne change pas d'un essai à l'autre. L'ordre d'arrivée n'est pas garanti non plus : fie-toi à occurred_at.
const http = require("node:http");
const seen = new Set(); // en vrai : ta base de données
http.createServer((req, res) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
const rawBody = Buffer.concat(chunks).toString("utf8");
const header = req.headers["x-fitdistance-signature"] ?? "";
if (!verifyFitDistance(process.env.FITDISTANCE_WEBHOOK_SECRET, header, rawBody)) {
res.writeHead(401).end();
return;
}
res.writeHead(200).end(); // répondre d'abord
const event = JSON.parse(rawBody);
if (seen.has(event.id)) return; // déjà traité
seen.add(event.id);
handle(event); // le travail, après la réponse
});
}).listen(3000); // derrière ton HTTPS, sur le port 443Les nouveaux essais
Un envoi réussit sur un 2xx. Tout le reste est un essai en échec : un autre code, un délai dépassé, une erreur réseau, une redirection (un 3xx n'est jamais suivi) ou une adresse refusée. L'adresse est résolue et vérifiée à chaque envoi.
| Essai | Après un échec, essai suivant dans |
|---|---|
| 1 (immédiat) | 1 min |
| 2 | 5 min |
| 3 | 30 min |
| 4 | 2 h |
| 5 | 8 h |
| 6 | aucun : l'envoi est abandonné |
Après 50 échecs consécutifs, tous envois confondus, le webhook est désactivé et tu reçois un e-mail. Ses envois en cours passent à exhausted, et la réactivation ne les renvoie pas : une fois le webhook réactivé, rattrape ce que tu as manqué avec GET /events. Un 2xx remet le compte à zéro. Corrige ton serveur, puis réactive le webhook dans l'onglet Webhooks, ou avec webhooks.update et "enabled": true. Un webhook désactivé par l'équipe Fit'Distance ne se réactive pas de ton côté.
Tester et renvoyer
webhooks.ping envoie un événement de test pour vérifier ton adresse : son corps est une enveloppe avec "type": "ping" et "data": {}, signée comme les autres. Un ping n'apparaît jamais dans GET /events. webhooks.redeliver renvoie un envoi passé, désigné par son identifiant dans webhooks.deliveries. Les deux font un seul essai, tout de suite, et la réponse dit ce que ton serveur a répondu. Sur un webhook désactivé, ils répondent WEBHOOK_DISABLED. En échec, ils ne sont jamais réessayés. Ils comptent pourtant dans les échecs consécutifs du webhook.
Rattraper avec GET /events
GET /events rend les événements enregistrés de ton compte, du plus ancien au plus récent, dans la même enveloppe que l'envoi. Il est gratuit : aucun crédit, mais les plafonds de ta clé s'appliquent. Après une panne de ton serveur, il rattrape ce que tu as manqué.
curl "https://api.fitdistance.io/api/partner/v1/events?since=01M6PG9DYGM8Z4K6N3P5R7T9V1&types=workout.completed" \
-H "Authorization: Bearer $FITDISTANCE_KEY"Ses paramètres, tous facultatifs :
since un id d'événement (les suivants, sans lui), ou une date ISO 8601
avec fuseau, Z ou ±hh:mm (les événements survenus à partir de
cette date)
types des types séparés par des virgules ; sans lui, tous ceux que ta clé lit
cursor le next_cursor de la page précédente
limit de 1 à 200, 50 par défautLa réponse suit la pagination. Un événement y apparaît environ trois secondes après son enregistrement. Les événements sont gardés 30 jours, et ne sont enregistrés que pendant que tu as au moins un webhook activé : avant ton premier webhook, il n'y a aucun historique.
Une date sans fuseau, un type inconnu, ping, ou une valeur mal formée (limit hors de 1 à 200, cursor invalide, date inexistante, paramètre inconnu) répond 400 VALIDATION, sans crédit. Un type que ta clé ne lit pas répond 403 SCOPE_MISSING, avec le périmètre de lecture de sa famille dans details.scope, et une clé qui ne lit aucun type reçoit webhooks:read dans details.scope.
Les événements
| Type | Quand | data |
|---|---|---|
student.requested | Un élève s'inscrit par ton lien public. | student_id, first_name, last_name, requested_at |
student.activated | Le contrat d'un élève est créé, y compris quand tu acceptes une demande. | student |
student.left | Un élève te quitte. | student_id, left_at, reason (unassigned, student_deleted ou contract_ended) |
workout.started | Un élève démarre une séance planifiée. | workout, student_id |
workout.completed | Une séance est terminée, une fois par séance. | workout, student_id |
program.assigned | Un programme est envoyé à un élève. | program, student_id |
program.completed | Le programme d'un élève passe à terminé. | program, student_id |
bilan.submitted | Un bilan est créé, par l'élève ou par toi. | bilan |
form.submitted | Un élève répond à un formulaire que tu lui as assigné. | submission |
habit.logged | Une entrée d'habitude est enregistrée, par l'élève ou par toi pour lui. | entry |
message.received | Un élève t'envoie un message. | thread_uid, message_id, student_id |
appointment.booked | Un rendez-vous est créé avec au moins un participant : par toi, par la synchronisation Google Agenda, par une réservation publique, ou par un élève qui rejoint un créneau de groupe. | appointment, student_ids |
appointment.cancelled | Le rendez-vous lui-même passe à annulé. | appointment, student_ids |
contract.ending_soon | Le contrat d'un élève entre dans sa période de fin. | student_id, contract |
contract.ended | Le contrat d'un élève expire. | student_id, contract |
student.inactive | Un élève passe 7, 14 ou 30 jours sans séance terminée ni connexion. | student_id, inactive_days (7, 14 ou 30), last_activity_at (null s'il n'a jamais été actif) |
Chaque type demande le périmètre de lecture de sa famille, ou webhooks:read, qui les ouvre tous :
students:read student.*, contract.*
training:read workout.*, program.*
bilans:read bilan.submitted
forms:read form.submitted
habits:read habit.logged
messaging:read message.received
appointments:read appointment.*
webhooks:read tous les typesLes objets de data
Les objets reprennent les champs des routes de lecture, avec les mêmes noms. Une mesure de measurements vaut null quand elle n'est pas renseignée.
student id, coach_id, first_name, last_name, birth_date, joined_at,
contract_status, contract_start_at, contract_end_at,
tags (id, name, color), updated_at
workout id, program_id, student_id, coach_id, name, status, scheduled_at,
started_at, ended_at, day_in_program, updated_at
program id, student_id, coach_id, name, status, weeks_duration, start_at,
end_at, updated_at
bilan id, student_id, coach_id, status, is_initial, created_by,
submitted_at, treated_at, goal, measurements, comments_count,
updated_at
measurements bodyweight_kg, bodyfat_percent, waist_cm, chest_cm, hips_cm,
neck_cm, shoulders_cm, glutes_cm, arms_left_cm, arms_right_cm,
forearms_left_cm, forearms_right_cm, thighs_left_cm,
thighs_right_cm, calfs_left_cm, calfs_right_cm
submission id, assignment_id, form_id, form_title, student_id, submitted_at,
is_complete, answers_count, updated_at
entry id, habit_id, student_id, entry_date, completed_at, value_boolean,
value_number, value_rating, value_choice, updated_at
appointment id, coach_id, title, event_type, status, start_at, end_at,
duration_minutes, is_all_day, color, prestation_name,
cancelled_at, created_at, updated_at
contract status, start_at, end_atCe qui n'est jamais envoyé
Aucun texte libre ni aucune coordonnée ne figure dans data. Ni l'e-mail ni le téléphone des élèves. Ni les notes que tu écris sur un élève, ni le texte et la note que l'élève ajoute à une entrée d'habitude. Ni la description, ni le motif d'annulation, ni la liste des participants d'un rendez-vous, ni l'e-mail de ses invités : les élèves arrivent seulement dans student_ids, et un invité n'y figure jamais. Ni le texte des messages. Ni la santé du bilan initial : antécédents, allergies, notes médicales. Ni les réponses aux formulaires ni les commentaires d'un bilan : seulement leur nombre, dans answers_count et comments_count. Pour lire ce contenu, appelle la route ou l'opération qui le sert, avec le périmètre qu'elle demande.
Ces cas n'émettent aucun événement : un rendez-vous supprimé, et non annulé ; un participant ajouté en modifiant un rendez-vous ; un bloc de ton agenda sans participant ; une réponse à un formulaire public ; une séance libre ; un élève de démo ou masqué ; le bilan initial que tu remplis pour un élève ; les imports ponctuels de données.
Les refus des opérations
Sur les opérations webhooks.*, un refus répond 400 VALIDATION, et details.code en donne la raison, avec ses précisions dans les autres champs de details :
ENDPOINT_UNREACHABLE, avecreason: l'adresse n'est pas une adresse HTTPS publique sur le port 443.VALIDATION, avecreason: "scope_missing", scope, events: ta clé ne lit pas certains des événements demandés. Ajoute ce périmètre sur une nouvelle clé, ou retire ces événements.VALIDATION, avecreason: un autre refus de la requête.TERMS_REQUIRED, avecterms_version, accept_in: les conditions de l'API ne sont pas acceptées.WEBHOOK_LIMIT, avecmax: tu as déjà 5 webhooks.WEBHOOK_DISABLED, avecdisabled_reason: un test ou un renvoi vers un webhook désactivé, ou la réactivation d'un webhook désactivé par l'équipe Fit'Distance.
Ce refus est facturé : l'opération a tourné. Un uid de webhook ou d'envoi inconnu répond aussi 400 VALIDATION. Les autres codes sont dans Erreurs.