Skip to main content
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

MethodPathDescription
POST/api/v1/webhook-subscriptionsCreate a subscription.
GET/api/v1/webhook-subscriptionsList 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

Status: Live · Auth: Bearer or HMAC · Idempotent: No
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.
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"]
  }'
{
  "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"
  }
}

Request body

url
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.
events
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.
version
string
Subscription payload version label. Defaults to the current stable value (2026-04-01).

Response fields

id
string
Stable subscription id. Also emitted in the X-Webhook-Subscription-Id header on every delivery.
secret
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.
is_active
boolean
Whether this subscription is currently enabled for delivery. Update via PATCH.

GET /api/v1/webhook-subscriptions

Status: Live · Auth: Bearer or HMAC · Idempotent: Yes (read-only)
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.
200 OK
{
  "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/

Status: Live · Auth: Bearer or HMAC · Idempotent: Yes (read-only)
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/

Update one or more fields. Only the fields you include are modified.
Request
{
  "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:
FieldNotes
urlReplaces the destination URL. Must be HTTPS.
eventsReplaces the event allowlist. Use [] for all events.
versionReplaces the subscription version label.
is_activeEnables or disables delivery. Setting true also clears the failure counter.

DELETE /api/v1/webhook-subscriptions/

Permanently delete a subscription. Deliveries in-flight may still complete. Returns 200 OK with the standard envelope:
200 OK
{
  "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).
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)
}
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.
CaseBehavior
Matching subscription succeeds with 2xxEvent delivery is marked delivered.
Matching subscription returns 4xxNot retried for that delivery. The event is marked failed if no matching subscription succeeded.
Matching subscription returns 5xx or cannot be reachedChert makes up to 3 total delivery attempts with short exponential backoff before recording the final failure.
Subscription URL is https://dev-inboxNo 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>.
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

SymptomCauseFix
Signature always mismatchesVerifying against your tenant signing_secretUse the per-subscription secret returned by POST /webhook-subscriptions
Signature mismatches intermittentlyJSON middleware re-serialized the body before your verifier saw itCapture the raw body bytes before any parser runs
Signature mismatches after a clock changeServer clock skewed > 5 min from ChertSync via NTP; we reject anything outside ±300 s
Header value parses as undefinedReading X-Webhook-Signature case-sensitively in lowercase-normalising frameworksRead x-webhook-signature (lowercase) — most Node/Python frameworks normalise this way

See also