Lemon Squeey React Next.js Payments
shrey vijayvargiya

shrey vijayvargiya @shreyvijayvargiya

About: Just an artist with the keys

Location:
India
Joined:
Feb 23, 2021

Lemon Squeey React Next.js Payments

Publish Date: Jun 6
5 2

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

  1. Custom-made API endpoints 
  2. 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 = 
Enter fullscreen mode Exit fullscreen mode

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(),
       },
      },
     },
    },
   }),
  });
Enter fullscreen mode Exit fullscreen mode

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,
  });
Enter fullscreen mode Exit fullscreen mode

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

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" });
 }
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
 }
}
Enter fullscreen mode Exit fullscreen mode

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,
   })
  );

Enter fullscreen mode Exit fullscreen mode

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

Comments 2 total

Add comment