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.
Naming one
Section titled “Naming one”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.
What gets posted
Section titled “What gets posted”One post per event. A request fires one verdict, maybe request.failed after it, and one execution event if it carried an intent:
| 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. |
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... } }
}
Verifying the signature
Section titled “Verifying the signature”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:
- Read the raw body as bytes.
- Split
Outis-Signatureon commas intotand one or morev1. - Refuse a
tmore than 5 minutes from your clock. - HMAC-SHA256 over
t, a period and the raw body under the derived key, hex encoded, compared withv1in 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);
});
import hashlib, hmac, json, os
key = hmac.new(os.environ["OUTIS_KEY"].encode(), b"outis/callback-key/v1", hashlib.sha256).digest()
@app.post("/outis/decision")
def decision():
raw = request.get_data()
if not verify(raw, request.headers.get("Outis-Signature", ""), key):
abort(401)
event = json.loads(raw)
if first_time(event["id"]) and event["type"] == "request.authorized":
queue_deploy(event["data"]["request"]["params"])
return "", 204
func callbackKey(token string) []byte {
m := hmac.New(sha256.New, []byte(token))
m.Write([]byte("outis/callback-key/v1"))
return m.Sum(nil)
}
var key = callbackKey(os.Getenv("OUTIS_KEY"))
func decision(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil || !verify(body, r.Header.Get("Outis-Signature"), key, time.Now()) {
w.WriteHeader(http.StatusUnauthorized)
return
}
// Parse, dedupe on the event id, answer 204, then act.
}
# 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));
verify is the same function in every language as on Receive webhooks; only the key differs.
Dedupe on the event id
Section titled “Dedupe on the event id”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.
Answering
Section titled “Answering”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.
You execute, Outis doesn’t
Section titled “You execute, Outis doesn’t”When request.authorized arrives, your system runs the operation with its own credentials. Outis holds none of them and runs nothing.
The request route is the record
Section titled “The request route is the record”Delivery never blocks the ceremony. If your receiver misses the retry window, GET /v1/requests/{id} has the same answer.