> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fitsociety.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Manage outbound Public API webhook subscriptions and inspect delivery attempts.

Public API webhooks let an integration receive outbound FITsociety events at a
public HTTPS endpoint. These endpoints are management resources and use the same
OAuth Bearer authentication, scopes, idempotency, response envelope, and
rate-limit metadata as other Public API resources.

<Warning>
  `/public/v1/webhooks`, `/public/v1/webhooks/{id}`, and
  `/public/v1/webhooks/deliveries` manage outbound webhook subscriptions.
  Provider receiver routes below `/public/v1/webhooks/*`, such as payment
  provider callbacks or InBody sync triggers, are separate inbound callback
  routes and keep provider-specific authentication.
</Warning>

## Scopes

| Scope                     | Purpose                                                          |
| :------------------------ | :--------------------------------------------------------------- |
| `webhooks:read`           | List and read outbound webhook subscriptions.                    |
| `webhooks:write`          | Create, update, delete, and test outbound webhook subscriptions. |
| `webhook_deliveries:read` | List and inspect outbound webhook delivery attempts.             |

## Events

Webhook subscriptions validate event names against the backend event registry.
Unknown names are rejected on create and update.

### System

| Event               | When it is sent                                                      |
| :------------------ | :------------------------------------------------------------------- |
| `subscription.test` | A test event requested through `POST /public/v1/webhooks/{id}/test`. |

### Members

| Event                          | When it is sent                                              |
| :----------------------------- | :----------------------------------------------------------- |
| `client.created`               | A client was created or linked to the company.               |
| `client.updated`               | Public-safe client profile metadata changed.                 |
| `client.archived`              | A company-client relationship was archived.                  |
| `client.invited`               | A client portal invite was sent.                             |
| `client.portal_access.changed` | A client's portal access status changed.                     |
| `client.relationship.approved` | A client relationship request was approved.                  |
| `client.relationship.rejected` | A client relationship request was rejected.                  |
| `client.note.created`          | A client note was created. Note title/body are not included. |
| `client.note.updated`          | A client note was updated. Note title/body are not included. |
| `client.note.deleted`          | A client note was deleted.                                   |

### Bookings and calendar

| Event                       | When it is sent                                       |
| :-------------------------- | :---------------------------------------------------- |
| `booking.created`           | A booking was created.                                |
| `booking.updated`           | Attendance, timing, or safe booking metadata changed. |
| `booking.cancelled`         | A booking was cancelled after policy handling.        |
| `booking_request.created`   | A booking entered the coach approval flow.            |
| `booking_request.approved`  | A booking request was approved.                       |
| `booking_request.rejected`  | A booking request was rejected.                       |
| `recurring_booking.changed` | A recurring booking was updated or cancelled.         |
| `calendar_task.created`     | A calendar task was created.                          |
| `calendar_task.updated`     | A calendar task was updated.                          |
| `calendar_task.cancelled`   | A calendar task was cancelled.                        |

### Finance

| Event                       | When it is sent                                        |
| :-------------------------- | :----------------------------------------------------- |
| `invoice.created`           | An invoice was created.                                |
| `invoice.sent`              | An invoice copy was sent.                              |
| `invoice.overdue`           | A past-due invoice received a payment reminder.        |
| `invoice.cancelled`         | Reserved for a native invoice cancellation transition. |
| `invoice.paid`              | An invoice moved to paid state.                        |
| `payment.succeeded`         | A payment moved to a successful final state.           |
| `payment.failed`            | A payment moved to a failed final state.               |
| `payment.request.created`   | A payment request was created.                         |
| `payment.request.cancelled` | A payment request was cancelled.                       |
| `chargeback.created`        | A new chargeback was recorded.                         |
| `subscription.changed`      | A membership/subscription was materially changed.      |
| `subscription.cancelled`    | A membership/subscription was cancelled.               |

### Products and credits

