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.
tis part of the signed text, so an attacker cannot replay an old request with a new timestamp. Reject atthat is too far from your own clock; five minutes is a reasonable window.v1is 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
digestfield and thedigestandoverflow_summarykinds 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.