Webhooks let you receive task lifecycle updates by POST instead of
polling. Register a callback URL on a task and BeeOS will hit it
whenever the task transitions state.
Status — P2-A fully landed: callbacks are
(1) signable with HMAC-SHA256 (X-BeeOS-Signature),
(2) retried on failure with the documented exponential
backoff schedule (1m → 5m → 30m → 2h → 12h, then dead-letter), and
(3) auditable + replayable through
GET .../deliveries and POST .../deliveries/{id}/redeliver.
The single-attempt 10 s timeout still applies per attempt, but
failed attempts now survive a process crash and are picked up by
the background retry worker.
1. Lifecycle
2. Registering a webhook
Request
POST /api/v1/agents/agent_abc123/tasks/ch-uuid/webhooks
Authorization: Bearer oag_...
Content-Type: application/json
{
"url": "https://example.com/beeos/hook",
"token": "optional-bearer-shared-secret",
"secret": "wh_sec_a3b1...4f"
}
| Field | Required | Description |
|---|
url | yes | Absolute http:// or https://. No path validation beyond scheme. |
token | no | Shared secret. If set, BeeOS sends Authorization: Bearer <token> on every callback. If empty, the callback is anonymous. |
secret | no | P2-A HMAC-SHA256 signing key (16-256 chars). When set, every callback carries X-BeeOS-Signature (format t=<unix>,v1=<hex> — see §6 HMAC signing). Write-only — never returned on Get / List. |
Response (201)
{
"success": true,
"data": {
"webhook_id": "wh-uuid",
"task_id": "ch-uuid",
"url": "https://example.com/beeos/hook",
"has_secret": true,
"created_at": "2026-05-14T18:00:00Z"
}
}
token and secret are never returned in any list / get
response — once registered they’re only used server-side. The
has_secret boolean lets the UI / SDK surface “signing enabled”
without exposing the secret itself. To rotate a secret, Set the
webhook again with a fresh value (passing "" leaves the existing
secret untouched — use DELETE + re-register to clear).
Listing / deleting
GET /api/v1/agents/{agentId}/tasks/{taskId}/webhooks
DELETE /api/v1/agents/{agentId}/tasks/{taskId}/webhooks/{webhookId}
A task can hold any number of webhooks (no explicit cap today).
Deletion is immediate — no in-flight delivery cancellation, but no
new attempts after delete.
3. Current limitations
Things that constrain the delivery contract today:
| Limitation | Behaviour | Workaround |
|---|
| 10 s per-attempt timeout | If your receiver takes > 10 s the request is cancelled. With retry enabled it will be re-attempted on the backoff schedule. | Keep handlers fast (< 1 s). Queue the payload to a background worker and ACK with 200 immediately. |
| No redirect following | HTTP 3xx responses are treated as final and not followed. The retry scheduler treats them like any other non-2xx (will retry). | Register the actual destination URL, not a redirector. |
| Concurrency cap of 5 | At most 5 webhook deliveries fan out in parallel per firePushWebhooks call. Tasks with many subscribers can see fan-out queueing. | Avoid registering > 5 webhooks per task; consolidate at your edge. |
| No payload schema versioning | The payload format depends on which renderer your webhook was registered under (see §4). Renderer is fixed at registration time. | Stamp your own version into the receiver code; if BeeOS changes a renderer it’ll be additive (extra fields) per ADR-0017. |
| At-most-6 attempts | After the 1m / 5m / 30m / 2h / 12h schedule runs out, the row moves to dead_letter. | Manually replay via §7 deliveries / redeliver once you’ve fixed the receiver. |
BeeOS supports three renderers, selected by the protocol_filter
column at registration time. Webhooks registered via this OpenAPI
endpoint always use the openapi renderer.
openapi renderer — TaskEvent envelope
Matches the SSE-stream payload from GET /tasks/{id}/events so you
can share decoders. Emitted on every status transition (intermediate
and terminal).
POST {your-url}
Content-Type: application/json
X-BeeOS-Webhook-Format: openapi
Authorization: Bearer <your-token> ← only if you set `token` on register
{
"type": "task_status",
"task_id": "ch-uuid",
"context_id": "ch-uuid",
"status": "succeeded",
"final": true,
"timestamp": "2026-05-14T18:01:23.456Z"
}
Possible status values: queued, running, input_required,
auth_required, succeeded, failed, canceled, timeout,
rejected (see Calling Agents §3e).
final: true only on terminal states.
a2a and generic renderers
Used by A2A JSON-RPC clients and by generic / legacy subscribers that
registered without a protocol filter. Not selectable from the
OpenAPI Gateway — documented for reference only. See
webhook_renderer.go.
5. Receiver checklist
A production-grade receiver for the current delivery semantics
needs to:
- Run over HTTPS. No exceptions —
token and secret are
both sensitive over the wire.
- Verify the signature first (
X-BeeOS-Signature) when you’ve
registered a secret. Use the recipe in §6.
Reject 401 on mismatch or stale timestamp.
- Then verify the optional
Authorization bearer token when
you’ve registered a token. Reject 401 on mismatch.
- ACK within 1 s (10 s upper bound). Queue the payload
internally; don’t run business logic inline.
- Be idempotent — the retry scheduler can introduce
duplicates (at-least-once delivery). Key on
(task_id, status, timestamp) and drop duplicates server-side.
- Poll
GET /tasks/{id} periodically for any task whose
terminal-state webhook you must not lose (e.g. billing-relevant).
The webhook is “fast notification”; the API is “truth”.
- Discard unknown
type values — additions to the renderer
are additive (extra fields / new types) per ADR-0017; never
crash on a new event type.
app.post("/beeos/hook", express.json(), async (req, res) => {
const expected = `Bearer ${process.env.BEEOS_WEBHOOK_TOKEN}`;
if (req.header("authorization") !== expected) return res.sendStatus(401);
await queue.add(req.body); // fast ACK, real work later
res.sendStatus(200);
});
6. HMAC signing (P2-A)
Opt into HMAC-SHA256 body signing by setting secret on register
(see §2). The deliverer then sends two extra headers on every
callback:
X-BeeOS-Event: task.state
X-BeeOS-Task-Id: ch-uuid
X-BeeOS-Signature: t=<unix>,v1=<hex> where
hex = hmac_sha256(secret, "<unix>." || body)
Other headers (X-BeeOS-Webhook-Format, the optional Authorization: Bearer <token>) are unchanged. You can use signing in addition to
the bearer token during rotation.
Verification recipe — Python
import hmac, hashlib, time, json
from flask import abort, request
WEBHOOK_SECRET = open("/etc/secrets/beeos-webhook").read().strip().encode()
CLOCK_SKEW_SEC = 300 # ±5 min
def verify_signature() -> None:
sig_header = request.headers.get("X-BeeOS-Signature", "")
parts = dict(p.split("=", 1) for p in sig_header.split(",") if "=" in p)
try:
ts = int(parts.get("t", ""))
except ValueError:
abort(400, "malformed signature header")
if abs(time.time() - ts) > CLOCK_SKEW_SEC:
abort(401, "stale or future-dated signature")
body = request.get_data()
expected = hmac.new(
WEBHOOK_SECRET,
f"{ts}.".encode() + body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
abort(401, "bad signature")
Verification recipe — Node.js / Express
import crypto from "node:crypto";
const WEBHOOK_SECRET = process.env.BEEOS_WEBHOOK_SECRET!;
const CLOCK_SKEW_SEC = 300;
app.post("/beeos/hook",
express.raw({ type: "application/json" }), // raw bytes for HMAC
(req, res, next) => {
const sig = String(req.header("x-beeos-signature") ?? "");
const parts = Object.fromEntries(sig.split(",").map((p) => p.split("=")));
const ts = Number(parts.t);
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > CLOCK_SKEW_SEC) {
return res.sendStatus(401);
}
const body = req.body as Buffer;
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(`${ts}.`)
.update(body)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""))) {
return res.sendStatus(401);
}
// Now safe to JSON.parse(body.toString()).
next();
},
// ...your handler...
);
Critical: hash the raw request bytes, not a re-serialised
JSON object. Re-serialising changes whitespace / key order and the
HMAC will not match.
Why the ±5 min skew check?
Without a timestamp check, an attacker who captures one valid
callback can replay it forever. Rejecting old or future-dated
timestamps narrows the replay window. ±5 minutes is BeeOS’s
recommendation; receivers in closed networks may go tighter.
7. Delivery audit log + manual replay (P2-A part 3)
Every callback attempt is now recorded as a durable row that you
can list and replay through two REST endpoints. The rows survive
process crashes (the retry worker picks them up after the lease
expires), so a transient deploy / rolling restart no longer drops
in-flight callbacks.
Retry schedule
After a failed attempt the row is rescheduled per a fixed backoff:
| Attempt | Delay before next try |
|---|
| 1 → 2 | 1 minute |
| 2 → 3 | 5 minutes |
| 3 → 4 | 30 minutes |
| 4 → 5 | 2 hours |
| 5 → 6 | 12 hours |
| 6 → ✗ | Move to dead_letter |
A row stays in pending between attempts (the worker flips it
back to pending from failed when the schedule fires). Manual
replay via POST .../redeliver clones the row
into a fresh pending attempt regardless of how many automatic
retries already ran.
Lifecycle states
| Status | Meaning | Terminal? |
|---|
pending | Queued for the first attempt or awaiting a scheduled retry. | No |
succeeded | Receiver returned 2xx. | Yes |
failed | Last attempt errored / non-2xx; next attempt is scheduled. | No (intermediate) |
dead_letter | Exhausted the schedule. Requires manual redeliver to retry. | Yes |
GET .../deliveries
GET /api/v1/agents/{agentId}/tasks/{taskId}/webhooks/{webhookId}/deliveries?limit=50
Authorization: Bearer oag_...
Returns the most recent delivery rows, newest first. limit is
clamped to [1, 200], default 50.
{
"success": true,
"data": {
"deliveries": [
{
"delivery_id": "del-uuid",
"webhook_id": "wh-uuid",
"task_id": "ch-uuid",
"renderer": "openapi",
"status": "succeeded",
"attempt_num": 1,
"last_response_status": 200,
"last_error": "",
"next_attempt_at": "2026-05-19T10:00:00Z",
"last_attempted_at": "2026-05-19T10:00:00Z",
"created_at": "2026-05-19T10:00:00Z",
"completed_at": "2026-05-19T10:00:00Z"
},
{
"delivery_id": "del-uuid-2",
"webhook_id": "wh-uuid",
"task_id": "ch-uuid",
"renderer": "openapi",
"status": "dead_letter",
"attempt_num": 6,
"last_response_status": 502,
"last_error": "http_status: 502",
"next_attempt_at": "2026-05-19T22:00:00Z",
"last_attempted_at": "2026-05-19T22:00:00Z",
"created_at": "2026-05-19T08:00:00Z",
"completed_at": "2026-05-19T22:00:00Z"
}
]
}
}
The raw payload bytes, bearer token, and HMAC secret are
NEVER returned — only diagnostic fields. If you need to verify
what the receiver should have seen, consult your own log of the
originating task transition; the renderer field tells you
which payload schema applied.
POST .../redeliver
POST /api/v1/agents/{agentId}/tasks/{taskId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver
Authorization: Bearer oag_...
Clones a failed or dead_letter row into a fresh pending
row. The clone re-fires the exact payload bytes (and HMAC
signature) the receiver should have seen — receivers can treat
manually-replayed callbacks identically to automatic retries.
Returns 202 with the new pending row:
{
"success": true,
"data": {
"delivery_id": "del-uuid-new",
"status": "pending",
"attempt_num": 0,
"...": "..."
}
}
Errors:
| Status | Code | When |
|---|
404 | not_found | Delivery doesn’t exist or doesn’t belong to the named webhook. |
409 | conflict | Source row is pending or succeeded — only failed / dead_letter rows are replayable. |
403 | forbidden | Caller doesn’t own the task. |
Each redeliver call enqueues a NEW pending row — repeated calls
produce repeated deliveries. Treat this endpoint as a manual
rescue, not a caller-side retry-on-network-error.
See also