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
| Header | Value |
|---|---|
Omi-Signature | base64url(HMAC-SHA256(secret, "<timestamp>.<url>.<body>")), with padding |
Omi-Signature-Timestamp | ISO 8601 timestamp of the delivery attempt, e.g. 2026-09-02T10:18:41.120Z |
Omi-Event-Id | The event id, also in the body |
What is signed
Three parts, joined with a single .:
- the
Omi-Signature-Timestampheader value, exactly as sent; - the URL the delivery was sent to, exactly as registered on the endpoint (scheme, host, path, query — no trailing slash added or removed);
- 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 frameworksExpress: use
express.raw({ type: "application/json" })on the webhook route, notexpress.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".
Updated about 2 months ago