# Webhooks

BioFlow outbound webhooks: Standard Webhooks v1 signing, an event catalog, verification snippets, retry schedule, auto-disable ladder, and replay.

BioFlow POSTs signed events to your endpoint when things happen in a workspace —
a lead captured, a page published, a sale paid or refunded. Signing follows
Standard Webhooks v1, retries ride an exponential ladder, and everything is
manageable over the API: create, test, rotate, resend, replay.

## Events

Every delivery is a JSON envelope `{ id, type, created_at, data }`. The `id`
(`whmsg_…`) is stable across retries — delivery is at-least-once, so use it as
your dedup key.

### `contact.created`

A contact/lead is captured: newsletter signup, form submission, or gated download.

```json
{
  "id": "whmsg_evt_abc123",
  "type": "contact.created",
  "created_at": "2026-07-20T12:00:00.000Z",
  "data": {
    "contact": {
      "id": "ct_abc123",
      "email": "fan@example.com",
      "name": "Alex Fan",
      "source": "NEWSLETTER",
      "source_block_id": "blk_abc123",
      "created_at": "2026-07-20T12:00:00.000Z"
    }
  }
}
```

### `page.published`

A bio page goes live — an immediate publish or a scheduled one firing.

```json
{
  "id": "whmsg_evt_def456",
  "type": "page.published",
  "created_at": "2026-07-20T12:00:00.000Z",
  "data": {
    "page": {
      "id": "pg_abc123",
      "title": "My links",
      "slug": "maya",
      "published_at": "2026-07-20T12:00:00.000Z"
    }
  }
}
```

### `sale.paid`

A tip or digital-product sale completes (Stripe checkout succeeded).

```json
{
  "id": "whmsg_evt_ghi789",
  "type": "sale.paid",
  "created_at": "2026-07-20T12:00:00.000Z",
  "data": {
    "sale": {
      "id": "sl_abc123",
      "kind": "PRODUCT",
      "gross_amount_cents": 1500,
      "net_amount_cents": 1425,
      "currency": "usd",
      "customer_email": "buyer@example.com",
      "created_at": "2026-07-20T12:00:00.000Z"
    }
  }
}
```

### `sale.refunded`

A sale is refunded (Stripe `charge.refunded`). Same sale object as `sale.paid`.

```json
{
  "id": "whmsg_evt_jkl012",
  "type": "sale.refunded",
  "created_at": "2026-07-20T12:00:00.000Z",
  "data": {
    "sale": {
      "id": "sl_abc123",
      "kind": "PRODUCT",
      "gross_amount_cents": 1500,
      "net_amount_cents": 1425,
      "currency": "usd",
      "customer_email": "buyer@example.com",
      "created_at": "2026-07-20T12:00:00.000Z"
    }
  }
}
```

### `endpoint.test`

Sent synchronously at endpoint creation, on URL change, and on `POST …/test` — never persisted, bypasses your event-type filters.

```json
{
  "id": "whmsg_test_9b1c...",
  "type": "endpoint.test",
  "created_at": "2026-07-20T12:00:00.000Z",
  "data": {
    "message": "BioFlow webhook verification — reply 2xx to confirm this endpoint."
  }
}
```

### Subscribing

