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
Create Product
From the Keymint Dashboard:
- Products → Create Product
- Name: "MySaaS Self-Hosted License"
- Copy the product ID
Create License Policy
When generating a license key, save your settings as a Template (in More options, open Save as a template) for each SaaS tier.
| Tier | maxActivations | licenseType | expiry |
|---|---|---|---|
| Starter | 5 | node-locked | None |
| Pro | 25 | node-locked | None |
| Business | — | floating with maxConcurrentSessions: 50 | None |
Issue License
Install the SDK in your Next.js project:
npm install keymintCreate a license service module in your backend:
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: String(params.maxActivations),
expiryDate: params.subscriptionEndDate?.toISOString(),
newCustomer: {
name: params.customerName,
email: params.customerEmail,
},
metadata: {
plan: params.planName,
createdBy: 'nextjs-saas',
},
});
// license.key is the raw key. Store it now: the API can't return it later.
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,
});
}
// Requires an admin or read-only key. The details are in data.license.
export async function getLicenseInfo(licenseKey: string) {
const result = await admin.getKey({ productId: PRODUCT_ID, licenseKey });
return result.data.license;
}
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:
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.
The SDK throws on every non-2xx response, with status, code and errorCode on the error. A 401, 402, 403 or 404 (product deactivated or removed) means Keymint rejected the license; anything else is a temporary failure that the instance should retry rather than treat as "invalid":
// 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: true, message: result.message });
} catch (error: any) {
if ([401, 402, 403, 404].includes(error?.status)) {
return NextResponse.json({ valid: false, errorCode: error.errorCode });
}
// Keymint unreachable, rate limited or erroring: tell the instance to retry
return NextResponse.json(
{ error: 'License service temporarily unavailable' },
{ status: 503 },
);
}
}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:
- Navigate to Developer → API Keys
- Create a key with
clientscope, scoped to your product - Share this key with your customer for their instance
The customer's instance then calls Keymint directly:
// In your customer's self-hosted code
import { KeyMint } from 'keymint';
const client = new KeyMint('CUSTOMER_CLIENT_API_KEY');
try {
await client.activateKey({
productId: 'YOUR_PRODUCT_ID',
licenseKey: 'A8E2K-9F1BC-3D4GH-7J2KM',
hostId: KeyMint.getOrCreateInstallationId(),
});
console.log('License valid');
} catch (error: any) {
if ([401, 402, 403, 404].includes(error?.status)) {
console.error('License rejected:', error.errorCode);
} else {
console.error('Could not reach Keymint; retry later');
}
}Handle Activations
Display license usage in your Next.js dashboard using Server Components. Look keys up with an admin or read-only key, using the raw keys you stored at creation: the API finds a license only by its key. data.license.key is the raw key for admin keys and masked for read-only keys:
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 {
const { data } = await admin.getKey({ productId: PRODUCT_ID, licenseKey: key });
return { key, license: data.license };
} catch {
return null;
}
}),
);
return results.filter((r) => r !== null);
}
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(({ key, license }) => (
<tr key={key}>
<td>{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:
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:
import { NextRequest, NextResponse } from 'next/server';
import { blockLicense } from '@/lib/license-service';
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ key: string }> },
) {
const { key } = await params; // Next.js 15: params is a Promise
try {
await blockLicense(key);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: 'Failed to block license' },
{ status: 500 },
);
}
}Production Considerations
Environment Variables
KEYMINT_ADMIN_API_KEY=km_live_... # admin-scoped key
KEYMINT_CLIENT_API_KEY=km_live_... # client-scoped key
KEYMINT_PRODUCT_ID=YOUR_PRODUCT_ID
KEYMINT_WEBHOOK_SECRET=... # signing secret of your Keymint webhook endpointKEYMINT_ADMIN_API_KEY— used server-side only (license creation, blocking, info lookup)KEYMINT_CLIENT_API_KEY— used for activation proxying or shared with customersKEYMINT_PRODUCT_ID— the product all licenses belong to
Keymint Webhooks for Your SaaS
Register Keymint outbound webhooks to keep your database in sync:
| Keymint Event | What to do |
|---|---|
license.created | Store license in your database |
device.activated | Log activation, update seat count in your UI |
device.deactivated | Free up a seat in your UI |
license.disabled | Notify customer, update status |
license.enabled | Reactivate after payment |
Configure webhook endpoints under Developer → Webhooks in the Keymint Dashboard.
Verify the Keymint-Signature header against the raw body before you trust the event. The SDK's KeyMint.verifyWebhookSignature() checks the HMAC-SHA256 signature over timestamp + "." + raw_body and rejects timestamps more than 300 seconds old:
import { NextRequest } from 'next/server';
import { KeyMint } from 'keymint';
export async function POST(req: NextRequest) {
const payload = await req.text();
const signature = req.headers.get('keymint-signature') ?? '';
if (!KeyMint.verifyWebhookSignature(payload, signature, process.env.KEYMINT_WEBHOOK_SECRET!)) {
return Response.json({ error: 'Invalid signature' }, { status: 400 });
}
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:
stripe_customer_id | license_key | plan | status
cus_abc123 | A8E2K-9F1BC-... | pro | active
cus_def456 | T8XTR-AI21B-... | starter | expiredThis lets your Stripe webhook handler find and block/unblock the right license key when subscriptions change.
Rate Limits
| Plan | Requests/min |
|---|---|
| Free | 30 |
| Maker | 50 |
| Startup | 100 |
| Standard | 1,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:
// Always set maxConcurrentSessions: leaving it empty means unlimited seats.
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)
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: String(params.maxActivations),
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) {
const result = await admin.getKey({ productId: PRODUCT_ID, licenseKey });
return result.data.license;
}
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));
}