Free live virtual workshop: Set Up AI and Automate Workflows in Your Funeral Home. Fri, Nov 13, 10 AM PT (1 PM ET) Register

Verify webhook signatures

Check that each request came from Miraven and wasn’t changed, using the Standard Webhooks signature scheme.

Updated

View as Markdown

How signatures work

Miraven signs every request with your endpoint’s secret, following Standard Webhooks. Three headers carry the signature:

  • webhook-idheader

    The event’s id.

  • webhook-timestampheader

    When this attempt was sent, in Unix seconds. Each retry has a new one.

  • webhook-signatureheader

    v1, 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 bad

Verify 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.

InputValue
Secretwhsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
webhook-idmsg_p5jXN8AQM9LWM0D4loKWxJek
webhook-timestamp1614265330
Body{"test": 2432232314}
Expected webhook-signaturev1,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:

FrameworkRaw body
Expressexpress.raw({ type: "application/json" }) on the webhook route, registered before express.json()
Next.js (App Router)await request.text()
FastifyA content-type parser that keeps the body as a Buffer
Flaskrequest.get_data()
Djangorequest.body
FastAPIawait request.body()
Railsrequest.raw_post

Check the headers

  • Read webhook-id, webhook-timestamp and webhook-signature exactly. Some frameworks change the case of header names; the values are unchanged.
  • Split webhook-signature on spaces, and compare only the part after v1,.
  • 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.

See also

Questions? Email support@miraven.com.