A signed webhook starts a scrape and a brief
A routine with a b3os webhook trigger scrapes the URL a signed delivery names and briefs it; a repeat starts nothing and a wrong signature gets 401.
Any service that can send a signed POST can start work on the node, with no server of yours in between. The routine
has one webhook trigger with verify.scheme: b3os and two steps: vision/scrape@1 reads the URL the delivery
names, and infer/chat@1 says in one sentence what the page offers. Publishing answers with the hook's URL and its
signing secret, once; the sender signs "<unix seconds>.<raw body>" with HMAC-SHA256 and sends it as
B3OS-Signature: t=<seconds>,v1=<hex>. The hook URL takes no API key: the signature is the authentication.
The recipe also checks the two things a sender's retries and a stranger's guesses run into: the same delivery sent
again is answered with the first run's ID and starts nothing (deduplicated: true), and a delivery signed with any
other secret is refused with 401 webhook_signature_invalid. The hook's delivery log lists all three.
yamltriggers: - type: webhook id: inbound verify: scheme: b3os with: url: ${{ trigger.body.url }}
Verified 2026-11-01: this recipe's last run passed against https://beta-api.b3os.org, label live-recorded (recorded from production), from commit 771a9508. The recipe harness runs it again, and a failed run takes this page out of the cookbook.
Run it
bashAPI=https://beta-api.b3os.orgAUTH="Authorization: Bearer $B3OS_API_KEY"NAME=webhook-briefcurl -s -X PUT "$API/v1/routines/$NAME" -H "$AUTH" -H "Content-Type: application/json" -d '{ "name": "webhook-brief", "inputs": {"type": "object", "required": ["url"], "properties": {"url": {"type": "string"}}}, "triggers": [{"type": "webhook", "id": "inbound", "verify": {"scheme": "b3os"}, "with": {"url": "${{ trigger.body.url }}"}}], "steps": [ {"id": "page", "uses": "vision/scrape@1", "with": {"url": "${{ inputs.url }}", "formats": ["markdown"]}}, {"id": "brief", "uses": "infer/chat@1", "needs": ["page"], "with": { "model": "b3os/qwen3.5-9b", "max_tokens": 120, "temperature": 0, "chat_template_kwargs": {"enable_thinking": false}, "messages": [ {"role": "system", "content": "In one plain sentence of at most 30 words, say what this web page offers."}, {"role": "user", "content": "${{ steps.page.outputs.markdown }}"}]}}], "output": {"brief": "${{ steps.brief.outputs.choices[0].message.content }}"}}' > published.jsonHOOK_URL=$(jq -r '.triggers[0].hook.url' published.json)HOOK_SECRET=$(jq -r '.triggers[0].hook.secret' published.json)BODY='{"url": "https://beta.b3os.org/demo/pricing/"}'T=$(date +%s)SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$HOOK_SECRET" | sed 's/^.* //')RUN=$(curl -s "$HOOK_URL" -H "Content-Type: application/json" -H "B3OS-Signature: t=$T,v1=$SIG" -d "$BODY" \ | jq -r .run_id)sleep 2curl -s "$API/v1/routines/runs/$RUN" -H "$AUTH" | jq '{status, result}'curl -s -X DELETE "$API/v1/routines/$NAME" -H "$AUTH" > /dev/null
tsimport { signWebhook } from "@b3os/sdk/webhooks";const API = "https://beta-api.b3os.org";const headers = { authorization: `Bearer ${process.env.B3OS_API_KEY}`, "content-type": "application/json" };const call = async (method: string, path: string, body?: unknown): Promise<any> => (await fetch(`${API}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) })).json();const published = await call("PUT", "/v1/routines/webhook-brief", { name: "webhook-brief", inputs: { type: "object", required: ["url"], properties: { url: { type: "string" } } }, triggers: [{ type: "webhook", id: "inbound", verify: { scheme: "b3os" }, with: { url: "${{ trigger.body.url }}" } }], steps: [ { id: "page", uses: "vision/scrape@1", with: { url: "${{ inputs.url }}", formats: ["markdown"] } }, { id: "brief", uses: "infer/chat@1", needs: ["page"], with: { model: "b3os/qwen3.5-9b", max_tokens: 120, temperature: 0, chat_template_kwargs: { enable_thinking: false }, messages: [ { role: "system", content: "In one plain sentence of at most 30 words, say what this web page offers." }, { role: "user", content: "${{ steps.page.outputs.markdown }}" }, ], }, }, ], output: { brief: "${{ steps.brief.outputs.choices[0].message.content }}" },});const hook = published.triggers[0].hook;try { // What the sender does: sign the raw body with the secret and post it. No API key. const body = JSON.stringify({ url: "https://beta.b3os.org/demo/pricing/" }); const delivery: any = await ( await fetch(hook.url, { method: "POST", headers: { "content-type": "application/json", "B3OS-Signature": await signWebhook(body, hook.secret) }, body, }) ).json(); let run = await call("GET", `/v1/routines/runs/${delivery.run_id}`); while (!["succeeded", "failed", "cancelled", "timed_out"].includes(run.status)) { await new Promise((r) => setTimeout(r, 200)); run = await call("GET", `/v1/routines/runs/${delivery.run_id}`); } console.log(run.status, run.result.brief);} finally { await call("DELETE", "/v1/routines/webhook-brief");}
pythonimport jsonimport osimport timeimport requestsfrom b3os import sign_webhookAPI = "https://beta-api.b3os.org"s = requests.Session()s.headers["Authorization"] = f"Bearer {os.environ['B3OS_API_KEY']}"published = s.put(f"{API}/v1/routines/webhook-brief", json={ "name": "webhook-brief", "inputs": {"type": "object", "required": ["url"], "properties": {"url": {"type": "string"}}}, "triggers": [{"type": "webhook", "id": "inbound", "verify": {"scheme": "b3os"}, "with": {"url": "${{ trigger.body.url }}"}}], "steps": [ {"id": "page", "uses": "vision/scrape@1", "with": {"url": "${{ inputs.url }}", "formats": ["markdown"]}}, {"id": "brief", "uses": "infer/chat@1", "needs": ["page"], "with": { "model": "b3os/qwen3.5-9b", "max_tokens": 120, "temperature": 0, "chat_template_kwargs": {"enable_thinking": False}, "messages": [ {"role": "system", "content": "In one plain sentence of at most 30 words, say what this web page offers."}, {"role": "user", "content": "${{ steps.page.outputs.markdown }}"}]}}], "output": {"brief": "${{ steps.brief.outputs.choices[0].message.content }}"},}).json()hook = published["triggers"][0]["hook"]try: body = json.dumps({"url": "https://beta.b3os.org/demo/pricing/"}) delivery = requests.post(hook["url"], data=body, headers={ "Content-Type": "application/json", "B3OS-Signature": sign_webhook(body, hook["secret"])}).json() run = s.get(f"{API}/v1/routines/runs/{delivery['run_id']}").json() while run["status"] not in ("succeeded", "failed", "cancelled", "timed_out"): time.sleep(0.2) run = s.get(f"{API}/v1/routines/runs/{delivery['run_id']}").json() print(run["status"], run["result"]["brief"])finally: s.delete(f"{API}/v1/routines/webhook-brief")
Publishing a routine is an API call (the remote B3OS MCP server runs routines but does not publish them), and the delivery comes from the sender, not from your agent. Once the routine exists, ask your agent:
textShow me the last run of the routine webhook-brief: its status, how long each step took and its result.
The agent calls routine_get_run. To try the hook without a sender, POST /v1/routines/webhook-brief/hooks/{hook_id}/test
signs and delivers a test body for you.
delivery_to_done_ms below is the client's view, from sending the delivery to reading the finished run, polled every
100 ms through the edge; run_node_ms is the same run by its own timestamps on the node. The secret is shown once:
store it where the sender keeps secrets, and rotate it with POST /v1/routines/{routine}/hooks/{hook_id}/rotate.
Owner: Docs. Products: Docs, Routines, Vision, Infer.
Output of the last green run
bash$ node run.mjs# exit 0
json{ "hook_verify": "b3os", "delivery_status": 202, "delivery_deduplicated": false, "run_status": "succeeded", "brief": "This page offers flexible pricing plans for a project management tool with features like unlimited projects and shared docs.", "steps": [ { "id": "page", "status": "succeeded", "duration_ms": 0.279 }, { "id": "brief", "status": "succeeded", "duration_ms": 148.028 } ], "repeat_status": 202, "repeat_deduplicated": true, "repeat_same_run": true, "forged_status": 401, "forged_code": "webhook_signature_invalid", "log_entries": 3, "log_outcomes": [ "deduplicated", "rejected", "started" ], "publish_ms": 4484.1, "delivery_ms": 1271.2, "delivery_to_done_ms": 1782.7, "run_node_ms": 157, "deleted": true}
The outputs this recipe publishes (its recipe.json publish.outputs), with workspace identifiers redacted. The run's stdout, stderr and other outputs are not published.
Captured 2026-11-01 from commit
771a9508
, label
live-recorded
(recorded from production). Case
recipes/signed-webhook-brief
.