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

# Webhook subscriptions

> Manage webhook endpoints programmatically.

Webhook subscriptions let you register, update, and delete webhook endpoints via the API. Each subscription has its own secret and can filter to a specific set of event types. You can keep multiple active subscriptions for the same tenant.

## Endpoints

| Method   | Path                                 | Description                |
| -------- | ------------------------------------ | -------------------------- |
| `POST`   | `/api/v1/webhook-subscriptions`      | Create a subscription.     |
| `GET`    | `/api/v1/webhook-subscriptions`      | List all subscriptions.    |
| `GET`    | `/api/v1/webhook-subscriptions/{id}` | Get a single subscription. |
| `PATCH`  | `/api/v1/webhook-subscriptions/{id}` | Update a subscription.     |
| `DELETE` | `/api/v1/webhook-subscriptions/{id}` | Delete a subscription.     |

***

## POST /api/v1/webhook-subscriptions

<Note>
  **Status:** Live · **Auth:** Bearer or HMAC · **Idempotent:** No
</Note>

**What this does.** Validates that `url` is an HTTPS URL, generates a 32-byte random `secret`, and creates a webhook subscription. Returns the full subscription including `secret` (shown once — store immediately). Subsequent GET responses never include the secret. Multiple subscriptions per tenant are supported; each has its own secret and event filter.

Create a new webhook subscription. The `secret` is shown **once** in the response — save it immediately.

<RequestExample>
  ```bash curl theme={null}
  curl -X POST https://console.trychert.com/api/v1/webhook-subscriptions \
    -H "x-chert-tenant: $SLUG" \
    -H "x-chert-signature: v1,$TS,$SIG" \
    -H "content-type: application/json" \
    -d '{
      "url": "https://your-app.example/webhooks/chert",
      "events": ["message.received"]
    }'
  ```
</RequestExample>

<ResponseExample>
  ```json 201 Created theme={null}
  {
    "subscription": {
      "id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f",
      "url": "https://your-app.example/webhooks/chert",
      "events": ["message.received"],
      "version": "2026-04-01",
      "is_active": true,
      "created_at": "2026-05-01T12:00:00Z",
      "secret": "1a2b3c4d5e6f7081a2b3c4d5e6f70819aabbccddeeff00112233445566778899"
    }
  }
  ```
</ResponseExample>

### Request body

<ParamField body="url" type="string" required>
  HTTPS URL that Chert will POST events to. Must be publicly reachable. For local development, use `https://dev-inbox` to record events without sending outbound HTTP.
</ParamField>

<ParamField body="events" type="string[]">
  Event types to subscribe to. Omit this field, or send an empty array, to receive all events. Use `message.received` for real-time inbound replies. Legacy `lead_reply` is still available for summary-style reply notifications; reaction events may appear as `reaction.added` and `reaction.removed`.
</ParamField>

<ParamField body="version" type="string">
  Subscription payload version label. Defaults to the current stable value (`2026-04-01`).
</ParamField>

### Response fields

<ResponseField name="id" type="string">
  Stable subscription id. Also emitted in the `X-Webhook-Subscription-Id` header on every delivery.
</ResponseField>

