Skip to content
TryWebhook
New URL

Payments · Free · No signup

Test and debug Lemon Squeezy webhooks

Point a Lemon Squeezy webhook at a throwaway URL, place a test-mode order, and read the delivery as it arrived — the JSON:API-shaped payload, the X-Event-Name header, and the signature over the raw body.

No signup · No email · Ready in about a second

At a glance

Lemon Squeezy webhooks in brief

Category
Payments
Where to configure
Lemon Squeezy → Settings → Webhooks → Add endpoint
Signature header
X-Signature
Secret
Signing secret (you type it in when creating the webhook)
Retries
Three attempts. Failed deliveries stay in the webhook's log in the dashboard and can be resent by hand.
Response deadline
Around 5 seconds; a slower response counts as a failure.
Treated as success
Any 2xx. Everything else is a failed attempt.

Cross-checked against Lemon Squeezy's own webhook documentation. Providers change these; if something here is stale, tell us.

The payload

What Lemon Squeezy actually sends

Shape-accurate, with obviously fake identifiers. These are the same samples the inspector's Send test webhook panel fires, so you can read one here and then send the identical bytes to your own URL.

POST/w/{your-bin-id}513 B

JSON:API envelope — the fields you want are under `data.attributes`.

2 headers
x-event-name
order_created
user-agent
Lemon Squeezy Webhooks
Body · application/json · 1 line
{"meta":{"event_name":"order_created","test_mode":true,"custom_data":{"user_id":"1234"}},"data":{"type":"orders","id":"1234567","attributes":{"store_id":42,"customer_id":987654,"identifier":"00000000-1111-2222-3333-444444444444","order_number":1001,"user_name":"Ada Lovelace","user_email":"buyer@example.com","currency":"USD","subtotal":2900,"total":2900,"total_formatted":"$29.00","status":"paid","refunded":false,"first_order_item":{"product_name":"Example App","variant_name":"Pro — monthly","price":2900}}}}

No sample carries a signature header. A valid one can only be produced with your own signing secret, and a fabricated one would make the Signature tab report a mismatch that isn't real. Send a genuine delivery when you want to test verification.

Verification

Verifying a Lemon Squeezy signature

Signed payload

A hex HMAC-SHA256 of the raw request body, keyed with the signing secret you chose when you created the webhook. No timestamp and no prefix — just the digest.

Implemented as: Configure the header, hash, encoding, and signed-payload template yourself.

Lemon Squeezy has no dedicated tab, but its scheme is expressible in the Custom HMAC tab. Configure it like this:

Header name
X-Signature
Hash
SHA-256
Encoding
hex
Signed payload
{body}

The signed data is the raw body and nothing else, so no timestamp header and no prefix are involved. Use the secret exactly as you typed it into the dashboard.

There is no dedicated Lemon Squeezy tab because the scheme is a plain body HMAC with nothing provider-specific about it. The Custom HMAC tab covers it exactly — the settings below are already the right ones.

Events

Events worth subscribing to first

Not the full catalogue — the handful that carry most integrations. Subscribing to everything is the fastest way to a handler that times out.

EventFires when
order_createdA purchase completed. The main provisioning trigger for one-off products, and it fires for the first payment of a subscription too.
subscription_createdA subscription started. Check `attributes.status` — it can be `on_trial` rather than `active`.
subscription_updatedStatus, plan, or renewal date changed. The event to sync entitlements from, including cancellations that take effect at period end.
subscription_payment_successA renewal was paid. Use it to extend access, not to grant it for the first time.
license_key_createdA license key was issued for an order. Only fires for products with licensing enabled.

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.

Failure modes

Where Lemon Squeezy integrations usually break

The event name is in a header, not the body

`X-Event-Name: order_created`. The body has it too, at `meta.event_name`, but code that only reads `data.type` sees `orders` and cannot tell a creation from an update. Route on the header or on `meta.event_name`.

test_mode is easy to miss

`meta.test_mode: true` marks a test purchase. Both real and test orders arrive at the same endpoint with valid signatures, so a handler that ignores the flag will happily provision free products for anyone who finds your store's test mode.

Your custom data is under meta

`meta.custom_data` — not inside `data.attributes`. Values are strings, even when you sent a number, so `custom_data.user_id` comes back as `"1234"`.

The payload is JSON:API shaped

Everything real lives under `data.attributes`, with `data.type` and `data.id` above it and a `relationships` block of links you mostly ignore. `total` is at `data.attributes.total`, in cents, as a number.

You choose the secret

Unlike most providers, Lemon Squeezy does not generate the signing secret — you type it in. A short or reused one weakens verification, and there is no warning. Generate 32 random bytes and paste them.

Answers

Lemon Squeezy webhook questions

How do I verify a Lemon Squeezy signature?+

HMAC-SHA256 the raw request body with your signing secret, hex-encode it, and compare with the `X-Signature` header in constant time. There is no timestamp and no prefix. The Custom HMAC tab here does it with the settings shown above.

Why does my signature check fail even though the secret is right?+

Almost always because the body was parsed and re-serialised before hashing. `JSON.parse` then `JSON.stringify` changes key order and whitespace, and the digest changes with it. Capture the raw bytes first, verify, then parse.

How do I test Lemon Squeezy webhooks without real money?+

Switch the store to test mode and buy your own product with a test card. Deliveries carry `meta.test_mode: true` and are otherwise identical, including a valid signature. Past deliveries can also be resent from the webhook's log.

Which event should grant access?+

`order_created` for one-off products. For subscriptions, use `subscription_created` and then `subscription_updated` to track status, and treat `subscription_payment_success` as a renewal rather than a first grant.

What is the difference between this and Paddle?+

Both are merchants of record, so both handle tax. Paddle signs `{ts}:{body}` with a timestamped header; Lemon Squeezy signs the body alone. Paddle sends money as strings in minor units, Lemon Squeezy as numbers in cents.

Other providers

Testing something else?

One click

Point Lemon Squeezy at a URL and watch the payload land.

Lemon Squeezy → Settings → Webhooks → Add endpoint — paste the URL, trigger an event, and read exactly what arrived.

No signup · No email · Ready in about a second