Documentation menu▾

Selling with Lemon Squeezy

Keep selling on Lemon Squeezy and let Keygate issue licenses: what built in keys lack, a ready to run webhook receiver, refunds and expiring subscriptions.

Lemon Squeezy handles checkout, taxes and invoices, and it can generate license keys of its own. This guide explains when those keys are enough, and how to keep selling through Lemon Squeezy while Keygate issues and manages the licenses.

Built in keys or Keygate

Lemon Squeezy's keys have activation limits, a validation API your app can call and an expiry tied to subscriptions. For a simple app that is always online, that may be all you need. Keygate adds what they leave out:

Lemon Squeezy keysKeygate
Activation limits per keyYesYes
Works offlineNo, every check needs the networkYes, with signed tokens
Keys stop on refundNot documented; you disable keys yourselfYes, with the receiver below
Update feeds for Sparkle, Velopack, TauriNoYes, limited to licensed customers
Feature flags and usage limits per planNoYes
One license system across several storesNoYes

How it works

  1. The customer pays on Lemon Squeezy, as today.
  2. Lemon Squeezy sends a signed webhook to a small receiver you run, about 80 lines of Node.js.
  3. The receiver creates the license through the Keygate admin API, and Keygate emails the customer their key.
  4. Refunds revoke the license; a subscription that expires suspends it.

Turn off Generate License Keys on the product in Lemon Squeezy, so customers receive only one key.

Set it up

  1. In Keygate, create an API key with the licenses:write scope, limited to the product you sell. Note the product ID and the ID of each plan.
  2. In Lemon Squeezy, open Settings and then Webhooks. Add the address of your receiver, choose a signing secret and select the order_created, order_refunded and subscription_expired events.
  3. Note the variant ID of each thing you sell, and match it to a Keygate plan in PLANS below.
  4. Run the receiver anywhere that has a public HTTPS address, with KEYGATE_URL, KEYGATE_API_KEY, KEYGATE_PRODUCT_ID and LEMONSQUEEZY_SIGNING_SECRET set.

The webhook receiver

It needs Node.js 18 or later and no packages. Every request is checked against the X-Signature header, an HMAC of the raw body with your signing secret, before anything else happens.

webhook.mjs
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const KEYGATE = process.env.KEYGATE_URL; // https://licenses.example.com
const API_KEY = process.env.KEYGATE_API_KEY; // kg_live_... with licenses:write
const PRODUCT_ID = process.env.KEYGATE_PRODUCT_ID;
const SECRET = process.env.LEMONSQUEEZY_SIGNING_SECRET;

// Lemon Squeezy variant ID on the left, Keygate plan ID on the right.
const PLANS = {
  123456: "8c1e2b9a-...",
};

const headers = { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" };

// Each license remembers its Lemon Squeezy order in external_customer_id.
async function findLicense(orderId) {
  const url = `${KEYGATE}/api/v1/admin/licenses?external_customer_id=ls_order_${orderId}`;
  const body = await fetch(url, { headers }).then((r) => r.json());
  return body.data.licenses[0];
}

async function post(path, body = {}) {
  const res = await fetch(`${KEYGATE}/api/v1/admin/${path}`, {
    method: "POST",
    headers,
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`${path} answered ${res.status}`);
}

async function handle(event, data) {
  const a = data.attributes;

  if (event === "order_created") {
    const plan = PLANS[a.first_order_item.variant_id];
    if (!plan || (await findLicense(data.id))) return; // another product, or a repeated delivery
    await post("licenses", {
      product_id: PRODUCT_ID,
      plan_id: plan,
      email: a.user_email,
      external_customer_id: `ls_order_${data.id}`,
    });
  }

  // Sent for full and partial refunds alike.
  if (event === "order_refunded") {
    const license = await findLicense(data.id);
    if (license && license.status !== "revoked") await post(`licenses/${license.id}/revoke`);
  }

  // A canceled subscription expires at the end of the paid period.
  if (event === "subscription_expired") {
    const license = await findLicense(a.order_id);
    if (license?.status === "active") await post(`licenses/${license.id}/suspend`);
  }
}

createServer(async (req, res) => {
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  const raw = Buffer.concat(chunks);

  const expected = Buffer.from(createHmac("sha256", SECRET).update(raw).digest("hex"));
  const given = Buffer.from(String(req.headers["x-signature"] ?? ""));
  if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
    res.writeHead(401).end();
    return;
  }

  const { meta, data } = JSON.parse(raw);
  try {
    await handle(meta.event_name, data);
    res.writeHead(200).end();
  } catch (err) {
    console.error(err);
    res.writeHead(500).end(); // Lemon Squeezy retries a few times
  }
}).listen(3000);

Each license keeps its Lemon Squeezy order in external_customer_id, so a repeated delivery finds the license it already created instead of making a second one, and refunds find the license to revoke.

Things to know

  • Lemon Squeezy retries a failed delivery three more times and logs every delivery in the webhook settings, where you can resend one by hand.
  • order_refunded is also sent for partial refunds. If you give partial refunds and want the customer to keep the license, check the refund amount first.
  • Suspending a license emails the customer; revoking does not. Edit or turn off that email in Keygate's email settings. See Emails.
  • Your app then checks licenses against Keygate, not Lemon Squeezy. See Activating licenses in your app.

Last updated October 4, 2026