| Event                     | When it is sent                            |
| :------------------------ | :----------------------------------------- |
| `client_product.assigned` | A product was assigned to a client.        |
| `client_product.revoked`  | A client product was revoked or cancelled. |
| `credit.assigned`         | Credits were assigned to a client.         |
| `credit.adjusted`         | A client credit balance was adjusted.      |
| `credit.revoked`          | Credits were revoked or expired.           |
| `membership.assigned`     | A membership was assigned to a client.     |

### Forms

| Event               | When it is sent                                                    |
| :------------------ | :----------------------------------------------------------------- |
| `form.assigned`     | A form was assigned to a client.                                   |
| `form.submitted`    | A form was submitted. Answers and media are not included.          |
| `intake.submitted`  | An intake form was submitted. Answers and media are not included.  |
| `checkup.assigned`  | A check-up form was assigned to a client.                          |
| `checkup.submitted` | A check-up form was submitted. Answers and media are not included. |

### Progress

| Event                    | When it is sent                                                         |
| :----------------------- | :---------------------------------------------------------------------- |
| `measurement.created`    | A measurement entry was created. The measurement value is not included. |
| `measurement.updated`    | A measurement entry was updated. The measurement value is not included. |
| `progress_photo.created` | A progress photo was added. Image URLs are not included.                |
| `goal.updated`           | A client goal was updated.                                              |
| `habit_entry.created`    | A habit entry was created. Notes and values are not included.           |

### Documents

| Event               | When it is sent                                                  |
| :------------------ | :--------------------------------------------------------------- |
| `document.linked`   | Document metadata was linked to a client. URLs are not included. |
| `document.updated`  | Document metadata was updated.                                   |
| `document.archived` | A document metadata record was archived.                         |

## Payload contract

Every outbound delivery body has the same top-level shape:

| Field        | Type     | Description                                                |
| :----------- | :------- | :--------------------------------------------------------- |
| `id`         | `string` | Stable webhook event id.                                   |
| `type`       | `string` | Event type, for example `client.created`.                  |
| `occurredAt` | `string` | UTC ISO-8601 timestamp when FITsociety recorded the event. |
| `data`       | `object` | Event-specific public-safe payload.                        |

`data` is intentionally compact. It includes ids, statuses, timestamps, and
changed field names where relevant. It does not include raw model documents.
When an integration needs full detail, call the relevant scoped Public API read
endpoint with the ids from `data`.

<Warning>
  Webhook payloads do not include secrets, access tokens, API keys, raw provider
  responses, request headers, payment provider ids, PDF/download URLs, image or
  media URLs, invoice line items, note bodies, form answers, health notes, free
  text rejection/reason messages, or internal audit/history arrays.
</Warning>

## Endpoints

| Method and path                           | Scope                     | Purpose                                                 |
| :---------------------------------------- | :------------------------ | :------------------------------------------------------ |
| `GET /public/v1/webhooks`                 | `webhooks:read`           | List webhook subscriptions.                             |
| `POST /public/v1/webhooks`                | `webhooks:write`          | Create a webhook subscription.                          |
| `GET /public/v1/webhooks/{id}`            | `webhooks:read`           | Get one webhook subscription.                           |
| `PATCH /public/v1/webhooks/{id}`          | `webhooks:write`          | Update a subscription name, status, URL, or event list. |
| `DELETE /public/v1/webhooks/{id}`         | `webhooks:write`          | Soft-delete a subscription.                             |
| `POST /public/v1/webhooks/{id}/test`      | `webhooks:write`          | Queue a `subscription.test` delivery.                   |
| `GET /public/v1/webhooks/deliveries`      | `webhook_deliveries:read` | List delivery attempts.                                 |
| `GET /public/v1/webhooks/deliveries/{id}` | `webhook_deliveries:read` | Get one delivery attempt.                               |

Write requests require `Idempotency-Key`.

## Target URL validation

Webhook targets must be public HTTPS URLs. The API rejects invalid URLs,
non-HTTPS URLs, local-only hostnames, private IPv4 ranges, loopback addresses,
link-local addresses, and private IPv6 addresses.

## Request validation

