Next.js SaaS Licensing

Add license key management to your Next.js SaaS. Use API routes for key creation, Server Components for validation, and webhooks to sync Stripe billing.

Problem

Your Next.js SaaS app sells to businesses that want license keys for on-premise deployments, self-hosted instances, or desktop companion apps. You need a licensing API that integrates with your existing Next.js backend, supports webhooks, and works with your Stripe billing.

Keymint handles all licensing primitives — key generation, activation counting, device binding, floating sessions — while your Next.js app owns the business logic and billing.

Architecture Diagram

mermaid
Rendering diagram...

Create Product

From the Keymint Dashboard:

  1. ProductsCreate Product
  2. Name: "MySaaS Self-Hosted License"
  3. Copy the product ID

Create License Policy

When generating a license key, save your settings as a Template (click "Save current configuration as a template") for each SaaS tier.

TiermaxActivationslicenseTypeexpiry
Starter5node-lockedNone
Pro25node-lockedNone
BusinessunlimitedfloatingNone

Issue License

Install the SDK in your Next.js project:

bash
npm install keymint

Create a license service module in your backend:

typescript
import { KeyMint } from 'keymint';

const admin = new KeyMint(process.env.KEYMINT_ADMIN_API_KEY!);
const PRODUCT_ID = process.env.KEYMINT_PRODUCT_ID!;

interface CreateLicenseParams {
  customerEmail: string;
  customerName: string;
  maxActivations: number;
  planName: string;
  subscriptionEndDate?: Date;
}

export async function createCustomerLicense(params: CreateLicenseParams) {
  const license = await admin.createKey({
    productId: PRODUCT_ID,
    maxActivations: params.maxActivations,
    licenseType: params.maxActivations > 100 ? 'floating' : 'node-locked',
    expiryDate: params.subscriptionEndDate?.toISOString(),
    newCustomer: {
      name: params.customerName,
      email: params.customerEmail,
    },
    metadata: {
      plan: params.planName,
      createdBy: 'nextjs-saas',
    },
  });

  return license;
}

export async function blockLicense(licenseKey: string) {
  return admin.blockKey({
    productId: PRODUCT_ID,
    licenseKey,
  });
}

export async function unblockLicense(licenseKey: string) {
  return admin.unblockKey({
    productId: PRODUCT_ID,
    licenseKey,
  });
}

export async function getLicenseInfo(licenseKey: string) {
  return admin.getKeyInfo(PRODUCT_ID, licenseKey);
}

export async function updateLicenseExpiry(
  licenseKey: string,
  expiryDate: Date,
) {
  return admin.updateKey({
    productId: PRODUCT_ID,
    licenseKey,
    expiryDate: expiryDate.toISOString(),
  });
}

Wire it into a Next.js API route for your dashboard:

typescript
import { NextRequest, NextResponse } from 'next/server';
import { createCustomerLicense } from '@/lib/license-service';
import { getServerSession } from '@/lib/auth';

export async function POST(req: NextRequest) {
  const session = await getServerSession();
  if (!session) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const body = await req.json();
  const { customerEmail, customerName, maxActivations, planName } = body;

  try {
    const license = await createCustomerLicense({
      customerEmail,
      customerName,
      maxActivations,
      planName,
    });

    return NextResponse.json({
      success: true,
      licenseKey: license.key,
    });
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to create license' },
      { status: 500 },
    );
  }
}

Validate License

Your customers run self-hosted instances that need to validate their license. Provide them with a validation endpoint they call from their instance:

typescript
// This route is called BY your customer's self-hosted instance
// It proxies the activation to Keymint
import { NextRequest, NextResponse } from 'next/server';
import { KeyMint } from 'keymint';

const client = new KeyMint(process.env.KEYMINT_CLIENT_API_KEY!);
const PRODUCT_ID = process.env.KEYMINT_PRODUCT_ID!;

export async function POST(req: NextRequest) {
  const body = await req.json();
  const { licenseKey, hostId } = body;

  if (!licenseKey || !hostId) {
    return NextResponse.json(
      { error: 'licenseKey and hostId are required' },
      { status: 400 },
    );
  }

  try {
    const result = await client.activateKey({
      productId: PRODUCT_ID,
      licenseKey,
      hostId,
    });

    return NextResponse.json({
      valid: result.code === 0,
      message: result.message,
      code: result.code,
    });
  } catch (error: any) {
    return NextResponse.json(
      { valid: false, message: error.message || 'Validation failed' },
      { status: 500 },
    );
  }
}

