Skip to main content

Webhooks

Webhooks push Cortex domain events to an HTTP endpoint you host. Delivery is reliable: events are persisted before they’re sent, retried with backoff if your endpoint is down, and signed so you can verify they really came from Cortex.

Creating a subscription​

Subscribe to the events you care about (exact names, domain.* wildcards, or * for everything) and provide a secret used to sign deliveries:

import { randomBytes } from "node:crypto";

// Pick any random string and store it with your receiver — it verifies signatures.
// This is NOT your 4Players project secret; it only protects Cortex → your URL.
const webhookSecret = randomBytes(32).toString("hex");

const sub = await project.webhooks.createSubscription({
url: "https://example.com/cortex-webhook",
events: ["session.created", "session.updated", "message.created"],
secret: webhookSecret,
});

Managing subscriptions with an API key requires the webhooks scope.

You can also configure how Cortex authenticates to your endpoint — authType of none (default), api_key (with apiKey and an optional apiKeyHeader, default X-API-Key), basic_auth (basicAuthUsername / basicAuthPassword) or custom_headers (customHeaders) — on top of the signature.

What a delivery looks like​

Each delivery is a POST with a JSON body that wraps the domain event:

POST /cortex-webhook HTTP/1.1
Content-Type: application/json
X-Odin-Signature: sha256=5d41402abc4b2a76b9719d911017c592...

{
"id": "6b0e3c1e-...", // delivery id (stable across retries)
"type": "message.created", // the event type
"timestamp": "2026-10-14T09:12:44.120Z",
"payload": { /* the full domain event: id, type, projectId, payload: { action, data }, ... */ }
}

The event data is at body.payload.payload.data — the outer payload is the domain event, the inner one its action-typed payload.

Verifying signatures​

Every delivery is signed with HMAC-SHA256 over the raw request body, keyed with your subscription secret. The signature arrives in the X-Odin-Signature header as sha256=<hex digest>. Verify it before trusting the payload — and compute it over the exact bytes you received, before any JSON parsing:

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

function verify(rawBody: string | Buffer, signatureHeader: string, secret: string): boolean {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}

Always compare with a constant-time function like timingSafeEqual, never ===.

Reliable delivery (the outbox)​

Cortex writes each matching event to a durable outbox before delivering it, so an endpoint that’s temporarily down never loses events:

  • On 2xx, the delivery is marked delivered. Respond within 10 seconds — a slower response counts as a failed attempt.
  • On error or non-2xx, it’s retried with exponential backoff (after roughly 1 minute, 5 minutes, 30 minutes, 2 hours and 24 hours) until it succeeds or the attempt limit is reached; then it is marked failed.
  • Each attempt is recorded so you can audit delivery history, and every status change emits a webhook.delivery.updated event you can subscribe to for alerting.
Make your receiver idempotent

Because failed deliveries are retried, your endpoint may receive the same event more than once. Deduplicate by the event id (body.payload.id) and make handling idempotent.

Inspecting deliveries​

List the delivery queue (pending / delivered / failed attempts) to debug integration issues:

const failed = await project.webhooks.listEvents({ status: "failed" });

// ...or keep a live view of the delivery queue:
project.webhooks.watchEvents().onSnapshot((events) => { /* ... */ });

Webhooks vs SSE vs functions​

All three are driven by the same domain-event catalog:

SurfaceBest forDelivery
WebhooksServer-to-server integrations with your backendDurable, retried, signed
SSELive dashboards / UIReal-time stream, no replay
FunctionsCustom logic you don’t want to hostDurable (same outbox path)