# Your Webhook — receiving runs

How ONBF delivers a user's message to your agent: the agent.run.created event hitting your URL, how to verify its signature, and how to handle cancellations. This is the inbound half of every run — the Reply API is how you respond.

## What your webhook is

Your **webhook** is the single URL ONBF calls whenever something happens to one of your agent's runs. When a user messages your agent, ONBF POSTs an `agent.run.created` event there carrying the message, a one-time **reply token**, and an in-run **MCP session token**. It's the *inbound* half of a run.

> **One round-trip, two pages:** ONBF calls **you** here (the webhook), then **you** call **us** to deliver the answer — that outbound half lives on the **[Reply API](/docs/reply-api)** page. The `reply.token` you receive in this webhook is exactly what the Reply API consumes.

## Register your URL

Open **Settings → Agent** and set your **Webhook URL** (HTTPS only) plus an optional **signing secret** (`onbf_whsec_…`). The secret unlocks signature verification — see [Verifying signatures](#verifying) below. That's the only setup: from then on every run is delivered to that URL.

## The inbound request

This is what lands at your webhook URL when a user messages your agent — the request line and headers first, then the JSON body:

_Request line & headers_

```http
POST /onbf/webhook HTTP/1.1
Host: your-agent.example.com
Content-Type: application/json
User-Agent: ONBF-AgentRuntime/1
X-ONBF-Event: agent.run.created
X-ONBF-Signature: t=1735732800,v1=2b9f…   # only if a signing secret is set

{ … the JSON body below … }

# Replies go back to onbf.ai/api/agents/reply
```

_POST body — agent.run.created_

```json
{
  "type": "agent.run.created",
  "run": { "id": "run_abc123", "createdAt": "2025-01-01T12:00:00.000Z" },
  "project": { "id": "proj_xyz" },
  "input": { "message": "Summarize today's support tickets." },
  "reply": {
    "url": "https://onbf.ai/api/agents/reply",
    "token": "the-one-time-reply-token",
    "expiresInSeconds": 120
  },
  "mcp": {
    "url": "https://onbf.ai/api/mcp",
    "token": "onbf_sess_…"
  }
}
```

| Field | Meaning |
| --- | --- |
| `run.id` | Stable id for this run — use it to correlate logs. |
| `input.message` | The user's message text. |
| `reply.url` | Where you POST your result (see the Reply API). |
| `reply.token` | One-time credential — never trust a run id from elsewhere. |
| `reply.expiresInSeconds` | Your time budget; after it the run expires. |
| `mcp` | Optional: endpoint + session token for in-run context (see Passport MCP). |

## Acknowledge fast, reply async

Treat the webhook as a doorbell, not a workbench. Return `2xx` immediately to acknowledge receipt — the run becomes `running` — then do your real work and deliver the answer separately via the Reply API.

> **Never block on your LLM call:** Your webhook must return `2xx` within the dispatch timeout — it's just an acknowledgement. If you wait for your model before responding, the dispatch can time out and the run may be retried. Respond first, work after.

## Verifying signatures

Set a signing secret (`onbf_whsec_…`) in Settings → Agent and ONBF signs every webhook with an `X-ONBF-Signature` header, using the same scheme as Stripe — `t=<unix>,v1=<hmac_sha256>` over `"<t>.<rawBody>"` — so the timestamp is signed too (replay-resistant). Always verify it before trusting a payload.

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";

// Set a signing secret in your agent's Settings → Agent tab. ONBF then signs
// every webhook so you can verify it came from us (and wasn't replayed).
function verifyOnbfSignature(rawBody, header, secret) {
  // Header format: "t=<unix>,v1=<hex hmac>"
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=")),
  );
  const timestamp = parts.t;
  const provided = parts.v1;
  if (!timestamp || !provided) return false;

  // Reject old timestamps to stop replay (5-minute tolerance).
  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (ageSeconds > 300) return false;

  // Recompute HMAC over "<t>.<rawBody>" — use the RAW request body, not the
  // re-serialized JSON (key order / whitespace must match byte-for-byte).
  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(provided);
  return a.length === b.length && timingSafeEqual(a, b);
}

// Express: capture the raw body so the signature check sees exact bytes.
// app.use(express.raw({ type: "application/json" }));
// const ok = verifyOnbfSignature(req.body.toString(), req.get("x-onbf-signature"), SECRET);
```

> **Use the raw body:** Compute the HMAC over the exact bytes you received, not re-serialized JSON — key order and whitespace must match. Capture the raw request body before any JSON middleware parses it.

## Cancellation

If a user stops a run (or it's cancelled server-side), ONBF best-effort POSTs an `agent.run.cancelled` event to the same webhook URL — signed the same way. There's no reply token (a cancel is a "stop", not a credential); match it to your in-flight job by `run.id` and abort.

_POST body — agent.run.cancelled_

```json
{
  "type": "agent.run.cancelled",
  "run": { "id": "run_abc123", "cancelledAt": "2025-01-01T12:01:00.000Z" },
  "project": { "id": "proj_xyz" },
  "reason": "user_cancelled"
}
```
