Verifying signatures
Verify the Standard Webhooks HMAC-SHA256 signature on every Ody delivery, in Node, Python, or any language.
Your webhook URL is public, so anyone can POST to it. Every Ody delivery is signed with the endpoint's secret. Verify the signature before you trust the payload.
#The headers
| Header | Example | Meaning |
|---|---|---|
webhook-id | evt_4f9b2c0d… | The event ID (same as the body's id) |
webhook-timestamp | 1792087542 | Unix seconds when this attempt was signed |
webhook-signature | v1,K5oZfz…= | v1, followed by the base64 HMAC-SHA256 signature |
Deliveries also carry content-type: application/json and user-agent: Ody-Webhooks/1.0.
#The algorithm
key = base64_decode( secret without the "whsec_" prefix )
message = webhook-id + "." + webhook-timestamp + "." + raw_request_body
signature = "v1," + base64( HMAC_SHA256(key, message) )Then:
- Compare your signature with the header value using a constant-time comparison. The header may hold several space-separated signatures; accept if any one matches.
- Reject the request if
webhook-timestampis more than 5 minutes from your clock. This stops replays.
Sign the bytes exactly as received. If a framework parses the JSON and you re-serialise it, whitespace and key order change and the signature won't match. In Express, use express.raw() on this route. In Next.js route handlers, use await req.text().
#Code
import express from "express";
import { verifyWebhookSignature } from "@ody/sdk";
const app = express();
app.post("/ody/webhooks", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyWebhookSignature(process.env.ODY_WEBHOOK_SECRET!, req.headers, req.body)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
res.status(200).end(); // acknowledge fast…
void handleEvent(event); // …then do the work
});import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret: string, headers: Record<string, string>, rawBody: string): boolean {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
const sigHeader = headers["webhook-signature"];
if (!id || !ts || !sigHeader) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest("base64");
return sigHeader.split(" ").some((part) => {
const [version, sig] = part.split(",");
if (version !== "v1" || !sig) return false;
const a = Buffer.from(sig);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
});
}import json, os
from flask import Flask, request
from ody import verify_webhook_signature
app = Flask(__name__)
@app.post("/ody/webhooks")
def ody_webhook():
if not verify_webhook_signature(os.environ["ODY_WEBHOOK_SECRET"], request.headers, request.get_data()):
return "", 401
event = json.loads(request.get_data())
# enqueue event for processing, then acknowledge
return "", 200# pip install standardwebhooks
from standardwebhooks.webhooks import Webhook
wh = Webhook(os.environ["ODY_WEBHOOK_SECRET"]) # accepts the whsec_ secret as-is
def handle(raw_body: bytes, headers: dict):
event = wh.verify(raw_body, headers) # raises on a bad signature or stale timestamp
return eventimport { verifyWebhookSignature } from "@ody/sdk";
export async function POST(req: Request) {
const raw = await req.text();
const ok = verifyWebhookSignature(process.env.ODY_WEBHOOK_SECRET!, Object.fromEntries(req.headers), raw);
if (!ok) return new Response(null, { status: 401 });
const event = JSON.parse(raw);
// …
return new Response(null, { status: 200 });
}Because Ody follows Standard Webhooks, the official standardwebhooks libraries (JavaScript, Python, Go, Ruby, PHP, Java, Rust, C#…) verify Ody deliveries unchanged.
#Test it
Send yourself a signed ping:
curl -X POST https://api.ody.co/v1/api/webhooks/$WEBHOOK_ID/test \
-H "Authorization: Bearer $ODY_API_KEY""delivered": true means your endpoint returned 2xx. If you get 401 from your own verifier, check that you're using the raw body and the right endpoint's secret.
#Rotating secrets
POST /webhooks/:id/rotate issues a new secret, and the old one stops working immediately. To rotate without dropping events:
- Rotate, and put the new secret in your config next to the old one.
- Make your verifier accept a signature from either secret.
- Once the new secret is deployed everywhere, remove the old one.
Rotate straight away if a secret might have leaked.