8 min

Lemonsqueezy Ultimate Subscription Setup guide

Lemonsqueezy Ultimate Subscription Setup guide

In this tutorial we will be going over a full subscription flow with LemonSqueezy.

By the end of the tutorial we will have setup:

  • Implementing and understanding Lemon-squeezy Subscriptions
  • Signing up and Creating Products in Lemon Dash
  • Adding a Subscription with Lemon Hosted Checkout
  • Managing Subscriptions with Lemon Customer Portal
  • Working with Lemon webhooks

Lemon Subscription Flow

Similar to the stripe flow, the subscription process requires three main parts

  • Hosted Checkout to complete a purchase
  • Webhook to asynchronously update a database after purchase confirmed or subscription update
  • Hosted Customer Portal to allow customers to update their subscription or payment information

This is the exact same process used by Stripe to set up subscriptions.

We can implement each of these processes below.

Create Products and Variants

To get started we first need to create products and variants in the lemon dashboard. Also signup for a LemonSqueezy account if you haven't already.

Product and Variants are similar to stripe's products and price_ids

For example we can have 2 products with 2 variants. One product could be a Basic tier product with a monthly price variant and yearly price variant. Another can be a Premium tier product also with a monthly and yearly price variant.

This will give us 4 total variant ids. We can add these ids to a .env file

1# Variant Ids
2NEXT_PUBLIC_VARIANT_ID_BASIC_MONTHLY=
3NEXT_PUBLIC_VARIANT_ID_BASIC_YEARLY=
4NEXT_PUBLIC_VARIANT_ID_PREMIUM_MONTHLY=
5NEXT_PUBLIC_VARIANT_ID_PREMIUM_YEARLY=

Store Id and API key

Un like Stripe, lemon squeezy has Stores. We need a store id work with the Lemon Squeezy SDK.

We also need an API key to make authorized request to Lemon

We can add these both to the .env as well.

⚠ Do not prefix the LEMON_API_SECRET_KEY= with NEXT_PUBLIC_, as this will make the secret available in publicly accessible client code.

1NEXT_PUBLIC_LEMON_STORE_ID=
2LEMON_API_SECRET_KEY=

Initialize lemon-squeezy sdk

After setting our .env variables we can now initialize our sdk.

First we can install the library.


npm install @lemonsqueezy/lemonsqueezy.js

ℹ There is also a Typescript lemon client lemonsqueezy.ts, this is not an official library and not recommended for use.

Checkout

Below is code for implementing the checkout session. Note we can pass in a user_id from our database to the config. Then this user_id will be available in our webhook event after the user completes checkout. Then in the Webhook we will user this user_id to save the data in our own db.

This is essentially how we will keep track of which user made the purchase after they are redirected away from our front end and into the lemon hosted page.


lemon/session.ts
1
2'use server';
3import clientLemon from '../init/payments';
4
5export const createCheckoutSession = async ({ variant_id, user }) => {
6 const storeId = Number(process.env.NEXT_PUBLIC_LEMON_STORE_ID);
7 const variantId = variant_id;
8
9 const email = user.email;
10 const user_id = user.id
11
12 let attributes = {
13 checkout_data: {
14 email,
15 custom: {
16 user_id
17 }
18 },
19 product_options: {
20 redirect_url: `/payment/cancel`
21 }
22 };
23
24 const res = await clientLemon.createCheckout({
25 storeId,
26 variantId,
27 attributes
28 });
29
30 return res.data.attributes.url;
31};
32
33
34// component.tsx
35// redirect after fetching the lemon signed URL
36// variant_id's set in .env and passed in here
37const handleCheckout = async (variant_id: string) => {
38 const redirectUrl = await createCheckoutSession({ variant_id, user });
39 redirect(redirectUrl);
40}
41

Billing

Below is the code for retrieving a signed URL for the Lemon customer portal. Simply redirect the user using this URL and they will land of the lemon hosted page in a logged in state.

When a user performs an action in the lemon hosted page that affects their subscription. A subscription.updated event will be sent by the lemon webhook.

We can then listen and use this webhook data to update the customer subscription information in our own database such as plan type or subscription status.

Certain actions done by the user such as updating their payment information does not need to be handled on our end.

lemon/portal.ts
1'use server';
2
3import clientLemon from '../init/payments';
4
5interface SubscriptionPropsI {
6 customer_id: string;
7}
8
9export const GetBillingUrl = async ({ customer_id }: SubscriptionPropsI): Promise<string> => {
10 const id = Number(customer_id);
11 const res = await clientLemon.getCustomer({ id });
12
13 return res.data.attributes.urls.customer_portal;
14};
15
16
17// customer_id is retrieved and saved in our own db.
18// (see next webhook section on setting customer_id)
19
20const handleSubscription = async () => {
21 const redirectUrl = await GetBillingUrl({ customer_id });
22 redirect(redirectUrl);
23};

Webhooks

Similar to stripe, Lemon implements webhooks to allow us to receive important async events related to billing and subscriptions.

Lemon Dashboard Webhook setup.

The first thing we have to do is setup the nextjs route handler that will accept the webhooks.

There are 2 steps to this. First is to setup the webhook on the Lemon Dashboard.

Lemon Dashboard Webhook Setup Guide

We only need the Lemon webhook to send subscription_created and the subscription_updated events, so ensure these are selected in the Lemon webhook settings.

Route Handler

Now we can setup our route:

/api/payments/route.ts
1import { NextResponse } from 'next/server';
2import { headers } from 'next/headers';
3import { WebhookEventHandler } from '@/lib/API/Services/payments/webhook';
4import type { NextRequest } from 'next/server';
5
6import crypto from 'crypto';
7
8export async function POST(req: NextRequest) {
9 console.log('Starting...');
10
11 const webhookSecret = process.env.WEBHOOK_SECRET;
12 const body = await req.text();
13 const hmac = crypto.createHmac('sha256', webhookSecret);
14 const digest = Buffer.from(hmac.update(body).digest('hex'), 'utf8');
15 const signature = Buffer.from(headers().get('X-Signature') || '', 'utf8');
16
17 if (!crypto.timingSafeEqual(digest, signature)) {
18 throw new Error('Invalid signature.');
19 }
20
21 const payload = JSON.parse(body);
22
23 try {
24 await WebhookEventHandler(payload);
25 return NextResponse.json({ received: true }, { status: 200 });
26 } catch (err) {
27 return NextResponse.json({ error: err.message }, { status: 500 });
28 }
29}

Testing Subscriptions and Webhooks

There are a few ways we can test our Lemon Subscription. Probably the best way is to use ngrok to set a public url in our local development server and set that url for the Lemon Squeezy webhook endpoint.

Lemon Webhooks Docs

Since we are using a hosted page for both checkout and managing subscriptions we don't need to run tests on these hosted pages, as running tests on third party services is considered an anti-pattern.

Therefore our tests will only focus on the webhooks since this is where all our business logic is implemented.

Automation Testing with Playwright

Below we can find an example of an integration test using playwright, that tests our webhook.

Essentially we can use prisma or another database ORM to make mock db queries, then running assertions based on the results of the queries.

Mock Lemon Subscription Object

tests/subscriptions.ts
1import { test, expect } from '@playwright/test';
2
3import { GetOrgMock, GetSubscriptionMock, MockOrg, clearDB } from '../prisma';
4
5import { WebhookEventHandler } from '@/lib/API/Services/payments/webhook';
6import { MockWebhookPayload } from '../utils';
7
8test.describe('Subscription Tests', () => {
9 test.afterAll(async () => {
10 await clearDB();
11 });
12
13 test('Create Subscription Webhook Flow', async () => {
14 const org_create = await MockOrg();
15 const org_id = org_create.id;
16 MockWebhookPayload.meta.custom_data.org_id = org_id;
17
18 await WebhookEventHandler(MockWebhookPayload);
19
20 const subscription_id = MockWebhookPayload.data.id;
21 const customer_id = MockWebhookPayload.data.attributes.customer_id;
22 const status = MockWebhookPayload.data.attributes.status;
23 const price_id = MockWebhookPayload.data.attributes.variant_id.toString();
24
25 const mockSub = { id: subscription_id, price_id, status };
26 const mockOrg = {
27 id: org_id,
28 name: org_create.name,
29 owner_user_id: org_create.owner_user_id,
30 subscription_id,
31 customer_id
32 };
33
34 const subscription = await GetSubscriptionMock(subscription_id);
35 //asserting on date field leads to flakey behavior
36 delete subscription.period_ends_at;
37
38 const org = await GetOrgMock(org_id);
39
40 expect(subscription).toEqual(mockSub);
41 expect(org).toEqual(mockOrg);
42 });
43
44 test('Update Subscription Webhook Flow', async () => {
45 const subscription_id = MockWebhookPayload.data.id;
46
47 MockWebhookPayload.meta.event_name = 'subscription_updated';
48
49 const mockSubUpdate = {
50 price_id: 858585
51 };
52
53 MockWebhookPayload.data.attributes.variant_id = mockSubUpdate.price_id;
54
55 await WebhookEventHandler(MockWebhookPayload);
56
57 const subscription = await GetSubscriptionMock(subscription_id);
58 expect(subscription.price_id).toEqual(mockSubUpdate.price_id.toString());
59 });
60});

Types and Enums

The exported types in the Lemon SDK is not always correct, so we can just set our own types.

utils/types.ts
1
2export enum WebhookEventsE {
3 SUBSCRIPTION_CREATED = 'subscription_created',
4 SUBSCRIPTION_UPDATED = 'subscription_updated'
5}
6
7type SubscriptionEventNames = 'subscription_created' | 'subscription_updated';
8
9type SubscriptionInvoiceEventNames =
10 | 'subscription_payment_success'
11 | 'subscription_payment_failed'
12 | 'subscription_payment_recovered';
13
14export type WebhookPayload = {
15 meta: {
16 event_name: SubscriptionEventNames | SubscriptionInvoiceEventNames;
17 custom_data?: { org_id: string };
18 };
19 data: Subscription;
20};
21
22export type EventName = WebhookPayload['meta']['event_name'];
23
24export type Subscription = {
25 type: 'subscriptions';
26 id: string;
27 attributes: {
28 store_id: number;
29 order_id: number;
30 customer_id: number;
31 order_item_id: number;
32 product_id: number;
33 variant_id: number;
34 product_name: string;
35 variant_name: string;
36 user_name: string;
37 user_email: string;
38 status: SubscriptionStatus;
39 status_formatted: string;
40 pause: any | null;
41 cancelled: boolean;
42 trial_ends_at: string | null;
43 billing_anchor: number;
44 urls: {
45 update_payment_method: string;
46 };
47 renews_at: string;
48 /**
49 * If the subscription has as status of cancelled or expired, this will be an ISO-8601 formatted date-time string indicating when the subscription expires (or expired). For all other status values, this will be null.
50 */
51 ends_at: string | null;
52 created_at: string;
53 updated_at: string;
54 test_mode: boolean;
55 };
56};
57
58export type SubscriptionStatus =
59 | 'on_trial'
60 | 'active'
61 | 'paused'
62 | 'past_due'
63 | 'unpaid'
64 | 'cancelled'
65 | 'expired';
66

Conclusion

This is it. We now have setup a professional level subscription system using Lemon Squeezy