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

mermaid
Rendering diagram...

Create Product

First, create a product in Keymint. This groups all licenses under one software title.

  1. Sign in to the Keymint Dashboard
  2. Navigate to Products
  3. Enter a name (e.g. "My Desktop App")
  4. 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.

typescript
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:

  1. Verifies the Stripe webhook signature (required for security)
  2. On invoice.paid, retrieves the subscription details
  3. Creates a license key scoped to the Stripe subscription period
  4. Auto-creates a customer record and assigns the license
  5. 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.

typescript
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 hostId is 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.

ScenarioBehavior
First activationLicense becomes activated: true. activations count increments to 1.
Same host reactivatesReturns success. No new activation record.
New host activatesCount increments if under maxActivations.
Limit reachedReturns 403 with "Activation limit reached".
License expiredReturns 403. Expired licenses reject all activations.
License blockedReturns 403. Vendor-disabled licenses reject all activations.

To check current activation count programmatically:

typescript
// 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.

typescript
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:

typescript
await client.deactivateKey({
  productId: process.env.KEYMINT_PRODUCT_ID!,
  licenseKey,
  hostId: "machine-fingerprint",
});

Production Considerations

Security

  • Never bundle admin API keys in your desktop app. Use client-scoped keys for runtime activation.
  • Store admin keys 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 EventKeymint Action
checkout.session.completedCreate license key, email customer
invoice.paidCreate or extend license key expiry
invoice.payment_failedNotify customer (license still valid during dunning)
customer.subscription.updatedSync license expiry date
customer.subscription.deletedBlock license key

Keymint Webhook Events for Monitoring

Subscribe your backend to Keymint outbound webhooks for additional safety:

Keymint EventUse
license.createdLog license creation in your CRM
device.activatedTrack activations, send welcome email
license.disabledConfirm revocation, send exit survey

Rate Limits by Plan

PlanRequests/min
Free10
Maker50
Startup100
Standard1,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)
typescript
// 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
typescript
// 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.");
}