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:

HTTP
Oatmilk-Signature: t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t is when the delivery was signed, in Unix seconds.
  • v1 is an HMAC-SHA256 of the text {t}.{raw body}, keyed with your endpoint's secret, as hex.

Check it

  1. 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.
  2. Split the header on commas, then each part on the first =, to get t and v1.
  3. Refuse the delivery if t is more than 300 seconds from now. This stops someone replaying an old delivery.
  4. Compute the HMAC of {t}.{raw body} with your secret and compare it to v1 with 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

SymptomLikely cause
Every delivery failsThe wrong secret, or a framework parsed the body before you read it.
Deliveries fail after you rotated the secretYour server still uses the old secret. Rotation takes effect for the next delivery.
Only some deliveries failA proxy or middleware is changing the body, such as re-encoding characters.
Failures mention the timeYour 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.