How signatures work
Miraven signs every request with your endpoint’s secret, following Standard Webhooks. Three headers carry the signature:
webhook-idheaderThe event’s
id.webhook-timestampheaderWhen this attempt was sent, in Unix seconds. Each retry has a new one.
webhook-signatureheaderv1,and a base64 HMAC-SHA256 of{webhook-id}.{webhook-timestamp}.{body}. It can hold several space-separated signatures; the request is valid if any one matches.
Verify with a library (recommended)
Standard Webhooks publishes libraries for most languages. They check the signature and the timestamp in one call.
// npm install standardwebhooks
import { Webhook } from "standardwebhooks";
const webhook = new Webhook(process.env.MIRAVEN_WEBHOOK_SECRET);
// rawBody: the request body exactly as it arrived (a string or Buffer).
// headers: an object with webhook-id, webhook-timestamp and webhook-signature.
const event = webhook.verify(rawBody, headers); // throws if the signature or timestamp is badVerify manually
Without a library, it takes four steps.
Step 1: Decode the key
Remove the whsec_ prefix from your secret and base64-decode the rest.
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");Step 2: Build the signed content
Join webhook-id, webhook-timestamp and the raw body with periods.
const signedContent = `${headers["webhook-id"]}.${headers["webhook-timestamp"]}.${rawBody}`;Step 3: Compare the signatures
Compute an HMAC-SHA256 of the signed content with the key, and compare it to each v1 signature with a constant-time comparison. Never compare with ==.
const expected = crypto.createHmac("sha256", key).update(signedContent).digest();
const valid = headers["webhook-signature"].split(" ").some((entry) => {
const [version, signature] = entry.split(",");
if (version !== "v1" || !signature) return false;
const given = Buffer.from(signature, "base64");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});Step 4: Check the timestamp
Reject the request if webhook-timestamp is more than 5 minutes from your clock, so a captured request can’t be replayed later.
const age = Math.abs(Date.now() / 1000 - Number(headers["webhook-timestamp"]));
if (age > 5 * 60) throw new Error("Webhook timestamp is too old");Preventing replay attacks
Someone who captures a valid request could send it again. The timestamp is part of what’s signed, so it can’t be changed without breaking the signature, and rejecting old timestamps stops the replay. Recording each event id you’ve processed also protects you inside the 5-minute window.
Test your verification code
Your code should produce this signature from these inputs. Skip the timestamp check while you test, since the timestamp is in the past.
| Input | Value |
|---|---|
| Secret | whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw |
webhook-id | msg_p5jXN8AQM9LWM0D4loKWxJek |
webhook-timestamp | 1614265330 |
| Body | {"test": 2432232314} |
Expected webhook-signature | v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= |
Signature checker
To check a real request, paste its headers and raw body. It starts with the example request, signed with the example secret whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw.
Signature checker
Runs in your browser. Nothing you enter is sent anywhere.
Expected webhook-signature
…
Resolve signature verification errors
Check the secret
Use the whole secret, including whsec_, for the endpoint the request was sent to. Each endpoint has its own secret, and rolling it replaces the old one.
Check the request body
Sign the body exactly as it arrived. Most failures come from a framework parsing the JSON first. Read the raw body like this:
| Framework | Raw body |
|---|---|
| Express | express.raw({ type: "application/json" }) on the webhook route, registered before express.json() |
| Next.js (App Router) | await request.text() |
| Fastify | A content-type parser that keeps the body as a Buffer |
| Flask | request.get_data() |
| Django | request.body |
| FastAPI | await request.body() |
| Rails | request.raw_post |
Check the headers
- Read
webhook-id,webhook-timestampandwebhook-signatureexactly. Some frameworks change the case of header names; the values are unchanged. - Split
webhook-signatureon spaces, and compare only the part afterv1,. - Make sure a proxy or API gateway in front of your server isn’t rewriting the body or dropping headers.
- Check your server clock: a clock more than 5 minutes off fails the timestamp check.
Roll your signing secret
Email support@miraven.com for a new secret, and do it right away if your secret may have leaked. The old secret stops working when the new one is issued, so have your system ready to switch.