Building a Self-Hosted Software Licensing System with Stripe, Keygen CE, and Node.js

When I built SummaryLens, a macOS menubar app for AI-powered text explanations, I needed a way to sell licenses. The requirements were simple:

  1. Customer pays via Stripe
  2. License key is automatically generated
  3. Customer receives license via email
  4. App validates license on startup

I didn't want to pay monthly fees for a licensing service, so I went with Keygen CE (Community Edition) - a self-hosted, open-source license server. Here's how I wired it all together.

Architecture Overview

Customer → Stripe Checkout → Webhook → Node.js Server
                                            ↓
                                    Keygen CE (create license)
                                            ↓
                                    Email (send license key)

The flow is straightforward:

  1. Customer clicks a Stripe Payment Link
  2. Stripe sends a checkout.session.completed webhook
  3. Our server creates a license in Keygen
  4. Server emails the license key to the customer

Prerequisites

  • Keygen CE running somewhere (I use Coolify on a VPS)
  • Stripe account (test mode works fine for development)
  • SMTP credentials (I used Google Workspace, but any SMTP works)
  • Node.js 18+

Part 1: Setting Up Keygen CE

I won't cover the Keygen CE installation here (their docs are solid), but once it's running, you need to create:

1. Admin Token

First, get an admin token. If you set up Keygen with an admin email/password, you can get a token via Basic Auth:

curl -X POST https://your-keygen-host/v1/accounts/YOUR_ACCOUNT_ID/tokens \
  -u "admin@example.com:your-password" \
  -H "Content-Type: application/vnd.api+json" \
  -d '{
    "data": {
      "type": "tokens",
      "attributes": {
        "name": "Admin Token"
      }
    }
  }'

2. Product

curl -X POST https://your-keygen-host/v1/accounts/YOUR_ACCOUNT_ID/products \
  --header "Authorization: Bearer YOUR_ADMIN_TOKEN" \
  --header "Content-Type: application/vnd.api+json" \
  --data '{
    "data": {
      "type": "products",
      "attributes": {
        "name": "Your App Name"
      }
    }
  }'

3. Policy

The policy defines license behavior. For a lifetime license with 3 machine activations:

curl -X POST https://your-keygen-host/v1/accounts/YOUR_ACCOUNT_ID/policies \
  --header "Authorization: Bearer YOUR_ADMIN_TOKEN" \
  --header "Content-Type: application/vnd.api+json" \
  --data '{
    "data": {
      "type": "policies",
      "attributes": {
        "name": "Lifetime",
        "duration": null,
        "maxMachines": 3,
        "floating": true,
        "authenticationStrategy": "LICENSE"
      },
      "relationships": {
        "product": {
          "data": { "type": "products", "id": "YOUR_PRODUCT_ID" }
        }
      }
    }
  }'

Gotcha #1: If you want maxMachines > 1, you MUST set "floating": true. Otherwise Keygen rejects the request with a cryptic "maxMachines must equal 1" error.

Gotcha #2: Set "authenticationStrategy": "LICENSE" to allow your app to activate machines using the license key for authentication. Without this, machine activation will fail with "license key authentication is not allowed by policy".

Save the policy ID - you'll need it for the webhook server.

Part 2: Setting Up Stripe

Create Product and Price

Using the Stripe CLI (much faster than the dashboard):

# Create product
stripe products create \
  --name="Your App Name" \
  --description="Lifetime license for Your App"

# Create price ($12 one-time payment)
stripe prices create \
  --product="prod_xxx" \
  --unit-amount=1200 \
  --currency=usd

Save the price ID (starts with price_).

For simple sales, Payment Links are perfect - no custom checkout code needed:

stripe payment_links create \
  -d "line_items[0][price]=price_xxx" \
  -d "line_items[0][quantity]=1"

This gives you a hosted checkout URL you can share directly.

Part 3: The Webhook Server

Here's the complete Node.js server. Create a new directory and initialize:

mkdir licensing-server && cd licensing-server
npm init -y
npm install express stripe nodemailer dotenv

package.json

