Skip to main content

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

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 LicenseProvider interface.
  • Server-side proxy owns Lemon Squeezy secrets and API calls.

High-level architecture

  • Client: src/pro/license.js continues 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 → LS POST /v1/licenses/activate.
  • api/license/validate.ts — backend proxy → LS POST /v1/licenses/validate.
  • api/license/deactivate.ts — backend proxy → LS POST /v1/licenses/deactivate.

Environment configuration

  • Server:
    • LEMON_SQUEEZY_API_KEY
    • LEMON_SQUEEZY_STORE_ID
    • LEMON_SQUEEZY_PRODUCT_ID or LEMON_SQUEEZY_VARIANT_ID
  • Client:
    • None for LS secrets. Continue accepting PSWP_PRO_KEY at init for runtime key input.

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_id and product_id/variant_id in LS responses match configured values.
  • Optionally verify purchaser email against meta.customer_email on 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_KEY to the client.
  • Rate limit proxy endpoints and log minimal, anonymized metrics (opt‑in only).
  • Enforce product/store checks before granting access.

Distribution

  • Community: photoswipe via public npm.
  • Pro: photoswipe-pro via private registry or ZIP; runtime gating via this licensing flow.

Notes on brand and packaging

  • Use "FotoSwipe" strictly in docs/marketing; keep npm packages photoswipe and photoswipe-pro unchanged.