Building a License Activation & Validation System for Figma Plugins

A real-world guide to building a global licensing system for Figma plugins — with usage tracking, activation limits, and error handling built-in using the Lemon Squeezy API.

Jun 14, 2025 · 6 min read

From Free Plugins to Licensing — Why I Took the Leap

I’ve created quite a few Figma plugins over the past couple of years — most of them were experiments, personal tools, or ideas shared freely with the design community. Monetization wasn't my priority.

But recently, I started wondering:
What would it take to actually license a plugin properly?
Not just to make money, but to understand the real-world challenges behind building a product with paid access, usage limits, and license enforcement.

So — for the sake of learning — I picked one of my plugins and built a proper license management system around it. That experiment is now live and running in production, with real users actively using it.
And surprisingly, it works beautifully. 😄

This post is a detailed look at everything I learned while integrating Lemon Squeezy as a licensing backend for a Figma plugin — from API confusion to some crazy edge cases to tricky activation logic.


Why Not Use Figma’s Built-in Licensing?

Figma now supports native license APIs, which is awesome if you’re in a supported region.

Unfortunately, I’m not. And many of my plugin users are from countries where Figma’s monetization and payment systems don’t work yet. That alone pushed me to explore a global-first, payment-agnostic alternative.

Lemon Squeezy quickly stood out because:

  • Global support (including India)
  • Multiple payment options (not just cards)
  • Better insights into customer and license behavior
  • Custom pricing models (subscriptions, one-time, seat-based)

So I dove in. What I thought would be a simple integration… turned into a multi-week project.


Hitting the Wall Early

I started by skimming the Lemon Squeezy docs and grabbing the official JS SDK. It looked straightforward at first. But the moment I tried to wire up validation and activation flows, things got messy:

  • I wasn’t sure which endpoint to use for each step
  • I didn't understand how activation limits worked
  • My license keys kept getting stuck in weird states
  • I had no idea how to handle offline scenarios

Eventually, I stopped trying to build everything at once. I zoomed out and focused on one flow:

Validate → Activate → Store → Verify

This was my foundation. Once that worked, everything else started to click into place.


Step 1: Setting Up the Foundation

import { lemonSqueezySetup, validateLicense } from "@lemonsqueezy/lemonsqueezy.js";

const lemon = lemonSqueezySetup({
  apiKey: process.env.LEMON_API_KEY
});

Never expose your API key in the client. I made this mistake early on, had to rotate the key, and learned to always keep sensitive logic server-side.

Step 2: Validate

This was the first piece that fully worked. A clean /api/validate route on my server:

export async function POST(req) {
  const { key } = await req.json();
  if (!key) return NextResponse.json({ error: "Missing license key" }, { status: 400 });

  try {
    const validationRes = await validateLicense(key);

    if (validationRes.error && validationRes.data?.valid === undefined) {
      throw { error: "Failed to validate license", status: validationRes.statusCode };
    }

    if (validationRes.data?.valid === false) {
      return NextResponse.json({ valid: false, details: validationRes.data.error || "Invalid license" });
    }

    const isValid = validationRes.data?.valid &&
      validationRes.data.meta.product_id === Number(process.env.LEMON_PRODUCT_ID);

    return NextResponse.json({ valid: isValid, license_key: validationRes.data?.license_key });

  } catch (error) {
    return NextResponse.json({ error: error.error || "Something went wrong" }, { status: error.status || 500 });
  }
}

Lemon Squeezy returns both API errors and validation errors, and they behave differently. Separating the two in code helped me trace bugs faster.

Step 3: The Trickiest Bit — Activation

I struggled here more than anywhere else.

Unlike simple validation, activation is about managing instances and enforcing activation limits. Here's the final structure that worked:

export async function POST(req) {
  const { key, instance } = await req.json();

  const validationRes = await validateLicense(key);
  if (validationRes.data?.valid === false) {
    return NextResponse.json({ error: "invalid-license" }, { status: 400 });
  }

  const license_key = validationRes.data.license_key;
  const max = license_key.activation_limit;
  const used = license_key.activation_usage;

  if (used >= max) {
    return NextResponse.json({
      error: 'activation-reached',
      details: `Maximum activations reached: ${used}/${max}`
    }, { status: 400 });
  }

  const activationRes = await activateLicense(key, instance);
  if (activationRes.data?.activated === false) {
    return NextResponse.json({ error: "activation-failed" }, { status: 400 });
  }

  return NextResponse.json({
    valid: true,
    license_key: activationRes.data.license_key,
    instance: activationRes.data.instance
  });
}

