Lelemon
  • Get started
  • Use cases
  • Guides
  • API basics
  • Webhooks
  • SDK, CLI and MCP
  • API reference
  • Changelog
  • v1 (legacy)
Receive eventsVerify signaturesEvent catalog
powered by Zudoku
Webhooks

Verify signatures

Each delivery carries a header like this:

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

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

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.

Receive eventsEvent catalog
On this page
  • With the SDK
  • Without the SDK
  • Rotating the secret
Lelemon-Signature: t=1730675442,v1=5f2b...
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; } });
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))); }
TypeScript