Okapi

Webhooks

A Webhook notification channel sends each alert to your own https:// endpoint as a signed JSON POST. Use it to feed alerts into an incident tool, a chat system Okapi does not support, or your own automation.

The request

Method POST
Content-Type application/json
User-Agent Okapi/<version>
X-Okapi-Signature t=<unix seconds>,v1=<hex>

Answer with any 2xx status within 10 seconds. Okapi does not follow redirects. A 429, a 5xx, or a timeout is retried; any other 4xx fails the delivery permanently. See Delivery and retries.

Verifying the signature

Every delivery is signed; Okapi never sends an unsigned webhook. The signature is:

v1 = lowercase hex of HMAC-SHA256(signing secret, "<t>.<raw request body>")
  • The signing secret is the 64-character hex string that Reveal shows on the channel. Use the string itself as the key, as UTF-8 text. Do not hex-decode it. Refuse to start your receiver without it: an empty key lets anyone compute a valid signature.
  • t is part of the signed text, so an attacker cannot replay an old request with a new timestamp. Reject a t that is too far from your own clock; five minutes is a reasonable window.
  • v1 is always exactly 64 lowercase hex characters. Reject anything else before you decode it, so a truncated or padded signature can never match.
  • Compute the HMAC over the raw body bytes as received. Do not parse and re-serialize the JSON first: Okapi's encoder writes <, >, and & as the escapes \u003c, \u003e, and \u0026, so a re-serialized body has different bytes and the signature does not match.
  • Compare in constant time.

In TypeScript, with Node's crypto:

import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;
const SIGNATURE_PATTERN = /^[0-9a-f]{64}$/;

// Call this once at startup, so a missing secret stops the receiver
// instead of verifying against an empty key.
export function loadSigningSecret(value: string | undefined): string {
  if (value === undefined || value.trim() === "") {
    throw new Error("The Okapi webhook signing secret is not set; refusing to start.");
  }
  return value.trim();
}

export function verifyOkapiSignature(
  rawBody: Buffer,
  header: string | undefined,
  secret: string,
  nowSeconds: number = Math.floor(Date.now() / 1000),
): boolean {
  if (secret === "") throw new Error("Refusing to verify with an empty signing secret.");
  if (!header) return false;

  let timestamp: string | undefined;
  let signature: string | undefined;
  for (const part of header.split(",")) {
    const separator = part.indexOf("=");
    if (separator === -1) continue;
    const key = part.slice(0, separator);
    const value = part.slice(separator + 1);
    if (key === "t") timestamp = value;
    if (key === "v1") signature = value;
  }
  if (!timestamp || !/^\d+$/.test(timestamp)) return false;
  if (!signature || !SIGNATURE_PATTERN.test(signature)) return false;
  if (Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest();
  // The pattern check guarantees 32 bytes, so the lengths always match.
  const received = Buffer.from(signature, "hex");
  return timingSafeEqual(received, expected);
}

interface DigestIssue {
  shortId: string;
  title: string;
  url: string;
}

interface OkapiWebhookPayload {
  kind: string;
  title: string;
  body: string;
  url: string;
  digest?: {
    headline: string;
    sections: { projectName: string; countsLine: string; topIssues: DigestIssue[] | null }[] | null;
  };
}

// One line per thing worth showing. topIssues is null when a project
// section has no issues to link, so treat null as an empty list.
export function summarizeOkapiPayload(payload: OkapiWebhookPayload): string[] {
  const lines = [`${payload.kind}: ${payload.title}`];
  for (const section of payload.digest?.sections ?? []) {
    lines.push(`${section.projectName}: ${section.countsLine}`);
    for (const issue of section.topIssues ?? []) {
      lines.push(`  ${issue.shortId} ${issue.title} ${issue.url}`);
    }
  }
  return lines;
}

With Express, read the body raw so the bytes are the ones Okapi signed:

import express from "express";
import { loadSigningSecret, summarizeOkapiPayload, verifyOkapiSignature } from "./verify-okapi-signature";

const secret = loadSigningSecret(process.env.WEBHOOK_SIGNING_SECRET);
const app = express();

app.post("/okapi-webhook", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyOkapiSignature(req.body, req.header("X-Okapi-Signature"), secret)) {
    res.sendStatus(401);
    return;
  }

  const payload = JSON.parse(req.body.toString("utf8"));
  for (const line of summarizeOkapiPayload(payload)) console.log(line);
  res.sendStatus(204);
});

app.listen(3000);

Rotate on the channel replaces the signing secret immediately. Deploy the new secret to your receiver at the same time, or it rejects the deliveries that follow.

Payload fields

The body is one JSON object. Fields marked optional are left out when they have no value.