| Field                            | Used by                   | Required    | Rule                                                                                     |
| :------------------------------- | :------------------------ | :---------- | :--------------------------------------------------------------------------------------- |
| `name`                           | create, update            | create yes  | Trimmed string, maximum 120 characters.                                                  |
| `url`                            | create, update            | conditional | Public HTTPS URL. Required on create unless `target.url` is supplied.                    |
| `target.type`                    | create, update            | no          | Only `url` is supported.                                                                 |
| `target.url`                     | create, update            | conditional | Public HTTPS URL. Alternative to top-level `url`.                                        |
| `targetType`                     | create                    | no          | Legacy alias for target type. Only `url` is supported.                                   |
| `events[]`                       | create, update            | create yes  | Non-empty array of supported event names from the registry. Unknown events are rejected. |
| `status`                         | update                    | no          | `active` or `disabled`.                                                                  |
| `id` path parameter              | get, update, delete, test | yes         | Webhook subscription ID belonging to the authenticated company and not deleted.          |
| `subscriptionId` query parameter | list deliveries           | no          | Optional subscription filter. Invalid IDs return an empty delivery list.                 |

The authenticated company is always derived from the Bearer token. Request
payloads never accept `companyId`, `createdByCoachId`, secret hashes, retry
counters, delivery timestamps, or signing-secret metadata.

## Subscription response fields

List responses:

| Field                                   | Type      | Nullable | Description                                                                     |
| :-------------------------------------- | :-------- | :------- | :------------------------------------------------------------------------------ |
| `data.page`                             | integer   | no       | Current page.                                                                   |
| `data.limit`                            | integer   | no       | Page size after cap, maximum `100`.                                             |
| `data.total`                            | integer   | no       | Total matching subscriptions.                                                   |
| `data.totalPages`                       | integer   | no       | Total pages.                                                                    |
| `data.hasNextPage`                      | boolean   | no       | Next page availability.                                                         |
| `data.hasPrevPage`                      | boolean   | no       | Previous page availability.                                                     |
| `data.subscriptions[].id`               | string    | no       | Webhook subscription ID.                                                        |
| `data.subscriptions[].name`             | string    | no       | Subscription name.                                                              |
| `data.subscriptions[].status`           | string    | no       | `active` or `disabled`. Deleted subscriptions are excluded from list responses. |
| `data.subscriptions[].events[]`         | string\[] | no       | Subscribed event names.                                                         |
| `data.subscriptions[].target.type`      | string    | no       | Target type, currently `url`.                                                   |
| `data.subscriptions[].target.url`       | string    | no       | Public HTTPS target URL.                                                        |
| `data.subscriptions[].secret.prefix`    | string    | no       | Signing-secret prefix metadata.                                                 |
| `data.subscriptions[].secret.last4`     | string    | no       | Signing-secret last four characters.                                            |
| `data.subscriptions[].secret.rotatedAt` | string    | yes      | Last secret rotation timestamp.                                                 |
| `data.subscriptions[].lastDeliveryAt`   | string    | yes      | Last delivery attempt timestamp.                                                |
| `data.subscriptions[].lastSuccessAt`    | string    | yes      | Last successful delivery timestamp.                                             |
| `data.subscriptions[].lastFailureAt`    | string    | yes      | Last failed delivery timestamp.                                                 |
| `data.subscriptions[].createdAt`        | string    | yes      | Creation timestamp.                                                             |
| `data.subscriptions[].updatedAt`        | string    | yes      | Last update timestamp.                                                          |

Single subscription responses from create, get, update, and delete:

