Stripe Subscription Licensing
Sell subscription-based software licenses with Stripe and Keymint. Every subscription payment automatically provisions and manages license keys.
Problem
You sell a SaaS or desktop application with monthly/annual subscriptions via Stripe. You need every paid subscription to automatically generate a license key, and you need cancelled subscriptions to revoke access. Building this plumbing from scratch means maintaining Stripe webhooks, a license database, rate-limiting, offline validation, and customer-facing activation flows. Keymint handles the licensing layer while Stripe handles billing.
Architecture Diagram
Create Product
First, create a product in Keymint. This groups all licenses under one software title.
- Sign in to the Keymint Dashboard
- Navigate to Products
- Enter a name (e.g. "My Desktop App")
- Copy the product ID — you will use it for all license operations.
Create License Policy
When generating a license key, save your settings as a Template (click "Save current configuration as a template") with these defaults:
- maxActivations:
1(one device per subscriber) - licenseType:
"node-locked"(tied to a specific machine) - expiryDate: Match the Stripe subscription period end
Issue License
When Stripe confirms a subscription payment (invoice.paid or checkout.session.completed), your backend creates a license key and emails it to the customer.
import { NextRequest } from "next/server";
import Stripe from "stripe";
import { KeyMint } from "keymint";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const keymint = new KeyMint(process.env.KEYMINT_ADMIN_API_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 === "invoice.paid") {
const invoice = event.data.object as Stripe.Invoice;
if (!invoice.subscription) return Response.json({ received: true });
const subscription = await stripe.subscriptions.retrieve(
invoice.subscription as string
);
const customerEmail = invoice.customer_email
|| (await stripe.customers.retrieve(invoice.customer as string)).email;
// Create a license key that expires at the end of the billing period
const license = await keymint.createKey({
productId: process.env.KEYMINT_PRODUCT_ID!,
maxActivations: 1,
expiryDate: new Date(subscription.current_period_end * 1000).toISOString(),
newCustomer: {
name: customerEmail ?? "Customer",
email: customerEmail ?? undefined,
},
});
// Send the license key to your customer
await sendLicenseEmail(customerEmail!, license.key);
}
return Response.json({ received: true });
}What this does:
- Verifies the Stripe webhook signature (required for security)
- On
invoice.paid, retrieves the subscription details - Creates a license key scoped to the Stripe subscription period
- Auto-creates a customer record and assigns the license
- Emails the raw license key to your customer
The license key is returned at creation. It is encrypted at rest in the database. You can retrieve it later via GET /key with an admin API key.
Validate License
Your desktop app validates the license on startup using a client-scoped API key.
import { KeyMint } from 'keymint';
import { machineIdSync } from 'node-machine-id';
const client = new KeyMint(process.env.KEYMINT_CLIENT_API_KEY!);
async function validateLicense(licenseKey: string): Promise<boolean> {
const hostId = machineIdSync(); // Unique per-machine fingerprint
try {
const result = await client.activateKey({
productId: process.env.KEYMINT_PRODUCT_ID!,
licenseKey,
hostId,
});
// result.code === 0 means success
// result.message === "License valid"
if (result.code === 0) {
console.log(`License valid. Licensee: ${result.licenseeName}`);
return true;
}
console.error(`Activation failed: ${result.message}`);
return false;
} catch (error) {
console.error('License validation failed:', error);
return false;
}
}First activation: The
hostIdis recorded. Subsequent startups on the same machine return success immediately without consuming another activation slot.
Handle Activations
Each activation consumes one seat. The maxActivations limit on the license key controls how many devices can activate.
| Scenario | Behavior |
|---|---|
| First activation | License becomes activated: true. activations count increments to 1. |
| Same host reactivates | Returns success. No new activation record. |
| New host activates | Count increments if under maxActivations. |
| Limit reached | Returns 403 with "Activation limit reached". |
| License expired | Returns 403. Expired licenses reject all activations. |
| License blocked | Returns 403. Vendor-disabled licenses reject all activations. |
To check current activation count programmatically:
// Requires admin or read-only API key
const info = await admin.getKeyInfo(productId, licenseKey);
console.log(`Activations: ${info.activations}/${info.maxActivations}`);Handle Revocations
When a subscription ends, block the license to prevent further activations. Active devices will fail on their next validation check.
if (event.type === "customer.subscription.deleted") {
const subscription = event.data.object as Stripe.Subscription;
// Find the license key associated with this Stripe customer
const licenseKey = await findLicenseByStripeCustomer(subscription.customer as string);
if (licenseKey) {
await keymint.blockKey({
productId: process.env.KEYMINT_PRODUCT_ID!,
licenseKey,
});
}
}You can also deactivate a single device without blocking the entire license:
await client.deactivateKey({
productId: process.env.KEYMINT_PRODUCT_ID!,
licenseKey,
hostId: "machine-fingerprint",
});Production Considerations
Security
- Never bundle
adminAPI keys in your desktop app. Useclient-scoped keys for runtime activation. - Store
adminkeys only in your backend environment variables. - Verify Stripe webhook signatures — the Stripe-Signature header must be validated to prevent spoofed events.
Stripe Webhook Events to Handle
| Stripe Event | Keymint Action |
|---|---|
checkout.session.completed | Create license key, email customer |
invoice.paid | Create or extend license key expiry |
invoice.payment_failed | Notify customer (license still valid during dunning) |
customer.subscription.updated | Sync license expiry date |
customer.subscription.deleted | Block license key |
Keymint Webhook Events for Monitoring
Subscribe your backend to Keymint outbound webhooks for additional safety:
| Keymint Event | Use |
|---|---|
license.created | Log license creation in your CRM |
device.activated | Track activations, send welcome email |
license.disabled | Confirm revocation, send exit survey |
Rate Limits by Plan
| Plan | Requests/min |
|---|---|
| Free | 10 |
| Maker | 50 |
| Startup | 100 |
| Standard | 1,000 |
Idempotency
Stripe may deliver webhooks more than once. Use processed_stripe_events (or your own deduplication store keyed by event.id) to handle retries safely. For direct REST calls, send an Idempotency-Key when a mutation may be retried; without one, repeating a create request can produce another key.
Complete Source Code
Full Stripe webhook handler (Next.js)
// app/api/webhooks/stripe-licensing/route.ts
import { NextRequest } from "next/server";
import Stripe from "stripe";
import { KeyMint } from "keymint";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const keymint = new KeyMint(process.env.KEYMINT_ADMIN_API_KEY!);
export async function POST(req: NextRequest) {
const payload = await req.text();
const sig = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
payload,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Stripe signature verification failed:", err);
return Response.json({ error: "Invalid signature" }, { status: 400 });
}
try {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
if (!session.subscription) break;
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
await handleNewSubscription(subscription);
break;
}
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
if (!invoice.subscription) break;
const subscription = await stripe.subscriptions.retrieve(
invoice.subscription as string
);
await handleSubscriptionRenewal(subscription);
break;
}
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionChange(subscription);
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionCancelled(subscription);
break;
}
}
return Response.json({ received: true });
} catch (error) {
console.error("Webhook handler error:", error);
return Response.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
async function handleNewSubscription(subscription: Stripe.Subscription) {
const customerEmail = await getCustomerEmail(subscription.customer as string);
const existingLicense = await findExistingLicense(customerEmail);
if (existingLicense) {
await keymint.updateKey({
productId: process.env.KEYMINT_PRODUCT_ID!,
licenseKey: existingLicense,
expiryDate: new Date(subscription.current_period_end * 1000).toISOString(),
});
return;
}
const license = await keymint.createKey({
productId: process.env.KEYMINT_PRODUCT_ID!,
maxActivations: 1,
expiryDate: new Date(subscription.current_period_end * 1000).toISOString(),
newCustomer: {
name: customerEmail,
email: customerEmail,
},
});
await sendLicenseEmail(customerEmail, license.key);
}
async function handleSubscriptionRenewal(subscription: Stripe.Subscription) {
const customerEmail = await getCustomerEmail(subscription.customer as string);
const licenseKey = await findExistingLicense(customerEmail);
if (!licenseKey) return;
await keymint.updateKey({
productId: process.env.KEYMINT_PRODUCT_ID!,
licenseKey,
expiryDate: new Date(subscription.current_period_end * 1000).toISOString(),
});
}
async function handleSubscriptionChange(subscription: Stripe.Subscription) {
const customerEmail = await getCustomerEmail(subscription.customer as string);
const licenseKey = await findExistingLicense(customerEmail);
if (!licenseKey) return;
const isActive = subscription.status === "active" || subscription.status === "trialing";
if (isActive) {
await keymint.unblockKey({
productId: process.env.KEYMINT_PRODUCT_ID!,
licenseKey,
});
await keymint.updateKey({
productId: process.env.KEYMINT_PRODUCT_ID!,
licenseKey,
expiryDate: new Date(subscription.current_period_end * 1000).toISOString(),
});
} else {
await keymint.blockKey({
productId: process.env.KEYMINT_PRODUCT_ID!,
licenseKey,
});
}
}
async function handleSubscriptionCancelled(subscription: Stripe.Subscription) {
const customerEmail = await getCustomerEmail(subscription.customer as string);
const licenseKey = await findExistingLicense(customerEmail);
if (!licenseKey) return;
await keymint.blockKey({
productId: process.env.KEYMINT_PRODUCT_ID!,
licenseKey,
});
}
async function getCustomerEmail(customerId: string): Promise<string> {
const customer = await stripe.customers.retrieve(customerId);
if ("deleted" in customer) throw new Error("Customer deleted");
return customer.email!;
}
// You must implement these based on your database
async function findExistingLicense(email: string): Promise<string | null> {
// Query your DB: which license key is assigned to this Stripe customer email?
return null;
}
async function sendLicenseEmail(email: string, licenseKey: string) {
// Send email via SendGrid, Resend, Amazon SES, etc.
console.log(`Sending license ${licenseKey} to ${email}`);
}Desktop app license validation
// license.ts — bundled in your desktop app
import { KeyMint } from 'keymint';
import { machineIdSync } from 'node-machine-id';
const client = new KeyMint(process.env.KEYMINT_CLIENT_API_KEY!);
const PRODUCT_ID = process.env.KEYMINT_PRODUCT_ID!;
let cachedValidation = false;
export async function checkLicense(licenseKey: string): Promise<boolean> {
if (cachedValidation) return true;
const hostId = machineIdSync();
try {
const result = await client.activateKey({
productId: PRODUCT_ID,
licenseKey,
hostId,
});
if (result.code === 0) {
cachedValidation = true;
return true;
}
console.error(`License invalid: ${result.message} (code ${result.code})`);
return false;
} catch (error) {
console.error('License check failed:', error);
return false;
}
}
// Call on app startup
export async function startLicenseCheck(licenseKey: string) {
const valid = await checkLicense(licenseKey);
if (!valid) {
console.error("License check failed. Exiting.");
process.exit(1);
}
console.log("License validated successfully.");
}