Lemon Squeezy
Configure the Lemon Squeezy provider — API key, store ID, test mode, webhooks, the variant-as-product model, and its limitations.
Lemon Squeezy is a merchant of record for digital products. Import the factory from
revenue-sdk/lemon-squeezy.
import { createClient } from 'revenue-sdk';
import { lemonSqueezy } from 'revenue-sdk/lemon-squeezy';
const client = createClient({
provider: lemonSqueezy({
apiKey: process.env.LEMON_SQUEEZY_API_KEY!,
storeId: process.env.LEMON_SQUEEZY_STORE_ID!,
}),
});
Factory options
apiKey?string
API key, sent as a Bearer credential.
stringstoreId?string | number
The store checkouts are created in and customers/subscriptions are filtered by. Coerced to a string.
string | numberbaseUrl?string
Used verbatim; defaults to https://api.lemonsqueezy.com.
stringfetch?typeof fetch
Custom fetch implementation.
typeof fetchAuthentication
Create an API key in the Lemon Squeezy dashboard under Settings → API. storeId is required — a
checkout must name the store it belongs to, and customer/subscription lists are scoped to it. You’ll
find the numeric store ID in Settings → Stores or via GET /v1/stores.
Sandbox & test mode
Test mode is a property of the API key, not a flag on the request: a key created while the store is
in test mode talks to test data, a live key talks to live data. There is no server option — swap the
key (and the matching storeId) via environment variables.
Limitations
- No filtering by customer.
subscriptions.list({ customerId })andorders.list({ customerId })throwunsupported(listSubscriptionsByCustomerandlistOrdersByCustomerarefalse) — Lemon Squeezy filters by store, product, variant, or email. Listing unfiltered works. - No customer metadata. A Lemon Squeezy customer has no custom-data field, so
metadataoncustomers.create/customers.updatethrowsunsupported. Creating a customer requires both a name and an email. - No immediate revoke and no cancellation reasons.
subscriptions.revokethrowsunsupportedandsubscriptions.cancelalways runs to the end of the period; passingreasonorcommentthrows. - Pause is immediate only.
subscriptions.pause({ behavior: 'period_end' })throwsunsupportedandpauseAtPeriodEndis alwaysfalse. The SDK fixes the pausemodetovoid, so invoices raised while the subscription is paused are voided; the alternativefreemode is not exposed. - No usage reporting.
usage.reportthrowsunsupported.POST /v1/usage-recordskeys on a subscription-item ID rather than a customer, and has no idempotency at all withincrementsemantics, so a single replay double-bills. See Usage-based billing. - One item per checkout, and no
customerId. More than one item throwsunsupported; so does attaching an existing customer — passcustomerEmailinstead. Quantities above1are supported. Checkout.statusis alwaysnull(checkoutStatus: false), and the customer portal takes noreturnUrl(portalReturnUrl: false).
Full values for every capability: capability matrix.
Webhooks
Create the endpoint in Settings → Webhooks, pick the events, and copy the signing secret you entered there. Verification and parsing come from the subpath:
import { parseWebhookEvent, verifyWebhook } from 'revenue-sdk/lemon-squeezy';
See Handle webhooks for the full handler.
Events worth subscribing to: subscription_created, subscription_updated, subscription_cancelled,
subscription_resumed, subscription_paused, subscription_unpaused, subscription_expired,
order_created, subscription_payment_success, and — if you sell license keys — license_key_created.
Three more things worth knowing. The first subscription payment fires order.paid once, not twice —
Lemon Squeezy raises both order_created and subscription_payment_success for it, and the SDK leaves
the invoice half (billing_reason: 'initial') as unknown, the same filter orders.list applies, so
both paths report that payment under its order ID. Checkout custom data arrives only in
meta.custom_data on the webhook envelope, never on the resource — it is the only place
Subscription.metadata gets populated here. And there is no event timestamp, so
event.createdAt is undefined; the dedupe key falls back to a hash of the raw body, which is stable
across retries — see Webhooks.
License keys
The public license API needs no credential, so validate, activate, and deactivate are standalone exports rather than client methods — safe to ship inside a desktop, mobile, or CLI app. See License keys for the shared signatures and return types.
Lemon Squeezy-specific options — the rest (key, activationId, label, fetch, signal) are the
same on every provider:
expect?{ storeId: string | number; productId?: string | number; variantId?: string | number }
Required on all three calls. Asserted against the response meta; IDs are compared as strings.
{ storeId: string | number; productId?: string | number; variantId?: string | number }baseUrl?string
Used verbatim; defaults to https://api.lemonsqueezy.com.
stringimport { validateLicenseKey } from 'revenue-sdk/lemon-squeezy';
const expect = { storeId: 76833, variantId: 1615641 };
const validation = await validateLicenseKey({ key: 'LICENSE-KEY', expect });
- Test and live keys share one host. Unlike the merchant API, the license API is not scoped by an
API key, so a test-mode key validates against production.
license_key.test_modeonrawis the only discriminator — check it if you ship test keys. LicenseKey.productIdisundefinedon merchant reads. Lemon Squeezy’sproduct_idnames the LS product while the unifiedProductis a variant, so the ID would not work withproducts.get. The public license path reportsmeta.variant_idinstead.
Orders
See Orders for the model. Lemon Squeezy has no single payments collection, so
a unified Order is the union of an Order and a Subscription Invoice: a one-off purchase raises an
order, a renewal raises an invoice and no order, and the first subscription payment raises both
— the same money under two IDs, in two numeric ID spaces, with nothing joining them. orders.list
returns all orders plus every subscription invoice whose billing_reason is not initial, which
yields each payment exactly once. Both requests are scoped to the factory’s storeId.
- A page can come back short, or empty. The
initialinvoices are dropped after Lemon Squeezy has already paged, and the page is never backfilled. Follow the cursor until it isundefined. orders.gettries both ID spaces. It reads the order, then falls back to the subscription invoice on anot_found; the fallback costs a second request whenever the ID is an invoice.subscriptionIdis only ever set on the invoice half — a Lemon Squeezy order carries no subscription reference.- Invoice URLs do not expire, so they are safe to store. A subscription invoice that is still
pending has no PDF link yet and throws
not_found.
Provider notes
- A
Productis a Lemon Squeezy VARIANT. The variant is the purchasable unit, soProduct.idandPrice.checkoutRefare both the variant ID, and every product has exactly one price. products.listreturns published variants from every store the key can reach./v1/variantsoffers no store filter upstream — unlike customers, subscriptions, orders, and license keys, this one collection ignores the factory’sstoreId. On a multi-store account, filter the results onraw.attributes.product_id, or callproducts.getwith the variant IDs you already know.- PayPal subscriptions ignore
PATCH /subscriptions, and the SDK throws rather than lie. Lemon Squeezy answers200with the record unchanged when the payment processor is PayPal, which affectschangePlan,uncancel,pause,resume, andendTrial: each throwsRevenueError { code: 'unsupported' }instead of returning an object that claims a mutation happened. Send those customers to the customer portal.subscriptions.cancelis deliberately not guarded — it is a different endpoint with no PayPal caveat, and blocking it would lock PayPal customers out of cancelling. - Update PATCHes re-validate the stored
trial_ends_at. Every subscription update answers422once the storedtrial_ends_atis in the past — which it is for every subscription whose trial has ended — even when the request does not touch the field. Sendingtrial_ends_at: nullbypasses the validation and leaves the stored value untouched.changePlanalways sends it (on a still-trialing subscription this also ends the trial, consistent with the plan change billing immediately);uncancelreads the subscription first and sends it only once the trial is over, so resuming a cancelled trial keeps the trial running. endTrialrefuses unless the subscription is trialing. Lemon Squeezy has no end-trial operation; the one documented lever,billing_anchor: null, resets the billing anchor unconditionally and would silently move a non-trialing customer’s billing day. So the SDK reads the subscription first and throwsRevenueError { code: 'validation' }when its status is noton_trial.- Portal and update-payment-method URLs expire after 24 hours, so the SDK fetches them on demand. A
customer without a subscription has no portal and throws
not_found. customAmountmaps tocustom_price. Lemon Squeezy applies it to the variant being bought; on a subscription it becomes the price of every renewal too, until the subscription moves to another variant.- Checkout expiry is settable and optional, and omitting it means the checkout never expires — not true of any other provider here. There is no upper bound.
cancelledis not terminal. It means “grace period untilends_at, still resumable”, so it maps toactive+cancelAtPeriodEnd: true. The terminal status isexpired.