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 bulkPOST …/deliveries/{delivery_id}/resend— restart the ladder for one deliveryPOST …/rotate-secret— rotatewhsec_with a dual-signature overlap window
Reference
The 10 webhook operations.
| Operation | Endpoint |
|---|---|
| List webhook endpoints | GET /v1/webhook-endpoints |
| Create a webhook endpoint | POST /v1/webhook-endpoints |
| Delete a webhook endpoint | DELETE /v1/webhook-endpoints/{endpoint_id} |
| Get a webhook endpoint | GET /v1/webhook-endpoints/{endpoint_id} |
| Update a webhook endpoint | PATCH /v1/webhook-endpoints/{endpoint_id} |
| List deliveries | GET /v1/webhook-endpoints/{endpoint_id}/deliveries |
| Resend a delivery | POST /v1/webhook-endpoints/{endpoint_id}/deliveries/{delivery_id}/resend |
| Replay failed deliveries | POST /v1/webhook-endpoints/{endpoint_id}/replay |
| Rotate the signing secret | POST /v1/webhook-endpoints/{endpoint_id}/rotate-secret |
| Send a test event | 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.
API documentation
BioFlow REST API docs: API-key auth, 22 operations across pages, contacts, files, analytics and webhooks — RFC 9457 errors, rate limits, and idempotency.
invalid_request
The request body or parameters failed validation. What causes the BioFlow API's invalid_request error (HTTP 400) and how to fix it.