Facebook Pixel
Fit'DistanceDocumentation
Open the app
On this page

Webhooks

Receive the events of your account on your server: create a webhook, verify the signature, answer fast, retries and GET /events to catch up.

A webhook tells your tool as soon as something happens in your account: a student signs up, finishes a workout, sends a check-in. Fit'Distance sends a signed POST to the address you choose. You no longer need to read the lists at regular intervals to see what changed.

Create a webhook

A webhook is an HTTPS address and the list of events it receives. You can have at most 5.

  • In the app: Settings › Integrations › API keys, Webhooks tab, New webhook button.
  • Through the API: POST /operations/webhooks.create, with the webhooks:write scope.
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": "My zap"}'

url is an HTTPS address on port 443, with no credentials, to a public host name: no IP address, no local name, 2,048 characters at most. events lists the types to receive, or is ["*"] alone for all of them. description is optional, 120 characters at most.

Nine operations manage your webhooks: list, get, create, update, rotate_secret, ping, deliveries, redeliver and delete, each one prefixed with webhooks.. The reference describes them.

A key manages only the webhooks whose events it can all read. With webhooks:read, it reads them all. Without it, each type requires the read scope of its family, given below the table of events. The scopes page details these rules.

The signing secret

The creation answers with the secret of the webhook in data.secret. It starts with whsec_.

{
  "data": {
    "endpoint": { "uid": "…", "url": "https://hooks.example.com/fitdistance", "events": ["workout.completed", "bilan.submitted"] },
    "secret": "whsec_…"
  },
  "message": "…",
  "dry_run": false
}

This secret is shown only once: store it right away where your server reads it. No read returns it. If it is lost or has leaked, webhooks.rotate_secret creates a new one, also shown only once. The old one stops verifying deliveries at once.

The request you receive

Each event arrives as a POST on your address:

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 gives the type of the event, X-FitDistance-Delivery the identifier of the delivery and X-FitDistance-Signature the signature to verify.

The body is the envelope of the event, the same one GET /events lists:

{
  "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": "Upper body",
      "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 identifies the event (a ULID, which grows with time). data depends on the type: see events.

Verify the signature

X-FitDistance-Signature is t=<timestamp>,v1=<hex>. <timestamp> is the time of the delivery, in seconds. <hex> is the HMAC-SHA256, with the secret of the webhook, of <timestamp>.<body>: the timestamp, a dot, then the raw body.

To verify a delivery:

  1. Read the raw body, byte for byte, before any JSON decoding. A body decoded then written again no longer gives the same signature.
  2. Refuse a t more than 5 minutes away from your clock: this protects against the replay of an old delivery.
  3. Compute the HMAC again and compare it with each v1 in constant time. One matching v1 is enough.

In Node, with the node:crypto module only:

const crypto = require("node:crypto");

// rawBody: the body received, byte for byte, before any 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)
  );
}

In Python, with the standard library:

import hashlib
import hmac
import re
import time


def verify_fitdistance(secret, header, raw_body, now_s=None):
    # raw_body: the bytes received, before any 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
    )

A malformed header gives False, never an exception.

Answer fast and de-duplicate

Your server has 10 seconds to answer a 2xx, the full response included. The body of your answer is ignored. Answer first, then do the work: a long task before the answer goes past the timeout, and the delivery is sent again.

The same event can arrive twice, for example when your answer gets lost. Keep the id values already handled and ignore an id already seen: it does not change from one attempt to the next. The order of arrival is not guaranteed either: rely on occurred_at.

const http = require("node:http");

const seen = new Set(); // for real: your database

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(); // answer first
    const event = JSON.parse(rawBody);
    if (seen.has(event.id)) return; // already handled
    seen.add(event.id);
    handle(event); // the work, after the answer
  });
}).listen(3000); // behind your HTTPS, on port 443

Retries

A delivery succeeds on a 2xx. Everything else is a failed attempt: another code, a timeout, a network error, a redirect (a 3xx is never followed) or a refused address. The address is resolved and checked at each delivery.

AttemptAfter a failure, next attempt in
1 (immediate)1 min
25 min
330 min
42 h
58 h
6none: the delivery is given up

After 50 consecutive failures, all deliveries included, the webhook is disabled and you receive an e-mail. Its open deliveries move to exhausted, and turning it back on does not send them again: once the webhook is back on, catch up on what you missed with GET /events. A 2xx resets the count to zero. Fix your server, then turn the webhook back on in the Webhooks tab, or with webhooks.update and "enabled": true. A webhook disabled by the Fit'Distance team cannot be turned back on from your side.

Test and redeliver

