Omniscio documentation
Browse all documentation
  1. Getting Started13
  2. Sessions & Agents115
  3. Inbox & Notifications59
  4. Projects & Tasks95
  5. Automation & Scheduling75
  6. Knowledge & Memory26
  7. AI Features60
  8. Integrations100
  9. Plugins & Marketplace33
  10. Cloud & Teams56
  11. Settings & Customization58
  12. Account & Billing28
  13. Troubleshooting84
  14. CLI & API Reference22
  15. Legal & Policies4
  16. Uncategorised22

Outbound Webhooks (push Omniscio events to your own server)

When something happens in Omniscio — an agent finishes, a budget is exceeded, a recipe completes — the app can POST a signed JSON payload to a callback URL you control, so your own systems react in real time instead of polling. Covers configuring one, the events you can subscribe to, and how to verify a delivery really came from here.

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:

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

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)

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

Last verified 2026-09-28