
Tour Kit + Lemon Squeezy: handling license validation for Pro features
Selling a Pro tier for an open-source React library means you need a way to check whether the person running your code actually paid. Most developers reach for Stripe, build a custom licensing server, and spend a week on infrastructure that has nothing to do with their product. Lemon Squeezy ships license key generation and validation as a built-in feature. Setup took us about 3 hours instead of the 2-3 days we budgeted.
We wired Lemon Squeezy's License API to Tour Kit's 8 Pro packages and hit three gotchas worth documenting. This article walks through the working integration: server-side validation, client-side caching with a 72-hour TTL, and the activation model quirk that almost cost us a support headache.
npm install @tourkit/core @tourkit/reactView the full docs at usertourkit.com
What you'll build
By the end of this guide, you'll have a useLicenseValidation hook that calls Lemon Squeezy's License API through your own API route, caches the result in localStorage with a configurable TTL, and gates Tour Kit Pro features behind the validation response. The whole flow runs without a dedicated licensing server because Lemon Squeezy acts as the validation backend.
Your React app calls POST /api/license/validate on first load. Lemon Squeezy confirms the key is active. Tour Kit's 8 Pro packages render without a watermark. Invalid keys get a console warning and a subtle watermark, but nothing breaks. Soft gating, not hard blocking.
Why Lemon Squeezy for license validation?
Lemon Squeezy is a Merchant of Record platform that handles payment processing, tax compliance, and software licensing in one dashboard. As of April 2026, it charges 5% + $0.50 per transaction and includes license key management at no extra cost (Lemon Squeezy pricing). Stripe acquired Lemon Squeezy in July 2024 for an undisclosed amount, which adds stability but raises questions about whether the product will merge into Stripe's own MoR beta (beehiiv, 2026). Gumroad charges a flat 10% per transaction by comparison (SaaSWorthy, 2026).
The advantage for library authors: Lemon Squeezy generates license keys automatically on checkout and exposes 3 API endpoints for validation. No custom backend required. Stripe charges 2.9% + $0.30 per transaction but has zero built-in licensing, so you'd spend days building what Lemon Squeezy ships out of the box.
| Feature | Lemon Squeezy | Stripe + custom licensing | Keygen.sh |
|---|---|---|---|
| License key generation | Built-in, automatic on purchase | Manual (build your own) | Built-in |
| Validation API | 3 endpoints (activate, validate, deactivate) | N/A (build your own) | Full REST API |
| Merchant of Record | Yes (handles tax globally) | No (you're the MoR) | No |
| Pricing | 5% + $0.50/txn | 2.9% + $0.30/txn + licensing server costs | $0-299/mo depending on tier |
| Activation limits | Configurable per product | Custom implementation | Configurable per policy |
| Webhook support | Yes (license_key_created, etc.) | Yes (custom events needed) | Yes |
MUI X uses a similar pattern: their @mui/x-license package validates a key client-side without any network calls (MUI X licensing docs). Tour Kit validates server-side through Lemon Squeezy's API instead, which means keys can be revoked remotely but adds a ~200ms network call on first load (cached for 72 hours after that).
One limitation to note: Tour Kit currently requires React 18+ and has no visual builder. The Pro packages are React-only, so this Lemon Squeezy integration pattern assumes a React + Next.js stack.
Prerequisites
You need a Next.js 14+ project with App Router, React 18.2+, TypeScript 5+, and a Lemon Squeezy account with a product configured for license key generation.
npm install @tourkit/core @tourkit/react @lemonsqueezy/lemonsqueezy.jsCreate a product in Lemon Squeezy and enable license keys under the product's "License keys" tab. Set the activation limit to 10 or higher for developer tools (we'll explain why in Step 2). Note the product ID and store ID.
Step 1: Set up the Lemon Squeezy License API route
Lemon Squeezy's License API has 3 endpoints: POST /v1/licenses/activate, POST /v1/licenses/validate, and POST /v1/licenses/deactivate (Lemon Squeezy License API docs). All three accept JSON bodies with a license_key field.
First gotcha: the License API doesn't use your store's API key for authentication. It's a public API that uses the license key itself as the credential. Your server-side route acts as a proxy to keep license keys out of client-side network requests, but you don't need an Authorization header.
// src/app/api/license/validate/route.ts
import { type NextRequest, NextResponse } from 'next/server'
interface LemonSqueezyValidateResponse {
valid: boolean
error: string | null
license_key: {
id: number
status: string
key: string
activation_limit: number
activation_usage: number
created_at: string
expires_at: string | null
}
instance: {
id: string
name: string
created_at: string
} | null
meta: {
store_id: number
order_id: number
order_item_id: number
product_id: number
product_name: string
variant_id: number
variant_name: string
customer_id: number
customer_name: string
customer_email: string
}
}
export async function POST(request: NextRequest) {
const { licenseKey, instanceId } = await request.json()
if (!licenseKey || typeof licenseKey !== 'string') {
return NextResponse.json(
{ valid: false, error: 'License key is required' },
{ status: 400 }
)
}
const body: Record<string, string> = { license_key: licenseKey }
if (instanceId) {
body.instance_id = instanceId
}
const response = await fetch(
'https://api.lemonsqueezy.com/v1/licenses/validate',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
)
const data: LemonSqueezyValidateResponse = await response.json()
// Only return what the client needs โ don't leak customer email or order details
return NextResponse.json({
valid: data.valid,
status: data.license_key?.status,
productName: data.meta?.product_name,
expiresAt: data.license_key?.expires_at,
activationUsage: data.license_key?.activation_usage,
activationLimit: data.license_key?.activation_limit,
})
}Notice the response filtering. Lemon Squeezy returns 10+ fields including customer email, order ID, and other PII in the validate response. Passing all of that to the client would be a privacy leak. Strip it to 6 fields: validity, status, product name, expiry, and activation counts.
Step 2: Add the activation endpoint
Activation is separate from validation. On first use, you activate the key against an instance name (typically the hostname). The API returns an instance_id (a UUID string) that subsequent validation calls use to confirm authorization. Each activation takes about 300-500ms depending on server location.
// src/app/api/license/activate/route.ts
import { type NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const { licenseKey, instanceName } = await request.json()
if (!licenseKey) {
return NextResponse.json(
{ valid: false, error: 'License key is required' },
{ status: 400 }
)
}
const response = 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 || 'default',
}),
}
)
if (!response.ok) {
const status = response.status
// 404 = invalid key, 400 = activation limit reached
const errorMap: Record<number, string> = {
404: 'Invalid license key',
400: 'Activation limit reached for this key',
}
return NextResponse.json(
{ valid: false, error: errorMap[status] || 'Activation failed' },
{ status }
)
}
const data = await response.json()
return NextResponse.json({
valid: data.activated,
instanceId: data.instance?.id,
activationUsage: data.license_key?.activation_usage,
activationLimit: data.license_key?.activation_limit,
})
}Second gotcha: Lemon Squeezy's activation limits are lifetime counts, not concurrent. A key with 5 activations gets permanently exhausted after 5 different browsers, even if you deactivate some. Deactivating doesn't free the slot. Keygen.sh handles this differently (deactivation reduces the count).
Plan your activation limits generously. Use 10+ for developer tools where people switch machines and browsers regularly.
Step 3: Build the React validation hook
The client-side hook handles 3 things: storing the activation instance ID in localStorage (about 200 bytes), caching validation results for 72 hours to avoid hammering the API on every page load, and exposing an isValid / isLoading / error interface for components.
// src/hooks/use-license-validation.ts
import { useCallback, useEffect, useState } from 'react'
interface LicenseState {
isValid: boolean
isLoading: boolean
error: string | null
status: string | null
expiresAt: string | null
}
const CACHE_KEY = 'tourkit-license-cache'
const INSTANCE_KEY = 'tourkit-license-instance'
const DEFAULT_TTL = 72 * 60 * 60 * 1000 // 72 hours in ms
function getCachedValidation(): LicenseState | null {
try {
const cached = localStorage.getItem(CACHE_KEY)
if (!cached) return null
const { state, timestamp } = JSON.parse(cached)
const age = Date.now() - timestamp
if (age > DEFAULT_TTL) {
localStorage.removeItem(CACHE_KEY)
return null
}
return state
} catch {
return null
}
}
function cacheValidation(state: LicenseState) {
try {
localStorage.setItem(
CACHE_KEY,
JSON.stringify({ state, timestamp: Date.now() })
)
} catch {
// localStorage full or unavailable โ continue without cache
}
}
export function useLicenseValidation(licenseKey: string | undefined) {
const [state, setState] = useState<LicenseState>({
isValid: false,
isLoading: true,
error: null,
status: null,
expiresAt: null,
})
const validate = useCallback(async () => {
if (!licenseKey) {
setState({
isValid: false,
isLoading: false,
error: null,
status: null,
expiresAt: null,
})
return
}
// Check cache first
const cached = getCachedValidation()
if (cached) {
setState({ ...cached, isLoading: false })
return
}
setState(prev => ({ ...prev, isLoading: true }))
try {
const instanceId = localStorage.getItem(INSTANCE_KEY)
// If no instance exists, activate first
if (!instanceId) {
const activateRes = await fetch('/api/license/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
licenseKey,
instanceName: window.location.hostname,
}),
})
const activateData = await activateRes.json()
if (activateData.valid && activateData.instanceId) {
localStorage.setItem(INSTANCE_KEY, activateData.instanceId)
} else if (!activateRes.ok) {
const result: LicenseState = {
isValid: false,
isLoading: false,
error: activateData.error,
status: 'activation_failed',
expiresAt: null,
}
setState(result)
return
}
}
// Validate with instance ID
const storedInstance = localStorage.getItem(INSTANCE_KEY)
const validateRes = await fetch('/api/license/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
licenseKey,
instanceId: storedInstance,
}),
})
const validateData = await validateRes.json()
const result: LicenseState = {
isValid: validateData.valid,
isLoading: false,
error: validateData.valid ? null : 'License key is not valid',
status: validateData.status,
expiresAt: validateData.expiresAt,
}
setState(result)
cacheValidation(result)
} catch {
// Network failure โ use cached result if available, otherwise fail open
const cached = getCachedValidation()
if (cached) {
setState({ ...cached, isLoading: false })
} else {
setState({
isValid: false,
isLoading: false,
error: 'Network error during validation',
status: null,
expiresAt: null,
})
}
}
}, [licenseKey])
useEffect(() => {
validate()
}, [validate])
return state
}The 72-hour cache TTL is a deliberate tradeoff. Too short and you're hitting Lemon Squeezy's API on every session, adding latency and risking rate limits. Too long and a revoked key keeps working for days. We settled on 72 hours after testing. It matches the typical gap between someone requesting a refund and expecting the software to stop working.
Step 4: Gate Tour Kit Pro features
Now connect the validation hook to Tour Kit's component tree. Pro packages render normally when validated (adds ~50 bytes to the React context). Invalid licenses show a watermark and log a console message. No crashes, no blank screens.
// src/providers/tour-kit-provider.tsx
'use client'
import { TourProvider, TourKitProvider } from '@tourkit/react'
import { useLicenseValidation } from '@/hooks/use-license-validation'
interface Props {
licenseKey: string | undefined
children: React.ReactNode
}
export function LicensedTourProvider({ licenseKey, children }: Props) {
const license = useLicenseValidation(licenseKey)
return (
<TourKitProvider
license={{
key: licenseKey,
isValid: license.isValid,
isLoading: license.isLoading,
}}
>
<TourProvider>{children}</TourProvider>
</TourKitProvider>
)
}// src/app/layout.tsx
import { LicensedTourProvider } from '@/providers/tour-kit-provider'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<LicensedTourProvider
licenseKey={process.env.NEXT_PUBLIC_TOURKIT_LICENSE_KEY}
>
{children}
</LicensedTourProvider>
</body>
</html>
)
}The license key goes in NEXT_PUBLIC_TOURKIT_LICENSE_KEY because it needs to reach the client. This is safe because license keys are designed to be embedded in client-side code. The validation happens server-side through your API route, so even if someone reads the key from your bundle, they can't use it beyond the activation limit.
Step 5: Handle webhooks for real-time revocation
Validation caching means a revoked key keeps working until the 72-hour cache expires. For immediate revocation (refunds, chargebacks), use Lemon Squeezy webhooks to invalidate the cache server-side. Lemon Squeezy supports 14 webhook event types as of April 2026 (Lemon Squeezy webhook docs).
// src/app/api/webhooks/lemonsqueezy/route.ts
import { type NextRequest, NextResponse } from 'next/server'
import crypto from 'node:crypto'
const WEBHOOK_SECRET = process.env.LEMONSQUEEZY_WEBHOOK_SECRET!
function verifySignature(payload: string, signature: string): boolean {
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET)
const digest = hmac.update(payload).digest('hex')
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(digest)
)
}
export async function POST(request: NextRequest) {
const payload = await request.text()
const signature = request.headers.get('x-signature') || ''
if (!verifySignature(payload, signature)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
const event = JSON.parse(payload)
const eventName = event.meta?.event_name
if (eventName === 'license_key_updated') {
const licenseKey = event.data?.attributes?.key
const status = event.data?.attributes?.status
if (status === 'disabled' || status === 'expired') {
// Store revoked keys in your database or KV store
// Your validate API route checks this list before returning valid=true
console.log(`License ${licenseKey} revoked: ${status}`)
}
}
return NextResponse.json({ received: true })
}Set up the webhook in your Lemon Squeezy dashboard under Settings > Webhooks. Select the license_key_updated event and point it to your /api/webhooks/lemonsqueezy endpoint. The signing secret goes in LEMONSQUEEZY_WEBHOOK_SECRET. Webhook delivery typically arrives within 2-5 seconds of the event.
Third gotcha: Lemon Squeezy sends webhooks with an x-signature header (not x-hub-signature or stripe-signature). We wasted 20 minutes on signature verification failures because we copied a Stripe webhook handler and forgot to change the header name. The signature is a hex-encoded HMAC-SHA256 of the raw request body.
Going further
This covers validate-on-load, cache, and webhook revocation. A few production additions worth considering:
Add a license key input UI so users can enter their key from within the app instead of an env variable. Filter validation responses by productId to match the correct tier (Tour Kit Pro at $99 one-time vs a hypothetical Enterprise tier). And add rate limiting on your API route: Lemon Squeezy's License API has a 60 requests per minute rate limit (stricter than the main API's 300/min).
Tour Kit's 3 free packages (@tourkit/core at under 8KB gzipped, @tourkit/react, @tourkit/hints) are MIT-licensed and work without validation. The 8 Pro packages show a watermark on invalid licenses but keep working. Tour Kit is our project, so take this guide knowing we're showing how to protect our own Pro tier. But the pattern works for any React library with a free/paid split. You can see the full free vs Pro breakdown at usertourkit.com.
FAQ
Does Lemon Squeezy license validation require a server?
Lemon Squeezy's License API accepts requests from any HTTP client, including browser-side JavaScript. But exposing license keys in client-side fetch calls lets anyone extract them from your bundle. A server-side proxy route (like the Next.js API routes shown here) keeps validation between your server and Lemon Squeezy. For static sites, client-side validation works but keys are visible in network requests.
What happens when Lemon Squeezy is down?
The useLicenseValidation hook checks localStorage cache first. If the cache is fresh (within 72 hours), no API call happens. When the cache is stale and the network request fails, the hook returns isValid: false. You can modify the fallback to fail open if you'd rather not block users during outages.
How does Lemon Squeezy compare to Polar.sh for license validation?
Both platforms generate and validate license keys. Lemon Squeezy operates as a full Merchant of Record at 5% + $0.50 per transaction. Polar.sh focuses on open-source monetization with a similar API but different activation semantics: Polar tracks lifetime activations while Lemon Squeezy tracks concurrent instances. The validation patterns in this article apply to either platform with minor changes.
Can I use this pattern for Electron or desktop apps?
Yes, with adjustments. Replace window.location.hostname with a machine-specific identifier (hardware UUID or device fingerprint). The API route approach works if your Electron app has a Node.js backend. For fully offline desktop apps, cache validation results more aggressively or use signed license tokens instead.
Is 72 hours too long for a cache TTL?
For developer tools, 72 hours is reasonable. Most refund flows take 24+ hours to process. Shorten to 4-8 hours for consumer software with instant refunds. DevExtreme and Syncfusion both validate offline with zero expiry (Syncfusion docs), making 72 hours stricter than industry standard.
Related articles

Tour Kit + Intercom: show tours before chat, not after
Integrate Tour Kit with Intercom to show contextual product tours before users open chat. Working code, event bridging, and the gotchas we hit.
Read article
Tour Kit + Segment: piping tour events to every analytics tool
Build a custom Segment plugin for Tour Kit that sends tour lifecycle events to 400+ destinations. TypeScript code, gotchas, and free tier limits.
Read article
Tour Kit + Storybook: documenting tour components in isolation
Build and test product tour components in Storybook with Autodocs, play functions, and the a11y addon. Working TypeScript examples included.
Read article
Tour Kit + Supabase: tracking tour state per user
Persist product tour progress in Supabase PostgreSQL with Row Level Security. Replace localStorage with cross-device tour state in under 100 lines.
Read article