---
title: Outbound Webhooks (push Omniscio events to your own server)
---

# Outbound Webhooks

## What it is

Push Omniscio events to **your own server**. When something happens in Omniscio — an agent
finishes, a budget is exceeded, a recipe completes — Omniscio can **POST a signed JSON payload**
to a callback URL you control, so your own systems can react in real time instead of polling.

Every delivery is **signed** with a per-webhook secret so you can prove it really came from
Omniscio, retried on failure, and recorded in a per-webhook delivery history.

## Where to find it

### Where it lives

Outbound Webhooks is an **in-development** feature, so it ships **hidden and off by default**.
Reveal it on a machine via **Settings → Lab → "Outbound Webhooks"** (or the `AMC_SHOW_*` env var
in a dev build). Once revealed, manage everything from **Settings → Outbound Webhooks**.

There are **two independent switches** (by design):

- **The Lab reveal** — shows/hides the panel. This is the in-development gate.
- **The master delivery toggle** (inside the panel) — turns webhook delivery **on or off** for the
  whole app. It is **off by default**. Pausing delivery never hides the panel, so "panel visible,
  delivery paused" is a valid state. Nothing is delivered until this is on **and** at least one
  active webhook exists.

## How it behaves

### Configuring a webhook

Each webhook is a small subscription:

- **Name** — a label for your reference.
- **Callback URL** — the public `https://` endpoint Omniscio POSTs to. Private, loopback,
  link-local, and cloud-metadata addresses are **rejected** when you save (see *Security* below).
- **Events** — which events this webhook receives. **Leave it empty to receive every event**, or
  pick a subset.
- **Active** — turn an individual webhook on or off without deleting it.
- **Signing secret** — used to sign every delivery. It is shown **exactly once**, when you create
  the webhook (and again only when you **rotate** it). Copy it then and store it somewhere safe —
  Omniscio never shows it again and never returns it when listing webhooks.

From the panel you can also **rotate the secret** (invalidates the old one, shows a new one once),
**send a test ping** (a real signed delivery you can verify end-to-end), and **view the delivery
history** (recent attempts, their status, and errors).

> **Creating a webhook from the CLI is retry-safe (F056).** The `POST /outbound-webhooks` CLI route
> is idempotency-keyed: a retry with the same `X-Client-Request-Id` header **replays** the original
> create instead of registering a second subscription — you get the same webhook back (including
> the signing secret, shown exactly once), flagged `idempotent: true` in the response. Even a retry
> without the header is deduplicated on the `callbackUrl` (+ scope) natural key — the callback URL is
> a webhook's identity, so a repeated create for the same target returns the existing subscription
> instead of registering a second webhook that would double-fire every future event. (The
> **test ping** route stays deliberately *not* idempotency-keyed — a retried test just sends a
> second harmless signed ping.)

### The events you can subscribe to

Only this **curated set** of events can leave your machine, and each carries a **safe, minimal
payload** — internal ids, file paths, and raw error text are deliberately stripped.

| Event | Fires when | Payload fields (`data`) |
|-------|-----------|--------------------------|
| `agent.started` | A session starts running | `sessionId`, `status` (`"running"`), `statusChangedAt?` |
| `agent.finished` | A session ends | `sessionId`, `status` (`"ended"`), `statusChangedAt?` |
| `agent.needs_you` | A session needs your input | `sessionId`, `status` (`"needs_you"`), `statusChangedAt?`, `pendingAction?` |
| `agent.errored` | A session hits an error | `sessionId`, `status` (`"error"`), `statusChangedAt?`, `pendingAction?` |
| `budget.warning` | A session nears its spend cap | `sessionId`, `sessionName?`, `projectName?`, `costUSD`, `capUSD` |
| `budget.exceeded` | A session passes its spend cap | `sessionId`, `sessionName?`, `projectName?`, `costUSD`, `capUSD` |
| `recipe.finished` | A recipe run ends | `runId`, `finalStatus`, `configId` (nullable), `code?`, `endedAt?` |
| `job.finished` | A background job ends | `jobId`, `status`, `runId?`, `retryAttempt?`, `maxRetries?` |
| `alert.raised` | An inbox alert is raised | `title`, `sourceKind`, `alertId`, `contentType?`, `textContent?`, `createdAt?` |
| `chat.webhook_received` | An external service posts into a Team Chat channel | `channelId`, `text?`, `webhookName?`, `clientMsgId?` |

Fields marked `?` are optional and may be absent.

### The test ping event