Create an endpoint with the event types you want — `POST /v1/webhook-endpoints`
or the [developer console](https://app.getbioflow.com/dashboard/settings/developers). Creation sends a signed
`endpoint.test` event synchronously; your endpoint must answer 2xx or creation
fails with
[`endpoint_verification_failed`](/docs/api/errors/endpoint-verification-failed).
The signing secret (`whsec_…`) is shown once, on creation.

## Signing

### The wire contract

Three headers ride every delivery:

```
webhook-id: whmsg_evt_abc123
webhook-timestamp: 1784548800
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pCPT...
```

The signature is HMAC-SHA256 over `${id}.${timestamp}.${rawBody}`, keyed with
the base64-decoded bytes after the `whsec_` prefix, base64-encoded. During
secret rotation the header carries two space-separated `v1,` entries —
verifying with either secret succeeds. Timestamp tolerance is 300 seconds.

### Verify with the SDK

```ts
// npm install @bioflow/sdk   (source: https://github.com/DevinoSolutions/bioflow-node)
import { isKnownWebhookEvent, verifyWebhook } from "@bioflow/sdk";

// IMPORTANT: pass the RAW request body — verify first, JSON.parse never.
export async function POST(request: Request) {
  const rawBody = await request.text();
  const event = verifyWebhook({
    payload: rawBody,
    headers: request.headers,
    secret: process.env.BIOFLOW_WEBHOOK_SECRET!, // whsec_...
  }); // throws WebhookVerificationError on ANY failure

  // New event types ship without a major SDK bump, so the event union is
  // OPEN. Narrow with the guard to reach the typed payloads; the else branch
  // is a real BioFlow event that is simply newer than your SDK.
  if (!isKnownWebhookEvent(event)) {
    console.log("unhandled event type", event.type);
    return new Response("ok");
  }

  switch (event.type) {
    case "contact.created":
      console.log("new contact", event.data.contact.email);
      break;
    case "sale.paid":
      console.log("sale", event.data.sale.gross_amount_cents);
      break;
  }
  return new Response("ok");
}
```

### Verify with Node.js crypto (no dependency)

```js
import { createHmac, timingSafeEqual } from "node:crypto";

/** Standard Webhooks v1 — verify BEFORE parsing, over the raw bytes. */
function verifyBioFlowWebhook(input) {
  const { id, timestamp, signatureHeader, rawBody, secret } = input;
  // 1. Reject stale timestamps (300s tolerance).
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  // 2. HMAC-SHA256 over "id.timestamp.rawBody", keyed with the
  //    base64-DECODED bytes after the whsec_ prefix.
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`, "utf8")
    .digest("base64");
  // 3. The header holds space-separated "v1,<base64>" entries (two during
  //    secret rotation) — constant-time compare against each.
  return signatureHeader.split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) return false;
    const a = Buffer.from(signature);
    const b = Buffer.from(expected);
    return a.length === b.length && timingSafeEqual(a, b);
  });
}
```

Verify over the RAW request bytes — parsing and re-serializing the JSON before
verifying will break the signature.

## Reliability

### Retry schedule

Up to 8 attempts per delivery — only a 2xx response counts
as delivered. Delay after each failed attempt (±20% jitter):

- attempt 1 fails → retry 30 seconds later
- attempt 2 fails → retry 2 minutes later
- attempt 3 fails → retry 10 minutes later
- attempt 4 fails → retry 1 hour later
- attempt 5 fails → retry 4 hours later
- attempt 6 fails → retry 12 hours later
- attempt 7 fails → retry 24 hours later

### Auto-disable at 20 consecutive failures

20 consecutive failed deliveries disable the
endpoint and notify the workspace owner. Fix your endpoint, re-enable it with
`PATCH /v1/webhook-endpoints/{endpoint_id}` `{"enabled": true}`, then bring
back what you missed:

- `POST …/replay` — re-enqueue failed deliveries in bulk
- `POST …/deliveries/{delivery_id}/resend` — restart the ladder for one delivery
- `POST …/rotate-secret` — rotate `whsec_` with a dual-signature overlap window

## Reference

The 10 webhook operations.

| Operation | Endpoint |
| --- | --- |
| [List webhook endpoints](/docs/api-reference/listWebhookEndpoints) | `GET /v1/webhook-endpoints` |
| [Create a webhook endpoint](/docs/api-reference/createWebhookEndpoint) | `POST /v1/webhook-endpoints` |
| [Delete a webhook endpoint](/docs/api-reference/deleteWebhookEndpoint) | `DELETE /v1/webhook-endpoints/{endpoint_id}` |
| [Get a webhook endpoint](/docs/api-reference/getWebhookEndpoint) | `GET /v1/webhook-endpoints/{endpoint_id}` |
| [Update a webhook endpoint](/docs/api-reference/updateWebhookEndpoint) | `PATCH /v1/webhook-endpoints/{endpoint_id}` |
| [List deliveries](/docs/api-reference/listWebhookDeliveries) | `GET /v1/webhook-endpoints/{endpoint_id}/deliveries` |
| [Resend a delivery](/docs/api-reference/resendWebhookDelivery) | `POST /v1/webhook-endpoints/{endpoint_id}/deliveries/{delivery_id}/resend` |
| [Replay failed deliveries](/docs/api-reference/replayWebhookDeliveries) | `POST /v1/webhook-endpoints/{endpoint_id}/replay` |
| [Rotate the signing secret](/docs/api-reference/rotateWebhookSecret) | `POST /v1/webhook-endpoints/{endpoint_id}/rotate-secret` |
| [Send a test event](/docs/api-reference/testWebhookEndpoint) | `POST /v1/webhook-endpoints/{endpoint_id}/test` |

Webhook scopes are `webhooks:read` and `webhooks:write`. Endpoint URLs must be
public HTTPS — private-network targets are rejected. Back to the
[API docs hub](/docs/api).
