BioFlow
API

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.

{
  "id": "whmsg_evt_abc123",
  "type": "contact.created",
  "created_at": "2026-07-20T12:00:00.000Z",
  "data": {
    "contact": {
      "id": "ct_abc123",
      "email": "[email protected]",
      "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.

{
  "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).

{
  "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": "[email protected]",
      "created_at": "2026-07-20T12:00:00.000Z"
    }
  }
}

sale.refunded

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

{
  "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": "[email protected]",
      "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.

{
  "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. Creation sends a signed endpoint.test event synchronously; your endpoint must answer 2xx or creation fails with 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

// 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)

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.

OperationEndpoint
List webhook endpointsGET /v1/webhook-endpoints
Create a webhook endpointPOST /v1/webhook-endpoints
Delete a webhook endpointDELETE /v1/webhook-endpoints/{endpoint_id}
Get a webhook endpointGET /v1/webhook-endpoints/{endpoint_id}
Update a webhook endpointPATCH /v1/webhook-endpoints/{endpoint_id}
List deliveriesGET /v1/webhook-endpoints/{endpoint_id}/deliveries
Resend a deliveryPOST /v1/webhook-endpoints/{endpoint_id}/deliveries/{delivery_id}/resend
Replay failed deliveriesPOST /v1/webhook-endpoints/{endpoint_id}/replay
Rotate the signing secretPOST /v1/webhook-endpoints/{endpoint_id}/rotate-secret
Send a test eventPOST /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.

On this page