Guide · Payment Processing
MCP Tools for Lemon Squeezy — Bearer API key, X-Event-Name dispatch, X-Signature HMAC webhook, store_id scoping, test_mode
Lemon Squeezy's API has three design choices that make MCP integration distinct from other payment platforms: the X-Event-Name header identifies the event type without needing to parse the body — you can dispatch to the right handler before reading the JSON, which matters for webhook signature verification order; test mode is a property on resources, not a separate environment — both test and live resources share the same API endpoint (api.lemonsqueezy.com), and the test_mode: true field on a resource indicates it was created in test mode; and the webhook signing secret is configured per webhook endpoint in the dashboard, not derived from your API key, so rotating the API key doesn't affect webhook verification and vice versa.
TL;DR
Auth: Authorization: Bearer {apiKey} — API key from dashboard, no expiry. Base URL: https://api.lemonsqueezy.com/v1. Webhook: X-Signature: {hex-hmac} where HMAC is SHA256 over raw body with signing secret; event type in X-Event-Name header — dispatch BEFORE parsing body. Resource scoping: add ?filter[store_id]={id} to list endpoints. Test mode: resources have test_mode: boolean field — same API URL for both. Pagination: data.links.next cursor URL. Amounts are in cents (integer). All responses follow JSON:API spec: data.attributes contains the resource fields.
Authentication — static Bearer API key
Lemon Squeezy uses a static API key issued from the dashboard under Settings → API. The key does not expire automatically, but you can rotate it at any time from the dashboard. All API calls use Authorization: Bearer {key}.
// Lemon Squeezy API client
class LemonSqueezyClient {
#apiKey;
#baseUrl = 'https://api.lemonsqueezy.com/v1';
constructor(apiKey) {
if (!apiKey?.startsWith('eyJ') && !apiKey?.length > 40) {
// Basic sanity check — LS keys are JWTs in some configurations
console.warn('[LemonSqueezy] API key format looks unexpected — verify it is correct');
}
this.#apiKey = apiKey;
}
async request(method, path, { body, params } = {}) {
const url = new URL(`${this.#baseUrl}${path}`);
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined) url.searchParams.set(k, String(v));
}
}
const res = await fetch(url.toString(), {
method,
headers: {
'Authorization': `Bearer ${this.#apiKey}`,
'Accept': 'application/vnd.api+json',
'Content-Type': 'application/vnd.api+json',
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json();
if (!res.ok) {
const errDetail = data.errors?.[0];
throw new Error(
`LemonSqueezy ${method} ${path} → ${res.status}: ` +
`${errDetail?.title || 'error'} — ${errDetail?.detail || JSON.stringify(data)}`
);
}
return data;
}
// All list responses are JSON:API format
async list(path, filters = {}) {
const params = {};
for (const [k, v] of Object.entries(filters)) {
params[`filter[${k}]`] = v;
}
return this.request('GET', path, { params });
}
}
const ls = new LemonSqueezyClient(process.env.LEMON_SQUEEZY_API_KEY);
Lemon Squeezy follows the JSON:API specification — all responses have a data object (or array) with id, type, and attributes. Resource data is always in data.attributes, not at the top level. Libraries that expect flat REST responses will need a wrapper to extract attributes.
Store ID scoping — filtering resources to a specific store
A single Lemon Squeezy account can have multiple stores. Resource list endpoints return resources across all stores by default, which can produce confusing results when you have test-mode and live-mode stores mixed together or stores for different products. Always filter by store ID when operating in a known context.
// Get all stores for the authenticated user
async function getStores(client) {
const data = await client.list('/stores');
return (data.data || []).map(store => ({
id: store.id,
name: store.attributes.name,
slug: store.attributes.slug,
domain: store.attributes.domain,
currency: store.attributes.currency,
totalRevenueCurrency: store.attributes.total_revenue_currency,
plan: store.attributes.plan, // 'fresh' | 'sweet' | 'tart'
}));
}
// List orders for a specific store, scoped to test or live mode
async function listOrders(client, storeId, { testMode = false, page = 1 } = {}) {
const data = await client.request('GET', '/orders', {
params: {
'filter[store_id]': storeId,
'page[number]': page,
'page[size]': 50,
},
});
const orders = (data.data || [])
// Filter by test_mode in attributes — API doesn't support filter[test_mode]
.filter(o => o.attributes.test_mode === testMode)
.map(o => ({
id: o.id,
orderNumber: o.attributes.order_number,
userEmail: o.attributes.user_email,
status: o.attributes.status, // 'pending' | 'failed' | 'paid' | 'refunded'
total: o.attributes.total, // cents
subtotal: o.attributes.subtotal,
tax: o.attributes.tax,
currency: o.attributes.currency,
testMode: o.attributes.test_mode,
createdAt: o.attributes.created_at,
}));
return {
orders,
meta: data.meta,
links: data.links, // links.next is the cursor URL for the next page
};
}
// Cursor-based pagination using links.next
async function* paginateOrders(client, storeId) {
let page = 1;
let hasMore = true;
while (hasMore) {
const result = await listOrders(client, storeId, { page });
yield result.orders;
hasMore = !!result.links?.next;
page++;
}
}
Test mode — a resource property, not a separate environment
Unlike PayPal (different base URL), Square (different base URL), or Adyen (different URL prefix), Lemon Squeezy uses a single API endpoint for both test and live resources. Test mode transactions are created through the same API with test-mode configuration — the test_mode: true attribute on an order or subscription indicates it was created using test mode, not a live transaction.
You configure test mode at the store level in the dashboard. When you enable test mode for a store and create a checkout, the resulting order has test_mode: true. In production, ensure your store is not in test mode — there is no API parameter to force test mode per request.
// Verify store is in expected mode before processing payments
async function validateStoreMode(client, storeId, expectedTestMode = false) {
const data = await client.request('GET', `/stores/${storeId}`);
const attrs = data.data.attributes;
// Check if the store is in test mode by looking at recent orders
// Lemon Squeezy doesn't expose a direct test_mode flag on stores in some API versions
const recentOrders = await listOrders(client, storeId, { testMode: !expectedTestMode });
if (recentOrders.orders.length > 0) {
const modeLabel = expectedTestMode ? 'live' : 'test';
console.warn(
`[LemonSqueezy] Store ${storeId} has ${recentOrders.orders.length} ` +
`${modeLabel}-mode orders — verify store is in correct mode in dashboard`
);
}
return {
storeId,
name: attrs.name,
currency: attrs.currency,
domain: attrs.domain,
};
}
Webhook verification — X-Signature HMAC and X-Event-Name dispatch
Lemon Squeezy webhooks include two headers that together enable a pattern not available in other payment APIs: X-Signature contains the HMAC-SHA256 of the raw body as a hex string, and X-Event-Name identifies the event type as a plain string. This allows you to verify the signature and dispatch to the correct handler before parsing the JSON body — useful when different event types have radically different body shapes.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyLemonSqueezyWebhook(rawBody, signature, signingSecret) {
if (!signature || !signingSecret) return false;
// X-Signature is the HMAC-SHA256 hex digest (not base64)
const expected = createHmac('sha256', signingSecret)
.update(rawBody) // rawBody must be Buffer or string of raw bytes
.digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
const receivedBuf = Buffer.from(signature, 'hex');
if (expectedBuf.length !== receivedBuf.length) return false;
return timingSafeEqual(expectedBuf, receivedBuf);
}
// Event dispatch map — define handlers per event type
const eventHandlers = {
'order_created': handleOrderCreated,
'order_refunded': handleOrderRefunded,
'subscription_created': handleSubscriptionCreated,
'subscription_updated': handleSubscriptionUpdated,
'subscription_cancelled': handleSubscriptionCancelled,
'subscription_expired': handleSubscriptionExpired,
'subscription_payment_success': handleSubscriptionPaymentSuccess,
'subscription_payment_failed': handleSubscriptionPaymentFailed,
'subscription_payment_recovered': handleSubscriptionPaymentRecovered,
'license_key_created': handleLicenseKeyCreated,
};
// Express handler
app.post('/webhooks/lemon-squeezy',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['x-signature'];
const eventName = req.headers['x-event-name'];
// Verify BEFORE parsing JSON — signature is over raw bytes
const isValid = verifyLemonSqueezyWebhook(
req.body,
signature,
process.env.LEMON_SQUEEZY_WEBHOOK_SECRET
);
if (!isValid) {
return res.status(403).json({ error: 'Invalid signature' });
}
// Now safe to parse
const event = JSON.parse(req.body.toString('utf8'));
// Dispatch by header value — no body parsing needed for routing
const handler = eventHandlers[eventName];
if (handler) {
await handler(event);
} else {
console.log(`Unhandled LemonSqueezy event: ${eventName}`);
}
res.sendStatus(200);
}
);
Subscription lifecycle events
Lemon Squeezy's event model maps directly to SaaS subscription states. The subscription_payment_failed event fires when a recurring charge fails; subscription_payment_recovered fires when a previously failed charge later succeeds (e.g., after a card update). Neither triggers without a prior failed attempt — they are not independent events for initial subscription creation.
order_created— first purchase completed (one-time or first subscription payment)subscription_created— subscription activatedsubscription_updated— plan changed, paused, or unpausedsubscription_cancelled— cancellation scheduled (still active until end of billing period)subscription_expired— billing period ended, access should be revokedsubscription_payment_success— recurring charge succeededsubscription_payment_failed— recurring charge failed; subscription moves topast_duesubscription_payment_recovered— previously failed charge retried successfully
Products, variants, and checkout links
Lemon Squeezy's product model has three levels: stores → products → variants (plans, license tiers). Checkout links (URLs generated in the dashboard) embed the variant ID. For programmatic checkout link generation, use the Checkouts API.
// List products for a store
async function listProducts(client, storeId) {
const data = await client.list('/products', { store_id: storeId });
return (data.data || []).map(p => ({
id: p.id,
name: p.attributes.name,
slug: p.attributes.slug,
description: p.attributes.description,
status: p.attributes.status, // 'published' | 'draft'
priceFormatted: p.attributes.price_formatted,
buyNowUrl: p.attributes.buy_now_url,
fromPrice: p.attributes.from_price,
toPrice: p.attributes.to_price,
}));
}
// Create a checkout for a specific variant (generates a checkout URL)
async function createCheckout(client, { storeId, variantId, shopperEmail, customData }) {
const data = await client.request('POST', '/checkouts', {
body: {
data: {
type: 'checkouts',
attributes: {
checkout_data: {
email: shopperEmail,
custom: customData || {}, // passed back in webhook event.meta.custom_data
},
},
relationships: {
store: { data: { type: 'stores', id: String(storeId) } },
variant: { data: { type: 'variants', id: String(variantId) } },
},
},
},
});
return {
checkoutId: data.data.id,
url: data.data.attributes.url, // send this URL to the customer
expiresAt: data.data.attributes.expires_at,
};
}
// Track custom data through the purchase funnel using event.meta.custom_data
// Embed your internal user ID or session ID as custom_data on checkout creation
// It arrives in webhook events under meta.custom_data — enables linking
// webhook events to your internal user records without email as primary key
The custom_data field on checkout creation is the recommended pattern for correlating webhooks to your internal user records. If you rely on email as the correlation key, users who change emails or use different emails break the link. Always embed an internal user/session ID in custom_data.
License keys and software activation
For one-time purchase software products, Lemon Squeezy provides a License Keys API. Each order for a license-keyed product creates a license key. MCP tools can validate license keys on behalf of software products using the public license validation endpoint — no API key required for validation, which makes it safe to call from client-side contexts.
// Validate a license key (no auth required — public endpoint)
async function validateLicenseKey(licenseKey, instanceName) {
const res = await fetch('https://api.lemonsqueezy.com/v1/licenses/validate', {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
license_key: licenseKey,
instance_name: instanceName, // e.g. 'macbook-pro-work'
}),
});
const data = await res.json();
return {
valid: data.valid, // boolean
licenseKeyStatus: data.license_key?.status, // 'active' | 'inactive' | 'expired' | 'disabled'
activationLimit: data.license_key?.activation_limit,
activationUsage: data.license_key?.activation_usage,
// instance_id returned on first activation — store for deactivation
instanceId: data.instance?.id,
};
}
// Activate a license key for a new installation
async function activateLicenseKey(licenseKey, instanceName) {
const res = await fetch('https://api.lemonsqueezy.com/v1/licenses/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ license_key: licenseKey, instance_name: instanceName }),
});
return res.json();
}
Health probe for Lemon Squeezy MCP servers
The recommended health probe is GET /v1/users/me — it validates the API key and returns basic account information without modifying any state. A 200 response confirms the API key is valid and the Lemon Squeezy API is reachable.
// Health probe: current user endpoint (validates API key)
async function checkLemonSqueezyHealth(client) {
const start = Date.now();
try {
const data = await client.request('GET', '/users/me');
return {
status: 'healthy',
userId: data.data?.id,
name: data.data?.attributes?.name,
email: data.data?.attributes?.email,
latencyMs: Date.now() - start,
};
} catch (err) {
return {
status: 'unhealthy',
error: err.message,
isAuthError: err.message.includes('401') || err.message.includes('403'),
latencyMs: Date.now() - start,
};
}
}
// Extended probe: also verify webhook endpoint is reachable
async function checkLemonSqueezyWebhookEndpoint(client) {
const data = await client.request('GET', '/webhooks');
const webhooks = data.data || [];
const activeWebhooks = webhooks.filter(
w => w.attributes.test_mode === false && w.attributes.events?.length > 0
);
return { webhookCount: webhooks.length, activeWebhookCount: activeWebhooks.length };
}
Monitor the Lemon Squeezy webhook list as part of your health probe — Lemon Squeezy silently disables webhook endpoints that return non-200 responses too many times. If your webhook URL returns errors, Lemon Squeezy may pause delivery without notification. See MCP server webhook patterns for strategies to prevent webhook endpoint disabling.