Verifying signatures
Every delivery is signed with the webhook's secret using the Standard Webhooks scheme. Verify before you act on a delivery: anyone can send a request to your endpoint.
- Signed content:
<webhook-id>.<webhook-timestamp>.<raw body>. - Key: the secret without its
whsec_prefix, base64-decoded. - Signature: base64 HMAC-SHA256, sent as
v1,<signature>. Several space-separated signatures can be present; accept the delivery if one matches. - Reject timestamps more than 5 minutes from your clock to stop replays. The libraries below do this for you.
- Verify the raw body bytes. Parsing and re-serializing the JSON changes the bytes and breaks the signature.
Any Standard Webhooks or Svix library verifies these deliveries unchanged (the svix-* headers carry the same values). To check a verifier before your first delivery, use the test vector.
Node.js with standardwebhooks
npm install express standardwebhooksimport express from "express";
import { Webhook } from "standardwebhooks";
const webhook = new Webhook(process.env.OIMO_WEBHOOK_SECRET); // whsec_...
const app = express();
// express.raw keeps the body as the exact bytes that were signed.
app.post("/hooks/oimo", express.raw({ type: "application/json", limit: "5mb" }), (req, res) => {
let event;
try {
event = webhook.verify(req.body, req.headers);
} catch {
return res.status(400).send("invalid signature");
}
// Queue the work and answer quickly; mark event.id processed once it succeeded.
res.sendStatus(204);
});
app.listen(3000);Python with standardwebhooks
pip install flask standardwebhooksimport os
from flask import Flask, request
from standardwebhooks import Webhook, WebhookVerificationError
webhook = Webhook(os.environ["OIMO_WEBHOOK_SECRET"]) # whsec_...
app = Flask(__name__)
@app.post("/hooks/oimo")
def receive():
try:
event = webhook.verify(request.get_data(), dict(request.headers))
except (WebhookVerificationError, ValueError):
# The library raises ValueError for a malformed webhook-signature
# (no comma, bad base64, non-ASCII) and a body that is not UTF-8.
return "invalid signature", 400
# Queue the work and answer quickly; mark event["id"] processed once it succeeded.
return "", 204By hand: Node.js
No dependencies; headers are the request headers with lower-case names and body the raw body as a Buffer. now (Unix seconds) is only for checking the test vector.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyOimo(secret, headers, body, now = Date.now() / 1000) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) throw new Error("missing signature headers");
if (!/^\d+$/.test(timestamp)) throw new Error("invalid timestamp");
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS)
throw new Error("timestamp outside tolerance");
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key)
.update(`${id}.${timestamp}.`)
.update(body)
.digest();
const valid = signatures.split(" ").some((entry) => {
const [version, value] = entry.split(",");
const given = Buffer.from(value ?? "", "base64");
return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
});
if (!valid) throw new Error("no matching signature");
return JSON.parse(body.toString("utf8"));
}By hand: Python
Standard library only; body is the raw request body as bytes. Every failure raises ValueError. now (Unix seconds) is only for checking the test vector.
import base64
import hashlib
import hmac
import json
import time
TOLERANCE_SECONDS = 5 * 60
def verify_oimo(secret: str, headers: dict, body: bytes, now: float | None = None) -> dict:
headers = {name.lower(): value for name, value in headers.items()}
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not (msg_id and timestamp and signatures):
raise ValueError("missing signature headers")
if not (timestamp.isascii() and timestamp.isdigit()):
raise ValueError("invalid timestamp")
if abs((time.time() if now is None else now) - int(timestamp)) > TOLERANCE_SECONDS:
raise ValueError("timestamp outside tolerance")
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
for entry in signatures.split(" "):
version, _, value = entry.partition(",")
# Compare bytes: compare_digest refuses a str with non-ASCII characters.
if version == "v1" and hmac.compare_digest(value.encode(), expected):
return json.loads(body)
raise ValueError("no matching signature")Test vector
Check a verifier offline, before any delivery arrives, with this fixed delivery. Its signature was made by the code that signs real deliveries.
| Input | Value |
|---|---|
| Secret | whsec_17oYahOTvWm18K0SwR0L3XU9qgiEEx00 |
webhook-id | test_6d1f3c2e-4b5a-4e8f-9a7b-1c2d3e4f5a6b_29453760 |
webhook-timestamp | 1767225600 (2026-01-01T00:00:00.000Z) |
| Body | The exact bytes below: one line, no trailing newline, UTF-8. |
webhook-signature | v1,42fyIJTVCYN3Kj+AcWKMoKP9NXr4+GTuuRrvM3UsfX4= |
{"version":"webhook-test.v1","type":"webhook.test","id":"test_6d1f3c2e-4b5a-4e8f-9a7b-1c2d3e4f5a6b_29453760","createdAt":"2026-01-01T00:00:00.000Z","test":true,"message":"Oimo webhook connection test"}The vector was signed at a fixed instant in the past, 2026-01-01T00:00:00.000Z, so a verifier that checks the timestamp against a real clock rejects it as too old. It passes only with that check disabled or the clock pinned to 1767225600. The hand-written verifiers above take the clock as their last argument:
import { verifyOimo } from "./verify.mjs";
const body = Buffer.from("{\"version\":\"webhook-test.v1\",\"type\":\"webhook.test\",\"id\":\"test_6d1f3c2e-4b5a-4e8f-9a7b-1c2d3e4f5a6b_29453760\",\"createdAt\":\"2026-01-01T00:00:00.000Z\",\"test\":true,\"message\":\"Oimo webhook connection test\"}");
const headers = {
"webhook-id": "test_6d1f3c2e-4b5a-4e8f-9a7b-1c2d3e4f5a6b_29453760",
"webhook-timestamp": "1767225600",
"webhook-signature": "v1,42fyIJTVCYN3Kj+AcWKMoKP9NXr4+GTuuRrvM3UsfX4=",
};
console.log(verifyOimo("whsec_17oYahOTvWm18K0SwR0L3XU9qgiEEx00", headers, body, 1767225600));from verify import verify_oimo
body = "{\"version\":\"webhook-test.v1\",\"type\":\"webhook.test\",\"id\":\"test_6d1f3c2e-4b5a-4e8f-9a7b-1c2d3e4f5a6b_29453760\",\"createdAt\":\"2026-01-01T00:00:00.000Z\",\"test\":true,\"message\":\"Oimo webhook connection test\"}".encode()
headers = {
"webhook-id": "test_6d1f3c2e-4b5a-4e8f-9a7b-1c2d3e4f5a6b_29453760",
"webhook-timestamp": "1767225600",
"webhook-signature": "v1,42fyIJTVCYN3Kj+AcWKMoKP9NXr4+GTuuRrvM3UsfX4=",
}
print(verify_oimo("whsec_17oYahOTvWm18K0SwR0L3XU9qgiEEx00", headers, body, now=1767225600))The standardwebhooks libraries have no clock option. Compute the signature with their sign and compare it with the expected value instead:
import { Webhook } from "standardwebhooks";
const body = "{\"version\":\"webhook-test.v1\",\"type\":\"webhook.test\",\"id\":\"test_6d1f3c2e-4b5a-4e8f-9a7b-1c2d3e4f5a6b_29453760\",\"createdAt\":\"2026-01-01T00:00:00.000Z\",\"test\":true,\"message\":\"Oimo webhook connection test\"}";
const signature = new Webhook("whsec_17oYahOTvWm18K0SwR0L3XU9qgiEEx00").sign(
"test_6d1f3c2e-4b5a-4e8f-9a7b-1c2d3e4f5a6b_29453760",
new Date(1767225600 * 1000),
body,
);
console.log(signature === "v1,42fyIJTVCYN3Kj+AcWKMoKP9NXr4+GTuuRrvM3UsfX4=");from datetime import datetime, timezone
from standardwebhooks import Webhook
body = "{\"version\":\"webhook-test.v1\",\"type\":\"webhook.test\",\"id\":\"test_6d1f3c2e-4b5a-4e8f-9a7b-1c2d3e4f5a6b_29453760\",\"createdAt\":\"2026-01-01T00:00:00.000Z\",\"test\":true,\"message\":\"Oimo webhook connection test\"}"
signature = Webhook("whsec_17oYahOTvWm18K0SwR0L3XU9qgiEEx00").sign(
"test_6d1f3c2e-4b5a-4e8f-9a7b-1c2d3e4f5a6b_29453760",
datetime.fromtimestamp(1767225600, tz=timezone.utc),
body,
)
print(signature == "v1,42fyIJTVCYN3Kj+AcWKMoKP9NXr4+GTuuRrvM3UsfX4=")Changing any byte of the body, the id or the timestamp must make your verifier reject the vector.
When verification fails
- The body was parsed first. Frameworks that parse JSON before your handler change the bytes. Read the raw body (
express.raw,request.get_data()). - The wrong secret. Each webhook has its own. After a rotation, use the new secret; the old one also verifies for 24 hours.
- The prefix. Strip
whsec_and base64-decode the rest to get the key; do not use the string itself as the key. - Clock skew. A timestamp more than 5 minutes off is rejected. Keep your server's clock synchronized.
Send a test event from the webhook's settings and compare with its entry under Deliveries.