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:
| Header | Description |
|---|---|
webhook-id | Unique message identifier. |
webhook-timestamp | Unix timestamp in seconds when the webhook was sent. |
webhook-signature | One or more space-separated signatures, each prefixed with v1,. |
Verification algorithm
- Get the signing secret from the webhook destination (starts with
whsec_). - Strip the
whsec_prefix and base64-decode the remainder to get the raw key bytes. - Build the signed content string:
{webhook-id}.{webhook-timestamp}.{rawBody}(the raw request body as a string). - Compute HMAC-SHA256 over the signed content using the raw key.
- Base64-encode the HMAC result.
- Compare the result against each signature in the
webhook-signatureheader (strip thev1,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.