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

# Signing & retries

> HMAC-SHA256 verification, retry policy, and replay protection.

## Signing — HMAC-SHA256

LMN signs every webhook with HMAC-SHA256 using a shared secret (Stripe-compatible scheme). Headers sent with each request:

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

```js theme={null}
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'),
  );
}
```

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

## 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_id`s 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.

<Note>
  **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.
</Note>

## 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).