Or, have your customers call Keymint directly from their self-hosted instance using their own client API key. You can create product-scoped API keys for each customer from the Keymint Dashboard:

  1. Navigate to Developer → API Keys
  2. Create a key with client scope, scoped to your product
  3. Share this key with your customer for their instance

The customer's instance then calls Keymint directly:

typescript
// In your customer's self-hosted code
import { KeyMint } from 'keymint';

const client = new KeyMint('CUSTOMER_CLIENT_API_KEY');

const result = await client.activateKey({
  productId: 'prod_Nx8K2mLpQ4rVtW9sBc',
  licenseKey: 'A8E2K-9F1BC-3D4GH-7J2KM',
  hostId: getHostId(),
});

if (result.code === 0) {
  console.log('License valid');
}

Handle Activations

Display license usage in your Next.js dashboard using Server Components:

typescript
import { KeyMint } from 'keymint';

const admin = new KeyMint(process.env.KEYMINT_ADMIN_API_KEY!);
const PRODUCT_ID = process.env.KEYMINT_PRODUCT_ID!;

async function getLicenseData(licenseKeys: string[]) {
  const results = await Promise.all(
    licenseKeys.map(async (key) => {
      try {
        return await admin.getKeyInfo(PRODUCT_ID, key);
      } catch {
        return null;
      }
    }),
  );
  return results.filter(Boolean);
}

