Lemon Squeezy
Configure the Lemon Squeezy provider — API key, store ID, test mode, webhooks, capabilities, and the variant-as-product model with its quirks.
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.
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.
Webhooks
Create the endpoint in Settings → Webhooks, pick the events, and copy the signing secret you entered there.
import { parseWebhookEvent, verifyWebhook } from 'revenue-sdk/lemon-squeezy';
const headers = request.headers;
const body = await request.text();
if (!(await verifyWebhook({ headers, body, secret: env.LEMON_SQUEEZY_WEBHOOK_SECRET }))) {
return new Response('invalid signature', { status: 401 });
}
const event = await parseWebhookEvent({ headers, body });
The X-Signature header carries a hex HMAC-SHA256 of the raw body, keyed by the secret used verbatim.
There is no timestamp, so there is no replay window to enforce — de-duplicate on your side.
Events worth subscribing to: subscription_created, subscription_updated, subscription_cancelled,
subscription_resumed, subscription_paused, subscription_unpaused, subscription_expired,
order_created, subscription_payment_success.
Capabilities
| Capability | Value |
|---|---|
cancellationReason |
false |
checkoutStatus |
false |
checkoutSuccessUrl |
true |
endTrial |
true |
hostedCheckout |
true |
listSubscriptionsByCustomer |
false |
portalReturnUrl |
false |
prorationBehaviors |
['invoice_now', 'prorate', 'none'] |
revoke |
false |
uncancel |
true |
Quirks
- 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. - Numeric IDs coerced to strings. JSON puts
data.idas a string but foreign keys insideattributesas numbers. The SDK coerces everything withString(), socustomerId,productId, andpriceIdare always strings. - The current price comes from
price-model./v1/prices?filter[variant_id]=…is an append-only price history; the SDK reads the variant’sprice-modelrelationship instead. - JSON content type on every request, including GETs (
application/vnd.api+json). - Empty objects serialize as
[]. Lemon Squeezy returns[]where an empty object is expected (custom,billing_address); the mappers tolerate it. - One item per checkout, and no
customerId. More than one item throwsunsupported; attaching an existing customer throwsunsupported— passcustomerEmailinstead. Quantities above1are supported (viavariant_quantities). - No cancellation reasons, and no immediate revoke —
subscriptions.cancelalways runs to the end of the period.subscriptions.revokethrowsunsupported. - Cannot filter subscriptions by customer.
subscriptions.list({ customerId })throwsunsupported; Lemon Squeezy filters by store, product, variant, or email. cancelledis not terminal. It means “grace period untilends_at, still resumable”, so it maps toactive+cancelAtPeriodEnd: true. The terminal status isexpired.- 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.returnUrlis unsupported. - PayPal subscriptions ignore
PATCH /subscriptions— Lemon Squeezy silently no-ops. Plan changes and uncancel therefore do nothing for PayPal-paid subscriptions; re-read the subscription and check before reporting success to the customer.