Webhooks
Verify signatures
Check that a delivery came from Oatmilk and wasn't changed on the way.
Anyone can send a request to your endpoint, so check every delivery before you trust it. Oatmilk signs each one with your endpoint's secret and puts the signature in the Oatmilk-Signature header:
Oatmilk-Signature: t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdtis when the delivery was signed, in Unix seconds.v1is an HMAC-SHA256 of the text{t}.{raw body}, keyed with your endpoint's secret, as hex.
Check it
- Read the raw body exactly as it arrived, before any JSON parsing. Parsing and re-serialising changes the bytes, and the signature won't match.
- Split the header on commas, then each part on the first
=, to gettandv1. - Refuse the delivery if
tis more than 300 seconds from now. This stops someone replaying an old delivery. - Compute the HMAC of
{t}.{raw body}with your secret and compare it tov1with a constant-time comparison.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyOatmilkSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map(part => part.trim().split("=")));
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
const received = Buffer.from(parts.v1 ?? "", "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}
// Use the raw request body exactly as received, before JSON parsing:
// verifyOatmilkSignature(body, request.headers.get("Oatmilk-Signature"), process.env.OATMILK_WEBHOOK_SECRET)Try it here
Paste a delivery's raw body, its Oatmilk-Signature header and your endpoint's secret to see whether it verifies, and why not if it doesn't. You can also sign a body with your own secret to send a test delivery to your server. Everything runs in your browser: nothing you type here is sent anywhere.
When verification fails
| Symptom | Likely cause |
|---|---|
| Every delivery fails | The wrong secret, or a framework parsed the body before you read it. |
| Deliveries fail after you rotated the secret | Your server still uses the old secret. Rotation takes effect for the next delivery. |
| Only some deliveries fail | A proxy or middleware is changing the body, such as re-encoding characters. |
| Failures mention the time | Your server's clock is off by more than 300 seconds. Sync it with NTP. |
Rotate a secret
Rotate an endpoint's secret in Developers › Webhooks or with webhooks.endpoints.rotateSecret. The new secret is returned once and signs every delivery from then on, so update your server first, then rotate.