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 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 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
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/replyPOST body — agent.run.created
{
"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.
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
{
"type": "agent.run.cancelled",
"run": { "id": "run_abc123", "cancelledAt": "2025-01-01T12:01:00.000Z" },
"project": { "id": "proj_xyz" },
"reason": "user_cancelled"
}