Signing and verify
Every Outemit delivery is signed with Standard Webhooks-compatible HMAC. Verify before you trust the payload.
Headers and signed content
Each delivery includes:
webhook-id– stable message id for dedupewebhook-timestamp– unix seconds when signedwebhook-signature– space-separatedv1,<base64>parts
Signed content is the UTF-8 string ${webhook-id}.${webhook-timestamp}.${rawBody}. The HMAC key is the base64-decoded bytes after stripping the whsec_ prefix. Digest is base64-encoded SHA-256 HMAC.
Timestamp tolerance
Reject deliveries whose webhook-timestamp is older (or newer) than about 5 minutes relative to your server clock. This limits replay of captured requests. Skewed clocks are a common false negative: NTP-sync your receivers.
Dual-secret rotation
When you rotate an endpoint secret, Outemit dual-signs with current and previous secrets until previous_valid_until. Your verifier should accept any valid v1, part. Deploy the new secret to receivers before the grace window ends, or call rotate again if you need more time.
API: POST /api/v1/endpoints/:id/rotate-secret (see API reference).
Full HMAC snippets
Use these when you cannot depend on the Standard Webhooks library, or want to see the algorithm.
import crypto from "node:crypto";
function verify(secret, rawBody, headers) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
const sigHeader = headers["webhook-signature"]; // "v1,<b64> ..."
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const toSign = `${id}.${ts}.${rawBody}`;
const expected = crypto.createHmac("sha256", key).update(toSign).digest("base64");
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(ts)) > 300) return false; // 5m tolerance
return sigHeader.split(" ").some((part) => {
const [, b64] = part.split(",");
try {
return b64 && crypto.timingSafeEqual(Buffer.from(b64), Buffer.from(expected));
} catch {
return false;
}
});
}
verify("whsec_...", rawBody, headers);standardwebhooks library
Prefer Outemit SDK verify helpers (SDKs) or the official Standard Webhooks packages when your stack has them:
import { Webhook } from "standardwebhooks";
const wh = new Webhook("whsec_...");
const payload = wh.verify(rawBody, {
"webhook-id": headers["webhook-id"],
"webhook-timestamp": headers["webhook-timestamp"],
"webhook-signature": headers["webhook-signature"],
});Common mistakes
- Verifying a re-serialized JSON body instead of the raw bytes
- Forgetting to strip
whsec_before base64-decoding the key - Using hex HMAC output instead of base64
- Comparing signatures with
===instead of a constant-time compare - Ignoring timestamp tolerance (replay risk) or setting it to zero (clock skew failures)
- Rotating secrets without dual-verify on the receiver during the grace window
- Putting
whsec_values in client-side code or public repos
${webhook-id}.${webhook-timestamp}.${rawBody}