Every tool call, pushed.
The activity log and the REST API are both pulls — somebody has to come and ask. A webhook is the same audit row POSTed to a URL you own as the call finishes, signed so you can prove it came from here.
Overview
Up to 5 endpoints per account, each subscribed to whichever events it cares about, each with its own signing secret. A delivery is one POST carrying a single event as JSON.
- Transport
- HTTPS only. A plain-http URL is refused when the endpoint is created, as are localhost, .local, .internal and private IPv4 ranges — this is a request our servers make, and an address only reachable from inside a network must never become one.
- Method
- POST, always. Redirects are not followed: a 3xx is recorded as a failed delivery rather than re-presenting your signature somewhere you did not sign up for.
- Timeout
- 8 seconds to respond. Any 2xx counts as delivered; everything else is a failure.
- Ordering
- Not guaranteed. Endpoints are POSTed concurrently and independently, so two calls a millisecond apart may arrive in either order. Sort on createdAt if order matters.
- Delivery
- At-most-once. There are no retries — see Failures below for what that means and what to do about it.
Events
Two, both about a completed tool call. Every event carries the same body shape, with the outcome in data.ok.
- tool_call.succeeded
- An upstream tool ran and returned a result.
- tool_call.failed
- A call was refused, or the upstream server returned an error.
Discovery calls — search_tools and describe_tool — do not fire a webhook. They are counted for billing but not stored as audit rows, and an event stream of everything an agent looked at would bury the calls it actually made.
tool_call.failed covers both kinds of failure: an upstream that returned an error, and a call Omniio itself refused — a spent quota, the burst limit, a policy that held it. data.error says which, in a sentence.
Delivery format
POST /hooks/omniio HTTP/1.1host: hooks.acme.comcontent-type: application/jsonuser-agent: Omniio (+https://omniio.dev)omniio-event: tool_call.succeededomniio-delivery: 6f1c2d20-8f5e-4a1e-9a1b-2c3d4e5f6a7bomniio-timestamp: 1760000000omniio-signature: v1=6d2f9c1a…{ "id": "0f4a6f0c-1b7e-4a3f-9c21-6d8a1f5b2c34", "event": "tool_call.succeeded", "createdAt": "2026-01-14T09:31:04.812Z", "data": { "clientId": "claude-code", "server": "GitHub", "serverSlug": "github", "tool": "create_pull_request", "qualifiedName": "github__create_pull_request", "ok": true, "durationMs": 812, "error": null, "arguments": { "owner": "acme", "repo": "web", "title": "Fix login" }, "result": { "url": "https://github.com/acme/web/pull/412" } }}Headers
- content-typeapplication/json
- The body is UTF-8 JSON. There is no other encoding and no form variant.
- user-agentOmniio (+https://omniio.dev)
- Constant. Useful for a WAF allowlist, but never sufficient on its own — anyone can send it. The signature is the proof.
- omniio-eventstring
- The event name, the same value as the body's event field. Present so a receiver can route or reject before parsing.
- omniio-deliveryUUID
- Unique to this attempt. Since there are no retries this is one-to-one with the event, but it is the right thing to log when you report a delivery problem.
- omniio-timestampinteger
- Unix seconds at the moment of signing. Part of the signed string, and what makes the replay window enforceable.
- omniio-signaturev1=<hex>
- HMAC-SHA256, lowercase hex, prefixed with the scheme version. The prefix is there so a future algorithm change is expressible rather than silently breaking every receiver.
Body
- idstring
- The audit row's UUID. The same id GET /activity returns for this call — use it to deduplicate and to reconcile.
- eventstring
- One of the event names above, or "test" for a test delivery.
- createdAtstring
- ISO 8601 UTC, when the call finished. Sort on this, not on arrival order.
- dataobject
- The call itself. Fields below.
Payload reference
Everything under data. These are the same fields GET /activity returns for the same call, so one receiver can consume both.
- clientIdstring | null
- Which connected client made the call. Null for calls made before client identification, or from a client that registered without one.
- serverstring
- The upstream server's display name.
- serverSlugstring
- Its slug — the half of a qualified name before the separator.
- toolstring
- The upstream tool's own name, unqualified.
- qualifiedNamestring
- The name the agent called, "slug__tool".
- okboolean
- Whether the upstream returned a result. Matches the event name; both are sent so a receiver can branch on either.
- durationMsnumber | null
- How long the upstream took, in milliseconds. Null where the call never reached one.
- errorstring | null
- The failure, in a sentence. Null when ok is true.
- argumentsobject | string | null
- What was sent upstream, parsed back to structure. Past 64 KB it is stored truncated, and arrives as a string ending in a sentence saying how many bytes there were and how many were kept.
- resultobject | string | null
- What came back, under the same rule. Null on a failed call.
A failed call, for comparison:
{ "id": "b1c8e4d2-77a0-4a63-8e2f-59d0c1a4b7e6", "event": "tool_call.failed", "createdAt": "2026-01-14T09:33:11.402Z", "data": { "clientId": "claude-code", "server": "Linear", "serverSlug": "linear", "tool": "create_issue", "qualifiedName": "linear__create_issue", "ok": false, "durationMs": 4021, "error": "The upstream server answered 401.", "arguments": { "team": "ENG", "title": "Login loops on Safari" }, "result": null }}Verifying a delivery
Anyone who learns your URL can POST to it, so a delivery is only worth acting on if you can prove it came from here. Each one carries an HMAC-SHA256 over `${timestamp}.${body}`, keyed with the signing secret shown once when the endpoint is created.
- Algorithm
- HMAC-SHA256, digested as lowercase hex and sent as v1=<hex>.
- Signed string
- The timestamp header, a full stop, then the raw request body, concatenated with no separator beyond that stop.
- Key
- The endpoint's signing secret, whsec_ followed by 32 random bytes, base64url. Shown once at creation and re-issuable at any time.
- Replay window
- 5 minutes. Reject a timestamp further than that from your own clock in either direction.
- Comparison
- Constant-time. A byte-by-byte comparison that returns early leaks how much of a forged signature was right, one request at a time.
import { createHmac, timingSafeEqual } from "node:crypto";export function verify(req, body, secret) { const signature = req.headers["omniio-signature"]; const timestamp = Number(req.headers["omniio-timestamp"]); // Reject anything older than the window before doing the maths, so a captured // delivery cannot be replayed later. const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp); if (!Number.isFinite(timestamp) || age > 300) return false; // The timestamp is inside the signed string. Signing the body alone would let // a replay simply rewrite the timestamp to now. const mac = createHmac("sha256", secret) .update(`${timestamp}.${body}`) .digest("hex"); const a = Buffer.from(`v1=${mac}`); const b = Buffer.from(signature ?? ""); return a.length === b.length && timingSafeEqual(a, b);}Why the timestamp is inside the signature rather than beside it: without it a signature is valid forever, and anyone who captures one delivery can replay it at any point in the future. With it, changing the timestamp invalidates the MAC.
import express from "express";const app = express();// The signature covers the exact bytes we sent. Parse to JSON *after*// verifying — a body that has been through JSON.parse and back is a different// string, and will not match.app.post( "/hooks/omniio", express.raw({ type: "application/json" }), (req, res) => { const body = req.body.toString("utf8"); if (!verify(req, body, process.env.OMNIIO_WEBHOOK_SECRET)) { return res.status(401).end(); } // Answer first, work afterwards: the delivery is abandoned after // 8 seconds and there is no second attempt. res.status(204).end(); void enqueue(JSON.parse(body)); },);Failures and retries
Delivery is at-most-once. One attempt, 8 seconds, and the outcome recorded on the endpoint for you to see — the status it answered with, the time, and the error if there was one.
- Success
- Any 2xx. The failure count resets to zero.
- Failure
- A non-2xx status, a redirect, a connection that could not be made, or no answer within 8 seconds. The count increments and the reason is stored.
- Auto-disable
- After 20 consecutive failures the endpoint switches itself off and the last error is kept. Not one — a single 502 is a deploy, not a dead URL — and not unlimited either, because an abandoned URL should not be hammered forever.
- Recovery
- Switching an endpoint back on clears the count, so it does not arrive back one bad delivery away from disabling itself again.
Answer quickly and do the work afterwards. A receiver that writes to a queue and returns 204 immediately will never time out; one that runs the whole downstream job inline will, on the day the job gets slow, and there is no second attempt to save it.
Testing
Every endpoint has a send test action that makes a real, signed delivery with omniio-event: test. It exercises the whole path — DNS, TLS, your signature check — because a pasted URL is otherwise unfalsifiable until the first real event fires, and a broken signature check is the thing you want to find on purpose rather than by accident.
{ "id": "3f7c0b18-9d2a-4d61-9a44-0b6e8c2d5f31", "event": "test", "createdAt": "2026-01-14T09:20:00.000Z", "data": { "message": "Omniio test delivery. A real one carries the same headers and a tool call under `data`." }}The body shape is the same; only data differs. A receiver that switches on event should ignore test rather than try to read a tool call out of it.
Managing endpoints
- Create
- Paste an https URL and choose events. The signing secret is shown once, at that moment, and never again — we store it sealed and cannot read it back to you. Up to 5 endpoints, each URL only once.
- Roll the secret
- Issues a new one and shows it once. The old secret stops working immediately, so deploy the new one first if you cannot tolerate a gap — a leaked secret has to be replaceable without tearing the endpoint down and losing the events in between.
- Pause
- Stops deliveries without losing the endpoint or its subscription. Turning it back on clears any failure count.
- Delete
- Removes the endpoint and its secret outright.
Endpoints are managed in the app, not over the API: a webhook secret is a credential, and issuing one to whoever holds an API key would let one credential mint another.