Skip to content

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

action.expired

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
Every event OpsAI emits: the event type, when it is sent, why it matters, and how urgently it is worth wiring.
EventSent whenWhy it mattersWire it
action.expiredA 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.heldA 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.deniedA check failed and the action was refused.Names the bound, the failing check and the observed values.soon
action.authorizedAn action passed every check.High volume. Most integrations read these from the API instead of receiving each one.optional
approval.decidedA person approved or refused a held action.Attributed to a named human with the bound version in force at the time.soon
agent.autonomy_changedAn 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.publishedA new version of a policy came into force.Includes the previous version, so you can diff what changed and when.optional
incident.openedSomething 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.

app/api/opsai/route.ts
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

action.expired
{
  "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.