Webhooks
Webhooks tell your integration what a client just did, so it can react right away
instead of polling. You register a URL and TrainerStudio sends it a signed
POST for each event you subscribe to.
Create an endpoint
In the coach app, go to Settings → Developers → Webhooks, add your URL and choose the events. Or use the API with your API key:
curl -X POST "https://api.trainerstudio.com/v2/coach/webhook-endpoints" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/trainerstudio",
"description": "Coach agent",
"eventTypes": ["chat.message_received", "workout.completed"]
}'The response includes secret (whsec_…). It is shown only once: store it.
If you lose it, rotate the secret.
- The URL must be public
https://, with no user name or password in it. "eventTypes": ["*"]subscribes to every event, including the ones added later.- A company can have up to 10 endpoints.
Events
| Type | Sent when | data |
|---|---|---|
chat.message_received | A client sends a message in the in-app chat | customerId, conversationId, messageId, messageType |
workout.completed | A client finishes the workout of a day | customerId, date, completedItems, totalItems |
workout.comment_posted | A client posts a comment, photo or video on a workout item | customerId, postId, workoutBlockId, itemId, nestedItemId, date, media |
metric.recorded | A value is recorded for a client's metric | customerId, metricId |
task.completed | A client completes a task | customerId, taskTitle |
progress_photo.uploaded | A client uploads a progress photo | customerId |
form.completed | A client answers a form | customerId, formName |
webhook.test | You ask for a test delivery | message |
GET /v2/coach/webhook-event-types returns this catalog with an example payload per type.
Payload
{
"id": "evt_6642f0c2a1b2c3d4e5f60718",
"type": "workout.completed",
"timestamp": "2026-11-13T09:30:00.000Z",
"data": {
"customerId": "60626cc191f3a274116fee34",
"date": "2026-11-13",
"completedItems": 6,
"totalItems": 6
}
}Payloads carry ids, not content: no message text and no measurements. Read the details with the API or the MCP server using your own credentials.
Verify the signature
Every request carries three headers, as defined by Standard Webhooks:
| Header | Value |
|---|---|
webhook-id | The event id. The same on every retry of that event. |
webhook-timestamp | Seconds since the epoch when the request was signed. |
webhook-signature | v1,<signature>. Several, separated by spaces, while a secret is being rotated. |
The signature is the base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body},
keyed with the bytes your secret encodes after the whsec_ prefix.
The Standard Webhooks libraries (JavaScript, Python, Go, Ruby, PHP, Java, Rust, C#) do it for you:
import { Webhook } from "standardwebhooks";
const webhook = new Webhook(process.env.TRAINERSTUDIO_WEBHOOK_SECRET);
app.post("/webhooks/trainerstudio", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = webhook.verify(req.body, req.headers); // throws on a bad signature or an old timestamp
} catch {
return res.status(400).end();
}
res.status(204).end(); // acknowledge first
enqueue(event); // then process
});Or by hand:
import { createHmac, timingSafeEqual } from "node:crypto";
function isValid(secret, headers, rawBody) {
const key = Buffer.from(secret.replace("whsec_", ""), "base64");
const signed = `${headers["webhook-id"]}.${headers["webhook-timestamp"]}.${rawBody}`;
const expected = Buffer.from(createHmac("sha256", key).update(signed).digest("base64"));
const fresh = Math.abs(Date.now() / 1000 - Number(headers["webhook-timestamp"])) < 300;
return (
fresh &&
headers["webhook-signature"].split(" ").some((signature) => {
const candidate = Buffer.from(signature.replace("v1,", ""));
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
})
);
}- Verify against the raw body, before any JSON parsing.
- Reject timestamps older than five minutes to stop replays.
Respond and retry
- Answer with any
2xxwithin 10 seconds. Acknowledge first and do the work afterwards. - Anything else is a failed attempt: other statuses, timeouts, connection errors. Redirects are not followed.
- A failed delivery is retried 8 times over about 28 hours: after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours.
Retry-Afteris honored up to 24 hours. - Delivery is at least once: the same event can arrive twice. Deduplicate on
webhook-id. - Order is not guaranteed. Use
timestampto order events.
Endpoint health
An endpoint is turned off, and its pending deliveries canceled, when:
- it answers
410 Gone, or - every attempt has failed for 3 days.
Events that happen while an endpoint is off are not sent later. Turn it on again
from Developers → Webhooks or with PATCH /v2/coach/webhook-endpoints/{id}
and { "status": "enabled" }.
Rotate the secret
POST /v2/coach/webhook-endpoints/{id}/secret-rotations returns a new secret.
For 24 hours every delivery is signed with both the new and the old secret, so
you can deploy the new one without rejecting events.
Test and debug
- Send a test event from the endpoint's page, or
POST /v2/coach/webhook-endpoints/{id}/test-events. - The delivery log keeps every delivery for 30 days with its payload and each attempt: status code, duration, error and the start of your response. Also at
GET /v2/coach/webhook-deliveries. - Send again re-sends a finished delivery with the same event id:
POST /v2/coach/webhook-deliveries/{id}/retries. GET /v2/coach/webhook-endpoints/{id}/statsreturns success rate, average response time and daily counts.
API
| Endpoint | Purpose |
|---|---|
GET /v2/coach/webhook-endpoints | List endpoints |
POST /v2/coach/webhook-endpoints | Create an endpoint; returns the secret once |
GET /v2/coach/webhook-endpoints/{id} | Get an endpoint and its health |
PATCH /v2/coach/webhook-endpoints/{id} | Change URL, description or events; turn it on or off |
DELETE /v2/coach/webhook-endpoints/{id} | Delete an endpoint |
POST /v2/coach/webhook-endpoints/{id}/secret-rotations | Rotate the signing secret |
POST /v2/coach/webhook-endpoints/{id}/test-events | Send a test event |
GET /v2/coach/webhook-endpoints/{id}/stats?days=7 | Delivery statistics |
GET /v2/coach/webhook-deliveries | Delivery log; filter by endpointId, status, eventType |
GET /v2/coach/webhook-deliveries/{id} | One delivery with payload and attempts |
POST /v2/coach/webhook-deliveries/{id}/retries | Send a delivery again |
GET /v2/coach/webhook-event-types | Event catalog |
Only the account owner can manage webhooks.
Errors on these routes are application/problem+json with a stable code:
{
"type": "about:blank",
"title": "Unprocessable Entity",
"status": 422,
"detail": "The URL must be a public HTTPS address without a user name or password",
"code": "webhook_url_not_allowed"
}| Code | Status | Meaning |
|---|---|---|
validation | 400 | The request does not match the documented fields |
forbidden | 403 | The caller cannot manage company settings |
not_found | 404 | The endpoint or delivery does not exist |
webhook_url_not_allowed | 422 | The URL is not public HTTPS, or carries credentials |
webhook_endpoint_limit_reached | 422 | The company already has 10 endpoints |
webhook_endpoint_disabled | 422 | Test or re-send on an endpoint that is turned off |