Skip to main content

Signing — HMAC-SHA256

LMN signs every webhook with HMAC-SHA256 using a shared secret (Stripe-compatible scheme). Headers sent with each request:
POST https://your-app.example.com/webhooks/lmn HTTP/1.1
Content-Type: application/json
X-LMN-Event-Id: evt_01HXYZ...
X-LMN-Timestamp: 1714567890
X-LMN-Signature: t=1714567890,v1=5257a869e7ecebeda32a...

What’s signed

The signed payload is:
<timestamp>.<raw_request_body_bytes>
  • <timestamp> is the value of X-LMN-Timestamp (Unix seconds as string).
  • . is a literal period.
  • <raw_request_body_bytes> is the exact bytes of the HTTP body — do not parse and re-stringify the JSON.
Then HMAC-SHA256 with the shared secret, hex-encoded.

Verification (Node.js)

import crypto from 'crypto';

function verifyLmnWebhook(rawBody, headers, secret) {
  const sigHeader = headers['x-lmn-signature'];
  const tsHeader = headers['x-lmn-timestamp'];
  if (!sigHeader || !tsHeader) return false;

  // Replay protection: reject events older than 5 minutes
  const ageSeconds = Math.abs(Date.now() / 1000 - parseInt(tsHeader, 10));
  if (ageSeconds > 300) return false;

  const parts = Object.fromEntries(sigHeader.split(',').map(p => p.split('=')));
  if (parts.t !== tsHeader) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${tsHeader}.${rawBody}`, 'utf8')
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(parts.v1, 'hex'),
  );
}
Common pitfalls (verify in your stack):
  • Many web frameworks parse JSON before middleware sees the raw body. Capture raw bytes before parsing (in Express: express.raw({ type: 'application/json' })).
  • JSON.stringify(req.body) produces different bytes than what was sent (whitespace, key order, escaping). Don’t use it.
  • String comparison (==) of signatures leaks timing info. Use timingSafeEqual or your stack’s equivalent.

Replay protection

MechanismPurpose
X-LMN-Timestamp 5-min windowReject delayed/replayed requests.
X-LMN-Event-Id deduplicationReject the same event delivered twice (e.g., on retry overlap).
Track received event_ids for at least 24 hours and reject duplicates. A simple Redis set with 24h TTL works. Clock skew tolerance: 5 minutes. NTP-sync your servers if drift exceeds that.

Retry policy (5 attempts)

AttemptDelay
1immediate
2+1 minute
3+15 minutes
4+2 hours
5+12 hours
Stops on 2xx (success) or 410 Gone (you say “stop sending”). After 5 failures, marked delivery_failed for manual replay via LMN ops.
Per-attempt timestamp. Each retry generates a fresh X-LMN-Timestamp and re-signs with the new timestamp — so the 5-minute replay window applies to the delivery time, not the original event. A retry at +12h is still verifiable.

Secret rotation

When LMN rotates the HMAC secret, both old and new secrets are accepted for a 14-day overlap. Verify against the new secret first; fall back to the old one if it fails. After overlap, only the new secret works.

Endpoint registration

Phase 1 (pilot): webhook URLs are exchanged offline (one URL per environment) during onboarding. Future: API-driven endpoint management (planned post-pilot).