Outbound Webhooks

Push Omniscio events to your own server, so your systems can react in real time instead of polling.

What it is

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. Every delivery is signed with a secret only you and Omniscio know, retried on failure, and recorded in a per-webhook delivery history.

Where it lives

Outbound Webhooks is an in-development feature, so it ships hidden and off by default. Reveal it via Settings → Lab → "Outbound Webhooks". While it is in development the panel has no sidebar row, so open it by searching Settings for "Outbound Webhooks" — the reveal toggle's own search result goes there too.

There are two independent switches, by design:

Configuring a webhook

From the panel you can also rotate the secret, send a test ping (a real signed delivery you can verify end-to-end), and view the delivery history.

The events you can subscribe to

Only this curated set of events can leave your machine, and each payload is built from an allow-list: no file paths, no raw error text, and no prompt or file content.

Two payload fields carry your own words, so read them before you switch delivery on: a budget event includes your session and project names, and an alert event includes the alert's own body text. The sessionId and alertId values are random UUIDs used for de-duplication; the internal ids they correlate to (projectId, sourceSessionId) and any form or prompt data are never sent.

EventFires whenPayload (data)
agent.startedA session starts runningsessionId, status, statusChangedAt
agent.finishedA session endssessionId, status, statusChangedAt
agent.needs_youA session needs your inputsessionId, status, statusChangedAt, pendingAction?
agent.erroredA session hits an errorsessionId, status, statusChangedAt, pendingAction?
budget.warningA session nears its spend capsessionId, sessionName, projectName, costUSD, capUSD
budget.exceededA session passes its spend capsessionId, sessionName, projectName, costUSD, capUSD
recipe.finishedA recipe run endsrunId, finalStatus, configId, code, endedAt
job.finishedA background job endsjobId, status, runId, retryAttempt, maxRetries
alert.raisedAn inbox alert is raisedtitle, sourceKind, alertId, contentType, textContent, createdAt
chat.webhook_receivedAn external service posts into a Team Chat channelchannelId, text, webhookName, clientMsgId

What a delivery looks like

Omniscio sends an HTTP POST with Content-Type: application/json and these headers:

HeaderMeaning
X-AMC-SignatureHMAC-SHA256 of the request body, as lowercase hex.
X-AMC-EventThe event name, e.g. agent.finished.
X-AMC-Delivery-IdA UUID stable across all retries of this delivery — use it to de-duplicate.
Idempotency-KeyThe same UUID, for receivers that key on the standard header.
Header naming The headers literally begin with X-AMC-. Match the exact names above in your receiver.

The body is a small JSON envelope; event-specific fields are always under data:

{
  "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

Security

Verifying the signature

Each request carries X-AMC-Signature: the 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 parse the JSON and re-serialize it to verify, the whitespace or key order can differ and the signature will never match. Read the raw body, verify with a constant-time comparison, then parse.

Also worth doing: reject stale deliveries by checking deliveredAt for replay protection, and de-duplicate on X-AMC-Delivery-Id. The test ping is signed the same way, so you can verify your endpoint with it before going live.

Node.js (Express)

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')
  }

  handleEvent(event.event, event.data) // your logic here
  res.sendStatus(200) // 2xx acknowledges; anything else is retried
})

app.listen(3000)

Python (Flask)

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)

    handle_event(event["event"], event["data"])  # your logic here
    return "", 200  # 2xx acknowledges; anything else is retried

← Back to Omniscio help