Developers
Webhooks
Receive decisions, approvals, risk events and incidents as they happen, so your own systems can react to what OpsAI observed.
Wire this one first
A hold that expires is a decision nobody made. Nothing failed, nobody clicked, no error was raised — the action simply did not happen.
It is the only outcome in the system you cannot discover from anywhere else.
What events are for
Most of this you could poll for. One thing you cannot.
Decisions are in the API and incidents are in the console, so for most of what OpsAI knows a webhook is a convenience. The exception is an expiry, and it is the reason this page exists.
When a policy needs a person, the action is held and the hold has a deadline. If nobody responds, the outcome is a refusal — which is the correct default for inattention, and also completely silent. No exception was thrown, no request failed, and nothing in your own systems has any reason to mention it.
That is a governance outcome produced by nobody doing anything, and the only way it becomes visible where you work is if something tells you. Hence action.expired.
The catalogue
Ordered by how likely you are to need it.
Not alphabetically. The first three are the ones that change what somebody does today; the rest are useful once you are running enough volume to care.
Webhook events
8 events| Event | Sent when | Why it matters | Wire it |
|---|---|---|---|
| action.expired | A held action reached its deadline with nobody deciding. | Wire this one first. It is the only outcome invisible from everywhere else — nothing failed and nobody clicked. | first |
| action.held | A policy requires a person before the action can proceed. | Carries who was asked and the deadline. Route it where that person actually works. | soon |
| action.denied | A check failed and the action was refused. | Names the bound, the failing check and the observed values. | soon |
| action.authorized | An action passed every check. | High volume. Most integrations read these from the API instead of receiving each one. | optional |
| approval.decided | A person approved or refused a held action. | Attributed to a named human with the bound version in force at the time. | soon |
| agent.autonomy_changed | An AI system moved up or down the autonomy ladder. | A drop is automatic and follows a broken bound. A rise is always a person’s decision. | soon |
| bound.published | A new version of a policy came into force. | Includes the previous version, so you can diff what changed and when. | optional |
| incident.opened | Something was detected that needs handling. | Detection and containment are separate events, so the gap between them is measurable. | optional |
Receiving them
Verify the signature, then be idempotent. In that order.
The signature check is the security boundary and the idempotency is the correctness one. Getting the second right and the first wrong means processing forged events reliably.
import { verifyWebhook } from '@opsai/sdk';
export async function POST(request: Request) {
const body = await request.text();
// Throws on a bad signature rather than returning null, so a forgotten
// check fails loudly instead of quietly accepting forged events.
const event = verifyWebhook({
body,
signature: request.headers.get('opsai-signature'),
secret: process.env.OPSAI_WEBHOOK_SECRET,
});
// At-least-once delivery. This has to be safe to run twice.
if (await alreadyProcessed(event.id)) {
return new Response(null, { status: 204 });
}
switch (event.type) {
case 'action.expired':
await escalateToHuman(event.data);
break;
case 'action.held':
await notifyApprover(event.data);
break;
}
await markProcessed(event.id);
return new Response(null, { status: 204 });
}What a payload looks like
{
"id": "evt_01J9X…",
"type": "action.expired",
"created": "2026-09-15T09:41:02Z",
"data": {
"action": "ACT-7512",
"agent": "refund-resolver",
"subject": "ORD-40122",
"bound": "refund.ceiling",
"asked": "rahul@acme.internal",
"held_for_seconds": 14400,
"outcome": "denied",
"reason": "expired_without_decision",
"evidence": "EV-7512"
}
}Note outcome is denied rather than something like timed_out. An expiry is not a separate state — it is a refusal that happened to be produced by silence, and it carries an evidence record exactly like any other decision.
- Retries
- Any non-2xx is retried with exponential backoff. Return 204 quickly and do the work after — a slow handler looks identical to a failing one.
- Ordering
- Not guaranteed. Use created if sequence matters, and do not assume action.held arrives before its approval.decided.
- Secrets
- One per endpoint, rotatable with an overlap window so rotation needs no downtime — the same dual-accept idea as credential rotation.
- What is never in a payload
- Credentials, grants, and the contents of anything read from a governed data source. An event carries references, not payloads.
Where to go
Route the expiry somewhere a person will see it today.
An expiry delivered to a dashboard nobody opens is the same as no expiry. It belongs wherever the approver already works, which is usually not another queue.