How to Accept Global Payments in JavaScript Apps Using LemonSqueezy

How to Accept Global Payments in JavaScript Apps Using LemonSqueezy

Receive worldwide payments easily

NOTE: I don’t want paywall to block the learning, here is the friendly link.

First of all, we software developers are really lucky, we can build products, courses and even start a complete B2B business from scratch.

But the one thing that will be holding you back will be how to accept payments.

The problem with this is sometimes we will have international clients and you can directly send money. For example, if you are in India like me I can send money via UPI to anyone in India who has a bank account but not to someone who is in the USA due to regulations.

How LemonSqueezy Solves this problem

The solution to this problem is to use a payment gateway that can handle international transactions. One such gateway that’s gaining popularity among developers is LemonSqueezy.

It’s not just a payment processor; it’s a complete payment platform brought up for digital products and services.

Why LemonSqueezy

LemonSqueezy is designed with developers in mind. It provides a simple API that you can easily integrate into your JavaScript applications. Whether you’re selling a SaaS product, digital downloads, or online courses, LemonSqueezy is what you need.

Note: Stripe is also great, now that Stripe acquired Lemon squezy, it's the best reason to start using it.

Getting Started

Sign up for a LemonSqueezy account. It’s free to start; you only pay when you make sales.
Create your product in the LemonSqueezy dashboard. You can set up one-time purchases, subscriptions, or even pay-what-you-want models.

The flow of the LemonSqueezy platform is shown below

  1. Store, where you have products

  2. Products, where you have variants of the product

  3. Variants, which can be Subscription, One-time Payment, Yearly payment

Now, install the LemonSqueezy SDK in your JavaScript project

npm install @lemonsqueezy/lemonsqueezy.js

Initialize the SDK in your application

const configureLemonSqueezy = lemonSqueezySetup({
  apiKey: process.env.LEMON_SQUEEZY_API_KEY,
  onError(error) {
    console.log(error);
  },
});

Now you are all set to start the actual LemonSqueezy thing. We need to have a checkout URL, so that we can send our users to a payment gateway.

The main arguments, that we need for the checkout URL are:

  1. Store ID

  2. Variant ID

  3. Checkout options

From the frontend of your code you can send the Variant ID and the store can be hard-coded as it is less likely to change

import {
  lemonSqueezySetup,
  type Variant,
} from "@lemonsqueezy/lemonsqueezy.js";