<ResponseField name="secret" type="string">
  Per-subscription HMAC secret. **Shown once.** Store it securely and use it to verify the `X-Webhook-Signature` (or `x-chert-signature`) header on inbound deliveries. See [Verifying signatures](/api/receiving-replies#verifying-the-signature).
</ResponseField>

<ResponseField name="is_active" type="boolean">
  Whether this subscription is currently enabled for delivery. Update via `PATCH`.
</ResponseField>

***

## GET /api/v1/webhook-subscriptions

<Note>
  **Status:** Live · **Auth:** Bearer or HMAC · **Idempotent:** Yes (read-only)
</Note>

**What this does.** Returns all webhook subscriptions for your tenant, ordered newest first. `secret` is never included.

List all subscriptions for your tenant, ordered newest first. `secret` is **not** included in list or get responses.

```json 200 OK theme={null}
{
  "subscriptions": [
    {
      "id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f",
      "url": "https://your-app.example/webhooks/chert",
      "events": ["message.received"],
      "version": "2026-04-01",
      "is_active": true,
      "consecutive_failures": 0,
      "last_success_at": "2026-05-01T13:42:11Z",
      "last_failure_at": null,
      "created_at": "2026-05-01T12:00:00Z",
      "updated_at": "2026-05-01T13:42:11Z"
    }
  ]
}
```

`updated_at` reflects the last metadata change (URL, events, is\_active, etc).

***

## GET /api/v1/webhook-subscriptions/{id}

<Note>
  **Status:** Live · **Auth:** Bearer or HMAC · **Idempotent:** Yes (read-only)
</Note>

**What this does.** Row lookup by `id` scoped to your tenant. Same shape as a list row.

Return a single subscription. Same shape as a list row.

***

## PATCH /api/v1/webhook-subscriptions/{id}

Update one or more fields. Only the fields you include are modified.

```json Request theme={null}
{
  "url": "https://your-app.example/webhooks/chert-v2",
  "events": ["message.received"],
  "is_active": true
}
```

Patch `"is_active": true` to enable a subscription.

Fields accepted by `PATCH`:

| Field       | Notes                                                                         |
| ----------- | ----------------------------------------------------------------------------- |
| `url`       | Replaces the destination URL. Must be HTTPS.                                  |
| `events`    | Replaces the event allowlist. Use `[]` for all events.                        |
| `version`   | Replaces the subscription version label.                                      |
| `is_active` | Enables or disables delivery. Setting `true` also clears the failure counter. |

***

## DELETE /api/v1/webhook-subscriptions/{id}

Permanently delete a subscription. Deliveries in-flight may still complete. Returns `200 OK` with the standard envelope:

```json 200 OK theme={null}
{
  "deleted": true,
  "id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f"
}
```

***

## Signature verification

Each subscription generates its **own** secret, returned **once** in the `POST` response. Use that subscription `secret` to verify deliveries — **not** your tenant `signing_secret`. The two are different values.

Every delivery carries both signature headers — verify whichever your stack reads more easily. Both compute the same HMAC:

```
HMAC-SHA256(subscription_secret, "<unix_ts>.<raw_body>")
```

The separator between timestamp and body is a **period (`.`)**, not a colon. The body is the exact raw bytes Chert sent — do not parse-and-re-serialize before verifying (JSON middleware that mutates whitespace will silently invalidate the signature).

<CodeGroup>
  ```js Node.js (Express) theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto"

  // IMPORTANT: capture the raw body BEFORE any JSON middleware parses it.
  // app.use("/webhooks/chert", express.raw({ type: "application/json" }))

  export function verifyChertWebhook(rawBody, headers, subscriptionSecret) {
    // Prefer the modern header; fall back to the legacy one.
    const header =
      headers["x-webhook-signature"] || headers["x-chert-signature"] || ""

    const modern = /^t=(\d+),v1=([a-f0-9]+)$/i.exec(header)
    const legacy = /^v1,(\d+),([a-f0-9]+)$/i.exec(header)
    const m = modern || legacy
    if (!m) return false

    const ts = Number(m[1])
    const provided = m[2].toLowerCase()

    // 5-minute replay window.
    if (Math.abs(Date.now() / 1000 - ts) > 300) return false

    const expected = createHmac("sha256", subscriptionSecret)
      .update(`${ts}.${rawBody}`)        // <-- period, not colon
      .digest("hex")

    const a = Buffer.from(provided, "hex")
    const b = Buffer.from(expected, "hex")
    return a.length === b.length && timingSafeEqual(a, b)
  }
  ```

  ```python Python (Flask) theme={null}
  import hmac, hashlib, re, time

  # IMPORTANT: read request.get_data() — NOT request.get_json() —
  # so you verify against the exact bytes Chert sent.

  def verify_chert_webhook(raw_body: bytes, headers, subscription_secret: str) -> bool:
      header = headers.get("X-Webhook-Signature") or headers.get("x-chert-signature") or ""
      m = re.match(r"^t=(\d+),v1=([a-f0-9]+)$", header) \
          or re.match(r"^v1,(\d+),([a-f0-9]+)$", header)
      if not m:
          return False
      ts, provided = int(m.group(1)), m.group(2).lower()
      if abs(time.time() - ts) > 300:
          return False
      expected = hmac.new(
          subscription_secret.encode(),
          f"{ts}.".encode() + raw_body,    # <-- period, then raw bytes
          hashlib.sha256,
      ).hexdigest()
      return hmac.compare_digest(provided, expected)
  ```

  ```bash curl (debug only) theme={null}
  # Sanity-check a single delivery from your logs.
  TS=1715000000                                # from X-Webhook-Timestamp
  BODY='{"event":"message.received",...}'      # the exact bytes received
  HEX_FROM_HEADER=abcdef...                    # the v1=... portion

  EXPECTED=$(printf '%s.%s' "$TS" "$BODY" \
    | openssl dgst -sha256 -hmac "$SUBSCRIPTION_SECRET" -hex \
    | awk '{print $2}')

  [ "$EXPECTED" = "$HEX_FROM_HEADER" ] && echo "ok" || echo "mismatch"
  ```
</CodeGroup>

The `X-Webhook-Subscription-Id` header identifies which subscription triggered the delivery, useful when multiple subscriptions point to the same endpoint with different secrets. This is a webhook delivery header from Chert to your endpoint; it is separate from `x-chert-tenant`, which your application sends when calling Chert APIs.

## Delivery and replay

Chert records every event before outbound delivery. If multiple active subscriptions match an event, Chert attempts each destination and records one delivery attempt per subscription on the event row.

| Case                                                     | Behavior                                                                                                       |
| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| Matching subscription succeeds with `2xx`                | Event delivery is marked `delivered`.                                                                          |
| Matching subscription returns `4xx`                      | Not retried for that delivery. The event is marked failed if no matching subscription succeeded.               |
| Matching subscription returns `5xx` or cannot be reached | Chert makes up to 3 total delivery attempts with short exponential backoff before recording the final failure. |
| Subscription URL is `https://dev-inbox`                  | No outbound POST is attempted; the event is stored for `GET /api/v1/dev-inbox`.                                |

Use `GET /api/v1/events` to inspect delivery status and `POST /api/v1/events/{id}/replay` to re-deliver a stored event. To replay to one subscription, pass `?subscription_id=<subscription_id>`.

```bash theme={null}
curl -X POST "https://console.trychert.com/api/v1/events/$EVENT_ROW_ID/replay?subscription_id=$SUBSCRIPTION_ID" \
  -H "Authorization: Bearer $CHERT_SIGNING_SECRET"
```

### Common pitfalls

| Symptom                                   | Cause                                                                              | Fix                                                                                     |
| ----------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| Signature always mismatches               | Verifying against your tenant `signing_secret`                                     | Use the per-subscription `secret` returned by `POST /webhook-subscriptions`             |
| Signature mismatches intermittently       | JSON middleware re-serialized the body before your verifier saw it                 | Capture the raw body bytes before any parser runs                                       |
| Signature mismatches after a clock change | Server clock skewed > 5 min from Chert                                             | Sync via NTP; we reject anything outside ±300 s                                         |
| Header value parses as undefined          | Reading `X-Webhook-Signature` case-sensitively in lowercase-normalising frameworks | Read `x-webhook-signature` (lowercase) — most Node/Python frameworks normalise this way |

***

## See also

* [Receiving replies](/api/receiving-replies)
* [Authentication](/api/authentication)
* [Events](/api/events)
* [Errors](/api/errors)
