Skip to content
OUTIS DOCS

Events and callbacks

A request that named a callback_url is posted each event it fires, and so is every enabled webhook endpoint in the organization that takes that event type. Both get the same envelope and the same signature scheme; only the key differs.

TYPEWHEN
request.authorizedThe quorum turned their keys. Fired once, when it happens, even while the integration is still making its one call.
request.deniedAn operator refused it. Nothing runs.
request.expiredThe request TTL ran out with the quorum unmet. Nothing runs.
request.abortedWithdrawn before a verdict, by an operator's ABORT key or by the system. Nothing runs.
request.failedIt was authorized, and the integration's one call to the platform that asked didn't land: the platform refused it for good, or it was still failing when the retry window closed (the request's TTL, between one and 24 hours). Follows request.authorized for the same request.
request.executedThe worker holding the request's claim ran its sealed intent and reported success. Only a request that carried an intent fires it, and it fires once.
request.execution_failedThe worker holding the claim reported that running the intent failed. The verdict stands and nothing retries it; a new attempt is a new request.
POST <callback_url or endpoint url>
Content-Type: application/json
Outis-Signature: t=<unix seconds>,v1=<hex>
Outis-Event-Id: evt_<32 hex>
Outis-Event-Type: request.denied
Outis-Delivery-Id: dlv_<24 hex>

{
  "id": "evt_<32 hex>",
  "type": "request.denied",
  "created_at": "<RFC 3339>",
  "org": "<organization id>",
  "data": { "request": { ...the request object GET /v1/requests/{id} answers... } }
}

created_at is when the request reached the outcome, or for request.executed and request.execution_failed when your worker reported. It’s never when this try was sent; t in the signature is the send time.

WHATHOW
Signature headerOutis-Signature
Event id headerOutis-Event-Id
Event type headerOutis-Event-Type
Delivery id headerOutis-Delivery-Id
KeyFor a callback URL the key is HMAC-SHA256 with the API key token as the key and the key_info string as the message. The server keeps only this derived key, sealed; you recompute it from the token you hold. For a webhook endpoint the key is the endpoint's secret, the whole whsec_ string as UTF-8 bytes, shown once when the endpoint is created or its secret rotated.
SignatureThe signature header is t=, the unix seconds it was signed at, then ,v1= and the hex HMAC-SHA256 of the timestamp, a period and the raw body, under the key. Compare in constant time, refuse a timestamp more than 300 seconds from your clock, and accept the post if any v1 matches.
RetriesA 2xx is delivered. A URL whose address the server won't post to (a private, loopback or reserved one, checked again at every connection) fails at once and isn't retried. Anything else, a timeout included, is retried after 5 seconds, then 10, 20 and so on, doubling up to an hour between tries and a little less at random, until 72 hours have passed. A redirect is never followed. Every try is kept in the delivery history, and the read route is the record whatever became of the post.
# shell, with openssl. SIG is the Outis-Signature header, body.json the raw body.
T=$(printf '%s' "$SIG" | sed 's/.*t=\([0-9]*\).*/\1/')
KEY=$(printf 'outis/callback-key/v1' | openssl dgst -sha256 -hmac "$OUTIS_KEY" | awk '{print $NF}')
MAC="hexkey:$KEY"
WANT=$( (printf '%s.' "$T"; cat body.json) | openssl dgst -sha256 -mac HMAC -macopt "$MAC" | awk '{print $NF}')
# accept if WANT equals a v1= value (compare in constant time) and T is within 300s of now

// node
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const key = crypto.createHmac("sha256", process.env.OUTIS_KEY).update("outis/callback-key/v1").digest();
const want = crypto.createHmac("sha256", key).update(parts.t + ".").update(rawBody).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
const ok = fresh && parts.v1?.length === want.length &&
  crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(want));

For a webhook endpoint, skip the derivation and key the HMAC with the endpoint’s whsec_ secret itself. Receive webhooks has the full receiver in TypeScript, Python and Go.

Delivery never blocks the ceremony. A receiver that misses the retry window reads the same decision from GET /v1/requests/{id}.