Under the Hood
Welcome to the new blog
Looking for the SAAS apps trend, whether you are a Vibe developer, a solid backend engineer or a machine learning developer, launching websites needs some solid frontend skills. In addition, most of the saas applications require payment integration either for subscription or a one-time payment.
Payment is among the most required features in almost all of SAAS applications. Integrating payments can be executed through 2 steps
- Custom-made API endpoints
- Third-party payments API
Ofcourse, we will be going with the second option for integrating payments and among the couple of following options, we will be using Lemon Squeezy for this blog
- Stripe
- Razorpay
- Lemon Squeezy
- Polar
Here is an extensive list of 10+ payment options, https://ihatereading.in/t/10+-Payment-Gateways-
Lemon Squeezy
Docs link for API endpoint: https://docs.lemonsqueezy.com/help/checkout
- Create account
- Verify account
- Add Payouts
- Create an API key
- Create a new product
- Get the product variant ID from the product dropdown
- Get the Lemon Squeezy store ID from the settings page
- Create webhook
- Add webhook link using ngrok
Store the API key, product and variant ID in the .env file
VARIANT_ID =
PRODUCT_ID =
API_KEY =
Payments Flow
- Create a Next.js repository or use the starter one
- Add required packages like Tailwind, Lucide Icons
- Create an endpoint to make an HTTP API call to the lemon squeezy endpoint
- Redirect to the payment checkout URL
- Listen to the payment status using webhook (webhook can be added using server-side API, same as an endpoint for creating a payment)
Create Payment Endpoint
- Create a new file named create-payment inside pages/api/create-payment directory
- Add the below code
const response = await fetch("https://api.lemonsqueezy.com/v1/checkouts", {
method: "POST",
headers: {
Accept: "application/vnd.api+json",
"Content-Type": "application/vnd.api+json",
Authorization: `Bearer ${process.env.LEMON_SQUEEZY_API_KEY}`,
},
body: JSON.stringify({
data: {
type: "checkouts",
attributes: {
store_id: process.env.STORE_ID.toString(),
variant_id: process.env.VARIANT_ID.toString(),
checkout_data: {
custom: {
user_id: userId.toString(),
},
},
product_options: {
redirect_url: "http://localhost:3000?subscription=true",
},
},
relationships: {
store: {
data: {
type: "stores",
id: process.env.STORE_ID.toString(),
},
},
variant: {
data: {
type: "variants",
id: process.env.VARIANT_ID.toString(),
},
},
},
},
}),
});
This will make an API call to the Lemon Squeezy endpoint and return the checkout URL
// Return the checkout URL and ID
return res.status(200).json({
url: data.data.attributes.url,
checkoutId: data.data.id,
});
The create payment endpoint initiates the checkout process, generating a specific variant of the product checkout link and returning it as shown above.
Read more, https://docs.lemonsqueezy.com/api/checkouts/create-checkout
API call in Frontend
- Simply add a button to make an API call to the above /api/create-payment
- Pass the variant ID or other details are needed,d or if adding more than one product with more than 2 prices or variants
- Redirect to the checkout URL returned from the API response
Once the URL is returned, we will redirect to that link on the client side to render the Lemon Squeezy checkout page, as shown below.
Lemon Squeezy checkout page for a specific product
The same page will render on the client side once it’s redirected to the checkout URL.
Now the user or the client will make the payment, and whether it’s successful or not that we will perform the actions on the client side.
The payment process is by default asynchronous in nature, meaning it might be completed after a few seconds or even after a minute, making it difficult to track the payment status directly or only on the client side.
When I wrote the word “client side”, remember, we are talking about a frontend developer 😃
Webhook
https://docs.lemonsqueezy.com/help/webhooks/webhook-requests
Webhooks are the real-time APIs that work under the hood in the background of a website or server, or client side.
A webhook is needed in most of the payment options for 2 reasons
- Payments are by default asynchronous in nature
- Payments are more secure on the server side instead of the client side
That’s it, no need to go into too much detail.
- Create a webhook.js file inside the pages/api directory
- Add the below code in the file
export const config = {
api: {
bodyParser: false, // Disable the default body parser
},
};
export default async function handler(req, res) {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
try {
// Get the raw body
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const rawBody = Buffer.concat(chunks).toString("utf8");
// Parse the body for processing
const event = JSON.parse(rawBody);
console.log("Received webhook event:", rawBody);
const signature = req.headers["x-signature"];
// Verify webhook signature
if (!process.env.LEMON_SQUEEZY_WEBHOOK_SECRET) {
console.error("Webhook secret is not configured");
return res.status(500).json({ message: "Webhook configuration error" });
}
// Create HMAC using the webhook secret
const hmac = crypto.createHmac(
"sha256",
process.env.LEMON_SQUEEZY_WEBHOOK_SECRET
);
// Update HMAC with the raw request body
hmac.update(rawBody);
// Get the digest in hex format
const digest = hmac.digest("hex");
// Compare the signatures
if (signature !== digest) {
console.error("Invalid webhook signature", {
received: signature,
calculated: digest,
secret: process.env.LEMON_SQUEEZY_WEBHOOK_SECRET
? "present"
: "missing",
rawBody: rawBody,
});
return res.status(401).json({ message: "Invalid signature" });
}
// Handle different event types
let updateResult;
switch (event.meta.event_name) {
case "subscription_created":
case "subscription_updated":
updateResult = await handleSubscriptionUpdate(event.data, event.meta);
break;
case "subscription_cancelled":
updateResult = await handleSubscriptionCancellation(event.data);
break;
default:
console.log("Unhandled event type:", event.meta.event_name);
}
return res.status(200).json({ received: true });
} catch (error) {
console.error("Webhook error:", error);
return res.status(500).json({ message: "Internal server error" });
}
}
Webhook on Lemon Squeezy
- Create a webhook inside the Settings page
Lemon Squeezy webhook creating form
The callback URL is the URL to send events to your client-side app.
Lemon Squeezy servers want a URL to send the response generated from the checkout API endpoint, This response object will contain the status of the payments and more custom datasets.
Creating Callback URL
- For development purposes, use ngrok, install it using npm and then run “ngrok http 3000” and this will return the URL, which will be the callback URL
- Make sure to store webhook_secret as well to create hash in the webhook endpoint to match the crypto signature
- On the server side or production app, use the vercel server-side API along with the domain name, such as {domain_name}.vercel.app/api/webhook
Now listening to this webhook, we will track the payment status, but one must ask the question, how to differentiate which user has which webhook
{
"data": {
"id": "1237727",
"type": "subscriptions",
"links": {
"self": "https://api.lemonsqueezy.com/v1/subscriptions/1237727"
},
"attributes": {
"urls": {
"customer_portal": "https://profiler.lemonsqueezy.com/billing?expires=1748897884&test_mode=0&user=190278&signature=f58fa3f383a4b9372b9933bb65ca6d373ce5c5873e17b7ebab5772e0851b9274",
"update_payment_method": "https://profiler.lemonsqueezy.com/subscription/1237727/payment-details?expires=1748897884&signature=5cdafe90bf97863ca762b74af6b5da54ff6b8596260d36c4d32c85c9d14b2e7f",
"customer_portal_update_subscription": "https://profiler.lemonsqueezy.com/billing/1237727/update?expires=1748897884&user=190278&signature=e54218c8c5f6aede8a2d5ba4e604ba34f9ebfa04219c23f132f64ac015921d5a"
},
"pause": null,
"status": "on_trial",
"ends_at": null,
"order_id": 5623672,
"store_id": 12787,
"cancelled": false,
"renews_at": "2025-06-09T14:57:10.000000Z",
"test_mode": false,
"user_name": "shreyvijayvargiya",
"card_brand": "jcb",
"created_at": "2025-05-30T14:57:11.000000Z",
"product_id": 535121,
"updated_at": "2025-05-30T14:57:48.000000Z",
"user_email": "shreyvijayvargiya26@gmail.com",
"variant_id": 826440,
"customer_id": 5422203,
"product_name": "Profiler Pro Plan Subscription",
"variant_name": "Default",
"order_item_id": 5562298,
"trial_ends_at": "2025-06-09T14:57:10.000000Z",
"billing_anchor": 9,
"card_last_four": "1412",
"status_formatted": "On Trial",
"first_subscription_item": {
"id": 2348383,
"price_id": 1278483,
"quantity": 1,
"created_at": "2025-05-30T14:57:18.000000Z",
"updated_at": "2025-06-02T14:58:03.000000Z",
"is_usage_based": false,
"subscription_id": 1237727
}
},
"relationships": {
"order": {
"links": {
"self": "https://api.lemonsqueezy.com/v1/subscriptions/1237727/relationships/order",
"related": "https://api.lemonsqueezy.com/v1/subscriptions/1237727/order"
}
},
"store": {
"links": {
"self": "https://api.lemonsqueezy.com/v1/subscriptions/1237727/relationships/store",
"related": "https://api.lemonsqueezy.com/v1/subscriptions/1237727/store"
}
},
"product": {
"links": {
"self": "https://api.lemonsqueezy.com/v1/subscriptions/1237727/relationships/product",
"related": "https://api.lemonsqueezy.com/v1/subscriptions/1237727/product"
}
},
"variant": {
"links": {
"self": "https://api.lemonsqueezy.com/v1/subscriptions/1237727/relationships/variant",
"related": "https://api.lemonsqueezy.com/v1/subscriptions/1237727/variant"
}
},
"customer": {
"links": {
"self": "https://api.lemonsqueezy.com/v1/subscriptions/1237727/relationships/customer",
"related": "https://api.lemonsqueezy.com/v1/subscriptions/1237727/customer"
}
},
"order-item": {
"links": {
"self": "https://api.lemonsqueezy.com/v1/subscriptions/1237727/relationships/order-item",
"related": "https://api.lemonsqueezy.com/v1/subscriptions/1237727/order-item"
}
},
"subscription-items": {
"links": {
"self": "https://api.lemonsqueezy.com/v1/subscriptions/1237727/relationships/subscription-items",
"related": "https://api.lemonsqueezy.com/v1/subscriptions/1237727/subscription-items"
}
},
"subscription-invoices": {
"links": {
"self": "https://api.lemonsqueezy.com/v1/subscriptions/1237727/relationships/subscription-invoices",
"related": "https://api.lemonsqueezy.com/v1/subscriptions/1237727/subscription-invoices"
}
}
}
},
"meta": {
"test_mode": false,
"event_name": "subscription_updated",
"webhook_id": "ace649ec-7dee-4c6d-b5c1-a37781a3b16b",
"custom_data": {
"user_id": "6mMNr7KeDuXQqnJCP2DjI0qn1fm1"
}
}
}
The above JSON object is the webhook response sent by the Lemon Squeezy to our webhook server-side API.pages/api/webhook.js
This object can be seen to contain the “status” key as well as a meta object that contains the user_id, which is the custom data we usually send to /checkout endpoint to track each user's payment status using the user ID
Using the user_id, we can update the user's payment status in the database as well.
Other keys are also important, such as update_payment_method and subscription-invoices, subscription-items and more. Each has its own importance and use case. For more details, read here: https://docs.lemonsqueezy.com/api/checkouts/retrieve-checkout
Updating Database
- Make an API call to the checkout HTTP endpoint
- Redirect to the checkout URL from the API response
- Once the payment is done, redirect to the redirect_url
- Listen to the webhooks
- Update the data is status === “on_trail”
This should make sense.
Webhook in the code is listening to the lemon squeezy webhook events, and if the signature matches, it moves ahead and checks the subscription status and stores the data in the database if status === “in_trail”
async function handleSubscriptionUpdate(data, meta) {
try {
// Extract data from the webhook payload
const { attributes } = data;
const { status, variant_id } = attributes;
const { custom_data } = meta;
const user_id = custom_data?.user_id;
if (!user_id) {
console.error("No user_id in webhook data");
return;
}
// Get the user document
const userRef = doc(db, "users", user_id);
const userDoc = await getDoc(userRef);
if (!userDoc.exists()) {
console.error("User document not found:", user_id);
return;
}
// Determine if the user is now a pro user
const isPro = status === "active" || status === "on_trial";
// Update the user's subscription status
// Return the updated status for client-side updates
return {
userId: user_id,
status,
isPro,
subscriptionId: data.id,
};
} catch (error) {
console.error("Error updating subscription:", error);
throw error;
}
}
async function handleSubscriptionCancellation(data) {
try {
// Extract data from the webhook payload
const { attributes } = data;
const { custom_data } = attributes;
const user_id = custom_data?.user_id;
if (!user_id) {
console.error("No user_id in webhook data");
return;
}
// Update the user's subscription status
// Update Redux store with subscription status
store.dispatch(
setSubscriptionStatus({
status: "pending",
subscriptionId: data.data.id,
})
);
console.log("Successfully cancelled subscription for user:", user_id);
// Return the updated status for client-side updates
return {
userId: user_id,
status: "cancelled",
isPro: false,
};
} catch (error) {
console.error("Error cancelling subscription:", error);
throw error;
}
}
Using the above 2 methods, we will store and update the user payment status in the database and provide the client-side access accordingly.
We can store the payment status and other details in the Redux storage to update the client side accordingly.
// Update Redux store with subscription status
store.dispatch(
setSubscriptionStatus({
status: "on_trail",
subscriptionId: data.data.id,
})
);
Deploying
For deployments, use the Lemon Squeezy webhook callback URL as vercel domain_name/api/webhook
Test the application before pushing it the production
Do check the lemon squeezy FEE structure
That’s it for today, see you in the next one
Shrey
Oginally published on iHateReading