| Field                                | Type      | Nullable | Description                                                |
| :----------------------------------- | :-------- | :------- | :--------------------------------------------------------- |
| `data.subscription.id`               | string    | no       | Webhook subscription ID.                                   |
| `data.subscription.name`             | string    | no       | Subscription name.                                         |
| `data.subscription.status`           | string    | no       | `active`, `disabled`, or `deleted` after delete.           |
| `data.subscription.events[]`         | string\[] | no       | Subscribed event names.                                    |
| `data.subscription.target.type`      | string    | no       | Target type, currently `url`.                              |
| `data.subscription.target.url`       | string    | no       | Public HTTPS target URL.                                   |
| `data.subscription.secret.prefix`    | string    | no       | Signing-secret prefix metadata.                            |
| `data.subscription.secret.last4`     | string    | no       | Signing-secret last four characters.                       |
| `data.subscription.secret.rotatedAt` | string    | yes      | Last secret rotation timestamp.                            |
| `data.subscription.signingSecret`    | string    | no       | One-time signing secret returned only by create responses. |
| `data.subscription.lastDeliveryAt`   | string    | yes      | Last delivery attempt timestamp.                           |
| `data.subscription.lastSuccessAt`    | string    | yes      | Last successful delivery timestamp.                        |
| `data.subscription.lastFailureAt`    | string    | yes      | Last failed delivery timestamp.                            |
| `data.subscription.createdAt`        | string    | yes      | Creation timestamp.                                        |
| `data.subscription.updatedAt`        | string    | yes      | Last update timestamp.                                     |

## Create a webhook

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://api.fitsociety.io/public/v1/webhooks" \
    -H "Authorization: Bearer $FITSOCIETY_ACCESS_TOKEN" \
    -H "Idempotency-Key: webhook-create-crm-sync-20260714" \
    -H "Content-Type: application/json" \
    -d '{
      "name": "CRM sync",
      "url": "https://example.com/fitsociety/webhooks",
      "events": ["client.created", "client.updated"]
    }'
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch("https://api.fitsociety.io/public/v1/webhooks", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.FITSOCIETY_ACCESS_TOKEN}`,
      "Idempotency-Key": "webhook-create-crm-sync-20260714",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: "CRM sync",
      url: "https://example.com/fitsociety/webhooks",
      events: ["client.created", "client.updated"],
    }),
  });

  const body = await response.json();
  if (response.status === 201) {
    // signingSecret is returned exactly once. Store it securely.
    console.log("Store this signing secret:", body.data.subscription.signingSecret);
  }
  ```

  ```python Python theme={null}
  import os
  import requests

  response = requests.post(
      "https://api.fitsociety.io/public/v1/webhooks",
      headers={
          "Authorization": f"Bearer {os.environ['FITSOCIETY_ACCESS_TOKEN']}",
          "Idempotency-Key": "webhook-create-crm-sync-20260714",
      },
      json={
          "name": "CRM sync",
          "url": "https://example.com/fitsociety/webhooks",
          "events": ["client.created", "client.updated"],
      },
  )
  response.raise_for_status()

  body = response.json()
  if response.status_code == 201:
      # signingSecret is returned exactly once. Store it securely.
      print("Store this signing secret:", body["data"]["subscription"]["signingSecret"])
  ```
</CodeGroup>

The create response includes `data.subscription.signingSecret` exactly once.
Store it securely. Later list and detail responses only return secret metadata:
`prefix`, `last4`, and `rotatedAt`.

```json theme={null}
{
  "data": {
    "subscription": {
      "id": "66f7b8b1e13c8d25f4d3d90a",
      "name": "CRM sync",
      "status": "active",
      "events": ["client.created", "client.updated"],
      "target": {
        "type": "url",
        "url": "https://example.com/fitsociety/webhooks"
      },
      "secret": {
        "prefix": "whsec_12",
        "last4": "9abc",
        "rotatedAt": "2026-07-14T09:30:00.000Z"
      },
      "signingSecret": "whsec_0123456789abcdefghijklmnopqrstuvwxyz",
      "lastDeliveryAt": null,
      "lastSuccessAt": null,
      "lastFailureAt": null,
      "createdAt": "2026-07-14T09:30:00.000Z",
      "updatedAt": "2026-07-14T09:30:00.000Z"
    }
  },
  "meta": {
    "requestId": "req_0123456789abcdef",
    "rateLimit": {
      "limit": 10,
      "remaining": 9,
      "resetSeconds": 1
    }
  }
}
```

## Update, delete, and test responses

