> ## Documentation Index
> Fetch the complete documentation index at: https://developers.mageloyalty.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Verifying Webhook Signatures

> Confirm that incoming webhook requests are genuinely from Mage Loyalty and haven't been tampered with.

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

<Steps>
  <Step title="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
    ```
  </Step>

  <Step title="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.

    <Warning>
      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.
    </Warning>
  </Step>

  <Step title="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",...}
    ```
  </Step>

  <Step title="Compute the expected signature">
    Create an HMAC-SHA256 hash of the signing string using your signing secret, then hex-encode it and prepend `sha256=`.
  </Step>

  <Step title="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.
  </Step>
</Steps>

## Complete examples

<Tabs>
  <Tab title="Node.js">
    ```javascript theme={null}
    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:

    ```javascript theme={null}
    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');
    });
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    import hmac
    import hashlib

    def verify_webhook(signature: str, timestamp: str, raw_body: str, secret: str) -> bool:
        # 1. Build the signing string
        signing_string = f"{timestamp}.{raw_body}"

        # 2. Compute the expected signature
        expected = "sha256=" + hmac.new(
            secret.encode("utf-8"),
            signing_string.encode("utf-8"),
            hashlib.sha256
        ).hexdigest()

        # 3. Timing-safe comparison
        return hmac.compare_digest(signature, expected)
    ```

    **Flask example:**

    ```python theme={null}
    from flask import Flask, request, abort

    app = Flask(__name__)

    @app.route("/webhooks/loyalty", methods=["POST"])
    def handle_webhook():
        signature = request.headers.get("X-Webhook-Signature", "")
        timestamp = request.headers.get("X-Webhook-Timestamp", "")
        raw_body = request.get_data(as_text=True)

        if not verify_webhook(signature, timestamp, raw_body, MAGE_WEBHOOK_SECRET):
            abort(401)

        payload = request.get_json()
        print(f"Received {payload['event']}", payload["data"])
        return "OK", 200
    ```
  </Tab>

  <Tab title="Ruby">
    ```ruby theme={null}
    require 'openssl'

    def verify_webhook(signature, timestamp, raw_body, secret)
      # 1. Build the signing string
      signing_string = "#{timestamp}.#{raw_body}"

      # 2. Compute the expected signature
      digest = OpenSSL::HMAC.hexdigest('sha256', secret, signing_string)
      expected = "sha256=#{digest}"

      # 3. Timing-safe comparison
      ActiveSupport::SecurityUtils.secure_compare(signature, expected)
    end
    ```
  </Tab>

  <Tab title="PHP">
    ```php theme={null}
    function verifyWebhook(
      string $signature,
      string $timestamp,
      string $rawBody,
      string $secret
    ): bool {
        // 1. Build the signing string
        $signingString = $timestamp . '.' . $rawBody;

        // 2. Compute the expected signature
        $expected = 'sha256=' . hash_hmac('sha256', $signingString, $secret);

        // 3. Timing-safe comparison
        return hash_equals($expected, $signature);
    }
    ```
  </Tab>
</Tabs>

## 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:

```javascript theme={null}
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

<AccordionGroup>
  <Accordion title="Signature mismatch — common causes">
    * **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.
  </Accordion>

  <Accordion title="I lost my signing secret">
    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.
  </Accordion>

  <Accordion title="How do I test locally?">
    Use a tunneling tool like [ngrok](https://ngrok.com) 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.
  </Accordion>
</AccordionGroup>
