lemon-squeezy-license-integration
Lemon Squeezy license integration for photoswipe-pro
Purpose: clean, provider-agnostic licensing that keeps Pro code tree‑shakeable, validates keys locally first with an offline grace period, and never ships secrets to the client. Aligns with .cursorrules, DRY, and SOLID.
References
- Lemon Squeezy tutorial: Validating License Keys With the License API
Goals
- Pro-only code remains tree‑shakeable and gated.
- Local-first validation with 14‑day offline grace; no hard fail phone‑home.
- Provider-agnostic via a narrow
LicenseProviderinterface. - Server-side proxy owns Lemon Squeezy secrets and API calls.
High-level architecture
- Client:
src/pro/license.jscontinues to gate features. When local grace is absent/expired, it delegates to an injected provider that calls our backend proxy. - Backend proxy (serverless or server): minimal endpoints that call Lemon Squeezy License API and enforce product/store checks.
- No direct calls to Lemon Squeezy from the browser.
File layout
src/pro/licensing/LicenseProvider.ts— interface only.src/pro/licensing/LemonSqueezyProvider.ts— implementation that talks to our backend proxy.src/pro/license.js— existing gate; augmented to optionally consult a provider when grace has lapsed.api/license/activate.ts— backend proxy → LSPOST /v1/licenses/activate.api/license/validate.ts— backend proxy → LSPOST /v1/licenses/validate.api/license/deactivate.ts— backend proxy → LSPOST /v1/licenses/deactivate.
Environment configuration
- Server:
LEMON_SQUEEZY_API_KEYLEMON_SQUEEZY_STORE_IDLEMON_SQUEEZY_PRODUCT_IDorLEMON_SQUEEZY_VARIANT_ID
- Client:
- None for LS secrets. Continue accepting
PSWP_PRO_KEYat init for runtime key input.
- None for LS secrets. Continue accepting
LicenseProvider interface
export interface LicenseProvider {
activate(input: { licenseKey: string; email?: string; instanceName?: string }): Promise<{ instanceId: string; status: 'active'|'inactive' }>;
validate(input: { licenseKey: string; instanceId?: string }): Promise<{ valid: boolean; status: 'active'|'inactive'|'expired'|'disabled'; instanceId?: string }>;
deactivate(input: { licenseKey: string; instanceId: string }): Promise<{ deactivated: boolean }>;
}
Client integration flow
1) On first run or when offline grace is absent/expired, call provider.validate.
2) If not activated, call provider.activate (optionally include purchaser email for extra assurance).
3) Cache instanceId and last_validation_ts; honor offline grace for subsequent runs.
4) If validate returns invalid/expired/disabled, fail closed gracefully and show a clear UI message.
Example: wiring provider into the existing gate (pseudo-code)
import { withLicenseGate } from '../license';
import { LemonSqueezyProvider } from './LemonSqueezyProvider';
const provider = new LemonSqueezyProvider({ baseUrl: '/api/license' });
export const withRemoteAwareGate = (fn: (o: any) => any) => withLicenseGate(async (o: any) => {
const key = o.licenseKey;
const instanceId = readInstanceId();
const withinGrace = isWithinOfflineGrace();
if (!withinGrace) {
const result = await provider.validate({ licenseKey: key, instanceId });
if (!result.valid) return { name: 'pro-disabled', onBeforeOpen() {} };
if (result.instanceId) storeInstanceId(result.instanceId);
markValidatedNow();
}
return fn(o);
});
Backend proxy endpoints
All endpoints must:
- Use server-stored secrets; never read from client.
- Verify
store_idandproduct_id/variant_idin LS responses match configured values. - Optionally verify purchaser email against
meta.customer_emailon activation. - Return minimal normalized shapes to the client.
Example (Node/Express; adapt to your platform)
import express from 'express';
import fetch from 'node-fetch';
const router = express.Router();
const LS_KEY = process.env.LEMON_SQUEEZY_API_KEY!;
const STORE_ID = process.env.LEMON_SQUEEZY_STORE_ID!;
const PRODUCT_ID = process.env.LEMON_SQUEEZY_PRODUCT_ID;
const VARIANT_ID = process.env.LEMON_SQUEEZY_VARIANT_ID;
function authHeaders() {
return { 'Accept': 'application/json', 'Authorization': `Bearer ${LS_KEY}` };
}
function assertProduct(meta: any) {
if (String(meta.store_id) !== String(STORE_ID)) throw new Error('store mismatch');
if (PRODUCT_ID && String(meta.product_id) !== String(PRODUCT_ID)) throw new Error('product mismatch');
if (VARIANT_ID && String(meta.variant_id) !== String(VARIANT_ID)) throw new Error('variant mismatch');
}
router.post('/activate', express.json(), async (req, res) => {
const { licenseKey, instanceName, email } = req.body || {};
const body = new URLSearchParams({ license_key: licenseKey, instance_name: instanceName || 'default' });
const r = await fetch('https://api.lemonsqueezy.com/v1/licenses/activate', { method: 'POST', headers: authHeaders(), body });
const json = await r.json();
if (!json.activated) return res.status(400).json({ error: json.error || 'activate_failed' });
assertProduct(json.meta);
if (email && json.meta.customer_email && email.toLowerCase() !== String(json.meta.customer_email).toLowerCase()) {
return res.status(403).json({ error: 'email_mismatch' });
}
return res.json({ instanceId: json.instance.id, status: json.license_key.status });
});
router.post('/validate', express.json(), async (req, res) => {
const { licenseKey, instanceId } = req.body || {};
const params: any = { license_key: licenseKey };
if (instanceId) params.instance_id = instanceId;
const body = new URLSearchParams(params);
const r = await fetch('https://api.lemonsqueezy.com/v1/licenses/validate', { method: 'POST', headers: authHeaders(), body });
const json = await r.json();
assertProduct(json.meta);
return res.json({ valid: !!json.valid, status: json.license_key.status, instanceId: json.instance?.id });
});
router.post('/deactivate', express.json(), async (req, res) => {
const { licenseKey, instanceId } = req.body || {};
const body = new URLSearchParams({ license_key: licenseKey, instance_id: instanceId });
const r = await fetch('https://api.lemonsqueezy.com/v1/licenses/deactivate', { method: 'POST', headers: authHeaders(), body });
const json = await r.json();
assertProduct(json.meta);
return res.json({ deactivated: !!json.deactivated });
});
export default router;
UX guidance
- Provide a license key entry UI and surface clear states: trial, active, expired, disabled.
- During offline grace, show a subtle banner with remaining days; never block immediately.
- Offer a paste‑key → activate flow and a simple deactivate action.
QA
- Mock provider responses for:
active,inactive,expired,disabled,email_mismatch. - Tests: activation happy path, invalid key, expired lockout, offline grace honored, deactivation path.
Security & privacy
- Do not expose
LEMON_SQUEEZY_API_KEYto the client. - Rate limit proxy endpoints and log minimal, anonymized metrics (opt‑in only).
- Enforce product/store checks before granting access.
Distribution
- Community:
photoswipevia public npm. - Pro:
photoswipe-provia private registry or ZIP; runtime gating via this licensing flow.
Notes on brand and packaging
- Use "FotoSwipe" strictly in docs/marketing; keep npm packages
photoswipeandphotoswipe-prounchanged.