# Guide to Lemon Squeezy Webhooks: Features and Best Practices

Lemon Squeezy webhooks tell your application when customers buy, subscribe, renew, pay late, cancel or ask for a refund in your Lemon Squeezy store. If you sell software, licences or memberships through Lemon Squeezy, webhooks are how you grant and revoke access, issue license keys and keep your own records in step with the store.

This guide covers how Lemon Squeezy webhooks are configured and signed, the events and their JSON:API payloads, the short retry window, and the best practices for running a Lemon Squeezy receiver in production.

## What are Lemon Squeezy webhooks?

A Lemon Squeezy webhook is a URL, a signing secret and a list of events, set up for your store in **Settings > Webhooks** or through the Lemon Squeezy API. When a selected event happens, Lemon Squeezy sends a `POST` with the related object as a JSON:API resource. An order event carries an Order object, a subscription event a Subscription object, and so on.

Each request has three headers that matter: `Content-Type: application/json`, `X-Event-Name` with the event name, and `X-Signature` with a hex HMAC-SHA256 of the body. The secret is one you choose when you create the webhook. Lemon Squeezy doesn't generate it.

## Lemon Squeezy webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Settings > Webhooks**, or the Lemon Squeezy API |
| Subscription model | You choose the events per webhook |
| Signature header | `X-Signature` |
| Scheme | HMAC-SHA256, hex digest |
| Signed content | The raw request body, nothing else |
| Secret | Chosen by you, "normally a random string between 6 and 40 characters" |
| Event header | `X-Event-Name`, also in the body as `meta.event_name` |
| Replay window | None. No timestamp is sent or signed |
| Success | HTTP `200` |
| Retries | Up to three more attempts with exponential backoff, for example 5, 25 and 125 seconds |
| TLS | The certificate of an HTTPS URL is verified before sending |
| Delivery log | Recent deliveries and full payloads in the dashboard, with resend |
| Testing | Test mode events can be simulated |

Lemon Squeezy publishes no source IP list for webhooks, so verify the signature.

## Common events

Lemon Squeezy calls its event table "the full list of webhook events":

| Event | Object | Fires when |
| --- | --- | --- |
| `order_created` | Order | A new order is placed |
| `order_refunded` | Order | An order is fully or partly refunded |
| `customer_updated` | Customer | A customer's data changes |
| `subscription_created` | Subscription | A subscription is created. Always sent alongside `order_created` |
| `subscription_updated` | Subscription | A subscription changes. Lemon Squeezy suggests it as a "catch-all" for the latest data |
| `subscription_cancelled` | Subscription | A subscription is cancelled and enters its grace period until the next billing date |
| `subscription_resumed` | Subscription | A cancelled subscription is resumed |
| `subscription_expired` | Subscription | A subscription ends after cancellation, or after dunning for a `past_due` subscription |
| `subscription_paused`, `subscription_unpaused` | Subscription | Payment collection is paused or resumed |
| `subscription_payment_success` | Subscription invoice | A subscription payment succeeds |
| `subscription_payment_failed` | Subscription invoice | A renewal payment fails |
| `subscription_payment_recovered` | Subscription invoice | A payment succeeds after a failure. Always sent alongside `subscription_payment_success` |
| `subscription_payment_refunded` | Subscription invoice | A subscription payment is refunded |
| `license_key_created` | License key | A license key is created from a new order. Always sent alongside `order_created` |
| `license_key_updated` | License key | A license key changes |
| `affiliate_activated` | Affiliate | An affiliate is activated |

Every body has a `meta` object and a `data` resource. Here is the start of the `order_created` example from Lemon Squeezy's webhook requests page, trimmed:

```json
{
  "meta": {
    "event_name": "order_created",
    "custom_data": { "customer_id": 25 }
  },
  "data": {
    "type": "orders",
    "id": "1",
    "attributes": {
      "store_id": 1,
      "customer_id": 1,
      "identifier": "104e18a2-d755-4d4b-80c4-a6c1dcbe1c10",
      "order_number": 1,
      "user_name": "John Doe",
      "currency": "USD",
      "subtotal": 999,
      "tax": 200,
      "total": 1199,
      "status": "paid",
      "refunded": false,
      "total_formatted": "$11.99"
    }
  }
}

```

`meta.event_name` is the event. `meta.custom_data` appears on Order, Subscription and License key events when you passed custom data through the checkout, which is how you link a purchase back to a user in your system. `data.type` is the resource type (`orders`, `subscriptions`), not the event: `subscription_created`, `subscription_cancelled` and `subscription_paused` all have `data.type` of `subscriptions`.

## Setting up Lemon Squeezy webhooks

1. Generate a random signing secret, for example with `openssl rand -hex 20`, and store it server-side as `LEMONSQUEEZY_WEBHOOK_SECRET`.
2. In your Lemon Squeezy dashboard, go to **Settings > Webhooks** and add a webhook with your HTTPS URL, the signing secret and the events you need. You can also create webhooks through the Lemon Squeezy API.
3. Use test mode, and the simulate webhook events feature, to send test deliveries before going live.
4. Watch the webhooks settings page, which shows "which events have been sent, the full payloads of each request" and lets you resend them.

For local development, the [Hookdeck CLI](/docs/cli) (`hookdeck listen 3000 lemon-squeezy --path /webhooks/lemon-squeezy`) gives you a public HTTPS URL that forwards to your local server, with no account required. Use it as the webhook URL in test mode.

## Securing Lemon Squeezy webhooks

