Concepts
Webhooks
Get HubFit events, like a client added or a check-in submitted, at your own URL as they happen. Covers event types, Standard Webhooks signatures and retries.
A webhook endpoint is a URL HubFit sends events to as they happen, instead of you polling the API. Add one in Settings > API, or with Add a webhook endpoint and a key with webhooks:write that belongs to the workspace's owner or an admin. A workspace can have up to 10.
An event carries ids, not the record: data.id and the clientId it belongs to. Fetch the record with your own key, which checks it's still yours to read and returns it as it is now.
Requests are POSTs with Content-Type: application/json and User-Agent: HubFit-Webhooks/1, signed as Standard Webhooks describes, so its libraries can verify them too.
POST /hubfit-webhooks HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: HubFit-Webhooks/1
webhook-id: evt_6ad6af72e7830e73e784a170
webhook-timestamp: 1792333926
webhook-signature: v1,6Sg6WEvSdqSIg3H1VxyItzl+JuUMgoUl9uAPe1sSTlk={
"id": "evt_6ad6af72e7830e73e784a170",
"type": "check_in_submission.created",
"createdAt": "2026-10-26T14:32:06.000Z",
"data": {
"id": "6ad6af25540e6a26540ffd23",
"clientId": "6ad6af41d3199442d31b2743"
}
}Each endpoint subscribes to the event types it needs, and only gets those. Every type needs a key with clients:read to fetch its record.
| Event | Sent when |
|---|---|
client.created | A client was added. Sent when a client is added, however they were added (in the app, through the API, by a package purchase or by Zapier). Fetch them with GET /clients/{clientId}. |
check_in_submission.created | A client submitted a check-in. Fetch the submission with GET /check-in-submissions/{id}. |
completed_workout.created | A workout was completed. Sent when a completed workout is saved, whether the client logged it or a coach logged it for them. Edits to it aren't sent. Fetch it with GET /completed-workouts/{id}. |
package_purchase.created | A client bought a package. Sent once the client exists, for a paid or a free package. Fetch the purchase with GET /package-purchases/{id}. It records a purchase, not revenue: renewals and refunds aren't sent. |
questionnaire_submission.created | A client submitted a questionnaire. Fetch the submission with GET /questionnaire-submissions/{id}. |
New event types can appear, and events can gain fields. Reply 2xx to a type you don't handle, or it's retried.
Requests don't come from fixed IP addresses, so check the signature to know one is from HubFit. Each request has three headers:
webhook-id: the event'sid, the same on every retry.webhook-timestamp: when this attempt was sent, in seconds since 1970. Refuse a request more than 5 minutes old.webhook-signature:v1,and the base64 HMAC-SHA256 of{webhook-id}.{webhook-timestamp}.{body}, keyed by the base64-decoded part of your signing secret afterwhsec_. We send one signature, but the header can hold a space-separated list: trust the request if any of them matches.
Sign the body exactly as received. Most frameworks parse JSON for you, and serialising it again changes it and breaks the signature.
The response that creates an endpoint carries its signing secret, the only time the API shows it. In Settings > API, the workspace's owner can copy the secret or rotate it, and rotating stops the old one at once.
import crypto from "node:crypto";
// Pass the raw body: JSON parsed and serialised again won't match the signature.
export function verifyHubFitWebhook(rawBody, headers, secret) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
// Refuse requests more than 5 minutes old, to block replays.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto
.createHmac("sha256", key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest();
// One signature today, but the header can hold several, space-separated.
return (headers["webhook-signature"] ?? "").split(" ").some((entry) => {
const [version, signature] = entry.split(",");
const given = Buffer.from(signature ?? "", "base64");
return version === "v1" && given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}import base64
import hashlib
import hmac
import time
# Pass the raw body: JSON parsed and serialised again won't match the signature.
def verify_hubfit_webhook(raw_body: bytes, headers, secret: str) -> bool:
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
# Refuse requests more than 5 minutes old, to block replays.
if abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
# One signature today, but the header can hold several, space-separated.
for entry in headers.get("webhook-signature", "").split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return FalseReply with a 2xx within 10 seconds, and before you fetch anything: the 10 seconds include your work. Then fetch the record. Fetches count towards your rate limit.
A fetch can answer 404 or 409 if the record or its client has since been deleted or archived, or your key can't reach them: skip that event.
Events can arrive more than once and out of order. Use webhook-id (the event's id) to skip one you already have.
An event doesn't name its workspace, so a receiver for several workspaces needs a URL for each, with a path or query parameter, to know which secret checks it.
import express from "express";
import { verifyHubFitWebhook } from "./verify.js";
const app = express();
const handled = new Set(); // Use your database in production.
app.post("/hubfit-webhooks", express.raw({ type: "application/json" }), async (req, res) => {
if (!verifyHubFitWebhook(req.body.toString(), req.headers, process.env.HUBFIT_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
// Reply first: the 10 seconds HubFit waits include your work.
res.sendStatus(200);
const event = JSON.parse(req.body);
if (handled.has(event.id)) return; // The same id on every retry.
handled.add(event.id);
if (event.type === "check_in_submission.created") {
const response = await fetch(`https://api.hubfit.com/v1/check-in-submissions/${event.data.id}`, {
headers: { Authorization: `Bearer ${process.env.HUBFIT_API_KEY}` },
});
// Deleted, archived or out of the key's reach since: skip it.
if (response.status === 404 || response.status === 409) return;
const { data: submission } = await response.json();
// ...
}
});A request that doesn't get a 2xx within 10 seconds is retried 3 times: after 1 minute, 1 hour and 6 hours. Redirects aren't followed. An event that still fails after the last retry isn't sent again. Settings > API keeps 30 days of deliveries for each endpoint.
A 410 Gone reply, or 5 days without a success, turns the endpoint off. Nothing is recorded while it's off, so turning it back on, in Settings or with status: enabled, doesn't send what you missed.
To catch up on what you missed while an endpoint was off or your server was down, read the lists:
- List clients and List package purchases from the newest, until one you've seen.
- List check-in submissions with
status=allandsubmittedAfter. - List completed workouts, as its description says.
An endpoint lives as long as what made it. One made in Settings belongs to the workspace. One made with a key can only be changed or deleted with that key, and other keys see just the origin of its URL. It's turned off for good if its key is revoked or expires, its coach is removed from the workspace, or its coach is made a Standard member. An integration that needs a new secret deletes its endpoint and creates it again.
An enabled endpoint gets nothing while the workspace is off a plan with webhooks, unpaid or suspended, and nothing from that time once it's back.
- HTTPS on port 443, with TLS 1.2 or later and a certificate from a publicly trusted authority.
- A public domain name written in lowercase, never an IP address, and no user name, password or
#fragment. - An A record: requests go over IPv4 only.
To test while you build, expose your local server with a tunnel such as ngrok or Cloudflare Tunnel, add the tunnel's URL as an endpoint, then act as a test client in the HubFit app: submit a check-in or finish a workout.