`PATCH /public/v1/webhooks/{id}` and `DELETE /public/v1/webhooks/{id}` return
`data.subscription.id`, `name`, `status`, `events[]`, `target.type`,
`target.url`, `secret.prefix`, `secret.last4`, `secret.rotatedAt`,
`lastDeliveryAt`, `lastSuccessAt`, `lastFailureAt`, `createdAt`, and
`updatedAt`. Delete is a soft delete: it sets
`data.subscription.status = deleted` and removes the subscription from future
list/get responses.

`POST /public/v1/webhooks/{id}/test` queues a `subscription.test` event only
for an active subscription.

Test response fields:

| Field                     | Type      | Nullable | Description                       |
| :------------------------ | :-------- | :------- | :-------------------------------- |
| `data.test.eventId`       | string    | no       | Created webhook event ID.         |
| `data.test.deliveryIds[]` | string\[] | no       | Delivery IDs queued for dispatch. |

## Signing headers

FITsociety sends each outbound delivery as an HTTP `POST` with JSON body and
these headers:

| Header                           | Description                                               |
| :------------------------------- | :-------------------------------------------------------- |
| `X-FITsociety-Webhook-Event`     | Event type, for example `client.created`.                 |
| `X-FITsociety-Webhook-Delivery`  | Delivery id. Use this for idempotent receiver processing. |
| `X-FITsociety-Webhook-Timestamp` | Unix timestamp in seconds.                                |
| `X-FITsociety-Webhook-Signature` | `v1=<hex_hmac_sha256>` signature.                         |

The signature uses the one-time signing secret and this signed string:

```txt theme={null}
<timestamp>.<deliveryId>.<eventId>.<canonical_payload_json>
```

The payload JSON is canonicalized by sorting object keys recursively and keeping
array order. Receivers should reject stale timestamps and compare the computed
signature in constant time.

Outbound payloads use this shape:

```json theme={null}
{
  "id": "66f7b8b1e13c8d25f4d3d92a",
  "type": "client.created",
  "occurredAt": "2026-07-14T09:31:00.000Z",
  "data": {
    "clientId": "66f7b8b1e13c8d25f4d3d93a"
  }
}
```

## Verifying webhook signatures

Verify every delivery before processing it:

1. Read `X-FITsociety-Webhook-Timestamp`, `X-FITsociety-Webhook-Delivery`, and
   `X-FITsociety-Webhook-Signature`. The event id is the `id` field of the JSON
   body; it is not sent as a header.
2. Reject stale timestamps. The examples below allow 5 minutes of clock skew.
3. Canonicalize the parsed JSON body by recursively sorting object keys while
   keeping array order. Do not sign the raw request bytes: FITsociety signs the
   canonical form, and the transmitted JSON is not key-sorted.
4. Rebuild the signed string
   `<timestamp>.<deliveryId>.<eventId>.<canonical_payload_json>`, compute
   `HMAC-SHA256` with your stored signing secret, prefix the hex digest with
   `v1=`, and compare against the signature header in constant time.

The `stableJson` helpers below reproduce the canonicalization FITsociety uses
when signing. Payload values are object ids, statuses, ISO timestamps,
booleans, nulls, and integers, so both implementations produce identical
canonical strings.

