Skip to content
OUTIS DOCS

Receive callbacks

Name a callback_url when you create a request and each event that request fires is posted there, signed with a key only you and Outis can compute. The body is an event envelope whose data.request is exactly what GET /v1/requests/{id} answers.

For every request in your organization instead of one, add a webhook endpoint. It gets the same envelope, signed the same way.

The URL must be absolute https to a public address. Loopback, private ranges, link local, shared address space and cloud metadata addresses are refused with a 422 when you create the request, and checked again when the post connects. Redirects aren’t followed.

A callback is signed under the API key the request was created with, so a request naming one has to arrive with a key.

One post per event. A request fires one verdict, maybe request.failed after it, and one execution event if it carried an intent:

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.

request.authorized goes out the moment the quorum turns their keys. If the integration that owns the action makes a call back to the platform that asked (merging the pull request, say), that happens after, and request.failed follows if the platform refused it for good or the call was still failing when its retry window closed.

POST <callback_url>
Content-Type: application/json
Outis-Signature: t=<unix seconds>,v1=<hex>
Outis-Event-Id: evt_3f1c9a27b0e84d65a1c2b7e9d4f06a18
Outis-Event-Type: request.authorized
Outis-Delivery-Id: dlv_8e2d41c07b9a5f3e6d1c2b0a

{
  "id": "evt_3f1c9a27b0e84d65a1c2b7e9d4f06a18",
  "type": "request.authorized",
  "created_at": "2026-09-27T12:00:00Z",
  "org": "org_7c1d2e",
  "data": { "request": { ...the request object... } }
}

The key is derived from your API key: HMAC-SHA256 with the token as the key and outis/callback-key/v1 as the message. There’s no second secret to exchange, and Outis only ever stores the derived key.

Then it’s the same four steps as a webhook:

  1. Read the raw body as bytes.
  2. Split Outis-Signature on commas into t and one or more v1.
  3. Refuse a t more than 5 minutes from your clock.
  4. HMAC-SHA256 over t, a period and the raw body under the derived key, hex encoded, compared with v1 in constant time.
import crypto from "node:crypto";

const key = crypto.createHmac("sha256", process.env.OUTIS_KEY!).update("outis/callback-key/v1").digest();

app.post("/outis/decision", express.raw({ type: "application/json" }), async (req, res) => {
  if (!verify(req.body, req.get("Outis-Signature") ?? "", key)) return res.status(401).end();
  const event = JSON.parse(req.body.toString("utf8"));
  if (!(await firstTime(event.id))) return res.status(204).end();
  res.status(204).end();
  if (event.type === "request.authorized") await queueDeploy(event.data.request.params);
});

verify is the same function in every language as on Receive webhooks; only the key differs.

A retry carries the same Outis-Event-Id, so record the ids you’ve handled and skip one you’ve seen. The id depends only on the request and the event type, which means a callback and a webhook endpoint that both get the event see the same id too.

Answer 2xx as soon as the signature checks out, then do the work. 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.

When request.authorized arrives, your system runs the operation with its own credentials. Outis holds none of them and runs nothing.

Delivery never blocks the ceremony. If your receiver misses the retry window, GET /v1/requests/{id} has the same answer.