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
| Mechanism | Purpose |
|---|
X-LMN-Timestamp 5-min window | Reject delayed/replayed requests. |
X-LMN-Event-Id deduplication | Reject 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)
| Attempt | Delay |
|---|
| 1 | immediate |
| 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).