export default async function LicensesPage() {
  // In production, fetch license keys from your database
  const customerLicenseKeys = await db.license.findMany({
    where: { userId: session.user.id },
    select: { key: true },
  });

  const licenses = await getLicenseData(
    customerLicenseKeys.map((l) => l.key),
  );

  return (
    <div>
      <h1>Your Licenses</h1>
      <table>
        <thead>
          <tr>
            <th>License Key</th>
            <th>Activations</th>
            <th>Status</th>
            <th>Expires</th>
          </tr>
        </thead>
        <tbody>
          {licenses.map((license) => (
            <tr key={license.id}>
              <td>{license.key?.substring(0, 12)}...</td>
              <td>
                {license.activations}/{license.maxActivations ?? 'unlimited'}
              </td>
              <td>
                {license.activated ? 'Active' : 'Inactive'}
              </td>
              <td>
                {license.expirationDate
                  ? new Date(license.expirationDate).toLocaleDateString()
                  : 'Never'}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

Handle Revocations

When a Stripe subscription cancels, block the license. Wire this into your Stripe webhook handler:

typescript
import { NextRequest } from "next/server";
import Stripe from "stripe";
import { blockLicense } from "@/lib/license-service";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: NextRequest) {
  const payload = await req.text();
  const sig = req.headers.get("stripe-signature")!;

  const event = stripe.webhooks.constructEvent(
    payload, sig, process.env.STRIPE_WEBHOOK_SECRET!,
  );

  if (event.type === "customer.subscription.deleted") {
    const subscription = event.data.object as Stripe.Subscription;
    const customerId = subscription.customer as string;

    // Find the license key linked to this Stripe customer
    const licenseKeys = await getLicenseKeysForStripeCustomer(customerId);

    for (const key of licenseKeys) {
      await blockLicense(key);
      console.log(`Blocked license ${key} for cancelled subscription`);
    }
  }

  return Response.json({ received: true });
}

You can also expose a manual revoke action in your dashboard:

typescript
import { NextRequest, NextResponse } from 'next/server';
import { blockLicense } from '@/lib/license-service';

export async function POST(
  req: NextRequest,
  { params }: { params: { key: string } },
) {
  try {
    await blockLicense(params.key);
    return NextResponse.json({ success: true });
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to block license' },
      { status: 500 },
    );
  }
}

Production Considerations

Environment Variables

text
KEYMINT_ADMIN_API_KEY=km_admin_abc123...
KEYMINT_CLIENT_API_KEY=km_client_def456...
KEYMINT_PRODUCT_ID=prod_xyz789...
  • KEYMINT_ADMIN_API_KEY — used server-side only (license creation, blocking, info lookup)
  • KEYMINT_CLIENT_API_KEY — used for activation proxying or shared with customers
  • KEYMINT_PRODUCT_ID — the product all licenses belong to

Keymint Webhooks for Your SaaS

Register Keymint outbound webhooks to keep your database in sync:

Keymint EventWhat to do
license.createdStore license in your database
device.activatedLog activation, update seat count in your UI
device.deactivatedFree up a seat in your UI
license.disabledNotify customer, update status
license.enabledReactivate after payment

Configure webhook endpoints at /dashboard/[orgSlug]/developer/webhooks in the Keymint Dashboard.

Verify webhook signatures:

typescript
import { NextRequest } from 'next/server';

export async function POST(req: NextRequest) {
  const payload = await req.text();
  const signature = req.headers.get('keymint-signature');
  const timestamp = req.headers.get('keymint-timestamp');

  // Verify HMAC-SHA256 signature
  // signed_payload = timestamp + "." + raw_body
  // expected = HMAC-SHA256(endpoint_secret, signed_payload)
  // Replay protection: |now - timestamp| > 300 seconds = reject

  const event = JSON.parse(payload);
  console.log(`Received webhook: ${event.type}`);

  // Handle based on event.type
  switch (event.type) {
    case 'device.activated':
      // Update your activation count
      break;
    case 'license.disabled':
      // Notify customer
      break;
  }

  return Response.json({ received: true });
}

Stripe Integration at Scale

For production, store the link between Stripe customer IDs and Keymint license keys in your own database:

text
stripe_customer_id | license_key      | plan    | status
cus_abc123         | KM-XXXX-XXXX...  | pro     | active
cus_def456         | KM-YYYY-YYYY...  | starter | expired

This lets your Stripe webhook handler find and block/unblock the right license key when subscriptions change.

Rate Limits

PlanRequests/min
Free10
Maker50
Startup100
Standard1,000

For SaaS usage, Standard is recommended. The activation endpoint (POST /key/activate) should be called once per instance startup — not on every request.

Floating Licenses for Higher Tiers

If your customers need concurrent seat licensing (e.g. 50 seats shared across a team), use floating licenses:

typescript
const license = await admin.createKey({
  productId: PRODUCT_ID,
  licenseType: 'floating',
  maxConcurrentSessions: 50,
  heartbeatInterval: 120,
  sessionLeaseDuration: 300,
  newCustomer: { name: 'Acme Corp', email: 'it@acme.com' },
});

Customers then use POST /key/checkout, POST /key/heartbeat, and POST /key/checkin to manage seats. See Floating Licenses.

Complete Source Code

Full license service (src/lib/license-service.ts)
typescript
import { KeyMint } from 'keymint';
import { db } from '@/lib/db';
import { licenseKeys } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';

const admin = new KeyMint(process.env.KEYMINT_ADMIN_API_KEY!);
const PRODUCT_ID = process.env.KEYMINT_PRODUCT_ID!;

interface CreateLicenseParams {
  customerEmail: string;
  customerName: string;
  maxActivations: number;
  planName: string;
  subscriptionEndDate?: Date;
  userId: string;
}

export async function createCustomerLicense(params: CreateLicenseParams) {
  const license = await admin.createKey({
    productId: PRODUCT_ID,
    maxActivations: params.maxActivations,
    licenseType: params.maxActivations > 100 ? 'floating' : 'node-locked',
    expiryDate: params.subscriptionEndDate?.toISOString(),
    newCustomer: {
      name: params.customerName,
      email: params.customerEmail,
    },
    metadata: {
      plan: params.planName,
    },
  });

  // Store in your database
  await db.insert(licenseKeys).values({
    userId: params.userId,
    licenseKey: license.key,
    productId: PRODUCT_ID,
    status: 'active',
    plan: params.planName,
  });

  return license;
}

export async function blockLicense(licenseKey: string) {
  await admin.blockKey({ productId: PRODUCT_ID, licenseKey });
  await db
    .update(licenseKeys)
    .set({ status: 'blocked' })
    .where(eq(licenseKeys.licenseKey, licenseKey));
}

export async function unblockLicense(licenseKey: string) {
  await admin.unblockKey({ productId: PRODUCT_ID, licenseKey });
  await db
    .update(licenseKeys)
    .set({ status: 'active' })
    .where(eq(licenseKeys.licenseKey, licenseKey));
}

export async function getLicenseInfo(licenseKey: string) {
  return admin.getKeyInfo(PRODUCT_ID, licenseKey);
}

export async function updateLicenseExpiry(
  licenseKey: string,
  expiryDate: Date,
) {
  return admin.updateKey({
    productId: PRODUCT_ID,
    licenseKey,
    expiryDate: expiryDate.toISOString(),
  });
}

export async function getCustomerLicenses(userId: string) {
  return db
    .select()
    .from(licenseKeys)
    .where(eq(licenseKeys.userId, userId));
}