Skip to main content

Tour Kit + Lemon Squeezy: handling license validation for Pro features

Wire Lemon Squeezy license keys to Tour Kit Pro feature gating in React. Server-side validation, client caching, and the three gotchas we hit.

DomiDex
DomiDexCreator of Tour Kit
April 9, 20269 min read
Share
Tour Kit + Lemon Squeezy: handling license validation for Pro features

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/react

View 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.

FeatureLemon SqueezyStripe + custom licensingKeygen.sh
License key generationBuilt-in, automatic on purchaseManual (build your own)Built-in
Validation API3 endpoints (activate, validate, deactivate)N/A (build your own)Full REST API
Merchant of RecordYes (handles tax globally)No (you're the MoR)No
Pricing5% + $0.50/txn2.9% + $0.30/txn + licensing server costs$0-299/mo depending on tier
Activation limitsConfigurable per productCustom implementationConfigurable per policy
Webhook supportYes (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.js

Create 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.

Ready to try userTourKit?

$ pnpm add @tour-kit/react