Tracking churn with Lemon Squeezy webhooks
Which Lemon Squeezy webhook events mark a cancellation, an expiry, a failed payment or a recovery, and how to turn them into a reliable churn log.
If you sell subscriptions through Lemon Squeezy, every churn signal you need passes through its webhooks: cancellations, expiries, failed renewals, recoveries, pauses. The hard part is not receiving them. It is deciding which event means what, and recording them so that your churn numbers still add up six months later.
This guide maps Lemon Squeezy’s subscription events to churn concepts, shows how to store them, and lists the delivery details that break naive handlers. It sticks to Lemon Squeezy’s own documentation: the webhook event types, the webhook requests page, signing requests, the sync with webhooks guide and the Subscription object reference (all accessed November 2026).
The subscription events
Lemon Squeezy’s event types page lists these subscription events:
| Event | What Lemon Squeezy says triggers it | Churn meaning |
|---|---|---|
subscription_created |
A new subscription | New (or returning) customer |
subscription_updated |
Any change after the initial payment | Use to sync status |
subscription_cancelled |
Cancelled by the customer or store owner; enters a grace period until the next billing date | Churn scheduled, not yet happened |
subscription_resumed |
Resumed after being cancelled | Scheduled churn reversed |
subscription_expired |
Ended after being cancelled, or once dunning completed for past_due subscriptions |
Churn happened |
subscription_paused |
Payment collection paused | Not churn; track separately |
subscription_unpaused |
Payment collection resumed | End of pause |
subscription_payment_success |
A successful payment | Revenue |
subscription_payment_failed |
A renewal payment failed | Involuntary churn risk |
subscription_payment_recovered |
A successful payment after a failed one | Risk resolved |
subscription_payment_refunded |
A payment refunded | Revenue adjustment |
Two of these deserve a closer look.
Cancelled is not churned
subscription_cancelled fires when the customer clicks cancel, but the subscription keeps running until ends_at. During that grace period it can be resumed, which sends subscription_resumed. If you count churn on subscription_cancelled, every resumed subscription becomes a false churn, and your churn date is off by up to a full billing period.
Count churn on subscription_expired. Keep subscription_cancelled as a separate, leading metric: “cancellations scheduled this month”.
One event, two kinds of churn
subscription_expired fires for two very different reasons: a customer who cancelled and reached ends_at (voluntary churn), and a subscription that went through dunning after failed payments without recovering (involuntary churn). The event name does not tell you which.
To separate them, look at what came before. If your log has a subscription_cancelled for that subscription and no subscription_resumed after it, it is voluntary. If it has subscription_payment_failed events and no recovery, it is involuntary. You can also read the subscription’s state when you receive the event: the Subscription object reference shows a cancelled boolean alongside status.
Voluntary and involuntary churn need different fixes, so this split is the most useful thing your log can give you.
What the requests look like
Per the webhook requests page:
- Each request is a
POSTwith a JSON:API body: the resource related to the event (a subscription for most subscription events, a subscription invoice for the payment events). - The
X-Event-Nameheader carries the event name. - The
X-Signatureheader carries the signature. - Your endpoint must return
200. Any other status is retried up to three more times with exponential backoff (the docs give 5, 25 and 125 seconds as an example), after which the request is considered failed.
The webhooks settings page in your dashboard logs recent deliveries with their payloads and lets you resend them. That is your safety net if your endpoint was down.
Verify the signature
Signing requests explains the scheme: you set a signing secret when you create the webhook (6 to 40 characters), and Lemon Squeezy computes an HMAC-SHA256 hex digest of the request body with it. Recompute the digest on the raw body, not on re-serialized JSON, and compare with a timing-safe function. Reject anything that does not match.
Payment events carry invoices
The subscription_payment_* events carry a subscription invoice object, not the subscription. It references the subscription, so store that ID and, if you need the status, fetch the subscription.
Building a churn log step by step
The goal is an append-only table of what happened to each subscription, from which every churn metric can be recomputed.
- Create one endpoint for all subscription events. Subscribe it to the events in the table above. Lemon Squeezy’s sync guide notes that
subscription_updatedcovers every change, so you can subscribe to it alone if you only want state. For churn tracking, the specific events are easier to reason about, so take both. - Verify, then store the raw payload. Write the event name, the subscription ID, the customer ID, the timestamp and the raw JSON before doing anything else.
- Deduplicate. Retries and manual resends mean the same event can arrive twice. The docs we used do not describe a unique event ID in the request, so build your own key from the event name, the resource ID and the resource’s
updated_at, and ignore repeats. - Respond fast. Return
200as soon as the event is stored, and process it in a background job. A slow handler that times out triggers retries. - Derive, do not overwrite. Keep the log immutable. Compute “current status” and churn counts from it, so a bug in your logic can be fixed by replaying the log.
- Reconcile occasionally. Once a week, list subscriptions through the API and compare their statuses to what your log says. Any mismatch means a missed event.
The metrics you can compute
With that log, these are straightforward:
- Customer churn rate for a month: subscriptions that expired during the month, divided by subscriptions active at the start of the month.
- Voluntary vs involuntary share: expiries split by the rule above.
- Cancellations scheduled:
subscription_cancelledevents in the month, a leading indicator for next month’s churn. - Grace-period saves: cancelled subscriptions followed by
subscription_resumed. - Payment recovery rate: subscriptions with a
subscription_payment_recovereddivided by subscriptions with asubscription_payment_failedin the same period. - Paused share: subscriptions currently
paused. Report them separately; they are neither active payers nor churned.
Define each metric once, in writing, and keep the definition stable. A churn rate is only useful compared to itself over time.
Testing before you trust it
Lemon Squeezy’s test mode can simulate webhook events for subscriptions. Run each lifecycle through it: create, cancel, resume, cancel again and let it expire; a failed payment that recovers; one that does not. Check that your log and your metrics match what you expect at every step.
Doing this with NoChurn
Lemon Squeezy stays the source of truth for subscriptions and payments; NoChurn does not change them. NoChurn subscribes to your Lemon Squeezy webhooks, stores and deduplicates the events, and separates voluntary from involuntary churn for you. On top of that it adds a churn score from 0 to 100 per customer, custom data you send by API, and the cancel flow answers that explain why people leave. Disputes, which Lemon Squeezy handles as the merchant of record, are shown read only. See NoChurn + Lemon Squeezy.