<CodeGroup>
  ```javascript JavaScript (Express) theme={null}
  const crypto = require("crypto");
  const express = require("express");

  const app = express();
  app.use(express.json());

  // The one-time signingSecret (whsec_...) from the webhook create response.
  const WEBHOOK_SECRET = process.env.FITSOCIETY_WEBHOOK_SECRET;
  const TOLERANCE_SECONDS = 300;

  // Canonical JSON: recursively sort object keys, keep array order.
  // This matches FITsociety's stableJson exactly.
  function stableJson(value) {
    if (value === null || typeof value !== "object") {
      return JSON.stringify(value);
    }
    if (Array.isArray(value)) {
      return `[${value.map((item) => stableJson(item)).join(",")}]`;
    }
    return `{${Object.keys(value)
      .sort()
      .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
      .join(",")}}`;
  }

  app.post("/fitsociety/webhooks", (req, res) => {
    const timestamp = req.get("X-FITsociety-Webhook-Timestamp") || "";
    const deliveryId = req.get("X-FITsociety-Webhook-Delivery") || "";
    const signatureHeader = req.get("X-FITsociety-Webhook-Signature") || "";
    const eventId = req.body?.id || "";

    // 1. Reject stale or missing timestamps to limit replay windows.
    const nowSeconds = Math.floor(Date.now() / 1000);
    if (
      !/^\d+$/.test(timestamp) ||
      Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS
    ) {
      return res.status(400).send("stale or missing timestamp");
    }

    // 2. Rebuild the signed string:
    //    timestamp.deliveryId.eventId.stableJson(payload)
    const signedPayload = [
      timestamp,
      deliveryId,
      eventId,
      stableJson(req.body),
    ].join(".");
    const expected = `v1=${crypto
      .createHmac("sha256", WEBHOOK_SECRET)
      .update(signedPayload)
      .digest("hex")}`;

    // 3. Compare in constant time.
    const expectedBuffer = Buffer.from(expected);
    const receivedBuffer = Buffer.from(signatureHeader);
    const valid =
      expectedBuffer.length === receivedBuffer.length &&
      crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
    if (!valid) {
      return res.status(401).send("invalid signature");
    }

    // 4. Deduplicate on the delivery id, then acknowledge with a 2xx quickly.
    console.log(
      `Verified ${req.get("X-FITsociety-Webhook-Event")} delivery ${deliveryId}`,
    );
    return res.status(200).json({ ok: true });
  });

  app.listen(3000);
  ```

  ```python Python (Flask) theme={null}
  import hashlib
  import hmac
  import json
  import os
  import time

  from flask import Flask, jsonify, request

  app = Flask(__name__)

  # The one-time signingSecret (whsec_...) from the webhook create response.
  WEBHOOK_SECRET = os.environ["FITSOCIETY_WEBHOOK_SECRET"]
  TOLERANCE_SECONDS = 300


  def stable_json(value):
      """Canonical JSON: recursively sort object keys, keep array order.

      This matches FITsociety's stableJson exactly.
      """
      if isinstance(value, dict):
          return (
              "{"
              + ",".join(
                  json.dumps(key, ensure_ascii=False, separators=(",", ":"))
                  + ":"
                  + stable_json(value[key])
                  for key in sorted(value.keys())
              )
              + "}"
          )
      if isinstance(value, list):
          return "[" + ",".join(stable_json(item) for item in value) + "]"
      return json.dumps(value, ensure_ascii=False, separators=(",", ":"))


  @app.post("/fitsociety/webhooks")
  def fitsociety_webhook():
      timestamp = request.headers.get("X-FITsociety-Webhook-Timestamp", "")
      delivery_id = request.headers.get("X-FITsociety-Webhook-Delivery", "")
      signature_header = request.headers.get("X-FITsociety-Webhook-Signature", "")
      payload = request.get_json(silent=True) or {}
      event_id = payload.get("id", "")

      # 1. Reject stale or missing timestamps to limit replay windows.
      if (
          not timestamp.isdigit()
          or abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS
      ):
          return "stale or missing timestamp", 400

      # 2. Rebuild the signed string:
      #    timestamp.deliveryId.eventId.stableJson(payload)
      signed_payload = ".".join(
          [timestamp, delivery_id, event_id, stable_json(payload)]
      )
      expected = "v1=" + hmac.new(
          WEBHOOK_SECRET.encode("utf-8"),
          signed_payload.encode("utf-8"),
          hashlib.sha256,
      ).hexdigest()

      # 3. Compare in constant time.
      if not hmac.compare_digest(expected, signature_header):
          return "invalid signature", 401

      # 4. Deduplicate on the delivery id, then acknowledge with a 2xx quickly.
      event_type = request.headers.get("X-FITsociety-Webhook-Event", "")
      print(f"Verified {event_type} delivery {delivery_id}")
      return jsonify(ok=True), 200
  ```
</CodeGroup>

