Skip to content
OUTIS DOCS

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.

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.

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.

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 <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.

Do this before you parse the body. It takes four steps and no SDK.

  1. Read the raw request body as bytes, before any JSON middleware touches it.
  2. Split Outis-Signature on commas. t= is the unix time it was signed; each v1= is a signature. There’s usually one v1, but accept the post if any of them matches.
  3. Refuse the post if t is more than 5 minutes from your clock. That’s what stops someone replaying a captured post next week.
  4. Compute HMAC-SHA256 over t, a period, then the raw body, keyed with your whsec_ secret as UTF-8 bytes. Hex encode it and compare with the v1 value 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);
});

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.

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 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.

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.

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.

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.

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.