The **Send test ping** button in the panel posts one extra event that is **not** in the table above,
because you cannot subscribe to it — it only ever fires when a human presses that button. Your
receiver will still need to handle it, so its exact shape is pinned here:

| Event | Fires when | Payload fields (`data`) |
|-------|-----------|--------------------------|
| `test.ping` | You press **Send test ping** on a webhook | `message` |

It carries the same envelope and the same signature headers as every other delivery, and it is
recorded in that webhook's delivery history like any other attempt. Treat it as a smoke test for
your receiver: if it arrives and its signature verifies, your endpoint is wired up correctly.

### What a delivery looks like

Omniscio sends an HTTP **`POST`** with `Content-Type: application/json` and these headers:

| Header | Meaning |
|--------|---------|
| `X-AMC-Signature` | The HMAC-SHA256 signature of the request body, as lowercase hex (see *Verifying the signature*). |
| `X-AMC-Event` | The event name, e.g. `agent.finished`. |
| `X-AMC-Delivery-Id` | A UUID that is **stable across all retries of this delivery** — use it to de-duplicate. |
| `Idempotency-Key` | The same UUID, for receivers that key on the standard header. |

> **Header naming note.** The headers literally begin with `X-AMC-` (Omniscio was formerly "Agent
> Mission Control"). Match the exact names above in your receiver.

The body is a small JSON envelope. The event-specific fields are always under `data`:

```json
{
  "event": "agent.finished",
  "deliveryId": "9f1c2b3a-4d5e-4f6a-8b7c-0d1e2f3a4b5c",
  "deliveredAt": "2026-07-30T18:24:05.187Z",
  "webhookId": "c7e0a1b2-3c4d-4e5f-9a0b-1c2d3e4f5a6b",
  "data": { "sessionId": "sess_abc123", "status": "ended" }
}
```

### How delivery behaves

- **Retries.** Up to **3 attempts** with full-jitter exponential backoff (each attempt times out
  after **10 seconds**). Redirects (3xx) are **not** followed — a redirect counts as a failure.
- **Acknowledge with a 2xx.** Return any **2xx** status to acknowledge. A non-2xx response, a
  timeout, or a network error is a failed attempt — but a **permanent 4xx** (any 4xx *except* **408**
  and **429**) is terminal: it **fails fast on the first attempt and is never retried** (a
  bad-credentials, wrong-endpoint, or malformed-payload rejection can't be fixed by a replay). Only
  **408**, **429**, and **5xx** responses, timeouts, and network errors are retryable and get the
  full 3 attempts.
- **Auto-disable.** After **3 consecutive failures** a webhook is automatically set inactive so a
  dead endpoint doesn't retry forever. Its already-failed deliveries stay **queued for retry** (they
  are retained, not dropped, while it's disabled) — re-enable it in the panel once your endpoint is
  healthy and those queued deliveries are re-driven; a single success resets the failure count.
  (Queued deliveries still expire under the DLQ retention window if the webhook stays disabled.)
- **History.** Each attempt is recorded per webhook (status, error, timestamps), keeping the most
  recent **500** rows.
- **Deleting a webhook removes its history.** Deleting a webhook also reclaims its recorded
  deliveries in the same operation. As a backstop, the periodic data-retention sweep (**Settings →
  System → Data Retention**) purges any leftover delivery rows of webhooks that are deleted or
  already gone, once they are older than the retention window.
- **Ordering + duplicates.** Delivery is **at-least-once**, not exactly-once or ordered — a lost
  acknowledgement can cause a retry, so make your handler idempotent using `X-AMC-Delivery-Id`.

### Security

- **No internal targets (SSRF).** A callback URL that is private, loopback, link-local, or a
  cloud-metadata address (e.g. `169.254.169.254`) is **rejected** when you save the webhook, and
  again at delivery time if a public host resolves to a private IP.
- **Secret shown once.** The signing secret is returned only on create and rotate, is stored
  encrypted on your machine, and is never included when webhooks are listed.
- **Signatures are your job to check.** Anyone who learns your callback URL can POST to it —
  **always verify the signature** (below) before trusting a delivery.

### Verifying the signature

**This is the one thing to get right.** Each request carries `X-AMC-Signature`, which is:

> **`HMAC-SHA256` of the exact request body, keyed by your webhook's signing secret, encoded as
> lowercase hex.**

To verify, recompute that HMAC on your side and compare.

**The one rule that matters: sign the RAW received body bytes — before any JSON parsing.** The
signature is over the exact bytes Omniscio sent. If you let your framework parse the JSON and then
re-serialize it to verify, the whitespace and key order can differ and the signature will **never**
match. Read the raw body first, verify, *then* parse. Use a **constant-time** comparison to avoid
leaking timing information.

Also worth doing: reject **stale** deliveries by checking `deliveredAt` (e.g. older than a few
minutes) for replay protection, and **de-duplicate** on `X-AMC-Delivery-Id` since delivery is
at-least-once. The **test ping** is signed the exact same way, so you can verify your endpoint with
it before going live.

### Node.js (Express)

```js
const express = require('express')
const crypto = require('crypto')

const app = express()
const SECRET = process.env.OMNISCIO_WEBHOOK_SECRET // the secret shown once on create/rotate

// express.raw keeps the body as the exact received bytes — do NOT use express.json() here.
app.post('/webhooks/omniscio', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body // a Buffer of the exact bytes Omniscio signed
  const expected = crypto.createHmac('sha256', SECRET).update(raw).digest('hex')
  const got = req.get('X-AMC-Signature') || ''

  // Constant-time compare (equal-length hex buffers).
  const valid =
    got.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(got, 'hex'), Buffer.from(expected, 'hex'))
  if (!valid) return res.status(401).send('bad signature')

  const event = JSON.parse(raw.toString('utf8'))

  // Optional — replay protection: reject deliveries older than 5 minutes.
  if (Date.now() - Date.parse(event.deliveredAt) > 5 * 60_000) {
    return res.status(401).send('stale delivery')
  }

  // Optional — idempotency: skip if you've already handled this delivery id.
  // if (alreadyProcessed(req.get('X-AMC-Delivery-Id'))) return res.sendStatus(200)

  handleEvent(event.event, event.data) // your logic here
  res.sendStatus(200) // 2xx acknowledges; anything else is retried
})

app.listen(3000)
```

### Python (Flask)

```python
import hashlib
import hmac
import os
from datetime import datetime, timezone

from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["OMNISCIO_WEBHOOK_SECRET"].encode()  # the secret shown once on create/rotate


@app.post("/webhooks/omniscio")
def omniscio_webhook():
    raw = request.get_data()  # the exact received bytes Omniscio signed (do NOT re-serialize)
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    got = request.headers.get("X-AMC-Signature", "")

    if not hmac.compare_digest(got, expected):  # constant-time compare
        abort(401)

    event = request.get_json()

    # Optional - replay protection: reject deliveries older than 5 minutes.
    delivered = datetime.strptime(event["deliveredAt"], "%Y-%m-%dT%H:%M:%S.%fZ").replace(
        tzinfo=timezone.utc
    )
    if (datetime.now(timezone.utc) - delivered).total_seconds() > 300:
        abort(401)

    # Optional - idempotency: dedupe on request.headers["X-AMC-Delivery-Id"].

    handle_event(event["event"], event["data"])  # your logic here
    return "", 200  # 2xx acknowledges; anything else is retried
```

## For agents

### For developers

- **Engine + wire shape.** `src/main/services/webhook/outbound-webhook-service.ts` — `signPayload`
  (HMAC-SHA256 hex) and `postWithRetry` (the SSRF-guarded, retrying POST + the `X-AMC-*` headers).
- **Event catalog + safe payloads.** `src/main/services/webhook/outbound-webhook-catalog.ts` — the
  curated events and their reshape functions; the shared event ids are
  `src/shared/types/outbound-webhook-events.ts`.
- **Dispatcher.** `src/main/services/webhook/outbound-webhook-dispatcher.ts` — builds the
  `{ event, deliveryId, deliveredAt, webhookId, data }` envelope and applies the on/off gate,
  filter matching, health tracking, and auto-disable.
- **Contract.** The invariants (default-deny catalog, secret-shown-once, SSRF-at-registration,
  off-by-default, two-flag gating) live in
  `.claude/memory/contracts/outbound-webhooks-contract.md`.
- **Signature ↔ docs lock.** `tests/unit/docs/outbound-webhook-signature-sample.test.ts` asserts the
  recipe documented above equals `signPayload()`, so these samples can't silently drift from the
  engine.

## Related

- [mission-control.md](mission-control.md) — the board events that also flow through the workflow engine.
- [cli-control.md](cli-control.md) — the other way an outside process talks to Omniscio, in the opposite direction.
- [automations-and-auto-replies.md](automations-and-auto-replies.md) — reacting to an event from inside the app instead of out.