export async function getCheckoutURL(variantId: number, embed = true) {

  if (!Number.isInteger(variantId) || variantId <= 0) {
    throw new Error("Invalid variantId. Must be a positive integer.");
  }
  if (typeof embed !== "boolean") {
    throw new Error("Invalid embed value. Must be a boolean.");
  }
  const { userId } = auth();
  const user = await currentUser();

  // get emailj
  const userEmail = user?.emailAddresses[0].emailAddress ?? "";

  const checkout = await createCheckout(
    process.env.LEMONSQUEEZY_STORE_ID as string,
    variantId,
    {
      checkoutOptions: {
        embed,
        media: true,
        logo: true,
        dark: true,
      },
      checkoutData: {
        email: userEmail,
        custom: {
          user_id: userId,
        },
      },

      productOptions: {
        enabledVariants: [variantId],
        redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing/`,
        receiptButtonText: "Go to Dashboard",
        receiptThankYouNote: "Thank you for signing up to Lemon Stand!",
      },
    }
  );


  return checkout.data?.data.attributes.url;
}

Now with this, you can process the payment in your application. But there is a problem: How do we track all these?

LemonSqueezy Webhooks

If you don’t know what webhooks are, here is a simple explanation:

A webhook is an event triggered when a certain event happens and which will send a POST request to your endpoint and you can catch that event and later process it.

LemonSqueezy makes it easy to handle webhooks, the SDK provides various methods to work with webhooks. You should grab your webhook secret from the LemonSqueezy console first.

And then in you /api/webhooks/route.ts, you can have a POST endpoint.

export async function POST(request: Request) {
  try {
    if (!process.env.LEMONSQUEEZY_WEBHOOK_SECRET) {
      return new Response("Lemon Squeezy Webhook Secret not set in .env", {
        status: 400,
      });
    }
  } catch (error) {
    console.log(error);
  }
}

Now we need to make sure the request is from Lemon Squeezy and not from some internet nerds to protect our application.

const rawBody = await request.text();
  const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET;

  const hmac = crypto.createHmac("sha256", secret);
  const digest = Buffer.from(hmac.update(rawBody).digest("hex"), "utf8");
  const signature = Buffer.from(
    request.headers.get("X-Signature") || "",
    "utf8"
  );

  if (!crypto.timingSafeEqual(digest, signature)) {
    throw new Error("Invalid signature.");
  }
  console.log("Signature verified");

  const data = JSON.parse(rawBody) as unknown;

We need to temporarily store the data of the webhook, just to keep track if a webhook fails.

export async function storeWebhookEvent(eventName: string, body: any) {
  const { data, error } = await supabase
    .from("webhook_events")
    .upsert(
      [
        {
          user_id: body.meta.custom_data.user_id,
          event_name: eventName,
          body,
        },
      ],
      { onConflict: "id" }
    )
    .select();

  if (error) {
    throw error;
  }
  return data;
}

Now we can process the webhook depending up on your needs.

export async function processWebhookEvent(webhookEvent: any) {
  try {
    const { data: dbWebhookEvent, error } = await supabase
      .from("webhook_events")
      .select("*")
      .eq("id", webhookEvent[0].id)
      .single();

    if (error || !dbWebhookEvent) {
      throw new Error(
        `Webhook event #${webhookEvent.id} not found in the database.`
      );
    }
  } catch (error) {
    console.error(error);
  }

  const eventBody = webhookEvent[0].body;


  if (
    webhookEvent[0].event_name.startsWith("subscription_updated") ||
    webhookEvent[0].event_name.startsWith("subscription_created")
  ) {
    const attributes = eventBody.data.attributes;
    const variantId = attributes.variant_id;

    const { data: plan, error: planError } = await supabase
      .from("plans")
      .select("*")
      .eq("variant_id", variantId)
      .single();

    if (planError || !plan) {
      throw new Error(`Plan with variantId ${variantId} not found.`);
    }
    let priceId = eventBody.data.attributes.first_subscription_item.price_id;
    let lPrice = await getPrice(priceId);

    const updateData = {
      lemon_squeezy_id: eventBody.data.id,
      order_id: attributes.order_id,
      status: attributes.status,
      renews_at: attributes.renews_at,
      ends_at: attributes.ends_at,
      trial_ends_at: attributes.trial_ends_at,
      price: lPrice.data?.data.attributes.unit_price,
      is_paused: attributes.status == "paused" ? true : false,
      subscription_item_id: attributes.first_subscription_item.id,
      is_usage_based: attributes.first_subscription_item.is_usage_based,
      user_id: eventBody.meta.custom_data.user_id,
      plan_id: plan.id,
    };

    const { error: upsertError } = await supabase
      .from("subscriptions")
      .upsert([updateData], { onConflict: "lemon_squeezy_id" });
    if (upsertError) {
      throw new Error(`Failed to upsert subscription: ${upsertError.message}`);
    }

    /** 
     * Mark the webhook event as processed
     * You can remove the webhook from the db if it is processed successfully
     * Implement this in your DB
    */



}

Now you can have various utility functions like pauseUserSubscription, cancelSub etc to make your app complete.

LemonSqueezy provides methods to implement this, here is a dummy code for you to cancel the subscription

export async function cancelSub(id: string) {
  // Get user subscriptions
  const userSubscriptions = await getUserSubscription();

  // Check if the subscription exists
  const subscription = userSubscriptions?.find(
    (sub) => sub.lemon_squeezy_id === id
  );

  if (!subscription) {
    throw new Error(`Subscription #${id} not found.`);
  }

  const cancelledSub = await cancelSubscription(id);

  console.log(cancelledSub, "cancelledSub");

  if (cancelledSub.error) {
    throw new Error(cancelledSub.error.message);
  }

  // Update the db
  try {
   //DB logic
  } catch (error) {
    throw new Error(`Failed to cancel Subscription #${id} in the database.`);
  }

  revalidatePath("/");

  return cancelledSub;
}

That’s it. That’s all you need to get started with payment processing.

Now, you can check the documentation for your custom needs and use case and try building this on your own and you will see how easy it is to work with LemonSqueezy

Conclusion

In this detailed blog on LemonSqueezy, we learned how to implement payment processing in your full-stack application.

If you learned something new and useful, share this post with others.