webhooks.ping sends a test event to check your address: its body is an envelope with "type": "ping" and "data": {}, signed like the others. A ping never appears in GET /events. webhooks.redeliver sends a past delivery again, named by its identifier in webhooks.deliveries. Both make a single attempt, right away, and the answer tells what your server answered. On a disabled webhook, they answer WEBHOOK_DISABLED. On a failure, they are never retried. They still count in the consecutive failures of the webhook.

Catch up with GET /events

GET /events returns the recorded events of your account, oldest first, in the same envelope as the delivery. It is free: no credit, but the limits of your key apply. After an outage of your server, it catches up on what you missed.

curl "https://api.fitdistance.io/api/partner/v1/events?since=01M6PG9DYGM8Z4K6N3P5R7T9V1&types=workout.completed" \
  -H "Authorization: Bearer $FITDISTANCE_KEY"

Its parameters, all optional:

since   an event id (the ones after it, without it), or an ISO 8601 date
        with an offset, Z or ±hh:mm (the events that occurred from that
        date)
types   types separated by commas; without it, every type your key reads
cursor  the next_cursor of the previous page
limit   from 1 to 200, 50 by default

The response follows the pagination. An event shows up there about three seconds after it is recorded. Events are kept 30 days, and are recorded only while you have at least one enabled webhook: before your first webhook, there is no history.

A date without an offset, an unknown type, ping, or a malformed value (limit outside 1 to 200, an invalid cursor, a date that does not exist, an unknown parameter) answers 400 VALIDATION, with no credit. A type your key does not read answers 403 SCOPE_MISSING, with the read scope of its family in details.scope, and a key that reads no type gets webhooks:read in details.scope.

Events

TypeWhendata
student.requestedA student signs up through your public link.student_id, first_name, last_name, requested_at
student.activatedThe contract of a student is created, including when you accept a request.student
student.leftA student leaves you.student_id, left_at, reason (unassigned, student_deleted or contract_ended)
workout.startedA student starts a scheduled workout.workout, student_id
workout.completedA workout is finished, once per workout.workout, student_id
program.assignedA program is sent to a student.program, student_id
program.completedThe program of a student becomes finished.program, student_id
bilan.submittedA check-in is created, by the student or by you.bilan
form.submittedA student answers a form you assigned to them.submission
habit.loggedA habit entry is recorded, by the student or by you for them.entry
message.receivedA student sends you a message.thread_uid, message_id, student_id
appointment.bookedAn appointment is created with at least one participant: by you, by the Google Calendar sync, by a public booking, or by a student who joins a group slot.appointment, student_ids
appointment.cancelledThe appointment itself becomes cancelled.appointment, student_ids
contract.ending_soonThe contract of a student enters its ending period.student_id, contract
contract.endedThe contract of a student expires.student_id, contract
student.inactiveA student goes 7, 14 or 30 days without a finished workout or a login.student_id, inactive_days (7, 14 or 30), last_activity_at (null if they were never active)

Each type requires the read scope of its family, or webhooks:read, which opens them all:

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      every type

The objects in data

The objects carry the fields of the read routes, with the same names. A measure of measurements is null when it is not filled in.

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

What is never sent

No free text and no contact detail appears in data. Not the e-mail or the phone of students. Not the notes you write about a student, nor the text and the note the student adds to a habit entry. Not the description, the cancellation reason or the list of participants of an appointment, nor the e-mail of its guests: students come only in student_ids, and a guest never appears there. Not the text of messages. Not the health of the initial check-in: history, allergies, medical notes. Not the answers to forms or the comments of a check-in: only their count, in answers_count and comments_count. To read this content, call the route or the operation that serves it, with the scope it requires.

These cases send no event: an appointment deleted, and not cancelled; a participant added by editing an appointment; a block of your calendar without a participant; an answer to a public form; a free workout; a demo or hidden student; the initial check-in you fill in for a student; one-off data imports.

Operation refusals

On the webhooks.* operations, a refusal answers 400 VALIDATION, and details.code gives the reason, with its details in the other fields of details:

  • ENDPOINT_UNREACHABLE, with reason: the address is not a public HTTPS address on port 443.
  • VALIDATION, with reason: "scope_missing", scope, events: your key does not read some of the requested events. Add this scope on a new key, or remove these events.
  • VALIDATION, with reason: another refusal of the request.
  • TERMS_REQUIRED, with terms_version, accept_in: the terms of the API are not accepted.
  • WEBHOOK_LIMIT, with max: you already have 5 webhooks.
  • WEBHOOK_DISABLED, with disabled_reason: a test or a redelivery to a disabled webhook, or turning back on a webhook disabled by the Fit'Distance team.

This refusal is billed: the operation ran. An unknown webhook or delivery uid also answers 400 VALIDATION. The other codes are in Errors.

Next pageErrorsThe shape of an API error, the thirteen codes with their HTTP status and what they cost, and what to do with each.