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.
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.createdA 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.publishedA 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.paidA 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.refundedA 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.testSent 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."
}
}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.
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.
// SDK source: https://github.com/DevinoSolutions/bioflow-sdk
// (npm package @getbioflow/sdk is landing shortly — build from source today)
import { verifyWebhook } from "@getbioflow/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
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");
}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.
Up to 8 attempts per delivery — only a 2xx response counts as delivered. Delay after each failed attempt (±20% jitter):
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 — rotate whsec_ with a dual-signature overlap window| 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.