Skip to content

Webhooks

Bridge can POST an event to your server whenever something happens in your app: a workspace is created (a workspace, also called a tenant, is one customer account in your app), a user joins, a subscription changes, a payment fails, a quota runs out. You configure the receiving endpoint in Control Center (Bridge’s admin dashboard) on your app’s profile: the webhookUrl, an on/off switch, and an event filter that whitelists which event types you want delivered. Only event types you have subscribed to in the filter are sent.

This page covers the delivery model (envelope, signature, retries) and the REST endpoints for managing the webhook substrate: listing event types, inspecting and replaying deliveries, and rotating the signing secret.

Every webhook is a POST to your webhookUrl with a JSON body in this shape:

{
  "id": "evt_9f8e7d6c-5b4a-3f2e-1d0c-9b8a7f6e5d4c",
  "type": "subscription.plan_changed",
  "created": 1751875200,
  "app_id": "624c14cc0c01e70033356280",
  "workspace_id": "624c14cc0c01e70033356285",
  "data": {
    "workspaceId": "ws_2abc123",
    "fromPlan": "free",
    "toPlan": "pro"
  }
}

| Field | Type | Description | |---|---|---| | id | string | Unique envelope ID (evt_ prefix). Use it as your idempotency key: retries reuse the same ID, replays get a fresh one | | type | string | The event type (see Event types) | | created | number | Unix timestamp (seconds) when the envelope was built | | app_id | string | Your app’s ID | | workspace_id | string? | The workspace the event concerns, when applicable | | data | object | Event-specific payload |

Each request also carries these headers:

| Header | Description | |---|---| | Bridge-Signature | HMAC signature, see below | | Bridge-Event-Id | Same as the envelope id | | Bridge-Event-Type | Same as the envelope type | | Content-Type | application/json |

Legacy envelope retired. The old { "events": [{ "type": "TENANT_CREATED", ... }] } batch envelope and its SCREAMING_CASE event names are no longer sent. Consumers must handle the envelope above with the dot-separated event types listed on this page.

The Bridge-Signature header is Stripe-style:

Bridge-Signature: t=1751875200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

v1 is HMAC-SHA256(secret, "<t>.<raw body>") in hex, where secret is your app’s webhook signing secret (a whsec_... value shown in Control Center and returned by rotate secret). Signing the timestamp together with the body means a captured request cannot be replayed later: reject signatures whose t is more than 5 minutes from your current time, and compare digests with a constant-time comparison.

import { createHmac, timingSafeEqual } from 'crypto';

