Gareth Wilson Gareth Wilson

Guide to Lemon Squeezy Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationSettings > Webhooks, or the Lemon Squeezy API
Subscription modelYou choose the events per webhook
Signature headerX-Signature
SchemeHMAC-SHA256, hex digest
Signed contentThe raw request body, nothing else
SecretChosen by you, "normally a random string between 6 and 40 characters"
Event headerX-Event-Name, also in the body as meta.event_name
Replay windowNone. No timestamp is sent or signed
SuccessHTTP 200
RetriesUp to three more attempts with exponential backoff, for example 5, 25 and 125 seconds
TLSThe certificate of an HTTPS URL is verified before sending
Delivery logRecent deliveries and full payloads in the dashboard, with resend
TestingTest 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":

EventObjectFires when
order_createdOrderA new order is placed
order_refundedOrderAn order is fully or partly refunded
customer_updatedCustomerA customer's data changes
subscription_createdSubscriptionA subscription is created. Always sent alongside order_created
subscription_updatedSubscriptionA subscription changes. Lemon Squeezy suggests it as a "catch-all" for the latest data
subscription_cancelledSubscriptionA subscription is cancelled and enters its grace period until the next billing date
subscription_resumedSubscriptionA cancelled subscription is resumed
subscription_expiredSubscriptionA subscription ends after cancellation, or after dunning for a past_due subscription
subscription_paused, subscription_unpausedSubscriptionPayment collection is paused or resumed
subscription_payment_successSubscription invoiceA subscription payment succeeds
subscription_payment_failedSubscription invoiceA renewal payment fails
subscription_payment_recoveredSubscription invoiceA payment succeeds after a failure. Always sent alongside subscription_payment_success
subscription_payment_refundedSubscription invoiceA subscription payment is refunded
license_key_createdLicense keyA license key is created from a new order. Always sent alongside order_created
license_key_updatedLicense keyA license key changes
affiliate_activatedAffiliateAn 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:

{
  "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 (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.

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:

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 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 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 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 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.

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 and our guide to 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 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 for free and handle Lemon Squeezy webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.