Webhooks
Security & HMAC

Security & HMAC

Every webhook request includes a signature header so you can verify the request came from Loyalite and wasn't tampered with in transit.

The signature header

X-Loyalite-Signature-256: sha256=<hex>

The signature is an HMAC-SHA256 of the raw request body, using your endpoint's signing secret as the key. The format follows the same convention as GitHub webhooks (opens in a new tab).

Verification steps

  1. Read the raw request body before parsing JSON.
  2. Compute HMAC-SHA256(secret, raw_body) and hex-encode the result.
  3. Prepend sha256= to get the expected signature.
  4. Compare with the X-Loyalite-Signature-256 header using a timing-safe comparison.
  5. If they match, the request is authentic — proceed to parse and handle it.
  6. If they don't match, return 401 or 403 immediately.
🚫

Always use a constant-time comparison (e.g. hmac.Equal, secrets.compare_digest, hash_equals). String equality operators are vulnerable to timing attacks (opens in a new tab).

Code samples

import crypto from 'crypto';
 
// Express middleware example
function verifyLoyaliteSignature(secret) {
  return (req, res, next) => {
    const signature = req.headers['x-loyalite-signature-256'];
    if (!signature) {
      return res.status(401).json({ error: 'Missing signature' });
    }
 
    // Use raw body — configure express to expose it:
    // app.use(express.json({ verify: (req, _, buf) => { req.rawBody = buf; } }))
    const hmac = crypto.createHmac('sha256', secret);
    hmac.update(req.rawBody);
    const expected = 'sha256=' + hmac.digest('hex');
 
    const valid = crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
 
    if (!valid) {
      return res.status(401).json({ error: 'Invalid signature' });
    }
 
    next();
  };
}
 
// Usage
app.post('/webhooks/loyalite', verifyLoyaliteSignature(process.env.WEBHOOK_SECRET), (req, res) => {
  const { event, data } = req.body;
  console.log('Received event:', event, data);
  res.sendStatus(200);
});

Rotating your secret

If your secret is ever compromised, use Rotate Secret in the merchant app (Settings → Integrations → Webhooks → tap endpoint → Rotate Secret).

⚠️

Rotating your secret immediately invalidates the old one. Update your server's WEBHOOK_SECRET environment variable before rotating, or rotate during a maintenance window to avoid a gap in signature verification.

The new secret is displayed once after rotation — same rules as initial creation.

Replay attack prevention

Loyalite does not currently include a timestamp in the signature. To prevent replay attacks in high-security environments, track processed delivery IDs (the id field in the payload) and reject duplicates:

const processedIds = new Set(); // use Redis or a DB in production
 
app.post('/webhooks/loyalite', verifyLoyaliteSignature(secret), (req, res) => {
  const { id, event, data } = req.body;
 
  if (processedIds.has(id)) {
    return res.sendStatus(200); // already processed — idempotent response
  }
  processedIds.add(id);
 
  // handle event...
  res.sendStatus(200);
});