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:
- The Lab reveal — shows or hides the panel (the in-development gate).
- The master delivery toggle — turns delivery on or off for the whole app. It is off by default, and pausing it never hides the panel. Nothing is delivered until it is on and at least one active webhook exists.
Configuring a webhook
- Name — a label for your reference.
- Callback URL — the public
https://endpoint Omniscio POSTs to. Private, loopback, and cloud-metadata addresses are rejected when you save. - Events — which events this webhook receives. Leave it empty to receive every event, or pick a subset.
- Active — turn a webhook on or off without deleting it.
- Signing secret — used to sign every delivery. It is shown exactly once, on create (and again only when you rotate it). Copy it then and store it safely — Omniscio never shows it again.
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.
| Event | Fires when | Payload (data) |
|---|---|---|
agent.started | A session starts running | sessionId, status, statusChangedAt |
agent.finished | A session ends | sessionId, status, statusChangedAt |
agent.needs_you | A session needs your input | sessionId, status, statusChangedAt, pendingAction? |
agent.errored | A session hits an error | sessionId, status, 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, 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 |
What a delivery looks like
Omniscio sends an HTTP POST with Content-Type: application/json and these headers:
| Header | Meaning |
|---|---|
X-AMC-Signature | HMAC-SHA256 of the request body, as lowercase hex. |
X-AMC-Event | The event name, e.g. agent.finished. |
X-AMC-Delivery-Id | A UUID 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. |
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
- Retries — up to 3 attempts with jittered backoff; each attempt times out after 10 seconds. Redirects are not followed.
- Acknowledge with a 2xx — return any 2xx to acknowledge. A non-2xx, a timeout, or a network error is retried.
- Auto-disable — after 3 consecutive failures a webhook is set inactive so a dead endpoint doesn't retry forever; re-enable it in the panel, and one success resets the count.
- History — each attempt is recorded per webhook (status, error, timestamps), keeping the most recent 500 rows.
- At-least-once — a lost acknowledgement can cause a retry, so make your handler idempotent using
X-AMC-Delivery-Id.
Security
- No internal targets — a private, loopback, or cloud-metadata callback URL is rejected when you save, 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, stored encrypted on your machine using the OS keyring, and never included when webhooks are listed. On a machine where the OS keyring is unavailable (a headless launch, or a credential store the OS cannot unlock) Omniscio still creates the webhook and stores the secret in plain text rather than refusing, so treat the database file as sensitive on such a machine.
- Verify every delivery — anyone who learns your callback URL can POST to it, so always verify the signature before trusting a delivery.
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.
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