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 thewebhooks:writescope.
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:
- Read the raw body, byte for byte, before any JSON decoding. A body decoded then written again no longer gives the same signature.
- Refuse a
tmore than 5 minutes away from your clock: this protects against the replay of an old delivery. - Compute the HMAC again and compare it with each
v1in constant time. One matchingv1is 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 443Retries
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.
| Attempt | After a failure, next attempt in |
|---|---|
| 1 (immediate) | 1 min |
| 2 | 5 min |
| 3 | 30 min |
| 4 | 2 h |
| 5 | 8 h |
| 6 | none: 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 defaultThe 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
| Type | When | data |
|---|---|---|
student.requested | A student signs up through your public link. | student_id, first_name, last_name, requested_at |
student.activated | The contract of a student is created, including when you accept a request. | student |
student.left | A student leaves you. | student_id, left_at, reason (unassigned, student_deleted or contract_ended) |
workout.started | A student starts a scheduled workout. | workout, student_id |
workout.completed | A workout is finished, once per workout. | workout, student_id |
program.assigned | A program is sent to a student. | program, student_id |
program.completed | The program of a student becomes finished. | program, student_id |
bilan.submitted | A check-in is created, by the student or by you. | bilan |
form.submitted | A student answers a form you assigned to them. | submission |
habit.logged | A habit entry is recorded, by the student or by you for them. | entry |
message.received | A student sends you a message. | thread_uid, message_id, student_id |
appointment.booked | An 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.cancelled | The appointment itself becomes cancelled. | appointment, student_ids |
contract.ending_soon | The contract of a student enters its ending period. | student_id, contract |
contract.ended | The contract of a student expires. | student_id, contract |
student.inactive | A 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 typeThe 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_atWhat 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, withreason: the address is not a public HTTPS address on port 443.VALIDATION, withreason: "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, withreason: another refusal of the request.TERMS_REQUIRED, withterms_version, accept_in: the terms of the API are not accepted.WEBHOOK_LIMIT, withmax: you already have 5 webhooks.WEBHOOK_DISABLED, withdisabled_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.