Webhooks
Verify signatures
Every webhook request carries a signature. Your endpoint must verify it before it trusts the event.
On this page
The signature header#
Merxian-Signature: t=1789377893,v1=YOUR_SIGNATURE_IN_HEX| Part | Meaning |
|---|---|
t |
The time of signing, in Unix seconds. Merxian signs each delivery attempt when it sends it, so each attempt has a new t. |
v1 |
An HMAC-SHA256 signature, in lower-case hex. The header can hold more than one v1 value, for example after you rotate the secret. |
How to verify#
- Read the raw body. Use the body bytes exactly as you received them. Do not parse the JSON and serialize it again. A parsed and serialized body has different bytes, and the signature does not match.
- Parse the header. Split it at
,. Readt, and everyv1value. - Get the key. Remove the
whsec_prefix from your signing secret. Decode the rest from base64. The result is the key bytes. - Compute the expected signature. Compute HMAC-SHA256 with the key over the string
t, then., then the raw body. Encode the result as lower-case hex. - Compare. Compare the expected signature with each
v1value, with a constant-time function. The request is valid if one value matches. - Check the time. Reject the request if
tis older than your tolerance. Merxian recommends 5 minutes. Because Merxian signs each attempt when it sends it, a 5-minute tolerance does not reject retries.
The signed string, with a timestamp of 1789377893:
1789377893.{"id":"whevt_7Kd2VpXn4TqB9mLzR","object":"event", ...}Verify in Node.js#
This example uses node:crypto. It is an example, not an SDK.
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SECONDS = 5 * 60
/**
* rawBody: a Buffer with the request body, before JSON parsing.
* header: the value of the Merxian-Signature header.
* secret: your signing secret, whsec_...
*/
export function verifySignature(rawBody, header, secret) {
if (typeof header !== 'string' || !secret.startsWith('whsec_')) return false
let timestamp
const signatures = []
for (const part of header.split(',')) {
const [name, value] = part.split('=', 2)
if (name === 't') timestamp = value
if (name === 'v1' && value !== undefined) signatures.push(value)
}
if (timestamp === undefined || !/^\d+$/.test(timestamp) || signatures.length === 0) return false
const age = Math.floor(Date.now() / 1000) - Number(timestamp)
if (Math.abs(age) > TOLERANCE_SECONDS) return false
const key = Buffer.from(secret.slice('whsec_'.length), 'base64')
const expected = createHmac('sha256', key).update(`${timestamp}.`).update(rawBody).digest()
return signatures.some((signature) => {
const received = Buffer.from(signature, 'hex')
return received.length === expected.length && timingSafeEqual(received, expected)
})
}With Express, read the body as raw bytes on the webhook route only:
import express from 'express'
import { verifySignature } from './verify.js'
const app = express()
app.post('/webhooks/merxian', express.raw({ type: 'application/json' }), (request, response) => {
const valid = verifySignature(
request.body,
request.get('Merxian-Signature'),
process.env.MERXIAN_WEBHOOK_SECRET,
)
if (!valid) return response.sendStatus(400)
const event = JSON.parse(request.body.toString('utf8'))
// Store the event, answer, then process it.
response.sendStatus(200)
})Put the webhook route before any global JSON body parser. A global parser replaces the raw body.
Verify in Python#
This example uses the standard library. It is an example, not an SDK.
import base64
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 5 * 60
def verify_signature(raw_body: bytes, header: str | None, secret: str) -> bool:
"""raw_body: the request body bytes, before JSON parsing."""
if not header or not secret.startswith("whsec_"):
return False
timestamp = None
signatures = []
for part in header.split(","):
name, _, value = part.partition("=")
if name == "t":
timestamp = value
elif name == "v1" and value:
signatures.append(value)
if timestamp is None or not timestamp.isdigit() or not signatures:
return False
if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
return False
key = base64.b64decode(secret[len("whsec_"):])
expected = hmac.new(key, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, signature) for signature in signatures)In Flask, read the raw body with request.get_data(). In Django, use request.body.
Check a signature by hand#
To debug a mismatch, compute the signature in a shell. Put the raw body in a file first. The file must not have a trailing newline that the original body did not have.
SECRET="YOUR_WEBHOOK_SECRET" # whsec_...
TIMESTAMP="1789377893" # the t value of the header
KEY_HEX=$(printf '%s' "${SECRET#whsec_}" | base64 -d | xxd -p -c 256)
printf '%s.' "$TIMESTAMP" | cat - body.json \
| openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY_HEX" \
| sed 's/^.* //'Compare the output with the v1 values of the header. Do not paste a live secret into a shared terminal or a log.
Rotate the secret#
Rotate the secret in the dashboard when you think that it is exposed, or on a schedule.
- Rotate the secret of the endpoint in the dashboard. The dashboard shows the new secret once.
- Store the new secret and deploy it to your endpoint.
After a rotation, each delivery carries one v1 value for each valid secret, with the new secret first. Your code accepts a request if any v1 value matches, so it keeps working while you deploy the new secret.
Keep the secret safe#
- Store the secret in a secret store or an environment variable. Do not commit it.
- Do not write the secret, the key, or the
Merxian-Signatureheader to logs. - Use a different secret in sandbox and live. Each endpoint has its own secret.
- Rotate the secret if a person or a system that should not have it can read it.