Receive webhooks
A webhook endpoint gets every request event in your organization, whichever integration or API key asked for the request. A callback URL gets one request’s events and an endpoint gets all of them, in the same envelope and signed the same way, under a different key.
Outis decides, you execute
Section titled “Outis decides, you execute”An event tells you what the humans decided, and that’s all it does. When request.authorized lands, your system runs the operation with its own credentials, after its own checks. Outis never holds those credentials and never runs the operation. If you don’t act on the event, nothing happens.
Adding an endpoint
Section titled “Adding an endpoint”An admin adds an endpoint from the dashboard, which calls POST /api/webhooks behind their session. Give it an https URL and, if you like, the event types it should get. Leave the types out and it gets every one.
The URL has to be a public address. Loopback, private ranges, link local, shared address space and cloud metadata addresses are refused when you save the endpoint, and checked again every time a post connects, so a hostname that later resolves inside your network is still refused. Redirects aren’t followed.
The response carries the endpoint’s signing secret, whsec_ and 43 characters. It’s shown once. Store it where your receiver can read it; if you lose it, rotate the endpoint for a new one. After a rotation every post is signed with the new secret, retries of older events included.
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 <your endpoint>
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 GET /v1/requests/{id} answers... } }
}
data.request is the same object the decision route returns, so params is what the operators saw and approved. Act on that, not on your own copy.
Verifying the signature
Section titled “Verifying the signature”Do this before you parse the body. It takes four steps and no SDK.
- Read the raw request body as bytes, before any JSON middleware touches it.
- Split
Outis-Signatureon commas.t=is the unix time it was signed; eachv1=is a signature. There’s usually onev1, but accept the post if any of them matches. - Refuse the post if
tis more than 5 minutes from your clock. That’s what stops someone replaying a captured post next week. - Compute HMAC-SHA256 over
t, a period, then the raw body, keyed with yourwhsec_secret as UTF-8 bytes. Hex encode it and compare with thev1value in constant time.
import crypto from "node:crypto";
import express from "express";
const TOLERANCE_SECONDS = 300;
function verify(raw: Buffer, header: string, key: crypto.BinaryLike): boolean {
let t = "";
const sigs: string[] = [];
for (const part of header.split(",")) {
const [k, v] = part.trim().split("=");
if (k === "t") t = v;
if (k === "v1") sigs.push(v);
}
if (!/^\d+$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false;
const want = Buffer.from(crypto.createHmac("sha256", key).update(`${t}.`).update(raw).digest("hex"));
return sigs.some((s) => s.length === want.length && crypto.timingSafeEqual(Buffer.from(s), want));
}
const app = express();
app.post("/outis/events", express.raw({ type: "application/json" }), async (req, res) => {
if (!verify(req.body, req.get("Outis-Signature") ?? "", process.env.OUTIS_WEBHOOK_SECRET!)) {
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, time
from flask import Flask, abort, request
TOLERANCE_SECONDS = 300
app = Flask(__name__)
def verify(raw: bytes, header: str, key: bytes) -> bool:
t, sigs = "", []
for part in header.split(","):
k, _, v = part.strip().partition("=")
if k == "t":
t = v
elif k == "v1":
sigs.append(v)
if not t.isdigit() or abs(time.time() - int(t)) > TOLERANCE_SECONDS:
return False
want = hmac.new(key, t.encode() + b"." + raw, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(s, want) for s in sigs)
@app.post("/outis/events")
def events():
raw = request.get_data()
if not verify(raw, request.headers.get("Outis-Signature", ""), os.environ["OUTIS_WEBHOOK_SECRET"].encode()):
abort(401)
event = json.loads(raw)
if first_time(event["id"]) and event["type"] == "request.authorized":
queue_deploy(event["data"]["request"]["params"])
return "", 204
const tolerance = 5 * time.Minute
func verify(body []byte, header string, key []byte, now time.Time) bool {
var ts string
var sigs []string
for _, part := range strings.Split(header, ",") {
k, v, _ := strings.Cut(strings.TrimSpace(part), "=")
switch k {
case "t":
ts = v
case "v1":
sigs = append(sigs, v)
}
}
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return false
}
if d := now.Sub(time.Unix(sec, 0)); d > tolerance || d < -tolerance {
return false
}
m := hmac.New(sha256.New, key)
m.Write([]byte(ts + "."))
m.Write(body)
want := []byte(hex.EncodeToString(m.Sum(nil)))
for _, s := range sigs {
if hmac.Equal([]byte(s), want) {
return true
}
}
return false
}
func events(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"), []byte(os.Getenv("OUTIS_WEBHOOK_SECRET")), time.Now()) {
w.WriteHeader(http.StatusUnauthorized)
return
}
var event struct {
ID string `json:"id"`
Type string `json:"type"`
Data struct {
Request struct {
Params map[string]string `json:"params"`
} `json:"request"`
} `json:"data"`
}
if err := json.Unmarshal(body, &event); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
if firstTime(event.ID) && event.Type == "request.authorized" {
queueDeploy(event.Data.Request.Params)
}
}
# shell, with openssl. SIG is the Outis-Signature header, body.json the raw body.
T=$(printf '%s' "$SIG" | sed 's/.*t=\([0-9]*\).*/\1/')
MAC="key:$OUTIS_WEBHOOK_SECRET"
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 = process.env.OUTIS_WEBHOOK_SECRET;
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));
The HMAC covers the t from the header, so use that value rather than your own clock. And compare in constant time, because a plain == leaks how many characters matched.
Dedupe on the event id
Section titled “Dedupe on the event id”An event’s id depends only on the request and the event type. A retry carries the same id, and so does the same event arriving at a callback URL and an endpoint. Record the ids you’ve handled (a unique column works) and answer 2xx without acting on one you’ve seen. firstTime in the samples is that check.
Outis-Delivery-Id is different: it names one receiver’s delivery of the event, and it’s what the delivery history shows. Don’t dedupe on it.
Answer fast
Section titled “Answer fast”Answer 2xx as soon as the signature checks out and the id is recorded, then do the work. Outis waits ten seconds for an answer before it counts the try as failed.
Retries
Section titled “Retries”That schedule starts at 5 seconds and tops out at 1 hour between tries, so an endpoint that’s down for an afternoon catches up on its own. Past 72 hours the delivery is marked failed, and it’s written to your organization’s audit chain.
Disable an endpoint and nothing new is queued for it; deliveries still pending to it fail on their next try. Delete it and the same happens.
Delivery history
Section titled “Delivery history”Every try is kept: when it started and finished, the status your receiver answered and the error, if any. Admins read it per endpoint (GET /api/webhooks/{id}/deliveries) and per request (GET /api/requests/{id}/deliveries), which also shows the callback and the integration’s own call. When a receiver has been down, that’s where you find out what it missed.
The decision route is the record
Section titled “The decision route is the record”A post that never lands doesn’t change the decision. GET /v1/requests/{id} answers the same thing whatever became of the post, so a receiver that was down past the retry window can read what it missed.