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.
Event types
Section titled “Event types”| TYPE | WHEN |
|---|---|
| request.authorized | The quorum turned their keys. Fired once, when it happens, even while the integration is still making its one call. |
| request.denied | An operator refused it. Nothing runs. |
| request.expired | The request TTL ran out with the quorum unmet. Nothing runs. |
| request.aborted | Withdrawn before a verdict, by an operator's ABORT key or by the system. Nothing runs. |
| request.failed | It 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.executed | The 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_failed | The worker holding the claim reported that running the intent failed. The verdict stands and nothing retries it; a new attempt is a new request. |
The post
Section titled “The post”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.
| WHAT | HOW |
|---|---|
| Signature header | Outis-Signature |
| Event id header | Outis-Event-Id |
| Event type header | Outis-Event-Type |
| Delivery id header | Outis-Delivery-Id |
| Key | For 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. |
| Signature | The 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. |
| Retries | A 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. |
Verifying a callback
Section titled “Verifying a callback”# 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
Section titled “Delivery”Delivery never blocks the ceremony. A receiver that misses the retry window reads the same decision from GET /v1/requests/{id}.