Field Type Present Meaning
kind string always What happened — see Kinds.
title string always One-line headline: the event's title for an issue alert.
body string always Detail under the title; can be empty.
url string always Dashboard link for the subject; can be empty.
level string optional Event level: error, warning, …
environment string optional The event's environment.
projectName string optional The project the alert fired in.
issueShortId string optional The issue's short id, such as CHK-42.
timesSeen number optional The issue's event count.
location string optional The event's culprit (file and function).
ruleName string optional The rule that fired.
digest object optional The summary for digest and overflow_summary — see below. Not in a released self-host version yet.

Event-derived text (title, location, and so on) comes from your application's errors. Treat it as untrusted input.

The contract is additive. Okapi may add fields and kind values, but never renames or removes them. Ignore fields you do not recognize, and handle an unknown kind without failing.

Kinds

kind Sent for
new_issue the New issue occurs trigger
regression Resolved issue comes back
frequent Issue spikes
many_users Issue affects many people
milestone Issue hits a milestone
every_occurrence Every time issue occurs
new_release New release appears
test Send test on the channel or a rule
digest a scheduled digest. Not in a released self-host version yet.
overflow_summary an overflow summary. Not in a released self-host version yet.

An issue alert's kind is the name of the trigger that fired.

Examples

These are formatted for reading. The real body is compact JSON on one line.

An issue alert (regression here; every issue trigger has this shape):

{
  "kind": "regression",
  "title": "TypeError: Cannot read properties of undefined (reading 'total')",
  "body": "Resolved issue comes back · error · seen 318×",
  "url": "https://errors.example.com/organizations/acme/projects/checkout/issues/CHK-42",
  "level": "error",
  "environment": "production",
  "projectName": "Checkout",
  "issueShortId": "CHK-42",
  "timesSeen": 318,
  "location": "src/cart/CartSummary.tsx in CartSummary",
  "ruleName": "Resolved issue comes back"
}

A new release:

{
  "kind": "new_release",
  "title": "New release: checkout@2.14.0",
  "body": "New release appears",
  "url": "https://errors.example.com/organizations/acme/projects/checkout/releases",
  "projectName": "Checkout",
  "ruleName": "New release appears"
}

A channel's Send test. A rule's Send test also sends kind test, with the fields of an issue alert for the sample issue TEST-1.

{
  "kind": "test",
  "title": "Okapi test notification",
  "body": "This is a test delivery sent from Okapi's notification settings. No issue triggered it.",
  "url": "https://errors.example.com"
}

Digest and overflow summary payloads

Not in a released self-host version yet. The digest field and the digest and overflow_summary kinds are new.

Both carry a digest object: a headline and one entry in sections per project. Each section has projectName, a countsLine, and topIssues.

Field Type Meaning
digest.headline string The same text as title.
digest.sections array One entry per project with something to report.
sections[].projectName string The project.
sections[].countsLine string The project's counts, for example 142 events · 12 new issues.
sections[].topIssues array or null Up to five { shortId, title, url } entries. It is null, not an empty array, when the section has no issue to link — for example a project whose period had only resolutions.

A weekly digest:

{
  "kind": "digest",
  "title": "Weekly digest — Checkout: 3 new issues, 1 regression",
  "body": "1204 events · 3 new issues · 1 regression · 2 resolved",
  "url": "https://errors.example.com/organizations/acme/projects/checkout/issues",
  "projectName": "Checkout",
  "ruleName": "Weekly digest",
  "digest": {
    "headline": "Weekly digest — Checkout: 3 new issues, 1 regression",
    "sections": [
      {
        "projectName": "Checkout",
        "countsLine": "1204 events · 3 new issues · 1 regression · 2 resolved",
        "topIssues": [
          {
            "shortId": "CHK-42",
            "title": "TypeError: Cannot read properties of undefined (reading 'total')",
            "url": "https://errors.example.com/organizations/acme/projects/checkout/issues/CHK-42"
          }
        ]
      }
    ]
  }
}

An overflow summary. Its sections link to the project, not to an issue: shortId is "Project" and title is "Open project".

{
  "kind": "overflow_summary",
  "title": "14 more alerts were withheld or delayed in the last hour · 2026-10-04 14:00–2026-10-04 15:00 UTC",
  "body": "14 more alerts were withheld or delayed in the last hour · 2026-10-04 14:00–2026-10-04 15:00 UTC\nIncludes rate-limit delays and repeated refusals; this is not a count of distinct issues.",
  "url": "https://errors.example.com/organizations/acme/projects/checkout/issues",
  "digest": {
    "headline": "14 more alerts were withheld or delayed in the last hour · 2026-10-04 14:00–2026-10-04 15:00 UTC",
    "sections": [
      {
        "projectName": "Checkout",
        "countsLine": "14 more alerts were withheld or delayed",
        "topIssues": [
          {
            "shortId": "Project",
            "title": "Open project",
            "url": "https://errors.example.com/organizations/acme/projects/checkout/issues"
          }
        ]
      }
    ]
  }
}

Next: Quotas & usage.