Retried deliveries are re-signed at send time with a fresh
`X-FITsociety-Webhook-Timestamp`, so a timestamp-freshness check does not
reject legitimate retries. If signature verification keeps failing, confirm
that you stored the full one-time `signingSecret` (including the `whsec_`
prefix) and that you canonicalize the parsed body instead of hashing the raw
request bytes.

## Deliveries and retries

A delivery is successful only when the target returns an HTTP `2xx` response.
FITsociety stores the response status, a redacted response body preview, and the
last error summary for debugging.

| Status       | Meaning                                                     |
| :----------- | :---------------------------------------------------------- |
| `pending`    | Delivery was created and has not been attempted yet.        |
| `delivering` | Delivery attempt is in progress.                            |
| `delivered`  | Target returned HTTP `2xx`.                                 |
| `failed`     | The last attempt failed and another retry may be scheduled. |
| `exhausted`  | The delivery reached the maximum attempts.                  |

Each delivery has `maxAttempts: 3`. Failed attempts are retried with exponential
backoff, starting at 1 minute and then 2 minutes, with a 1 hour cap for future
retry schedules. `nextAttemptAt` is `null` after success or exhaustion.

## List deliveries

<CodeGroup>
  ```bash cURL theme={null}
  curl -H "Authorization: Bearer $FITSOCIETY_ACCESS_TOKEN" \
    "https://api.fitsociety.io/public/v1/webhooks/deliveries?subscriptionId=66f7b8b1e13c8d25f4d3d90a&page=1&limit=50"
  ```

  ```javascript JavaScript theme={null}
  const query = new URLSearchParams({
    subscriptionId: "66f7b8b1e13c8d25f4d3d90a",
    page: "1",
    limit: "50",
  });

  const response = await fetch(
    `https://api.fitsociety.io/public/v1/webhooks/deliveries?${query}`,
    {
      headers: {
        Authorization: `Bearer ${process.env.FITSOCIETY_ACCESS_TOKEN}`,
      },
    },
  );

  const body = await response.json();
  for (const delivery of body.data.deliveries) {
    console.log(delivery.id, delivery.eventType, delivery.status);
  }
  ```

  ```python Python theme={null}
  import os
  import requests

  response = requests.get(
      "https://api.fitsociety.io/public/v1/webhooks/deliveries",
      headers={"Authorization": f"Bearer {os.environ['FITSOCIETY_ACCESS_TOKEN']}"},
      params={
          "subscriptionId": "66f7b8b1e13c8d25f4d3d90a",
          "page": 1,
          "limit": 50,
      },
  )
  response.raise_for_status()

  body = response.json()
  for delivery in body["data"]["deliveries"]:
      print(delivery["id"], delivery["eventType"], delivery["status"])
  ```
</CodeGroup>

Delivery list and detail responses use the Public API envelope and include
`meta.rateLimit` when the request passed through the Public API rate limiter.

Delivery list response fields:

| Field                                   | Type    | Nullable | Description                                                     |
| :-------------------------------------- | :------ | :------- | :-------------------------------------------------------------- |
| `data.page`                             | integer | no       | Current page.                                                   |
| `data.limit`                            | integer | no       | Page size after cap, maximum `100`.                             |
| `data.total`                            | integer | no       | Total matching deliveries.                                      |
| `data.totalPages`                       | integer | no       | Total pages.                                                    |
| `data.hasNextPage`                      | boolean | no       | Next page availability.                                         |
| `data.hasPrevPage`                      | boolean | no       | Previous page availability.                                     |
| `data.deliveries[].id`                  | string  | no       | Delivery ID.                                                    |
| `data.deliveries[].subscriptionId`      | string  | no       | Webhook subscription ID.                                        |
| `data.deliveries[].eventId`             | string  | no       | Webhook event ID.                                               |
| `data.deliveries[].eventType`           | string  | no       | Event type.                                                     |
| `data.deliveries[].status`              | string  | no       | `pending`, `delivering`, `delivered`, `failed`, or `exhausted`. |
| `data.deliveries[].attemptCount`        | integer | no       | Attempt count.                                                  |
| `data.deliveries[].maxAttempts`         | integer | no       | Maximum attempts.                                               |
| `data.deliveries[].target.type`         | string  | no       | Target type, currently `url`.                                   |
| `data.deliveries[].target.url`          | string  | no       | Public HTTPS target URL.                                        |
| `data.deliveries[].responseStatusCode`  | integer | yes      | Last HTTP response status code.                                 |
| `data.deliveries[].responseBodyPreview` | string  | no       | Redacted response body preview.                                 |
| `data.deliveries[].errorMessage`        | string  | no       | Redacted last error summary.                                    |
| `data.deliveries[].nextAttemptAt`       | string  | yes      | Next retry timestamp.                                           |
| `data.deliveries[].deliveredAt`         | string  | yes      | Delivered timestamp.                                            |
| `data.deliveries[].lastAttemptAt`       | string  | yes      | Last attempt timestamp.                                         |
| `data.deliveries[].createdAt`           | string  | yes      | Creation timestamp.                                             |
| `data.deliveries[].updatedAt`           | string  | yes      | Last update timestamp.                                          |

Delivery detail response fields:

| Field                               | Type    | Nullable | Description                                                     |
| :---------------------------------- | :------ | :------- | :-------------------------------------------------------------- |
| `data.delivery.id`                  | string  | no       | Delivery ID.                                                    |
| `data.delivery.subscriptionId`      | string  | no       | Webhook subscription ID.                                        |
| `data.delivery.eventId`             | string  | no       | Webhook event ID.                                               |
| `data.delivery.eventType`           | string  | no       | Event type.                                                     |
| `data.delivery.status`              | string  | no       | `pending`, `delivering`, `delivered`, `failed`, or `exhausted`. |
| `data.delivery.attemptCount`        | integer | no       | Attempt count.                                                  |
| `data.delivery.maxAttempts`         | integer | no       | Maximum attempts.                                               |
| `data.delivery.target.type`         | string  | no       | Target type, currently `url`.                                   |
| `data.delivery.target.url`          | string  | no       | Public HTTPS target URL.                                        |
| `data.delivery.responseStatusCode`  | integer | yes      | Last HTTP response status code.                                 |
| `data.delivery.responseBodyPreview` | string  | no       | Redacted response body preview.                                 |
| `data.delivery.errorMessage`        | string  | no       | Redacted last error summary.                                    |
| `data.delivery.nextAttemptAt`       | string  | yes      | Next retry timestamp.                                           |
| `data.delivery.deliveredAt`         | string  | yes      | Delivered timestamp.                                            |
| `data.delivery.lastAttemptAt`       | string  | yes      | Last attempt timestamp.                                         |
| `data.delivery.createdAt`           | string  | yes      | Creation timestamp.                                             |
| `data.delivery.updatedAt`           | string  | yes      | Last update timestamp.                                          |

```json theme={null}
{
  "data": {
    "page": 1,
    "limit": 50,
    "total": 1,
    "totalPages": 1,
    "hasNextPage": false,
    "hasPrevPage": false,
    "deliveries": [
      {
        "id": "66f7b8b1e13c8d25f4d3d91a",
        "subscriptionId": "66f7b8b1e13c8d25f4d3d90a",
        "eventId": "66f7b8b1e13c8d25f4d3d92a",
        "eventType": "client.created",
        "status": "delivered",
        "attemptCount": 1,
        "maxAttempts": 3,
        "target": {
          "type": "url",
          "url": "https://example.com/fitsociety/webhooks"
        },
        "responseStatusCode": 200,
        "responseBodyPreview": "{\"ok\":true}",
        "errorMessage": "",
        "nextAttemptAt": null,
        "deliveredAt": "2026-07-14T09:31:01.000Z",
        "lastAttemptAt": "2026-07-14T09:31:01.000Z",
        "createdAt": "2026-07-14T09:31:00.000Z",
        "updatedAt": "2026-07-14T09:31:01.000Z"
      }
    ]
  },
  "meta": {
    "requestId": "req_0123456789abcdef",
    "rateLimit": {
      "limit": 10,
      "remaining": 8,
      "resetSeconds": 1
    }
  }
}
```
