Skip to content
revenue-sdk
Esc
navigateopen⌘Jpreview
On this page

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

PropType
apiKey?string

API key, sent as a Bearer credential.

Typestring
storeId?string | number

The store checkouts are created in and customers/subscriptions are filtered by. Coerced to a string.

Typestring | number
baseUrl?string

Used verbatim; defaults to https://api.lemonsqueezy.com.

Typestring
fetch?typeof fetch

Custom fetch implementation.

Typetypeof fetch

Authentication

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 }) and orders.list({ customerId }) throw unsupported (listSubscriptionsByCustomer and listOrdersByCustomer are false) — 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 metadata on customers.create/customers.update throws unsupported. Creating a customer requires both a name and an email.
  • No immediate revoke and no cancellation reasons. subscriptions.revoke throws unsupported and subscriptions.cancel always runs to the end of the period; passing reason or comment throws.
  • Pause is immediate only. subscriptions.pause({ behavior: 'period_end' }) throws unsupported and pauseAtPeriodEnd is always false. The SDK fixes the pause mode to void, so invoices raised while the subscription is paused are voided; the alternative free mode is not exposed.
  • No usage reporting. usage.report throws unsupported. POST /v1/usage-records keys on a subscription-item ID rather than a customer, and has no idempotency at all with increment semantics, so a single replay double-bills. See Usage-based billing.
  • One item per checkout, and no customerId. More than one item throws unsupported; so does attaching an existing customer — pass customerEmail instead. Quantities above 1 are supported.
  • Checkout.status is always null (checkoutStatus: false), and the customer portal takes no returnUrl (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:

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

Type{ storeId: string | number; productId?: string | number; variantId?: string | number }
baseUrl?string

Used verbatim; defaults to https://api.lemonsqueezy.com.

Typestring
import { 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_mode on raw is the only discriminator — check it if you ship test keys.
  • LicenseKey.productId is undefined on merchant reads. Lemon Squeezy’s product_id names the LS product while the unified Product is a variant, so the ID would not work with products.get. The public license path reports meta.variant_id instead.

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 initial invoices are dropped after Lemon Squeezy has already paged, and the page is never backfilled. Follow the cursor until it is undefined.
  • orders.get tries both ID spaces. It reads the order, then falls back to the subscription invoice on a not_found; the fallback costs a second request whenever the ID is an invoice.
  • subscriptionId is 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 Product is a Lemon Squeezy VARIANT. The variant is the purchasable unit, so Product.id and Price.checkoutRef are both the variant ID, and every product has exactly one price.
  • products.list returns published variants from every store the key can reach. /v1/variants offers no store filter upstream — unlike customers, subscriptions, orders, and license keys, this one collection ignores the factory’s storeId. On a multi-store account, filter the results on raw.attributes.product_id, or call products.get with the variant IDs you already know.
  • PayPal subscriptions ignore PATCH /subscriptions, and the SDK throws rather than lie. Lemon Squeezy answers 200 with the record unchanged when the payment processor is PayPal, which affects changePlan, uncancel, pause, resume, and endTrial: each throws RevenueError { code: 'unsupported' } instead of returning an object that claims a mutation happened. Send those customers to the customer portal. subscriptions.cancel is 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 answers 422 once the stored trial_ends_at is in the past — which it is for every subscription whose trial has ended — even when the request does not touch the field. Sending trial_ends_at: null bypasses the validation and leaves the stored value untouched. changePlan always sends it (on a still-trialing subscription this also ends the trial, consistent with the plan change billing immediately); uncancel reads the subscription first and sends it only once the trial is over, so resuming a cancelled trial keeps the trial running.
  • endTrial refuses 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 throws RevenueError { code: 'validation' } when its status is not on_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.
  • customAmount maps to custom_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.
  • cancelled is not terminal. It means “grace period until ends_at, still resumable”, so it maps to active + cancelAtPeriodEnd: true. The terminal status is expired.

Last updated on August 9, 2026

Was this page helpful?