Facebook Pixel
Fit'DistanceDocumentation
Ouvrir l'app
Sur cette page

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ètre webhooks: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 :

  1. 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.
  2. Refuse un t à plus de 5 minutes de ton horloge : c'est la protection contre le rejeu d'un ancien envoi.
  3. Recalcule le HMAC et compare-le à chaque v1 en temps constant. Un v1 qui 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 443

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

EssaiAprès un échec, essai suivant dans
1 (immédiat)1 min
25 min
330 min
42 h
58 h
6aucun : 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éfaut

La 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

TypeQuanddata
student.requestedUn élève s'inscrit par ton lien public.student_id, first_name, last_name, requested_at
student.activatedLe contrat d'un élève est créé, y compris quand tu acceptes une demande.student
student.leftUn élève te quitte.student_id, left_at, reason (unassigned, student_deleted ou contract_ended)
workout.startedUn élève démarre une séance planifiée.workout, student_id
workout.completedUne séance est terminée, une fois par séance.workout, student_id
program.assignedUn programme est envoyé à un élève.program, student_id
program.completedLe programme d'un élève passe à terminé.program, student_id
bilan.submittedUn bilan est créé, par l'élève ou par toi.bilan
form.submittedUn élève répond à un formulaire que tu lui as assigné.submission
habit.loggedUne entrée d'habitude est enregistrée, par l'élève ou par toi pour lui.entry
message.receivedUn élève t'envoie un message.thread_uid, message_id, student_id
appointment.bookedUn 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.cancelledLe rendez-vous lui-même passe à annulé.appointment, student_ids
contract.ending_soonLe contrat d'un élève entre dans sa période de fin.student_id, contract
contract.endedLe contrat d'un élève expire.student_id, contract
student.inactiveUn é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 types

Les 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_at

Ce 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, avec reason : l'adresse n'est pas une adresse HTTPS publique sur le port 443.
  • VALIDATION, avec reason: "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, avec reason : un autre refus de la requête.
  • TERMS_REQUIRED, avec terms_version, accept_in : les conditions de l'API ne sont pas acceptées.
  • WEBHOOK_LIMIT, avec max : tu as déjà 5 webhooks.
  • WEBHOOK_DISABLED, avec disabled_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.

Page suivanteErreursLa forme d'une erreur de l'API, les treize codes avec leur statut HTTP et ce qu'ils coûtent, et quoi faire de chacun.