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:

What’s signed

The signed payload is:
  • <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.

Signature header format

The v1= prefix leaves room for future signature schemes (v2=, …) without breaking existing clients. The header can carry more than one v1 entry. During the 1-hour grace window after a production secret rotation, we sign each delivery with both the new and the old key — new key first, old key last:
Verify by accepting any v1 entry that matches the secret you hold. See Secret rotation.

Verification (Node.js)

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.
  • Assuming exactly one v1 entry. A single-entry parser still verifies correctly — but it constrains when you can swap secrets during a rotation. See Secret rotation.

Replay protection

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.
This deduplication suppresses replays. A webhook re-sent via the console’s Replay action deliberately carries the same X-LMN-Event-Id as the original, so a handler that rejects seen ids will discard it before your business logic runs — you see nothing arrive, and Replay looks broken. Keep the dedupe (it is correct for retries); clear the id from your store or bypass the check while testing. See Replaying a webhook.

Retry policy (5 attempts)

Stops on 2xx (success) or 410 Gone (you say “stop sending”). After 5 failures, the event is marked delivery_failed — you can re-attempt it yourself from the console once your endpoint is fixed, without contacting LMN. See Replaying a webhook.
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

Rotate your own webhook signing secret from the partner consoleWebhooks → Production (or Sandbox). The new secret is shown once, at rotation time: LMN stores no readable copy and cannot show it to you again. Lose it and your only recourse is to rotate again. Production rotation is deliberately hard to trigger by accident. It requires all three of: LMN staff can also rotate on your behalf if you ask — that path is an immediate cutover with no grace window, so it’s coordinated with you first.

The 1-hour dual-signature grace window

A rotation you run yourself against production does not cut over instantly. For 1 hour afterwards, every delivery is signed with both keys and X-LMN-Signature carries two v1 entries — the new key first, the old key last:
After the hour, deliveries carry a single v1 entry, signed with the new key only.
No change on your side is required to survive a rotation. A verifier still holding only the old secret keeps passing for the full hour, including one using the single-entry Object.fromEntries parser — that parser takes the last v1 entry, which is the old key’s signature. You have the hour to deploy the new secret.

What to do, by parser

That second row is the reason to upgrade to the any-match parser: it decouples your deploy from our clock. Sandbox has no grace window — a sandbox rotation is an immediate cutover. Nothing live depends on it, and it’s the environment to rehearse your handling in.

Endpoint registration

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