Webhooks
Developer·5 minutes to read
Verified against docs/superpowers/specs/2026-09-15-p4-a-typed-http-api-openapi-webhooks.md §4.1, §4.3, §4.4, §4.6, §6; re-verified against the code at close-out.
What it does
An endpoint of yours receives a signed POST when a run finishes or an approval is raised; these endpoints create, read, change, pause, rotate, test and delete endpoints, and read and retry the deliveries they produced (F-DEV-5 — outbound HMAC-signed webhooks).
Authentication
An organisation API key of scope admin. Every route on this page is an admin route, including the reads: a member neither lists an endpoint nor sees a delivery. See Authentication and keys.
Deliveries themselves carry no API key. They are authenticated the other way round, by a signature your receiver checks — see Response below.
Request
GET /webhooks
<!-- generated:begin GET /webhooks --> <!-- generated:end GET /webhooks -->POST /webhooks
<!-- generated:begin POST /webhooks --> <!-- generated:end POST /webhooks -->GET /webhooks/{id}
<!-- generated:begin GET /webhooks/{id} --> <!-- generated:end GET /webhooks/{id} -->PATCH /webhooks/{id}
<!-- generated:begin PATCH /webhooks/{id} --> <!-- generated:end PATCH /webhooks/{id} -->DELETE /webhooks/{id}
<!-- generated:begin DELETE /webhooks/{id} --> <!-- generated:end DELETE /webhooks/{id} -->POST /webhooks/{id}/rotate
<!-- generated:begin POST /webhooks/{id}/rotate --> <!-- generated:end POST /webhooks/{id}/rotate -->POST /webhooks/{id}/test
<!-- generated:begin POST /webhooks/{id}/test --> <!-- generated:end POST /webhooks/{id}/test -->GET /webhooks/{id}/deliveries
<!-- generated:begin GET /webhooks/{id}/deliveries --> <!-- generated:end GET /webhooks/{id}/deliveries -->GET /deliveries/{id}
<!-- generated:begin GET /deliveries/{id} --> <!-- generated:end GET /deliveries/{id} -->POST /deliveries/{id}/redeliver
<!-- generated:begin POST /deliveries/{id}/redeliver --> <!-- generated:end POST /deliveries/{id}/redeliver -->An endpoint's URL must be https, at most 2 048 characters, and must not name a private address. The check runs twice: the literal localhost, an address literal in a private, loopback, link-local, carrier-grade or metadata range is refused when the endpoint is created, and every address the hostname resolves to is checked again at delivery, so a name that later points somewhere private fails the attempt rather than reaching it. A description is optional and at most 200 characters. An endpoint subscribes to at least one of the two events, run.finished and approval.needed, and an organisation may hold ten live endpoints.
POST /webhooks and POST /webhooks/{id}/rotate are the only responses that carry the signing secret, and each shows it once. Rotating takes effect at once: a delivery still in flight under the old secret fails at your receiver, which is the rotation working.
POST /webhooks/{id}/test sends a real delivery of kind webhook.test. An endpoint that is paused, disabled or deleted refuses it with conflict before anything is written.
curl -sS -X POST "$BOTSEON_API_ORIGIN/api/v1/webhooks" \
-H "authorization: Bearer $BOTSEON_API_KEY" \
-H "idempotency-key: 5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b" \
-H "content-type: application/json" \
-d '{"url":"https://hooks.example.test/botseon","description":"Release pipeline","events":["run.finished"]}'
const res = await fetch(`${process.env.BOTSEON_API_ORIGIN}/api/v1/webhooks?limit=10`, {
headers: {
authorization: `Bearer ${process.env.BOTSEON_API_KEY}`,
accept: 'application/json',
},
});
if (!res.ok) throw new Error(`webhooks: ${res.status}`);
const page = (await res.json()) as {
data: Array<{ id: string; url: string; state: string; consecutiveFailures: number }>;
nextCursor: string | null;
};
for (const endpoint of page.data) console.log(endpoint.state, endpoint.url);
Response
An endpoint row carries its id, URL, description, events, state (active, paused or disabled), the reason it was disabled, its consecutive failure count, when it was created and when it last received a delivery.
A delivery to your endpoint is a POST with this body:
{
"id": "9f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
"event": "run.finished",
"createdAt": "2026-09-18T08:03:11.000Z",
"organisationId": "2b7c9d10-3e4f-4a5b-8c9d-0e1f2a3b4c5d",
"data": {
"runId": "7c1e2d3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
"botId": "0f3a2b71-6c4d-4a1e-9b2c-7d5e8f0a1b2c",
"conversationId": "6b7c8d9e-0f1a-4b2c-8d3e-4f5a6b7c8d9e",
"routineId": null,
"state": "completed",
"trigger": "api",
"steps": 14,
"spendMicroCents": 310000,
"taint": false,
"finishedAt": "2026-09-18T08:03:11.000Z"
}
}
id is the delivery's id and is your replay key: the same delivery retried carries the same id. The payload holds identifiers and state and nothing else — no transcript text, no tool input, no person's name. To read what the run actually said, call Conversations and messages with the credential you already hold.
Verifying a delivery. Four headers ride with it: x-botseon-signature, x-botseon-timestamp, x-botseon-delivery and x-botseon-event. The signature is a hex SHA-256 HMAC of v1:<timestamp>:<raw body> under the endpoint's secret. Recompute it over the raw body, compare in constant time, and reject a timestamp more than 300 seconds from your own clock. The scheme is the same one the product's inbound webhook trigger uses, so one verifier serves both directions.
Retries. Five attempts at most, 60 seconds after the first failure, then 5 minutes, 30 minutes, 2 hours and 8 hours. A 2xx is a delivery; anything else is a failed attempt, recorded with the status code. A refusal before the request completes is recorded as one fixed word — address, dns, redirect, size, timeout, transport or scheme — never a message. A redirect is a failed attempt, not a hop: a redirect would strip the signature of its meaning. The request times out at 10 seconds and your response body is read to 64 KB and discarded. After ten consecutive failures the endpoint is disabled, and resuming it clears the count.
Errors
| Status | Code | When | What to do |
|---|---|---|---|
| 401 | unauthenticated | The credential is missing, refused, or below admin. | Use an admin key. |
| 400 | invalid_request | The URL is not https, names a private address or localhost, the events list is empty, a field is over its length, or the POST carries no Idempotency-Key. | Read details; the failing path is named. |
| 404 | not_found | No such endpoint or delivery in this organisation. | List first. |
| 409 | conflict | A test was sent to an endpoint that is not active, or a redelivery was asked for a delivery that has not failed. | Resume the endpoint first, or pick a failed delivery. |
| 429 | quota_exceeded | The organisation already holds ten live endpoints. | Delete one first. |
| 402 | payment_required | The organisation needs a trial or a payment. | Settle the account, then retry. |
| 429 | rate_limited | A bucket is full. | Wait for Retry-After. |
Rate limits
The endpoints on this page are counted against the two one-minute buckets, 300 per key and 1 000 per organisation. Deliveries are not: they go out on the worker's own schedule, at most five attempts per delivery on the ladder above, and an endpoint that keeps failing is disabled rather than retried for ever. A full bucket answers rate_limited with Retry-After: 60; wait that many seconds and retry. Failed authentication is counted separately, against the caller's address, so a working key's allowance is never spent by somebody else's bad credential. See Envelopes, errors, idempotency and paging.
Last verified against build c0f77aa.