> ## Documentation Index
> Fetch the complete documentation index at: https://docs.beta.b3os.org/llms.txt
> Use this file to discover all available pages before exploring further.

# 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.

```yaml
triggers:
  - 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

**curl**

```bash
API=https://beta-api.b3os.org
AUTH="Authorization: Bearer $B3OS_API_KEY"
NAME=webhook-brief

curl -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.json
HOOK_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 2
curl -s "$API/v1/routines/runs/$RUN" -H "$AUTH" | jq '{status, result}'
curl -s -X DELETE "$API/v1/routines/$NAME" -H "$AUTH" > /dev/null
```
**TypeScript**

```ts
import { 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");
}
```
**Python**

```python
import json
import os
import time
import requests
from b3os import sign_webhook

API = "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")
```
**MCP**

Publishing a routine is an API call (the remote [B3OS MCP server](/sdks/mcp) 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:

```text
Show 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

**Command**

```bash
$ node run.mjs
# exit 0
```
**Outputs**

```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`.