Skip to content

Lemon Squeezy orders, invoices and partial refunds, and what each does to access

The first payment arrives as an order and an invoice, refunded_amount is a running total, and chargebacks send no event. How to record each once and revoke access on the right one.

Lemon Squeezy3 min readships at docs/solutions/lemonsqueezy/orders-invoices-and-partial-refunds.md

Tags: lemonsqueezy · refunds · orders · invoices · entitlements · chargebacks

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_created when it is placed (normally already paid), order_refunded on every refund. A subscription's first payment is also an order.
  • Subscription invoices. One per charge on a subscription: billing_reason is initial, renewal or updated (a proration). subscription_payment_success when paid, subscription_payment_failed on a decline, subscription_payment_refunded on 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

EventWhat it records
order_created for a single-payment varianta one-time purchase, and its receipt
order_created for a subscription variantnothing: the subscription events own it
order_refundedthe purchase's refunded amount and status
subscription_*the subscription, re-read from the API
subscription_payment_successthe subscription, and the receipt for that invoice (initial and renewals)
subscription_payment_failedthe 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 refunded and grants nothing. The user falls back to the free plan on their next request.
  • Partial refund: partially_refunded keeps 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_cancelled arrives 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, not Number.isFinite: 19.99 in an integer column is off by 100 after rounding.
  • No defaults. ?? 0 or ?? "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.