# Verify signatures

Each delivery carries a header like this:

```text title="Header"
Lelemon-Signature: t=1730675442,v1=5f2b...
```


`v1` is an HMAC-SHA256, in hexadecimal, of `{t}.{raw body}` with your endpoint's secret. Reject the delivery if no `v1` matches or if `t` is more than 5 minutes from your clock.

## With the SDK

```ts title="Node.js (Express)"
import express from 'express';
import { Lelemon, LelemonSignatureVerificationError } from '@lelemon-ai/sdk';

const app = express();

app.post('/lelemon', express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    const event = await Lelemon.webhooks.constructEvent(
      req.body,
      req.header('Lelemon-Signature') ?? '',
      process.env.LELEMON_WEBHOOK_SECRET!,
    );
    res.sendStatus(200);
    queue(event);
  } catch (error) {
    if (error instanceof LelemonSignatureVerificationError) return res.sendStatus(400);
    throw error;
  }
});
```


`constructEvent` verifies the signature and returns the parsed event, typed by its `type`. `await Lelemon.webhooks.verifyHeader(payload, header, secret)` only verifies: it returns nothing and throws `LelemonSignatureVerificationError` when the signature, the timestamp or the secret do not match. Both are asynchronous and accept `{ toleranceSeconds }`, which defaults to 300.

## Without the SDK

```ts title="Node.js"
import { createHmac, timingSafeEqual } from 'node:crypto';

export function isValid(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
  const signatures = header
    .split(',')
    .filter((p) => p.startsWith('v1='))
    .map((p) => p.slice(3));
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
  return fresh && signatures.some((s) => s.length === expected.length && timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}
```


Always use the raw request body. A body that was parsed and serialized again will not match.

## Rotating the secret

`POST /v2/webhook-endpoints/{id}/rotate-secret` returns a new secret. For 24 hours both secrets sign, and the header carries two `v1` values. Accept either until you have deployed the new one.