function verify(secret: string, header: string, rawBody: string): boolean {
  const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=')));
  const ageMs = Math.abs(Date.now() - Number(parts.t) * 1000);
  if (ageMs > 5 * 60 * 1000) return false;

  const expected = createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex');
  const a = Buffer.from(parts.v1, 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}

Compute the HMAC over the raw request body bytes, before any JSON parsing or re-serialization.

Respond with any 2xx status within 10 seconds to acknowledge a delivery. Anything else (non-2xx, timeout, connection error) counts as a failed attempt, and Bridge retries with backoff:

| After attempt | Next retry in | |---|---| | 1 | 1 minute | | 2 | 5 minutes | | 3 | 30 minutes | | 4 | 2 hours | | 5 | 12 hours | | 6 | 24 hours | | 7 | none: the delivery is parked as dlq |

A delivery makes at most 7 attempts, then lands in the dead-letter queue (status: "dlq") where you can replay it manually. Pending deliveries are swept about once a minute, and each delivery’s target URL is snapshotted at dispatch time, so changing your webhookUrl does not retarget in-flight retries.

If 50 deliveries in a row exhaust their retries, Bridge automatically disables the webhook on your app and raises an operator alert. Re-enable it in Control Center once your endpoint is healthy, then replay what you missed.

Deliveries are retried independently, so events can arrive out of order and (on replay or edge cases) more than once. Deduplicate on the envelope id and use created to order.

The canonical catalogue is served by GET /webhooks/event-types. Current types:

| Type | Category | Fired when | |---|---|---| | tenant.created | tenant | A new workspace was created in this app | | tenant.updated | tenant | Workspace fields (name, plan, metadata) were updated | | tenant.deleted | tenant | A workspace was deleted | | tenant_user.created | tenant_user | A user joined a workspace (invite accepted or signup) | | tenant_user.updated | tenant_user | A workspace user was updated (role change, profile, etc.) | | tenant_user.deleted | tenant_user | A user left a workspace or was removed by an admin | | subscription.created | billing | A new subscription was created for the workspace | | subscription.plan_changed | billing | The workspace switched plans (upgrade or downgrade) | | subscription.trial_started | billing | The workspace started a trial on a paid plan | | subscription.trial_ended | billing | The trial period elapsed without conversion | | subscription.past_due | billing | A renewal payment failed and the subscription is past due | | subscription.reactivated | billing | A cancel-at-period-end subscription was reactivated | | subscription.canceled | billing | The subscription was canceled; endsAt in data is when access ends | | payment.succeeded | billing | A renewal or one-off invoice was paid successfully | | payment.failed | billing | A renewal payment failed; pastDueReason drives messaging | | quota.exhausted | billing | A metered quota crossed 100% of the plan limit | | entitlements.changed | billing | The workspace entitlement set changed | | usage.drift_resolved | billing | Reconciliation detected Bridge/Stripe usage drift and auto-healed it | | usage.drift_unrecoverable | billing | Reconciliation exhausted retries healing drift; investigate manually |

Two high-frequency SDK events, quota.updated and usage.recorded, are never delivered over webhooks. They exist only on the realtime SDK channel, so they cannot flood your endpoint.


Authentication. These endpoints act on your app’s own webhook configuration and authenticate the same way as Usage & Quotas: a signed-in user’s access token plus your app ID:

Authorization: Bearer <USER_ACCESS_TOKEN>
x-app-id: <YOUR_APP_ID>

The appId path parameters must match the app in the caller’s context; a mismatch returns HTTP 400.

The full catalogue of event types Bridge can deliver, with a sample data payload for each. Control Center renders its subscription checklist from this list; use it to build your own event-filter UI or to keep consumer code in sync.

GET https://api.thebridge.dev/webhooks/event-types

{ "events": [...] } where each entry is:

| Field | Type | Description | |---|---|---| | type | string | The event type, e.g. subscription.canceled | | category | string | tenant, tenant_user, or billing | | description | string | Human-readable description | | samplePayload | object | Illustrative example of the envelope’s data field |

Request example

curl --request GET 'https://api.thebridge.dev/webhooks/event-types' \
--header 'Authorization: Bearer USER_ACCESS_TOKEN' \
--header 'x-app-id: YOUR_APP_ID'

Response example (truncated):

{
  "events": [
    {
      "type": "tenant.created",
      "category": "tenant",
      "description": "A new workspace was created in this app.",
      "samplePayload": { "id": "ws_2abc123", "name": "Acme Inc.", "plan": "pro" }
    },
    {
      "type": "payment.failed",
      "category": "billing",
      "description": "A renewal payment failed. Use `pastDueReason` to drive customer-facing messaging.",
      "samplePayload": {
        "workspaceId": "ws_2abc123",
        "pastDueReason": "card_declined",
        "cardLast4": "4242"
      }
    }
  ]
}

The 20 most recent delivery records for your app, newest first, including the full payload that was (or will be) POSTed. Use this to debug a misbehaving receiver or to find dlq rows to replay.

GET https://api.thebridge.dev/webhooks/deliveries/APP_ID

Path Parameters

ParameterTypeRequiredDescription
APP_IDstringRequiredYour application ID. Must match the app in the caller's context

An array of delivery records:

| Field | Type | Description | |---|---|---| | id | string | The delivery record’s ID (use it to replay) | | eventId | string | The envelope id | | eventType | string | The envelope type | | targetUrl | string | URL snapshotted at dispatch time | | status | string | pending, delivered, failed, or dlq | | attempt | number | Current attempt number (1 to 7) | | responseCode | number? | HTTP status from the last attempt, or null | | lastError | string? | Error from the last failed attempt, or null | | nextRetryAt | string? | When the next attempt is due, or null | | deliveredAt | string? | When the delivery succeeded, or null | | createdAt | string | When the delivery was enqueued | | payload | object | The full envelope |

Request example

curl --request GET 'https://api.thebridge.dev/webhooks/deliveries/YOUR_APP_ID' \
--header 'Authorization: Bearer USER_ACCESS_TOKEN' \
--header 'x-app-id: YOUR_APP_ID'

Response example:

[
  {
    "id": "665f2c9e6d1b4f4e9d1a6a30",
    "eventId": "evt_9f8e7d6c-5b4a-3f2e-1d0c-9b8a7f6e5d4c",
    "eventType": "subscription.plan_changed",
    "targetUrl": "https://your-app.example.com/bridge/webhook",
    "status": "delivered",
    "attempt": 1,
    "responseCode": 200,
    "lastError": null,
    "nextRetryAt": null,
    "deliveredAt": "2026-07-07T08:00:03.000Z",
    "createdAt": "2026-07-07T08:00:00.000Z",
    "payload": {
      "id": "evt_9f8e7d6c-5b4a-3f2e-1d0c-9b8a7f6e5d4c",
      "type": "subscription.plan_changed",
      "created": 1751875200,
      "app_id": "624c14cc0c01e70033356280",
      "workspace_id": "624c14cc0c01e70033356285",
      "data": { "workspaceId": "ws_2abc123", "fromPlan": "free", "toPlan": "pro" }
    }
  }
]

Re-issue a delivery from scratch. The original record is preserved for audit; the replay creates a new delivery starting at attempt 1, with a fresh envelope id (prefixed evt_replay_) and a fresh created timestamp, sent to your current webhook configuration. Typically used on dlq rows after fixing your receiver.

POST https://api.thebridge.dev/webhooks/deliveries/DELIVERY_ID/replay

Path Parameters

ParameterTypeRequiredDescription
DELIVERY_IDstringRequiredThe delivery record's id from the deliveries list. Must belong to the caller's app

| Field | Type | Description | |---|---|---| | ok | boolean | Always true | | deliveryId | string | ID of the newly created delivery record |

Returns HTTP 404 when the delivery does not exist.

Request example

curl --request POST 'https://api.thebridge.dev/webhooks/deliveries/665f2c9e6d1b4f4e9d1a6a30/replay' \
--header 'Authorization: Bearer USER_ACCESS_TOKEN' \
--header 'x-app-id: YOUR_APP_ID'

Response example:

{
  "ok": true,
  "deliveryId": "665f2c9e6d1b4f4e9d1a6a45"
}

Generate a new webhook signing secret for your app. The previous secret remains valid for a 5-minute grace window, so you can deploy the new secret to your receiver without dropping in-flight deliveries. Store the returned secret immediately; it is what you verify Bridge-Signature against.

POST https://api.thebridge.dev/webhooks/secrets/rotate/APP_ID

Path Parameters

ParameterTypeRequiredDescription
APP_IDstringRequiredYour application ID. Must match the app in the caller's context

| Field | Type | Description | |---|---|---| | secret | string | The new signing secret (whsec_...) | | rotatedAt | string | ISO-8601 timestamp of the rotation |

Request example

curl --request POST 'https://api.thebridge.dev/webhooks/secrets/rotate/YOUR_APP_ID' \
--header 'Authorization: Bearer USER_ACCESS_TOKEN' \
--header 'x-app-id: YOUR_APP_ID'

Response example:

{
  "secret": "whsec_9c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d",
  "rotatedAt": "2026-07-07T08:12:00.000Z"
}