{
  "name": "licensing-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/index.js",
    "dev": "node --watch src/index.js"
  },
  "dependencies": {
    "dotenv": "^16.3.1",
    "express": "^4.18.2",
    "nodemailer": "^7.0.11",
    "stripe": "^14.0.0"
  }
}

src/index.js

import express from 'express';
import Stripe from 'stripe';
import dotenv from 'dotenv';
import { createLicense } from './keygen.js';
import { sendLicenseEmail } from './email.js';
import { getProductConfig } from './config.js';

dotenv.config();

const app = express();
const port = process.env.PORT || 3000;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

// Raw body needed for Stripe signature verification
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'];

  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    console.error('Webhook signature verification failed:', err.message);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  if (event.type === 'checkout.session.completed') {
    try {
      await handleCheckoutCompleted(event.data.object);
      console.log(`Successfully processed checkout session: ${event.data.object.id}`);
    } catch (err) {
      console.error('Error processing checkout:', err);
      return res.status(500).send('Error processing checkout');
    }
  }

  res.json({ received: true });
});

async function handleCheckoutCompleted(session) {
  // Get customer info
  const customerEmail = session.customer_details?.email || session.customer_email;
  const customerName = session.customer_details?.name || 'Customer';

  // Get line items to find the price
  const lineItems = await stripe.checkout.sessions.listLineItems(session.id);
  const priceId = lineItems.data[0]?.price?.id;

  if (!priceId) {
    throw new Error('No price ID found in checkout session');
  }

  // Get product config for this price
  const productConfig = getProductConfig(priceId);
  if (!productConfig) {
    throw new Error(`No product config found for price: ${priceId}`);
  }

  console.log(`Creating license for ${customerEmail} - Product: ${productConfig.productName}, Policy: ${productConfig.policyId}`);

  // Create license in Keygen
  const license = await createLicense({
    email: customerEmail,
    name: customerName,
    policyId: productConfig.policyId,
    productId: productConfig.keygenProductId,
    metadata: {
      stripeSessionId: session.id,
      stripePriceId: priceId
    }
  });

  // Send license email
  await sendLicenseEmail({
    to: customerEmail,
    customerName,
    licenseKey: license.key,
    productName: productConfig.productName,
    policyName: productConfig.policyName
  });

  console.log(`License email sent to ${customerEmail}`);
}

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.listen(port, () => {
  console.log(`Licensing server running on port ${port}`);
  console.log(`Webhook endpoint: POST /webhooks/stripe`);
});

src/keygen.js

function getConfig() {
  return {
    host: process.env.KEYGEN_HOST,
    accountId: process.env.KEYGEN_ACCOUNT_ID,
    adminToken: process.env.KEYGEN_ADMIN_TOKEN
  };
}

