Developers

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

TypeSent whendata
chat.message_receivedA client sends a message in the in-app chatcustomerId, conversationId, messageId, messageType
workout.completedA client finishes the workout of a daycustomerId, date, completedItems, totalItems
workout.comment_postedA client posts a comment, photo or video on a workout itemcustomerId, postId, workoutBlockId, itemId, nestedItemId, date, media
metric.recordedA value is recorded for a client's metriccustomerId, metricId
task.completedA client completes a taskcustomerId, taskTitle
progress_photo.uploadedA client uploads a progress photocustomerId
form.completedA client answers a formcustomerId, formName
webhook.testYou ask for a test deliverymessage

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:

HeaderValue
webhook-idThe event id. The same on every retry of that event.
webhook-timestampSeconds since the epoch when the request was signed.
webhook-signaturev1,<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 2xx within 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-After is 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 timestamp to 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}/stats returns success rate, average response time and daily counts.

API

EndpointPurpose
GET /v2/coach/webhook-endpointsList endpoints
POST /v2/coach/webhook-endpointsCreate 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-rotationsRotate the signing secret
POST /v2/coach/webhook-endpoints/{id}/test-eventsSend a test event
GET /v2/coach/webhook-endpoints/{id}/stats?days=7Delivery statistics
GET /v2/coach/webhook-deliveriesDelivery log; filter by endpointId, status, eventType
GET /v2/coach/webhook-deliveries/{id}One delivery with payload and attempts
POST /v2/coach/webhook-deliveries/{id}/retriesSend a delivery again
GET /v2/coach/webhook-event-typesEvent 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"
}
CodeStatusMeaning
validation400The request does not match the documented fields
forbidden403The caller cannot manage company settings
not_found404The endpoint or delivery does not exist
webhook_url_not_allowed422The URL is not public HTTPS, or carries credentials
webhook_endpoint_limit_reached422The company already has 10 endpoints
webhook_endpoint_disabled422Test or re-send on an endpoint that is turned off

On this page