Webhooks

Subscribe to events, verify signatures, and handle deliveries safely

Hand off to an LLM

bem can deliver every terminal event from a function — a successful transformation, an extraction error, a classification — to an HTTPS endpoint you control. This page walks through the full flow: enabling signatures, creating a subscription, and verifying deliveries in your receiver.

Concepts

There are two ways to receive webhooks from bem:

  1. A subscription binds a function to a webhook URL. Whenever that function produces a terminal event, bem POSTs the event JSON to the URL. The HTTP body is the event itself — no envelope. This page walks through subscriptions end-to-end.
  2. A Send function is a workflow node whose job is to deliver the upstream payload to a destination — including a webhook URL. Use Send functions when delivery should be part of the workflow graph (mid-pipeline, behind a Classify branch, fanned out to multiple destinations, or with a reshaped payload). The wire format and signature verification are identical to subscription deliveries; the rest of this page applies to both.

When a webhook signing secret is active, every delivery — subscription or Send — includes a bem-signature header you can use to confirm the request really came from bem (and that the body wasn't modified in transit). You should always have one active.

Step 1: Generate a signing secret

curl -X POST https://api.bem.ai/v3/webhook-secret \
  -H "x-api-key: $BEM_API_KEY"

The response contains the secret in plaintext. This is the only time it's shown — store it in your secrets manager immediately.

To rotate later, call the same endpoint again. To avoid downtime, update your verification logic to accept either the old or the new secret for a minute or two before revoking the old one.

Step 2: Subscribe a function to a URL

Subscriptions still live on the legacy v1-alpha surface and work against V3 functions:

curl -X POST https://api.bem.ai/v1-alpha/subscriptions \
  -H "Content-Type: application/json" \
  -H "x-api-key: $BEM_API_KEY" \
  -d '{
    "functionName": "invoice-extractor",
    "url": "https://your-app.example.com/webhooks/bem"
  }'

You can subscribe to any number of functions, and a single function can fan out to multiple URLs. Subscriptions trigger on terminal events only — intermediate function calls within a workflow don't fire on their own; the workflow's terminal nodes do.

Step 3: Build the receiver

The header looks like this:

bem-signature: t=1492774577,v1=0734be64d748aa8e8ee9dfe87407665541f2c33f9b0ebf19dfd0dd80f08f504c

t is a Unix timestamp. v1 is the hex-encoded HMAC-SHA256 of {t}.{raw_request_body} using your signing secret as the key.

To verify:

  1. Read the raw request body — not a re-serialized JSON object. Re-serialization can reorder keys or change spacing and break the signature.
  2. Parse bem-signature into t and v1.
  3. Compute HMAC-SHA256("{t}.{rawBody}", secret) and hex-encode it.
  4. Compare against v1 with a constant-time comparison.
  5. Reject if t is more than ~5 minutes old (replay protection).

Node.js (Express)

import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.BEM_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 5 * 60;

// Capture the raw body — Express's json() parser would mutate it.
app.post(
  "/webhooks/bem",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.header("bem-signature") ?? "";
    const parts = Object.fromEntries(
      sig.split(",").map((p) => p.split("=", 2))
    );
    const { t, v1 } = parts;
    if (!t || !v1) return res.status(400).send("missing signature");

    const ageSeconds = Math.floor(Date.now() / 1000) - Number(t);
    if (Math.abs(ageSeconds) > TOLERANCE_SECONDS) {
      return res.status(400).send("timestamp out of tolerance");
    }

    const expected = crypto
      .createHmac("sha256", SECRET)
      .update(`${t}.${req.body.toString("utf8")}`)
      .digest("hex");

    if (
      expected.length !== v1.length ||
      !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
    ) {
      return res.status(401).send("invalid signature");
    }

    const event = JSON.parse(req.body.toString("utf8"));
    // ack quickly, do real work async
    res.status(204).end();
    handleEvent(event).catch(console.error);
  }
);

Python (FastAPI)

import hmac, hashlib, os, time
from fastapi import FastAPI, Header, HTTPException, Request

app = FastAPI()
SECRET = os.environ["BEM_WEBHOOK_SECRET"]
TOLERANCE_SECONDS = 5 * 60