Key Insights: Don't activate without first checking activation_usage. I wasted so much time debugging "failed activations" that were silently hitting limits.

Step 4: Plugin-Side License Flow

Figma plugins can be slow to hit APIs, and users often expect things to "just work." So I stored license data locally using a simple strategy:

export function generateUniqueInstanceId() {
  const ts = new Date().getTime();
  const rand = Math.floor(Math.random() * 0x10000).toString(16);
  return `figma-plugin-${ts}-${rand}`;
}

export async function addLicense(license) {
  const instance = generateUniqueInstanceId();

  const response = await fetch("/api/activate", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ key: license, instance })
  });

  const result = await response.json();

  if (result.valid) {
    await ClientStorageManager.set('LICENSE_DATA', {
      licenseKey: license,
      licenseInstance: instance,
      instanceId: result.instance?.id,
      isValid: true,
      lastValidated: Date.now(),
      customUseCount: 0
    });

    figma.ui.postMessage({
      type: "license-activated",
      status: { isValid: true, license }
    });
  }

  return result;
}

🔹 generateUniqueInstanceId

This function creates a random, unique ID for each plugin installation using the current timestamp and a short hex string — perfect for tracking license activations per device or session.

🔹 ClientStorageManager

I built a small utility called ClientStorageManager to simplify working with Figma plugin storage. It handles setting and retrieving data (in this case license keys, usage counts, and instance info) using Figma's clientStorage API. Read more about it here.

Step 5: Deactivation and Reset Flow

People move devices. They re-install. Sometimes licenses get stuck. Here’s how I handled deactivation:

export async function handleResetLicense() {
  const licenseData = await ClientStorageManager.get('LICENSE_DATA');
  if (!licenseData?.licenseKey) return;

  await fetch("/api/deactivate", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      key: licenseData.licenseKey,
      instance_id: licenseData.instanceId,
      instance: licenseData.licenseInstance
    })
  });

  await ClientStorageManager.set('LICENSE_DATA', {
    isValid: false,
    licenseKey: '',
    licenseInstance: '',
    instanceId: '',
    lastValidated: 0,
    customUseCount: 0,
    lastResetDate: Date.now()
  });

  figma.ui.postMessage({ type: 'license-reset-success' });
}

Step 6: Usage Tracking (for Premium Limits)

I also wanted to enforce usage-based limits in the plugin — for example, how many times a user could run a feature per month.

export async function incrementUsageCount() {
  await resetCounterIfNewMonth();
  const data = await ClientStorageManager.get('LICENSE_DATA');
  const newCount = (data?.customUseCount || 0) + 1;

  await ClientStorageManager.set('LICENSE_DATA', {
    ...data,
    customUseCount: newCount
  });

  return newCount;
}

export async function resetCounterIfNewMonth() {
  const data = await ClientStorageManager.get('LICENSE_DATA');
  const now = new Date();

  if (!data?.lastResetDate || new Date(data.lastResetDate).getMonth() !== now.getMonth()) {
    await ClientStorageManager.set('LICENSE_DATA', {
      ...data,
      customUseCount: 0,
      lastResetDate: now.toISOString()
    });
  }
}

Lessons I Learned the Hard Way

  • Error handling matters more than you think: Don’t just check for .valid === true. Understand what failed — and show meaningful messages.

  • Product ID verification is critical: Validate that the license actually belongs to your plugin.

  • Always check activation limits manually: The API won’t warn you — it’ll just quietly fail.

  • Unique instance IDs are non-negotiable: For activation tracking, deactivation, and customer support — you’ll need them.

The Final System

After all this effort, I now have a lightweight but solid license system that:

✅ Validates licenses across all regions

🔁 Manages activation limits per user

📈 Tracks usage per month

💬 Gives clear error feedback to the user

And it's been working in production for months, handling hundreds of validations with minimal friction. Used this method for my Stippling plugin.


Final Thoughts for Fellow Plugin Developers

If you're thinking about adding licensing to your Figma plugin, start small, pick one plugin, and go step-by-step.

Don’t build everything at once. Mock responses before going live. Cache where you can. Show helpful errors. Log everything.

And remember: Your first version doesn’t need to be perfect — it just needs to work well enough to teach you what matters most.

stay hungry, stay foolish

-Steve Jobs

©realvjyvijay verma