Webhook signature verification

Maroo signs every webhook payload using the Standard Webhooks specification. You should verify signatures on your server to confirm that a webhook was sent by Maroo and not by a third party.

Headers

Each webhook delivery includes three headers:

HeaderDescription
webhook-idUnique message identifier.
webhook-timestampUnix timestamp in seconds when the webhook was sent.
webhook-signatureOne or more space-separated signatures, each prefixed with v1,.

Verification algorithm

  1. Get the signing secret from the webhook destination (starts with whsec_).
  2. Strip the whsec_ prefix and base64-decode the remainder to get the raw key bytes.
  3. Build the signed content string: {webhook-id}.{webhook-timestamp}.{rawBody} (the raw request body as a string).
  4. Compute HMAC-SHA256 over the signed content using the raw key.
  5. Base64-encode the HMAC result.
  6. Compare the result against each signature in the webhook-signature header (strip the v1, prefix from each). Use a timing-safe comparison.

Using a library

The standardwebhooks package handles all of the above for you. This is the recommended approach.

Node.js:

npm install standardwebhooks
const { Webhook } = require("standardwebhooks");

const wh = new Webhook(signingSecret); // the whsec_... string

// headers is an object with webhook-id, webhook-timestamp, webhook-signature
// rawBody is the raw request body as a string
wh.verify(rawBody, headers); // throws on invalid signature

Python:

pip install standardwebhooks
from standardwebhooks.webhooks import Webhook

wh = Webhook(signing_secret)  # the whsec_... string

# headers is a dict with webhook-id, webhook-timestamp, webhook-signature
# raw_body is the raw request body as a string
wh.verify(raw_body, headers)  # raises on invalid signature

Manual verification

If you prefer not to use a library, here is how to verify manually.

Node.js:

const crypto = require("crypto");

function verifyWebhook(rawBody, headers, signingSecret) {
  const msgId = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];

  // Reject webhooks with timestamps too far in the past (5 minutes)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
    throw new Error("Timestamp too old");
  }

  // Decode the signing secret
  const key = Buffer.from(signingSecret.replace("whsec_", ""), "base64");

  // Compute the expected signature
  const signedContent = `${msgId}.${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac("sha256", key)
    .update(signedContent)
    .digest("base64");

  // Compare against each signature in the header
  const valid = signatures.split(" ").some((sig) => {
    const sigValue = sig.replace("v1,", "");
    const expectedBuf = Buffer.from(expected);
    const actualBuf = Buffer.from(sigValue);
    return expectedBuf.length === actualBuf.length &&
      crypto.timingSafeEqual(expectedBuf, actualBuf);
  });

  if (!valid) {
    throw new Error("Invalid signature");
  }
}

Python:

import base64
import hashlib
import hmac
import time

def verify_webhook(raw_body: str, headers: dict, signing_secret: str) -> None:
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    signatures = headers["webhook-signature"]

    # Reject webhooks with timestamps too far in the past (5 minutes)
    if abs(time.time() - int(timestamp)) > 300:
        raise ValueError("Timestamp too old")

    # Decode the signing secret (requires Python 3.9+; use [len("whsec_"):] for older versions)
    key = base64.b64decode(signing_secret.removeprefix("whsec_"))

    # Compute the expected signature
    signed_content = f"{msg_id}.{timestamp}.{raw_body}"
    expected = base64.b64encode(
        hmac.new(key, signed_content.encode(), hashlib.sha256).digest()
    ).decode()

    # Compare against each signature in the header
    valid = any(
        hmac.compare_digest(expected, sig.removeprefix("v1,"))
        for sig in signatures.split(" ")
    )

    if not valid:
        raise ValueError("Invalid signature")

Best practices

Timestamp tolerance. Always check the webhook-timestamp header and reject webhooks with timestamps more than a few minutes in the past. This prevents replay attacks. The examples above use a 5-minute tolerance.

Secret rotation. When you rotate a signing secret via the Rotate signing secret endpoint, a new secret is generated immediately. During a grace period, Maroo signs each webhook delivery with both the old and new secrets, so the webhook-signature header will contain two space-separated signatures. Your verification code should check all signatures — if any one matches, the webhook is valid. The manual examples above already handle this.

The webhook destination response includes a previousSecretExpiresAt field that tells you when the old secret will stop being used. After that timestamp, deliveries will only be signed with the new secret. You can use this field to track when it is safe to remove the old secret from your configuration.