export async function createLicense({ email, name, policyId, productId, metadata = {} }) {
  const { host, accountId, adminToken } = getConfig();

  if (!accountId || !adminToken) {
    throw new Error('Keygen configuration missing');
  }

  const url = `${host}/v1/accounts/${accountId}/licenses`;

  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${adminToken}`,
      'Content-Type': 'application/vnd.api+json',
      'Accept': 'application/vnd.api+json'
    },
    body: JSON.stringify({
      data: {
        type: 'licenses',
        attributes: {
          name: `License for ${email}`,
          metadata: {
            customerEmail: email,
            customerName: name,
            ...metadata
          }
        },
        relationships: {
          policy: {
            data: { type: 'policies', id: policyId }
          }
        }
      }
    })
  });

  const data = await response.json();

  if (!response.ok) {
    const errorDetail = data.errors?.[0]?.detail || 'Unknown error';
    throw new Error(`Keygen API error: ${errorDetail}`);
  }

  return {
    id: data.data.id,
    key: data.data.attributes.key
  };
}

src/email.js

import nodemailer from 'nodemailer';

let transporter = null;

function getTransporter() {
  if (!transporter && process.env.SMTP_HOST) {
    transporter = nodemailer.createTransport({
      host: process.env.SMTP_HOST,
      port: parseInt(process.env.SMTP_PORT) || 587,
      secure: false,
      auth: {
        user: process.env.SMTP_USER,
        pass: process.env.SMTP_PASS
      }
    });
  }
  return transporter;
}

export async function sendLicenseEmail({ to, customerName, licenseKey, productName, policyName }) {
  if (!process.env.SMTP_HOST) {
    console.log(`[DEV] Would send license to ${to}: ${licenseKey}`);
    return;
  }

  const transport = getTransporter();

  await transport.sendMail({
    from: `${process.env.FROM_NAME} <${process.env.FROM_EMAIL}>`,
    to: to,
    subject: `Your ${productName} License Key`,
    text: `
Hi ${customerName},

Thank you for purchasing ${productName}!

Your license key: ${licenseKey}

To activate:
1. Open ${productName}
2. Go to Settings > License
3. Paste your license key
4. Click Activate

Your license allows activation on up to 3 devices.

Enjoy!
    `.trim()
  });
}

src/config.js

const PRODUCTS = {
  // Map Stripe price IDs to Keygen policies
  'price_xxx': {
    productName: 'Your App',
    policyName: 'Lifetime',
    policyId: 'your-keygen-policy-id',
    keygenProductId: 'your-keygen-product-id'
  }
};

export function getProductConfig(stripePriceId) {
  return PRODUCTS[stripePriceId] || null;
}

.env

# Stripe
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx

# Keygen
KEYGEN_HOST=https://your-keygen-host
KEYGEN_ACCOUNT_ID=your-account-id
KEYGEN_ADMIN_TOKEN=admin-xxx

# Email (SMTP)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=you@example.com
SMTP_PASS=your-app-password
FROM_EMAIL=you@example.com
FROM_NAME=Your App

Gotcha #3: If using Google Workspace/Gmail, you need an App Password, not your regular password. Go to Google Account → Security → 2-Step Verification → App passwords.

Gotcha #4: Environment variables read at ES module import time won't have dotenv values yet. Read them lazily inside functions (like getConfig() above) rather than at the top of the file.

Part 4: Testing Locally

Start the webhook listener

# Terminal 1: Start your server
npm run dev

# Terminal 2: Forward Stripe webhooks locally
stripe listen --forward-to localhost:3000/webhooks/stripe

The stripe listen command outputs a webhook secret (whsec_xxx) - add this to your .env.

Trigger a test checkout

Don't use stripe trigger - it creates its own test products with different price IDs. Instead, create a real checkout session:

stripe checkout sessions create \
  -d "line_items[0][price]=price_xxx" \
  -d "line_items[0][quantity]=1" \
  -d "mode=payment" \
  -d "success_url=https://example.com/success" \
  -d "cancel_url=https://example.com/cancel"

Then open the returned URL and complete checkout with test card 4242 4242 4242 4242.

Part 5: Production Deployment

Docker

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY src/ ./src/
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "src/index.js"]

Restricted Stripe API Key

For production, create a restricted key with minimal permissions:

  1. Go to Stripe Dashboard → API Keys → Create restricted key
  2. Enable only:
    • Checkout Sessions: Read
    • Customers: Read
    • Prices: Read
    • Products: Read

This key can only verify webhooks, not create charges.

Stripe Webhook (Production)

  1. Go to Stripe Dashboard → Webhooks
  2. Add endpoint: https://your-server.com/webhooks/stripe
  3. Select event: checkout.session.completed
  4. Copy the signing secret to your server's environment

Gotchas Summary

  1. Keygen floating licenses: Set "floating": true if you want maxMachines > 1
  2. Keygen license authentication: Set "authenticationStrategy": "LICENSE" in your policy to allow machine activation with license keys
  3. Gmail SMTP: Use App Passwords, not your regular password
  4. ES modules + dotenv: Read env vars lazily inside functions, not at import time
  5. Stripe trigger command: Creates its own test products - use real checkout sessions for testing

Cost Breakdown

  • Keygen CE: Free (self-hosted)
  • Stripe: 2.9% + $0.30 per transaction
  • SMTP: Free (using existing Google Workspace, which is $7-14/mo for one user)
  • VPS for Keygen: ~$5-10/month, depending on VPS arrangement

Total ongoing cost: Just Stripe fees and hosting.

Resources


This post was written while building SummaryLens, a macOS app for instant AI explanations of any text.