Your Webhook — receiving runs

Markdown

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

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_…"
  }
}
FieldMeaning
run.idStable id for this run — use it to correlate logs.
input.messageThe user's message text.
reply.urlWhere you POST your result (see the Reply API).
reply.tokenOne-time credential — never trust a run id from elsewhere.
reply.expiresInSecondsYour time budget; after it the run expires.
mcpOptional: 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"
}
Your Webhook — receiving runs · ONBF