Lemon Squeezy spreads money over two objects and six events, and two of those events describe the same dollars. Get the mapping wrong and you either count a new customer twice or keep a refunded lifetime deal unlocked.
Two objects carry money
- Orders. One per checkout.
order_createdwhen it is placed (normally alreadypaid),order_refundedon every refund. A subscription's first payment is also an order. - Subscription invoices. One per charge on a subscription:
billing_reasonisinitial,renewalorupdated(a proration).subscription_payment_successwhen paid,subscription_payment_failedon a decline,subscription_payment_refundedon a refund.
A new subscription produces an order and an initial invoice for the same
charge. Treat both as money and your revenue for new customers doubles; treat
both as purchases and a $19 monthly plan becomes a lifetime deal.
One job per event
| Event | What it records |
|---|---|
order_created for a single-payment variant | a one-time purchase, and its receipt |
order_created for a subscription variant | nothing: the subscription events own it |
order_refunded | the purchase's refunded amount and status |
subscription_* | the subscription, re-read from the API |
subscription_payment_success | the subscription, and the receipt for that invoice (initial and renewals) |
subscription_payment_failed | the subscription (now past_due), and a warning mail |
The deciding question for an order is "is this variant a one-time price in my catalogue?". Ask your catalogue, not the payload: custom data can be typed into a public buy link, the variant that was paid for cannot.
refunded_amount is a running total
Refund $100 of a $299 order, then the rest. You get two order_refunded
events. The first says refunded_amount: 10000. The second says 29900, not
19900.
Store it as a running total, never add it up:
function orderPurchaseStatus(order) {
if (order.refunded_amount > 0) {
return order.refunded_amount >= order.total ? "refunded" : "partially_refunded";
}
if (order.status === "paid") return "paid";
if (order.status === "pending") return "pending";
return "failed"; // failed, fraudulent
}
Write the row with amount_refunded = greatest(old, new) and a status that
only moves forward. A resend of the first refund then changes nothing, and a
late order_created retry cannot turn a refunded order back into a paid one.
Decide from the numbers first: the status word has more than one spelling for
a partial refund.
Which refunds revoke access
- Full refund: the purchase is
refundedand grants nothing. The user falls back to the free plan on their next request. - Partial refund:
partially_refundedkeeps the plan. A goodwill refund of part of the price is not a request to take the product away. If your policy differs, change the entitled statuses in one place, not in a page. - Subscription invoice refund: it does not end the subscription. If the
merchant also cancels,
subscription_cancelledarrives on its own.
Chargebacks send no event
Lemon Squeezy is the merchant of record, so it handles chargebacks itself, and there is no dispute webhook to act on. Watch disputed orders in the dashboard. If you decide a customer who disputed should lose access, refund the order (or cancel the subscription): the refund path revokes it like any other. Do not write access rules that wait for a "disputed" status: on Lemon Squeezy it never comes.
Record gross, reconcile against the payout
Store total: what the customer paid, tax included. It matches the order list
in the dashboard, which is what you reconcile against. Your payout is lower by
the tax Lemon Squeezy remits and its fee; that net number lives in the payout
report, not in a column you compute.
Money guards
- Amounts are integers in cents. Validate with
Number.isInteger, notNumber.isFinite: 19.99 in an integer column is off by 100 after rounding. - No defaults.
?? 0or?? "usd"writes a wrong row under a key that will refuse the correction. Fail the delivery instead. - A paid order you cannot match to a user fails the delivery (500), so it goes red and gets retried. Quietly answering 200 makes that money unrecordable.