Skip to main content

Client API Webhooks

Webhook infrastructure is designed for public-beta readiness but is currently gated while the Client API remains in internal beta. This page documents the contract and operational guidance so you can build and test your receiver now. For production runbooks, see: /sources/client-api-webhooks-ops.

Summary: what you must do

  1. Verify signatures using the raw request body (when Omni-Signature is present).
  2. Enforce a replay window using the included timestamp.
  3. Dedupe using event.id (safe even if retries are added later).
  4. Return 2xx quickly and process asynchronously.
  5. If Omni-Signature is absent, treat the delivery as unsigned and follow the guidance below.

Event families

  • usage.threshold_reached
  • invoice.finalized
  • invoice.payment_failed
  • api_key.revoked

Delivery model (operational contract)

Current (internal beta)

  • At-least-once intent with bounded retry for transient delivery failures.
  • Retry classes: network error (status=0), 408, 429, 5xx.
  • Default retry profile: 3 attempts, exponential backoff from 500ms with jitter.
  • Retry settings are exposed in internal beta diagnostics as retryPolicy.maxAttempts and retryPolicy.baseDelayMs.

Target (public-beta-ready)

  • At-least-once delivery.
  • Exponential retry with bounded maximum attempts.
  • Idempotent consumer requirement on the receiver.

Delivery diagnostics (internal beta)

Webhook delivery diagnostics include retry metadata:
  • attempts: number of delivery attempts made for an endpoint
  • retryExhausted: true when a retryable failure exhausted max attempts
These fields are visible in internal-beta webhook diagnostics and should be included in operational triage.

Event shape

Webhook POSTs carry a JSON payload with this high-level shape:
{
  "id": "evt_...",
  "type": "invoice.finalized",
  "created": "2026-02-18T05:22:55.984Z",
  "requestId": "req_...",
  "data": { }
}

Signature verification

Webhook requests include:
  • Omni-Timestamp
  • X-Request-Id
If signing is enabled, requests also include:
  • Omni-Signature

If Omni-Signature is absent

In the current internal beta, OMNI may deliver webhook events without a signature if signing is not enabled for your environment. Recommended receiver behavior:
  • Do not attempt signature verification if Omni-Signature is missing.
  • Keep your webhook URL unguessable (random path) and treat the endpoint as a secret.
  • Log and alert on unsigned deliveries so you can tighten posture when signing is enabled.

Signing payload

Compute the signature over:
${Omni-Timestamp}.${raw_request_body}
Where:
  • Omni-Timestamp is the exact header value (string, unix seconds).
  • raw_request_body is the exact raw bytes received over HTTP (before JSON parsing).

Algorithm

  • HMAC-SHA256
  • Secret: your webhook signing secret
  • Output format: lowercase hex digest

Verification steps

  1. Reject events older than your replay window.
  2. Recompute HMAC from timestamp + raw request body.
  3. Constant-time compare against Omni-Signature.
  4. Persist event ID for deduplication.

TypeScript (Node.js) example

import crypto from "node:crypto";

function strictHexToBuffer(hex: string, expectedBytes: number) {
  const expectedChars = expectedBytes * 2;
  if (typeof hex !== "string") return null;
  if (hex.length !== expectedChars) return null;
  if (!/^[0-9a-f]+$/i.test(hex)) return null;
  return Buffer.from(hex, "hex");
}

export function verifyOmniWebhookSignature(opts: {
  secret: string;
  timestamp: string;
  signature: string;
  rawBody: Buffer;
  toleranceSeconds?: number;
}) {
  const toleranceSeconds = opts.toleranceSeconds ?? 5 * 60;
  const ts = Number(opts.timestamp);
  if (!Number.isFinite(ts)) throw new Error("invalid Omni-Timestamp");
  const ageSeconds = Math.abs(Date.now() / 1000 - ts);
  if (ageSeconds > toleranceSeconds) throw new Error("event outside replay window");

  const payload = Buffer.concat([Buffer.from(`${opts.timestamp}.`), opts.rawBody]);
  const expected = crypto.createHmac("sha256", opts.secret).update(payload).digest("hex");
  const expectedBuf = Buffer.from(expected, "hex");
  const sigBuf = strictHexToBuffer(opts.signature, expectedBuf.length);
  if (!sigBuf) throw new Error("invalid signature");
  if (!crypto.timingSafeEqual(expectedBuf, sigBuf)) throw new Error("invalid signature");
}

Python example

import hashlib
import hmac
import time


def verify_omni_webhook_signature(*, secret: str, timestamp: str, signature: str, raw_body: bytes, tolerance_seconds: int = 300) -> None:
    try:
        ts = int(timestamp)
    except Exception as exc:
        raise ValueError("invalid Omni-Timestamp") from exc

    age = abs(int(time.time()) - ts)
    if age > tolerance_seconds:
        raise ValueError("event outside replay window")

    payload = (timestamp + ".").encode("utf-8") + raw_body
    expected = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, signature):
        raise ValueError("invalid signature")

Go example

mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(signature)) {
  return errors.New("invalid signature")
}

Replay protection

  • Store processed event IDs for at least 7 days.
  • Reject duplicate event IDs.
  • Keep handler logic idempotent.

Failure handling playbook

  1. Return 2xx once you have durably queued the work.
  2. Treat event.id as the dedupe key.
  3. If you return 4xx, OMNI will treat that as a signal your receiver rejected the payload.
  4. If you return 5xx, OMNI will treat that as a transient receiver failure.
  • Acknowledge fast: respond 2xx quickly and queue work to background processing.
  • Dedupe: use event.id as your primary dedupe key.
  • Handle out-of-order: do not assume event ordering.
  • Use request IDs: log X-Request-Id and event.requestId for correlation.

Beta status

Webhook event families and signature spec are documented and contract-defined but may not be enabled for all accounts yet.