Lemon Squeezy is the recommended provider for global SaaS products. It acts as the merchant of record, handling payment processing, VAT, GST, sales tax collection, and invoicing on your behalf.
After completing this guide, your application will be ready to accept subscription payments through Lemon Squeezy and automatically synchronize customer subscription data.
Before you begin, create a Lemon Squeezy account and complete account verification.
You should have:
The free Starter tier does not require a Lemon Squeezy product. Only paid
plans need products and variants.
Set the payment provider in your environment file. You probably already did it in the Payments page.
PAYMENT_PROVIDER=lemonsqueezyCreate one product for each paid tier in your application. A typical setup consists of a Pro product and a Business product, each containing a Monthly and Annual subscription variant.
Navigate to Store → Products in the Lemon Squeezy dashboard. On the page, click .
0 / 2,000 characters
In the product creation panel:
Pro in Product name.Scroll down and click Add Variant.
A new variant creation panel will open.
Create the monthly subscription variant.
Pro Monthly as the variant name.1 and select Month.When finished, scroll to the bottom and click Save and add another.
Create the annual subscription variant.
Pro Annual as the variant name.1 and select Year.When finished, scroll to the bottom and click Save and go back.
Review the product and verify that both variants are present:
If everything looks correct, click Publish Product.
Repeat the entire process for next paid tier, Business.
A typical configuration looks like:
Pro
├── Monthly
└── Annual
Business
├── Monthly
└── AnnualMost SaaS products offer a discount for annual billing in exchange for a longer commitment. As a result, the annual subscription price is typically lower than twelve monthly payments.
For example:
| Variant | Price |
|---|---|
| Monthly | $19/month |
| Annual | $190/year |
In this example, customers receive a discount by paying annually, while you benefit from improved retention and upfront revenue.
For detailed information about products, variants, pricing models, and subscription settings, refer to the official Lemon Squeezy documentation.
Navigate to Store → Products and click on a paid tier product to open the Product Details panel.
Scroll down to the Variants section and click on a variant. The URL will change to:
https://app.lemonsqueezy.com/products/NNNNNNN/variants/XXXXXXX
The number after /variants/ (shown here as XXXXXXX) is the variant ID. Copy it.
Repeat for every variant across all paid tiers. With two paid tiers (Pro and
Business) and two variants each, you will collect four variant IDs in total.
Paste all four IDs into config/pricing.ts:
export const PRICE_MAP = {
pro_monthly: { lemonsqueezy: "123456" },
pro_yearly: { lemonsqueezy: "123457" },
business_monthly: { lemonsqueezy: "123458" },
business_yearly: { lemonsqueezy: "123459" },
}Use the variant ID from the URL, not the product ID. They are both numbers and easy to mix up.
In your Lemon Squeezy dashboard, expand Settings in the left sidebar and click API.
Click the + button. Enter a name to identify the key and optionally set an expiration date, then click Set expiration date to confirm.
Lemon Squeezy will display the key exactly once. Copy it before closing the dialog. If not, you will need to delete it and create a new one.
Add it to your .env.local:
LEMONSQUEEZY_API_KEY=API keys created in test mode only work with test mode data. When you are ready to go live, return to Settings → API in your Lemon Squeezy dashboard and generate a new API key in live mode. Replace the value of LEMONSQUEEZY_API_KEY in your production environment with the new live mode key.
In your Lemon Squeezy dashboard, expand Settings in the left sidebar and click Stores. Your store is listed with its ID shown after the # symbol.
For example, a store listed as acme.lemonsqueezy.com · #123456 has a store ID of 123456.
Add it to your .env.local:
LEMONSQUEEZY_STORE_ID=123456If you have multiple stores, make sure you copy the ID for the store you intend to use with this application.
Lemon Squeezy requires a publicly accessible URL to deliver webhooks. During local development, your localhost server is not reachable from the internet, so you need a tunneling tool like ngrok to expose it temporarily.
In your terminal, run:
ngrok http 3000Once running, ngrok will give you a temporary public domain like https://abc123.ngrok-free.dev. Your full webhook URL will be:
<your-ngrok-domain>/api/payments/webhooks/lemonsqueezy
In your Lemon Squeezy dashboard, expand Settings in the left sidebar and click Webhooks. Click the + button to create a new webhook.
In the Edit Webhook panel, enter your webhook URL in the Callback URL field.
Then enter a secure random string in the Signing secret field. You can generate one with:
openssl rand -hex 32or:
node -e "console.log(crypto.randomBytes(32).toString('hex'))"Make a note of the generated string for later.
Under What updates should we send?, check the following events:
order_createdsubscription_createdsubscription_updatedsubscription_cancelledsubscription_expiredsubscription_payment_failedsubscription_payment_successsubscription_payment_recoveredClick Save Webhook.
Add the generated signing secret to your .env.local:
LEMONSQUEEZY_WEBHOOK_SECRET=When you deploy to production, remember to update the webhook callback URL in
your Lemon Squeezy dashboard from your ngrok URL to your real domain, such as
https://acme.com/api/payments/webhooks/lemonsqueezy.
New Lemon Squeezy accounts are in test mode by default. You can build and test your full integration in test mode, but you will not be able to accept live payments until your store is activated.
Store activation is a one-time process with two steps:
To begin, click the Activate your store button at the bottom of the left sidebar in your Lemon Squeezy dashboard.
Approvals typically take 2 to 3 business days. You can continue setting up your integration in test mode while you wait.
Products created in test mode are not automatically copied to live mode when your store is activated. After activation, use the Copy to Live Mode option in each product’s menu to make it available for live purchases.
Your configuration should now resemble:
PAYMENT_PROVIDER=lemonsqueezy
LEMONSQUEEZY_API_KEY=
LEMONSQUEEZY_STORE_ID=
LEMONSQUEEZY_WEBHOOK_SECRET=Make sure ngrok is running in a separate terminal window before starting the application:
ngrok http 3000Then simultaneously start the application in another terminal:
bun devOpen the pricing page and attempt a checkout using one of the following test cards:
| Card type | Number |
|---|---|
| Visa | 4242 4242 4242 4242 |
| Mastercard | 5555 5555 5555 4444 |
| American Express | 3782 822463 10005 |
| Insufficient funds | 4000 0000 0000 9995 |
| Expired card | 4000 0000 0000 0069 |
| 3D Secure | 4000 0027 6000 3184 |
Use any future expiration date, any three-digit CVC, and any value for other fields.
Verify that:
For the billing portal to work, you need to have your store approved and live on Lemon Squeezy.
The checkout button calls POST /api/payments/checkout when clicked. If nothing happens, open your browser’s developer console and check for errors. The most common causes are:
PAYMENT_PROVIDER is not set or is set incorrectly.
Open .env.local and confirm:
PAYMENT_PROVIDER=lemonsqueezyRestart the development server after making any changes to .env.local.
The API key is invalid or from the wrong mode.
An API key created in test mode only works with test mode data, and a live mode key only works with live data. Confirm that the key in LEMONSQUEEZY_API_KEY matches the mode your store is currently in. You can verify this in your Lemon Squeezy dashboard under Settings → API.
A variant ID in PRICE_MAP does not exist in Lemon Squeezy.
If the variant ID is wrong or points to a deleted variant, the checkout request will fail. Re-verify each ID by following the steps in the Collect variant IDs section.
This means webhooks are not reaching your application. The payment went through on LemonSqueezy’s side but your database was never notified.
Check recent deliveries in Lemon Squeezy. Navigate to Settings → Webhooks in your Lemon Squeezy dashboard. Click on your webhook endpoint and scroll down to Recent deliveries. If you see failed deliveries, click on one to see the response code and error message. This is the fastest way to diagnose the problem.
ngrok is not running. During local development, webhooks require ngrok to be running in a separate terminal. If ngrok is not running or has restarted with a new URL, Lemon Squeezy cannot reach your application. Restart ngrok and update the Callback URL in your webhook settings with the new ngrok URL.
The webhook URL is wrong. Confirm that the Callback URL in your Lemon Squeezy webhook settings matches exactly:
https://your-ngrok-or-domain.com/api/payments/webhooks/lemonsqueezy
A common mistake is using /api/webhooks/lemonsqueezy instead of /api/payments/webhooks/lemonsqueezy.
The signing secret does not match.
If Lemon Squeezy is delivering webhooks but your application is rejecting them with a 400 response, the signing secret is likely mismatched. The value in LEMONSQUEEZY_WEBHOOK_SECRET must be identical to what you entered in the Signing secret field when creating the webhook. They are case-sensitive. Re-generate and re-enter both if you are unsure.
The payment succeeded and the webhook was delivered, but the tier did not update. Check the webhook_events table for rows with status = 'failed'. You can do this in Drizzle Studio:
bun db:studioLook for the subscription_created event for the affected user. If status = 'failed', the error column will tell you what went wrong. The most common causes are a missing userId in the checkout metadata or a database write failure.
If a user has multiple subscription rows, either the checkout was completed twice or a webhook was delivered more than once. The webhook_events table has a unique constraint on (provider, provider_event_id) that prevents duplicate processing. Check whether a duplicate row exists in webhook_events with status = 'processed' for the same event. If it does, the duplicate subscription row was created before the idempotency constraint was in place and can be safely deleted, keeping only the most recent row.
A variant ID in PRICE_MAP is pointing to the wrong variant in your Lemon Squeezy dashboard. For example, the pro_monthly key might be mapped to the business_monthly variant ID. Re-verify each variant ID by following the Collect variant IDs section and cross-checking each ID against the correct product and variant in Lemon Squeezy.
Lemon Squeezy fires subscription_cancelled immediately when a user cancels, but access should continue until the end of the current billing period. The adapter handles this correctly by setting cancelAtPeriodEnd = true and keeping the status as active until subscription_expired fires at the actual period end.
If cancellation appears to revoke access immediately, check that the subscription_cancelled event is being normalized to subscription.updated and not subscription.deleted in the adapter. The subscription.deleted event should only be triggered by subscription_expired.
Lemon Squeezy does not support issuing refunds via API. The adapter throws an error if refundPayment() is called. All refunds must be issued manually through the Lemon Squeezy dashboard under Store → Orders. Find the order, click the options menu, and select Refund.
Your store is either still in test mode or your activation request was rejected. Check your activation status by navigating to Settings → General in your Lemon Squeezy dashboard. If activation is pending, approvals typically take 2 to 3 business days. If it was rejected, Lemon Squeezy will have sent an email with the reason.
If you completed everything in test mode and are now going live, make sure you have also:
LEMONSQUEEZY_API_KEYThe billing portal URL is retrieved directly from the subscription object in Lemon Squeezy. If the portal fails to open, confirm that the subscription exists in your database and that providerSubscriptionId is populated. Check this in Drizzle Studio by inspecting the subscriptions table for the affected user. If providerSubscriptionId is null or empty, the subscription_created webhook either never arrived or failed to process.