Skip to main content

Webhook Signature Verification

OmniChannel signs every outbound webhook payload using the Standard Webhooks specification (HMAC-SHA256). This lets you verify that the payload was sent by OmniChannel and has not been tampered with in transit.

Headers

Every webhook request includes three signing headers:

HeaderDescription
webhook-idUnique message identifier (e.g. msg_abc123)
webhook-timestampUnix timestamp (seconds) when the message was signed
webhook-signatureHMAC-SHA256 signature, prefixed with v1,

Secret format

Your webhook secret is a base64-encoded 32-byte key prefixed with whsec_. Example:

whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw7Kp/bMHKM0U=

When computing the HMAC, strip the whsec_ prefix and base64-decode the remainder to get the raw key bytes.

Signature computation

The signed content is the concatenation of:

{webhook-id}.{webhook-timestamp}.{payload}

where payload is the raw request body (UTF-8 JSON).

Compute HMAC-SHA256 over this string using the decoded secret, then base64-encode the result and prepend v1,:

v1,{base64(HMAC-SHA256(secret, "{webhook-id}.{webhook-timestamp}.{payload}"))}

Verification examples

curl + openssl

SECRET="whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw7Kp/bMHKM0U="
WEBHOOK_ID="msg_abc123"
WEBHOOK_TIMESTAMP="1717243200"
BODY='{"event":"viber_delivered","data":{"messageId":42}}'

# Decode secret (strip whsec_ prefix)
KEY=$(echo -n "${SECRET#whsec_}" | base64 -d | xxd -p -c 256)

# Compute expected signature
EXPECTED=$(echo -n "${WEBHOOK_ID}.${WEBHOOK_TIMESTAMP}.${BODY}" \
| openssl dgst -sha256 -hmac "$(echo -n "${SECRET#whsec_}" | base64 -d)" -binary \
| base64)

echo "v1,${EXPECTED}"

Node.js

const crypto = require("crypto");

function verifyWebhook(payload, headers, secret) {
const key = Buffer.from(secret.replace("whsec_", ""), "base64");
const signedContent = `${headers["webhook-id"]}.${headers["webhook-timestamp"]}.${payload}`;
const expected = crypto.createHmac("sha256", key).update(signedContent).digest("base64");
const expectedSig = `v1,${expected}`;

const signatures = headers["webhook-signature"].split(" ");
return signatures.some((sig) => sig === expectedSig);
}

Python

import base64
import hashlib
import hmac

def verify_webhook(payload: str, headers: dict, secret: str) -> bool:
key = base64.b64decode(secret.removeprefix("whsec_"))
signed_content = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.{payload}"
expected = base64.b64encode(
hmac.new(key, signed_content.encode(), hashlib.sha256).digest()
).decode()
expected_sig = f"v1,{expected}"

signatures = headers["webhook-signature"].split(" ")
return expected_sig in signatures

.NET

using StandardWebhooks;

var wh = new StandardWebhook("whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw7Kp/bMHKM0U=");

// Verify incoming webhook
wh.Verify(requestBody, request.Headers);

Or manually:

using System.Security.Cryptography;
using System.Text;

bool VerifyWebhook(string payload, string webhookId, string webhookTimestamp, string webhookSignature, string secret)
{
var key = Convert.FromBase64String(secret.Replace("whsec_", ""));
var signedContent = $"{webhookId}.{webhookTimestamp}.{payload}";
using var hmac = new HMACSHA256(key);
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signedContent));
var expected = $"v1,{Convert.ToBase64String(hash)}";

return webhookSignature.Split(' ').Contains(expected);
}

Timestamp tolerance

You should reject webhooks with a webhook-timestamp older than 5 minutes to prevent replay attacks. The Standard Webhooks library handles this automatically.

Delivery & retries

When your endpoint is unavailable or returns a non-2xx response, OmniChannel retries the delivery on an exponential schedule:

RetryDelay after previous attempt
130 seconds
25 minutes
330 minutes
42 hours
56 hours
612 hours
724 hours

That gives a total budget of roughly 45 hours from the initial attempt before the event is marked Dead. Dead events stay in your deliveries log and can be retried manually from the dashboard.

Your endpoint must return an HTTP 2xx status code for the delivery to count as successful. Any other response (including 3xx redirects) is treated as a failure and triggers a retry.

Auto-disable

If 100 consecutive events reach Dead status without a single successful delivery in between, OmniChannel will automatically disable the subscription, email the company owner, and stop sending further events. A single 2xx response at any point resets the counter — individual HTTP failures during the retry window do not count against the threshold, only events that fully exhaust their retry budget.

Re-enable the subscription from the dashboard once you've confirmed the endpoint is healthy again.