Webhook authentication

Verify that a delivery came from OmiCloud and was not altered or replayed.

OmiCloud signs every webhook delivery with the endpoint's secret using HMAC-SHA256. Verify the signature before you parse the body. An unverified webhook is untrusted input from the internet.

Headers

HeaderValue
Omi-Signaturebase64url(HMAC-SHA256(secret, "<timestamp>.<url>.<body>")), with padding
Omi-Signature-TimestampISO 8601 timestamp of the delivery attempt, e.g. 2026-09-02T10:18:41.120Z
Omi-Event-IdThe event id, also in the body

What is signed

Three parts, joined with a single .:

  1. the Omi-Signature-Timestamp header value, exactly as sent;
  2. the URL the delivery was sent to, exactly as registered on the endpoint (scheme, host, path, query — no trailing slash added or removed);
  3. the raw request body bytes.

Including the URL means a delivery captured from one endpoint cannot be replayed to another. Including the timestamp lets you reject old deliveries.

The body is compact JSON with no whitespace. Sign the bytes you received — never re-serialise the parsed object, because key order and spacing will differ and the signature will not match.

Verifying

expected = base64url_with_padding( HMAC_SHA256( secret, timestamp + "." + url + "." + raw_body ) )
ok = constant_time_equal( expected, header["Omi-Signature"] )
   and abs( now - parse(timestamp) ) < 5 minutes

Node.js

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

export function verifyOmiWebhook({ secret, url, rawBody, headers }) {
  const ts = headers["omi-signature-timestamp"];
  const sig = headers["omi-signature"];
  if (!ts || !sig) return false;

  if (Math.abs(Date.now() - Date.parse(ts)) > 5 * 60 * 1000) return false;

  // rawBody must be the exact bytes received (Buffer or string), never re-serialised JSON.
  const digest = createHmac("sha256", secret)
    .update(`${ts}.${url}.`, "utf8")
    .update(rawBody)
    .digest("base64url");
  const expected = padBase64Url(digest); // Node omits padding; OmiCloud includes it

  const a = Buffer.from(sig);
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}

function padBase64Url(s) {
  return s + "=".repeat((4 - (s.length % 4)) % 4);
}
📘

Raw body in frameworks

Express: use express.raw({ type: "application/json" }) on the webhook route, not express.json(). Next.js App Router: await req.text(). Fastify: register a content-type parser that keeps the buffer. Parsing first and re-stringifying breaks the signature.

Python

import base64, hmac, hashlib, datetime

def verify_omi_webhook(secret: str, url: str, raw_body: bytes, headers: dict) -> bool:
    ts = headers.get("omi-signature-timestamp")
    sig = headers.get("omi-signature")
    if not ts or not sig:
        return False
    sent = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00"))
    if abs((datetime.datetime.now(datetime.timezone.utc) - sent).total_seconds()) > 300:
        return False
    msg = f"{ts}.{url}.".encode() + raw_body
    digest = hmac.new(secret.encode(), msg, hashlib.sha256).digest()
    expected = base64.urlsafe_b64encode(digest).decode()  # includes padding
    return hmac.compare_digest(expected, sig)

Worked example

Endpoint secret whsec_9Qx2v7LmT4pRkZ1nHc8bWa3eYf6sUd0J, URL https://operator.example/hooks/omicloud.

Headers:

Omi-Signature-Timestamp: 2026-09-02T10:18:41.120Z
Omi-Event-Id: evt_01j6ybq7m2vx

Body (one line, no spaces):

{"id":"evt_01j6ybq7m2vx","type":"webhook.test","createdAt":"2026-09-02T10:18:41Z","version":"2026-09-01","operatorId":"op_ominari","endpointId":"whe_01j6ya2c8r5t","data":{}}

String to sign:

2026-09-02T10:18:41.120Z.https://operator.example/hooks/omicloud.{"id":"evt_01j6ybq7m2vx","type":"webhook.test","createdAt":"2026-09-02T10:18:41Z","version":"2026-09-01","operatorId":"op_ominari","endpointId":"whe_01j6ya2c8r5t","data":{}}

Resulting header:

Omi-Signature: MQ_7eSbNCwumgjP4jn5TqJkjMZPZlox2-BNMRePZvao=

If your implementation produces that value from those inputs, it is correct.

Use POST /v1/webhook-endpoints/{id}/test to get a real signed delivery against your own secret and confirm your implementation before going live.

Rotating a secret

POST /v1/webhook-endpoints/{id}/rotate-secret returns a new secret and keeps the old one valid for 24 hours. During the overlap, every delivery carries two signatures separated by a comma in Omi-Signature — one per secret. Accept the delivery if either matches. After 24 hours the old secret is dropped and single signatures resume.

Failing closed

If the signature does not verify, respond 401 and do nothing else. OmiCloud will retry (a 401 is treated like any non-2xx), and the deliveries log will show it, which is how you will notice a misconfigured secret. Never process an unverified event "just in case".


Did this page help you?