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 keys | Keygate | |
|---|---|---|
| Activation limits per key | Yes | Yes |
| Works offline | No, every check needs the network | Yes, with signed tokens |
| Keys stop on refund | Not documented; you disable keys yourself | Yes, with the receiver below |
| Update feeds for Sparkle, Velopack, Tauri | No | Yes, limited to licensed customers |
| Feature flags and usage limits per plan | No | Yes |
| One license system across several stores | No | Yes |
How it works
- The customer pays on Lemon Squeezy, as today.
- Lemon Squeezy sends a signed webhook to a small receiver you run, about 80 lines of Node.js.
- The receiver creates the license through the Keygate admin API, and Keygate emails the customer their key.
- 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
- In Keygate, create an API key with the
licenses:writescope, limited to the product you sell. Note the product ID and the ID of each plan. - In Lemon Squeezy, open Settings and then Webhooks. Add the address of your receiver, choose a signing secret and select the
order_created,order_refundedandsubscription_expiredevents. - Note the variant ID of each thing you sell, and match it to a Keygate plan in
PLANSbelow. - Run the receiver anywhere that has a public HTTPS address, with
KEYGATE_URL,KEYGATE_API_KEY,KEYGATE_PRODUCT_IDandLEMONSQUEEZY_SIGNING_SECRETset.
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.
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_refundedis 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