Skip to content
Merxian

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#

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#

  1. 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.
  2. Parse the header. Split it at ,. Read t, and every v1 value.
  3. Get the key. Remove the whsec_ prefix from your signing secret. Decode the rest from base64. The result is the key bytes.
  4. 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.
  5. Compare. Compare the expected signature with each v1 value, with a constant-time function. The request is valid if one value matches.
  6. Check the time. Reject the request if t is 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:

Signed payload
1789377893.{"id":"whevt_7Kd2VpXn4TqB9mLzR","object":"event", ...}

Verify in Node.js#

This example uses node:crypto. It is an example, not an SDK.

verify.js
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:

app.js
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.

verify.py
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.

Compute a signature with openssl
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.

  1. Rotate the secret of the endpoint in the dashboard. The dashboard shows the new secret once.
  2. 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-Signature header 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.

Try refund, payment.succeeded,POST /v1/transactions, orIdempotency-Key.