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.
Keymint has no built-in Stripe connection. Your own Stripe webhook handler, shown below, calls the Keymint API to create, extend and block licenses.
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 (in More options, open Save 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 the first payment of a subscription, your backend creates a license key and emails it to the customer. Renewals extend the existing key instead (see the complete source below).
These samples use invoice.subscription and subscription.current_period_end, which exist in stripe-node 17 and earlier. From Stripe API version 2025-03-31 (stripe-node 18) they moved to invoice.parent.subscription_details.subscription and the subscription items' current_period_end. Adjust for the API version your account uses.
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;
// Only the first invoice issues a key; renewals extend it.
if (!invoice.subscription || invoice.billing_reason !== "subscription_create") {
return Response.json({ received: true });
}
const subscription = await stripe.subscriptions.retrieve(
invoice.subscription as string
);
const customer = await stripe.customers.retrieve(invoice.customer as string);
const customerEmail = invoice.customer_email
|| ("deleted" in customer ? null : customer.email);
// Create a license key that expires at the end of the billing period.
// The idempotency key stops a redelivered event from creating a second key.
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,
},
}, { idempotencyKey: `stripe-${event.id}` });
// Send the license key to your customer
if (customerEmail && license.key) {
await sendLicenseEmail(customerEmail, license.key);
}
}
return Response.json({ received: true });
}What this does:
- Verifies the Stripe webhook signature (required for security)
- On the subscription's first
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 raw license key is returned only at creation and is encrypted at rest. Store it (mapped to the Stripe customer and subscription) before emailing it: the API can't look a key up without already knowing it. You can still view or export keys in the dashboard.
Validate License
Your desktop app validates the license on startup using a client-scoped API key.
import { KeyMint } from 'keymint';
const client = new KeyMint(process.env.KEYMINT_CLIENT_API_KEY!);
// 'unknown' = Keymint couldn't be reached; keep the last known state.
async function validateLicense(licenseKey: string): Promise<'valid' | 'rejected' | 'unknown'> {
const hostId = KeyMint.getOrCreateInstallationId(); // Stable per-machine ID
try {
const result = await client.activateKey({
productId: process.env.KEYMINT_PRODUCT_ID!,
licenseKey,
hostId,
});
console.log(`License valid (${result.message})`); // "License valid"
return 'valid';
} catch (error: any) {
// The SDK throws on every non-2xx response.
if ([401, 402, 403, 404].includes(error?.status)) {
console.error(`License rejected: ${error.errorCode}`);
return 'rejected';
}
console.error('Could not reach Keymint:', error?.message);
return 'unknown';
}
}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, code: 2, errorCode: "activation_limit_reached". |
| License expired | Returns 401, code: 1, errorCode: "license_invalid_or_forbidden". Expired licenses reject all activations. |
| License blocked | Returns 401, code: 1, errorCode: "license_invalid_or_forbidden". Blocked licenses reject all activations. |
To check current activation count programmatically:
// Requires an admin or read-only API key, such as the admin `keymint` client above
const { data } = await keymint.getKey({ productId: process.env.KEYMINT_PRODUCT_ID!, licenseKey });
console.log(`Activations: ${data.license.activations}/${data.license.maxActivations ?? 'unlimited'}`);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. Pass the hostId the device activated with (a client key must send it; an admin key may omit it to remove every device):
await keymint.deactivateKey({
productId: process.env.KEYMINT_PRODUCT_ID!,
licenseKey,
hostId: "HOST_ID_OF_THE_DEVICE",
});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 | Extend license key expiry on renewal |
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 | 30 |
| Maker | 50 |
| Startup | 100 |
| Standard | 1,000 |
Idempotency
Stripe may deliver webhooks more than once. Record each processed event.id in your own database and skip events you've already handled. Also send an Idempotency-Key when a Keymint mutation may be retried (the Node.js SDK takes it as { idempotencyKey }); without one, repeating a create request can produce another key. Keymint keeps idempotency records for 24 hours.
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,
},
}, { idempotencyKey: `stripe-sub-${subscription.id}` });
if (license.key) 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';
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;
try {
await client.activateKey({
productId: PRODUCT_ID,
licenseKey,
hostId: KeyMint.getOrCreateInstallationId(),
});
cachedValidation = true;
return true;
} catch (error: any) {
// The SDK throws on every non-2xx response.
if ([401, 402, 403, 404].includes(error?.status)) {
console.error(`License invalid: ${error.errorCode} (HTTP ${error.status})`);
return false;
}
// Offline or Keymint unavailable: decide whether to allow a grace period.
console.error('License check failed:', error?.message);
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.");
}