Adding a webhook
Create a URL above, then open Lemon Squeezy → Settings → Webhooks → Add endpoint. Paste the URL, pick your events, and — the part that differs from every other provider here — type your own signing secret. Generate one properly:
openssl rand -hex 32
Save it in your environment at the same time. There is no “reveal” button later that will show you a secret the platform generated, because it did not generate one.
Then switch the store to test mode and buy your own product with a test card. The delivery arrives here within a second or two, with meta.test_mode: true and a real signature.
Reading the payload
The body follows JSON:API, which puts one more level of nesting between you and the data than most webhooks do:
{
"meta": { "event_name": "order_created", "test_mode": true, "custom_data": { "user_id": "1234" } },
"data": {
"type": "orders",
"id": "1234567",
"attributes": { "store_id": 12345, "user_email": "buyer@example.com", "total": 2900, "status": "paid" }
}
}
Three things to internalise:
Route on the event name, not data.type. data.type is orders for creation, update, and refund alike. The distinguishing value is X-Event-Name in the headers, mirrored at meta.event_name.
Your identifiers are in meta.custom_data. Pass them at checkout — through the checkout[custom][user_id] parameter or the JS SDK — and they come back on every event about that order or subscription. They arrive as strings regardless of what you sent.
Money is a number in cents. "total": 2900 is $29.00. total_formatted is "$29.00" and is for display only; never parse it back.
Verifying X-Signature
The simplest scheme on this site:
const digest = hmacSha256Hex(signingSecret, rawBody);
const ok = timingSafeEqual(digest, request.headers.get('x-signature'));
No timestamp, no version prefix, no URL. That simplicity has one consequence worth stating plainly: because nothing in the signed data expires, a captured request stays replayable forever. If that matters for your endpoint, deduplicate on data.id plus the event name and make the handler idempotent — that is your only defence here.
The other failure mode is universal to body-HMAC schemes: hash the bytes as they arrived. Any framework that parses JSON before your handler runs has already destroyed the exact byte sequence that was signed.
test_mode is a security boundary
It is tempting to read meta.test_mode as a debugging convenience. It is not — it is the difference between a paid order and a free one, arriving at the same URL with an equally valid signature:
if (payload.meta.test_mode && process.env.NODE_ENV === 'production') {
return new Response('ignored', { status: 200 });
}
Return 200, not an error. The delivery was legitimate; you are simply choosing not to act on it, and a non-2xx would make Lemon Squeezy retry something you will keep declining.