@app.post("/webhooks/bem")
async def bem_webhook(request: Request, bem_signature: str = Header(...)):
    raw = await request.body()
    parts = dict(p.split("=", 1) for p in bem_signature.split(","))
    t = parts.get("t")
    v1 = parts.get("v1")
    if not t or not v1:
        raise HTTPException(400, "missing signature")
    if abs(int(time.time()) - int(t)) > TOLERANCE_SECONDS:
        raise HTTPException(400, "timestamp out of tolerance")

    signed = f"{t}.{raw.decode('utf-8')}".encode()
    expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, v1):
        raise HTTPException(401, "invalid signature")

    event = await request.json()
    # ack quickly, schedule real work
    return {"ok": True}

Go (net/http)

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

const toleranceSeconds = 5 * 60

func bemWebhook(secret string) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        body, err := io.ReadAll(r.Body)
        if err != nil {
            http.Error(w, "read failed", http.StatusBadRequest)
            return
        }

        parts := map[string]string{}
        for _, p := range strings.Split(r.Header.Get("bem-signature"), ",") {
            kv := strings.SplitN(p, "=", 2)
            if len(kv) == 2 {
                parts[kv[0]] = kv[1]
            }
        }
        t, v1 := parts["t"], parts["v1"]
        if t == "" || v1 == "" {
            http.Error(w, "missing signature", http.StatusBadRequest)
            return
        }

        ts, err := strconv.ParseInt(t, 10, 64)
        if err != nil || abs64(time.Now().Unix()-ts) > toleranceSeconds {
            http.Error(w, "timestamp out of tolerance", http.StatusBadRequest)
            return
        }

        mac := hmac.New(sha256.New, []byte(secret))
        mac.Write([]byte(t + "." + string(body)))
        expected := hex.EncodeToString(mac.Sum(nil))

        if !hmac.Equal([]byte(expected), []byte(v1)) {
            http.Error(w, "invalid signature", http.StatusUnauthorized)
            return
        }

        // ack quickly, do real work async
        w.WriteHeader(http.StatusNoContent)
        go handleEvent(body)
    }
}

func abs64(x int64) int64 {
    if x < 0 {
        return -x
    }
    return x
}

func main() {
    http.HandleFunc("/webhooks/bem", bemWebhook(os.Getenv("BEM_WEBHOOK_SECRET")))
    http.ListenAndServe(":8080", nil)
}

Best practices

  • Acknowledge fast. Return a 2xx within a few seconds. Do the real work after the response is sent.
  • Be idempotent. Dedupe by eventID — the same event can be redelivered after a network failure.
  • Accept both old and new during rotation. Store two valid secrets briefly, then revoke the old one once you've seen at least one delivery signed with the new one.
  • Reject anything older than your tolerance window. Replay attacks become easier the longer you accept stale timestamps.
  • Don't trust the body until verified. Parse JSON.parse(rawBody) only after the signature check passes.

What's in the body

The body is the event itself — same shape you'd get from GET /v3/outputs/{eventID} or GET /v3/errors/{eventID}. Inspect eventType to discriminate (transform, route/classify, split, join, etc.) and read the polymorphic payload accordingly.

Delivering from inside a workflow

If you'd rather make delivery part of the workflow graph, drop a Send function into the workflow as a node. A Send function configured with destinationType: "webhook" POSTs to its webhookUrl whenever an upstream node feeds it a payload — the wire format and bem-signature header are identical to a subscription delivery, so the receivers above work unchanged.

Reach for Send functions when:

  • Delivery should depend on the workflow shape (e.g. one webhook for invoices, another for receipts, branched off a Classify).
  • You need to fan out the same payload to several destinations.
  • You want to reshape the payload (chain a Payload Shaping node before the Send) or route it via S3/Google Drive instead of a webhook.

Reach for subscriptions when:

  • The rule is "every event from this function goes to this URL," with no workflow context to express.
  • You want delivery to happen automatically without modifying the workflow graph.

Both can coexist on the same function — a Send node inside a workflow and a subscription on the same function will both fire.

On this page