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 (click "Save current configuration as a template") for each SaaS tier.
| Tier | maxActivations | licenseType | expiry |
|---|---|---|---|
| Starter | 5 | node-locked | None |
| Pro | 25 | node-locked | None |
| Business | unlimited | floating | 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: 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:
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:
// 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:
- 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');
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:
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:
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: { 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
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 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 at /dashboard/[orgSlug]/developer/webhooks in the Keymint Dashboard.
Verify webhook signatures:
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:
stripe_customer_id | license_key | plan | status
cus_abc123 | KM-XXXX-XXXX... | pro | active
cus_def456 | KM-YYYY-YYYY... | starter | expiredThis lets your Stripe webhook handler find and block/unblock the right license key when subscriptions change.
Rate Limits
| Plan | Requests/min |
|---|---|
| Free | 10 |
| 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:
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: 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));
}