Read `X-Signature`, compute an HMAC-SHA256 of the raw request body with your signing secret as the key, encode it as hex and compare in constant time. This is exactly what Lemon Squeezy's own Node example does with `crypto.createHmac('sha256', secret)`, `request.rawBody` and `timingSafeEqual`. Only the body is signed, so verify the body's `meta.event_name` rather than trusting the `X-Event-Name` header.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyLemonSqueezySignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/lemon-squeezy', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyLemonSqueezySignature(req.body, req.get('x-signature'), process.env.LEMONSQUEEZY_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.meta.event_name (inside the signature), store, then answer with 200.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import os

def verify_lemon_squeezy_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    received = signature_header.strip().lower().encode("utf-8")
    return hmac.compare_digest(received, expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_lemon_squeezy_signature(raw_body, request.headers.get("x-signature"),
#                                os.environ["LEMONSQUEEZY_WEBHOOK_SECRET"])

```

To check your verifier, use the secret `lemon-test-secret` and this exact body:

```
{"meta":{"event_name":"order_created"},"data":{"type":"orders","id":"1"}}

```

The signature should be:

```
d981647f004dca22ba68a332a8b1cc1bfcafa57070356543ec2e2bee31448cfb

```

> **Make Lemon Squeezy webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies `X-Signature` with your signing secret, answers Lemon Squeezy with a `200` straight away, and durably queues every order and subscription event before your service sees it.

## Lemon Squeezy webhook limitations and pain points

### Three retries in a few minutes

**The Problem:** A customer pays during a deploy and never gets access, because the webhook failed and Lemon Squeezy stopped trying.

**Why It Happens:** Lemon Squeezy retries a failed delivery "up to three more times using an exponential backoff strategy (e.g. 5 secs, 25 secs, 125 secs)", and then "the request will be considered failed and will no longer be retried". At those intervals the whole sequence is over in under three minutes.

**Workarounds:**

* Keep the handler fast and simple so it rarely fails.
* After an outage, check the webhooks settings page and resend the failed deliveries.
* Reconcile orders and subscriptions with the Lemon Squeezy API on a schedule.

**How Hookdeck Can Help:** Hookdeck answers each delivery with a `200` as soon as it arrives and stores it. Your service then receives it with [retries](/docs/retries) over a period you choose, so a deploy measured in minutes no longer loses a purchase.

### Only `200` counts

**The Problem:** Your handler returns `201` or `204` and Lemon Squeezy treats it as a failure and retries.

**Why It Happens:** The docs say to "return a 200 response to show that the webhook was successfully captured", and "if the response status is anything else, the webhook will be retried".

**Workarounds:**

* Return exactly `200` from the webhook route.
* Make processing idempotent, so a retry caused by the wrong status does no harm.

**How Hookdeck Can Help:** Hookdeck replies to Lemon Squeezy with a `200` regardless of what your own service returns, and records your service's response separately.

### Two events for one purchase

**The Problem:** A new subscription is provisioned twice, once from `order_created` and once from `subscription_created`.

**Why It Happens:** Some events always arrive together: `order_created` with `subscription_created` or `license_key_created`, and `subscription_payment_success` with `subscription_payment_recovered`.

**Workarounds:**

* Decide which event owns each action. For subscriptions, provision on `subscription_created` and use `order_created` for receipts and accounting.
* Key your writes on the resource `data.id` so a second event updates rather than duplicates.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route each `meta.event_name` to the service that owns it, so one service never sees both events of a pair unless it needs to.

### No event ID

**The Problem:** A resent or retried delivery is processed twice.

**Why It Happens:** The body has a resource ID in `data.id` but no ID for the delivery or event.

**Workarounds:**

* Use `meta.event_name` plus `data.id` plus a field that changes with each event, such as `data.attributes.updated_at`, as an idempotency key.
* Make state changes idempotent: setting a subscription to `cancelled` twice is harmless.

**How Hookdeck Can Help:** Hookdeck [deduplication](/docs/deduplication) can drop repeated deliveries based on the fields you choose.

## Best practices

### Verify against the raw body

Mount `express.raw()` on the route, read `await request.body()` in FastAPI, or use `Buffer.from(await request.arrayBuffer())` in a Next.js route handler. Lemon Squeezy's own sample uses `request.rawBody` for the same reason.

### Route on `meta.event_name`

It is inside the signature, unlike the `X-Event-Name` header. Don't route on `data.type`, which is the resource type.

### Pass a user ID through the checkout

Add your own user ID as checkout custom data, and read it from `meta.custom_data` on Order, Subscription and License key events. It is the simplest way to link a purchase to an account.

### Start with the recommended events

Lemon Squeezy suggests `order_created` for single payments, and `subscription_created`, `subscription_payment_success` and `subscription_updated` for subscriptions, as a minimum. It says `subscription_updated` fires at every event after the initial payment, so in most cases the narrower events such as `subscription_resumed` and `subscription_expired` are not needed as well. Store the subscription's current state from `subscription_updated`, and add narrower events only for side effects such as emails.

### Acknowledge fast and process asynchronously

With retries that end within minutes, answer quickly and do the provisioning in a worker. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Lemon Squeezy signs every webhook with a hex HMAC-SHA256 of the raw body in `X-Signature`, using a secret you choose. Verify against the raw bytes, route on `meta.event_name`, link purchases through `meta.custom_data`, return exactly `200`, and plan for the fact that retries end within a few minutes.

[Hookdeck Event Gateway](https://hookdeck.com) verifies each signature, answers Lemon Squeezy immediately and holds every event until your service has processed it, so a short outage on your side doesn't lose an order or subscription update.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Lemon Squeezy webhooks reliably in minutes.