# 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.

Source: https://hubfit.com/developers/webhooks

## Overview

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](https://hubfit.com/developers/api/webhook-endpoints#create-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 `POST`s with `Content-Type: application/json` and `User-Agent: HubFit-Webhooks/1`, signed as [Standard Webhooks](https://www.standardwebhooks.com) describes, so its libraries can verify them too.

**Example request**

```http
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=
```

**Body**

```json
{
  "id": "evt_6ad6af72e7830e73e784a170",
  "type": "check_in_submission.created",
  "createdAt": "2026-10-26T14:32:06.000Z",
  "data": {
    "id": "6ad6af25540e6a26540ffd23",
    "clientId": "6ad6af41d3199442d31b2743"
  }
}
```

## Event types

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.

## Verifying signatures

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's `id`, 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 after `whsec_`. We send one signature, but the header can hold a space-separated list: trust the request if any of them matches.

> **Warning:** 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.

**Verify a signature**

Node.js:

```javascript
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);
  });
}
```

Python:

```python
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 False
```

## Responding to events

Reply 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](https://hubfit.com/developers/rate-limits).

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.

**Express handler**

```javascript
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();
    // ...
  }
});
```

## Retries and failures

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.

## Catching up

To catch up on what you missed while an endpoint was off or your server was down, read the lists:

- [List clients](https://hubfit.com/developers/api/clients#list-clients) and [List package purchases](https://hubfit.com/developers/api/package-purchases#list-package-purchases) from the newest, until one you've seen.
- [List check-in submissions](https://hubfit.com/developers/api/check-in-submissions#list-check-in-submissions) with `status=all` and `submittedAfter`.
- [List completed workouts](https://hubfit.com/developers/api/completed-workouts#list-completed-workouts), as its description says.

## Who manages an endpoint

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.

## URL requirements

- 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.
