Skip to main content
Every webhook request includes an X-Webhook-Signature header containing an HMAC-SHA256 signature. You must verify this signature before processing the payload — otherwise anyone who discovers your endpoint URL could send fake events.

How it works

Mage Loyalty signs every webhook using your subscription’s signing secret:
  1. A signing string is created by concatenating the timestamp, a dot, and the raw request body: {timestamp}.{rawBody}
  2. The signing string is hashed with HMAC-SHA256 using your signing secret as the key
  3. The result is hex-encoded and prefixed with sha256=
Your server repeats the same process and compares the result.

Step-by-step

1

Extract the headers

Read X-Webhook-Signature and X-Webhook-Timestamp from the incoming request.
X-Webhook-Signature: sha256=a1b2c3d4e5f6...
X-Webhook-Timestamp: 2026-02-18T12:00:00.000Z
2

Get the raw request body

You need the raw body string exactly as it was received — not a parsed-and-re-serialized version. Most frameworks provide a way to access this.
If you parse the JSON body and then JSON.stringify() it, the output may differ from the original (different key ordering, whitespace). This will cause signature verification to fail. Always use the raw body.
3

Build the signing string

Concatenate the timestamp, a literal . character, and the raw body:
2026-02-18T12:00:00.000Z.{"event":"points.earned","shop":"my-store",...}
4

Compute the expected signature

Create an HMAC-SHA256 hash of the signing string using your signing secret, then hex-encode it and prepend sha256=.
5

Compare using a timing-safe function

Compare your computed signature with the X-Webhook-Signature header using a timing-safe comparison function. Do not use === or == — these are vulnerable to timing attacks.If the two values match, the webhook is verified.

Complete examples

const crypto = require('crypto');

function verifyWebhook(req, secret) {
  const signature = req.headers['x-webhook-signature'];
  const timestamp = req.headers['x-webhook-timestamp'];
  const rawBody   = req.body; // raw request body string — NOT parsed JSON

  // 1. Build the signing string
  const signingString = timestamp + '.' + rawBody;

  // 2. Compute the expected signature
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(signingString)
    .digest('hex');

  // 3. Timing-safe comparison
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}
Express example — make sure to capture the raw body:
const express = require('express');
const app = express();

// Capture the raw body as a string alongside the parsed JSON
app.use(express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString('utf8');
  }
}));

app.post('/webhooks/loyalty', (req, res) => {
  const isValid = verifyWebhook(
    { headers: req.headers, body: req.rawBody },
    process.env.MAGE_WEBHOOK_SECRET
  );

  if (!isValid) {
    return res.status(401).send('Invalid signature');
  }

  const { event, data } = req.body;
  console.log(`Received ${event}`, data);

  res.status(200).send('OK');
});

Replay attack protection (optional)

The X-Webhook-Timestamp header is included in the signed data, so it cannot be forged. You can optionally reject requests where the timestamp is too old:
const MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes

function isTimestampValid(timestamp) {
  const age = Date.now() - new Date(timestamp).getTime();
  return age >= 0 && age <= MAX_AGE_MS;
}

Troubleshooting

  • Re-serialized body: You parsed the JSON and re-stringified it. Use the raw body exactly as received.
  • Wrong secret: Each webhook subscription has its own unique signing secret. Make sure you’re using the correct one.
  • Middleware modified the body: Some frameworks (e.g. Express without the verify option) consume the raw body during JSON parsing. Use the verify callback shown above to preserve it.
  • Encoding issues: The raw body must be read as UTF-8. Binary or other encodings will produce a different hash.
Signing secrets are shown only once at creation time. If you’ve lost yours, go to Settings > Webhooks, edit the subscription, and click Rotate signing secret. A new secret will be generated and shown once. Update your server with the new secret — the old one is immediately invalidated.
Use a tunneling tool like ngrok to expose your local server, then register the tunnel URL as your webhook endpoint. You can also use the Test button in the dashboard to send a test event.