Building a Software License Management System with Node.js, Fastify and PostgreSQL

Building a Software License Management System with Node.js, Fastify and PostgreSQL

Selling software online involves much more than accepting a payment. Once a customer pays, you need to issue a license key, tie that key to a user account, enforce activation limits, handle expiration, and expose an endpoint that the shipped application can call to verify that a given installation is legitimate. In this article we build a complete, self-contained license management system in Node.js using Fastify as the HTTP framework and PostgreSQL as the persistence layer. The system covers user accounts with JWT authentication, a product catalog, an online checkout flow backed by Stripe, automatic license issuance on successful payment, and a public validation and activation endpoint that client software can call at runtime.

The goal is a design that is small enough to read in one sitting but realistic enough to deploy. We favor explicit SQL over an ORM so that the data model stays transparent, and we lean on Fastify's schema validation and plugin system to keep the codebase organized.

Architecture Overview

The system is organized around five core entities. A user owns an account and authenticates with an email and password. A product describes a sellable license plan, including its price, the number of allowed activations, and an optional duration. An order records a purchase attempt and its payment status. A license is the actual entitlement issued to a user for a product after payment succeeds. Finally, an activation records each machine that has redeemed a given license, which is how we enforce per-license activation limits.

The request flow for a purchase looks like this: the customer registers and logs in, browses the product catalog, starts a checkout session for a product, is redirected to the payment provider, and pays. The provider then calls our webhook, at which point we mark the order as paid and issue the license inside a single database transaction. From then on, the shipped software can validate the license key against a public endpoint, and each new installation consumes one activation slot.

Technology Stack and Prerequisites

You will need Node.js 20 or later, a running PostgreSQL 14+ instance, and a Stripe account for the payment integration. The project relies on a small set of well-maintained libraries: fastify for the server, pg for the PostgreSQL driver, @fastify/jwt for token-based authentication, bcrypt for password hashing, stripe for payments, and @fastify/cors for cross-origin access from a front end.

Project Setup

Create a new project directory and initialize it. We use ECMAScript modules throughout, so the type field in package.json is set to module.

mkdir license-server && cd license-server
npm init -y
npm install fastify @fastify/jwt @fastify/cors pg bcrypt stripe dotenv
npm install --save-dev pino-pretty

Set the module type and a couple of convenience scripts in package.json.

{
  "name": "license-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js",
    "migrate": "node scripts/migrate.js"
  }
}

All configuration is read from environment variables. Create a .env file at the project root. Never commit this file to version control.

# Server
PORT=3000
HOST=0.0.0.0
NODE_ENV=development

# PostgreSQL
DATABASE_URL=postgres://license_user:secret@localhost:5432/license_db

# Security
JWT_SECRET=replace-with-a-long-random-string
LICENSE_SIGNING_SECRET=replace-with-another-long-random-string

# Stripe
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
CHECKOUT_SUCCESS_URL=http://localhost:5173/success
CHECKOUT_CANCEL_URL=http://localhost:5173/cancel

The Database Schema

The schema captures the five entities described above plus the relationships between them. We use BIGSERIAL primary keys, TIMESTAMPTZ for all timestamps so that time zones are unambiguous, and foreign keys with sensible referential actions. Prices are stored as integer cents to avoid floating-point rounding errors. Save the following as db/schema.sql.

-- Users own accounts and authenticate against the API
CREATE TABLE users (
    id            BIGSERIAL PRIMARY KEY,
    email         TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    full_name     TEXT,
    role          TEXT NOT NULL DEFAULT 'customer'
                  CHECK (role IN ('customer', 'admin')),
    is_active     BOOLEAN NOT NULL DEFAULT TRUE,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Products describe sellable license plans
CREATE TABLE products (
    id              BIGSERIAL PRIMARY KEY,
    sku             TEXT NOT NULL UNIQUE,
    name            TEXT NOT NULL,
    description     TEXT,
    price_cents     INTEGER NOT NULL CHECK (price_cents >= 0),
    currency        TEXT NOT NULL DEFAULT 'EUR',
    max_activations INTEGER NOT NULL DEFAULT 1 CHECK (max_activations > 0),
    duration_days   INTEGER, -- NULL means a perpetual license
    is_active       BOOLEAN NOT NULL DEFAULT TRUE,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Orders record a purchase attempt and its payment lifecycle
CREATE TABLE orders (
    id                      BIGSERIAL PRIMARY KEY,
    user_id                 BIGINT NOT NULL REFERENCES users(id),
    product_id              BIGINT NOT NULL REFERENCES products(id),
    status                  TEXT NOT NULL DEFAULT 'pending'
                            CHECK (status IN ('pending', 'paid', 'failed', 'refunded')),
    amount_cents            INTEGER NOT NULL,
    currency                TEXT NOT NULL,
    provider                TEXT NOT NULL DEFAULT 'stripe',
    provider_session_id     TEXT UNIQUE,
    provider_payment_intent TEXT,
    created_at              TIMESTAMPTZ NOT NULL DEFAULT now(),
    paid_at                 TIMESTAMPTZ
);

-- Licenses are the entitlements issued after a successful payment
CREATE TABLE licenses (
    id              BIGSERIAL PRIMARY KEY,
    license_key     TEXT NOT NULL UNIQUE,
    user_id         BIGINT NOT NULL REFERENCES users(id),
    product_id      BIGINT NOT NULL REFERENCES products(id),
    order_id        BIGINT REFERENCES orders(id),
    status          TEXT NOT NULL DEFAULT 'active'
                    CHECK (status IN ('active', 'suspended', 'revoked', 'expired')),
    max_activations INTEGER NOT NULL DEFAULT 1,
    issued_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at      TIMESTAMPTZ -- NULL means the license never expires
);

-- Activations track each machine that has redeemed a license
CREATE TABLE activations (
    id            BIGSERIAL PRIMARY KEY,
    license_id    BIGINT NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
    machine_id    TEXT NOT NULL,
    hostname      TEXT,
    ip_address    INET,
    activated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_seen_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (license_id, machine_id)
);

-- Indexes that support the most frequent lookups
CREATE INDEX idx_licenses_user      ON licenses(user_id);
CREATE INDEX idx_licenses_key       ON licenses(license_key);
CREATE INDEX idx_orders_user        ON orders(user_id);
CREATE INDEX idx_activations_license ON activations(license_id);

A short migration script applies this schema. It reads the SQL file and executes it against the configured database.

// scripts/migrate.js
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import pg from 'pg';
import 'dotenv/config';

const __dirname = dirname(fileURLToPath(import.meta.url));

async function migrate() {
  const sql = await readFile(join(__dirname, '..', 'db', 'schema.sql'), 'utf8');
  const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
  await client.connect();
  try {
    await client.query(sql);
    console.log('Schema applied successfully.');
  } finally {
    await client.end();
  }
}

migrate().catch((err) => {
  console.error('Migration failed:', err);
  process.exit(1);
});

The Database Access Layer

Rather than open a connection per request, we use a connection pool that Fastify shares across the whole application. The pool is exposed as a Fastify plugin so that every route handler can reach it through fastify.pg. We also add a small transaction helper, because license issuance must be atomic.

// src/db.js
import fp from 'fastify-plugin';
import pg from 'pg';

async function dbPlugin(fastify) {
  const pool = new pg.Pool({
    connectionString: process.env.DATABASE_URL,
    max: 10,
    idleTimeoutMillis: 30_000
  });

  // Verify connectivity at startup so the process fails fast on misconfiguration
  await pool.query('SELECT 1');

  // Run a callback inside a single transaction, committing or rolling back
  async function transaction(callback) {
    const client = await pool.connect();
    try {
      await client.query('BEGIN');
      const result = await callback(client);
      await client.query('COMMIT');
      return result;
    } catch (err) {
      await client.query('ROLLBACK');
      throw err;
    } finally {
      client.release();
    }
  }

  fastify.decorate('pg', pool);
  fastify.decorate('tx', transaction);

  fastify.addHook('onClose', async () => {
    await pool.end();
  });
}

export default fp(dbPlugin);

Note the use of fastify-plugin. Wrapping the plugin with fp prevents Fastify from creating a new encapsulation context, which is exactly what we want for a shared resource like a database pool.

Password Hashing

Passwords are never stored in plaintext. We hash them with bcrypt using a work factor of twelve, which is a reasonable balance between security and login latency. Two thin wrappers keep the rest of the code clean.

// src/lib/passwords.js
import bcrypt from 'bcrypt';

const ROUNDS = 12;

export function hashPassword(plain) {
  return bcrypt.hash(plain, ROUNDS);
}

export function verifyPassword(plain, hash) {
  return bcrypt.compare(plain, hash);
}

Generating and Signing License Keys

A license key needs two properties. It must be practically impossible to guess, and it should be self-describing enough that we can reject obviously malformed keys before touching the database. We build a key from random bytes formatted into readable groups, then append a short HMAC signature derived from a server-side secret. The signature lets a client perform a cheap structural check offline, while the authoritative check always happens against the database.

// src/lib/licenses.js
import crypto from 'node:crypto';

// Crockford-style alphabet avoids ambiguous characters like O/0 and I/1
const ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ0123456789';

function randomBlock(length) {
  const bytes = crypto.randomBytes(length);
  let out = '';
  for (let i = 0; i < length; i++) {
    out += ALPHABET[bytes[i] % ALPHABET.length];
  }
  return out;
}

function signature(base, secret) {
  return crypto
    .createHmac('sha256', secret)
    .update(base)
    .digest('hex')
    .slice(0, 6)
    .toUpperCase();
}

// Produce a key shaped like XXXXX-XXXXX-XXXXX-SIGSIG
export function generateLicenseKey(secret) {
  const base = [randomBlock(5), randomBlock(5), randomBlock(5)].join('-');
  const sig = signature(base, secret);
  return `${base}-${sig}`;
}

// Cheap structural validation before any database lookup
export function hasValidSignature(key, secret) {
  const parts = key.split('-');
  if (parts.length !== 4) return false;
  const base = parts.slice(0, 3).join('-');
  return signature(base, secret) === parts[3];
}

The HMAC approach means an attacker cannot fabricate a well-formed key without the signing secret. Even if they did, the database would still reject it because no matching row would exist. This defense-in-depth is intentional: the signature filters out noise, and the database is the source of truth.

Application Configuration and Bootstrap

We assemble the Fastify instance in a factory function so that the same app can be reused by tests and by the production entry point. The factory registers the database plugin, JWT support, CORS, and every route group.

// src/app.js
import Fastify from 'fastify';
import jwt from '@fastify/jwt';
import cors from '@fastify/cors';
import dbPlugin from './db.js';
import authRoutes from './routes/auth.js';
import productRoutes from './routes/products.js';
import checkoutRoutes from './routes/checkout.js';
import webhookRoutes from './routes/webhooks.js';
import licenseRoutes from './routes/licenses.js';

export async function buildApp(options = {}) {
  const app = Fastify({
    logger: {
      transport:
        process.env.NODE_ENV === 'development'
          ? { target: 'pino-pretty' }
          : undefined
    },
    ...options
  });

  await app.register(cors, { origin: true });
  await app.register(dbPlugin);
  await app.register(jwt, { secret: process.env.JWT_SECRET });

  // An authentication decorator every protected route can reuse
  app.decorate('authenticate', async (request, reply) => {
    try {
      await request.jwtVerify();
    } catch {
      reply.code(401).send({ error: 'Unauthorized' });
    }
  });

  // A guard that additionally requires the admin role
  app.decorate('requireAdmin', async (request, reply) => {
    try {
      await request.jwtVerify();
      if (request.user.role !== 'admin') {
        reply.code(403).send({ error: 'Forbidden' });
      }
    } catch {
      reply.code(401).send({ error: 'Unauthorized' });
    }
  });

  await app.register(authRoutes, { prefix: '/api/auth' });
  await app.register(productRoutes, { prefix: '/api/products' });
  await app.register(checkoutRoutes, { prefix: '/api/checkout' });
  await app.register(webhookRoutes, { prefix: '/api/webhooks' });
  await app.register(licenseRoutes, { prefix: '/api/licenses' });

  return app;
}

The production entry point simply builds the app and starts listening.

// server.js
import 'dotenv/config';
import { buildApp } from './src/app.js';

const app = await buildApp();

try {
  await app.listen({
    port: Number(process.env.PORT) || 3000,
    host: process.env.HOST || '0.0.0.0'
  });
} catch (err) {
  app.log.error(err);
  process.exit(1);
}

User Accounts and Authentication

The authentication routes handle registration and login. Fastify's JSON schema validation rejects malformed payloads before our handler runs, so the handler body can assume the input is well-formed. On registration we hash the password and insert the user. On login we look the user up, verify the password, and sign a JWT that carries the user id, email, and role.

// src/routes/auth.js
import { hashPassword, verifyPassword } from '../lib/passwords.js';

const registerSchema = {
  body: {
    type: 'object',
    required: ['email', 'password'],
    properties: {
      email: { type: 'string', format: 'email' },
      password: { type: 'string', minLength: 8 },
      full_name: { type: 'string' }
    }
  }
};

const loginSchema = {
  body: {
    type: 'object',
    required: ['email', 'password'],
    properties: {
      email: { type: 'string', format: 'email' },
      password: { type: 'string' }
    }
  }
};

export default async function authRoutes(fastify) {
  fastify.post('/register', { schema: registerSchema }, async (request, reply) => {
    const { email, password, full_name } = request.body;
    const passwordHash = await hashPassword(password);

    try {
      const { rows } = await fastify.pg.query(
        `INSERT INTO users (email, password_hash, full_name)
         VALUES ($1, $2, $3)
         RETURNING id, email, full_name, role`,
        [email.toLowerCase(), passwordHash, full_name ?? null]
      );
      reply.code(201).send({ user: rows[0] });
    } catch (err) {
      // 23505 is the PostgreSQL unique-violation error code
      if (err.code === '23505') {
        return reply.code(409).send({ error: 'Email already registered' });
      }
      throw err;
    }
  });

  fastify.post('/login', { schema: loginSchema }, async (request, reply) => {
    const { email, password } = request.body;

    const { rows } = await fastify.pg.query(
      `SELECT id, email, password_hash, role, is_active
       FROM users WHERE email = $1`,
      [email.toLowerCase()]
    );

    const user = rows[0];
    if (!user || !user.is_active) {
      return reply.code(401).send({ error: 'Invalid credentials' });
    }

    const ok = await verifyPassword(password, user.password_hash);
    if (!ok) {
      return reply.code(401).send({ error: 'Invalid credentials' });
    }

    const token = await reply.jwtSign(
      { id: user.id, email: user.email, role: user.role },
      { expiresIn: '7d' }
    );

    reply.send({ token });
  });

  // Returns the authenticated user's own profile
  fastify.get('/me', { onRequest: [fastify.authenticate] }, async (request) => {
    const { rows } = await fastify.pg.query(
      `SELECT id, email, full_name, role, created_at
       FROM users WHERE id = $1`,
      [request.user.id]
    );
    return { user: rows[0] };
  });
}

We always return the same generic error for a missing user and a wrong password. Distinguishing the two would leak whether an email is registered, which is a small but real information disclosure.

The Product Catalog

Products are readable by anyone but writable only by administrators. The public listing endpoint returns active products, while the create endpoint is guarded by the admin role. This is enough to seed the catalog and to power a storefront front end.

// src/routes/products.js
const createSchema = {
  body: {
    type: 'object',
    required: ['sku', 'name', 'price_cents'],
    properties: {
      sku: { type: 'string' },
      name: { type: 'string' },
      description: { type: 'string' },
      price_cents: { type: 'integer', minimum: 0 },
      currency: { type: 'string', default: 'EUR' },
      max_activations: { type: 'integer', minimum: 1, default: 1 },
      duration_days: { type: 'integer', minimum: 1 }
    }
  }
};

export default async function productRoutes(fastify) {
  fastify.get('/', async () => {
    const { rows } = await fastify.pg.query(
      `SELECT id, sku, name, description, price_cents, currency,
              max_activations, duration_days
       FROM products
       WHERE is_active = TRUE
       ORDER BY price_cents ASC`
    );
    return { products: rows };
  });

  fastify.post('/', { onRequest: [fastify.requireAdmin], schema: createSchema },
    async (request, reply) => {
      const p = request.body;
      const { rows } = await fastify.pg.query(
        `INSERT INTO products
           (sku, name, description, price_cents, currency, max_activations, duration_days)
         VALUES ($1, $2, $3, $4, $5, $6, $7)
         RETURNING *`,
        [
          p.sku, p.name, p.description ?? null, p.price_cents,
          p.currency ?? 'EUR', p.max_activations ?? 1, p.duration_days ?? null
        ]
      );
      reply.code(201).send({ product: rows[0] });
    });
}

The Online Checkout Flow

Selling a license online means integrating a payment provider. We use Stripe Checkout because it hosts the payment page, handles card data, and is compliant out of the box. The flow has two halves. First, an authenticated customer requests a checkout session for a product; we create a pending order and return a redirect URL. Second, after the customer pays, Stripe calls our webhook, and only then do we issue the license. Splitting the flow this way is essential: the redirect back to the site is not proof of payment, whereas the signed webhook is.

Instantiate the Stripe client in its own module so it can be reused.

// src/lib/stripe.js
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: '2024-06-20'
});

The checkout route creates the pending order and the Stripe session together. We pass our own order id into the session metadata so the webhook can correlate the payment back to the order.

// src/routes/checkout.js
import { stripe } from '../lib/stripe.js';

const checkoutSchema = {
  body: {
    type: 'object',
    required: ['product_id'],
    properties: {
      product_id: { type: 'integer' }
    }
  }
};

export default async function checkoutRoutes(fastify) {
  fastify.post('/session',
    { onRequest: [fastify.authenticate], schema: checkoutSchema },
    async (request, reply) => {
      const { product_id } = request.body;
      const userId = request.user.id;

      const { rows } = await fastify.pg.query(
        `SELECT id, name, price_cents, currency
         FROM products WHERE id = $1 AND is_active = TRUE`,
        [product_id]
      );
      const product = rows[0];
      if (!product) {
        return reply.code(404).send({ error: 'Product not found' });
      }

      // Create the pending order first so we always have a record
      const orderResult = await fastify.pg.query(
        `INSERT INTO orders (user_id, product_id, amount_cents, currency, status)
         VALUES ($1, $2, $3, $4, 'pending')
         RETURNING id`,
        [userId, product.id, product.price_cents, product.currency]
      );
      const orderId = orderResult.rows[0].id;

      const session = await stripe.checkout.sessions.create({
        mode: 'payment',
        success_url: process.env.CHECKOUT_SUCCESS_URL,
        cancel_url: process.env.CHECKOUT_CANCEL_URL,
        client_reference_id: String(userId),
        metadata: { order_id: String(orderId) },
        line_items: [
          {
            quantity: 1,
            price_data: {
              currency: product.currency.toLowerCase(),
              unit_amount: product.price_cents,
              product_data: { name: product.name }
            }
          }
        ]
      });

      // Persist the provider session id so the order can be reconciled later
      await fastify.pg.query(
        `UPDATE orders SET provider_session_id = $1 WHERE id = $2`,
        [session.id, orderId]
      );

      reply.send({ checkout_url: session.url });
    });
}

Handling the Payment Webhook and Issuing Licenses

The webhook is the security-critical part of the sale. Stripe signs every event, and we must verify that signature against the raw request body before trusting anything. Fastify parses JSON by default, which would destroy the raw body Stripe needs, so we register a custom content-type parser that keeps the raw buffer for this route group.

When a checkout.session.completed event arrives, we run a single transaction that marks the order paid and issues the license. Doing both in one transaction guarantees we never end up with a paid order that has no license, or a license attached to an unpaid order. We also make the handler idempotent: if the same event is delivered twice, the second delivery finds the order already paid and does nothing.

// src/routes/webhooks.js
import { stripe } from '../lib/stripe.js';
import { generateLicenseKey } from '../lib/licenses.js';

export default async function webhookRoutes(fastify) {
  // Preserve the raw body so Stripe's signature can be verified
  fastify.addContentTypeParser(
    'application/json',
    { parseAs: 'buffer' },
    (req, body, done) => {
      done(null, body);
    }
  );

  fastify.post('/stripe', async (request, reply) => {
    const signature = request.headers['stripe-signature'];
    let event;

    try {
      event = stripe.webhooks.constructEvent(
        request.body,
        signature,
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      fastify.log.warn({ err }, 'Invalid Stripe signature');
      return reply.code(400).send({ error: 'Invalid signature' });
    }

    if (event.type === 'checkout.session.completed') {
      const session = event.data.object;
      const orderId = Number(session.metadata.order_id);
      await issueLicenseForOrder(fastify, orderId, session.payment_intent);
    }

    // Acknowledge quickly; Stripe retries on any non-2xx response
    reply.send({ received: true });
  });
}

async function issueLicenseForOrder(fastify, orderId, paymentIntent) {
  await fastify.tx(async (client) => {
    // Lock the order row to serialize concurrent webhook deliveries
    const { rows } = await client.query(
      `SELECT o.id, o.user_id, o.product_id, o.status,
              p.max_activations, p.duration_days
       FROM orders o
       JOIN products p ON p.id = o.product_id
       WHERE o.id = $1
       FOR UPDATE`,
      [orderId]
    );

    const order = rows[0];
    if (!order || order.status === 'paid') {
      return; // Unknown order or already processed: nothing to do
    }

    await client.query(
      `UPDATE orders
       SET status = 'paid', paid_at = now(), provider_payment_intent = $1
       WHERE id = $2`,
      [paymentIntent, orderId]
    );

    const licenseKey = generateLicenseKey(process.env.LICENSE_SIGNING_SECRET);
    const expiresAt = order.duration_days
      ? `now() + interval '${order.duration_days} days'`
      : null;

    await client.query(
      `INSERT INTO licenses
         (license_key, user_id, product_id, order_id, max_activations, expires_at)
       VALUES ($1, $2, $3, $4, $5, ${expiresAt ? expiresAt : 'NULL'})`,
      [licenseKey, order.user_id, order.product_id, orderId, order.max_activations]
    );
  });
}

The FOR UPDATE clause is what makes concurrent deliveries safe. If two copies of the same webhook arrive at once, the first transaction locks the order row and issues the license; the second blocks until the first commits, then reads the status as paid and exits without issuing a duplicate. The interval expression for duration_days is built from a value that originates in our own catalog, not from user input, so it is safe to interpolate; anything derived from a client would need parameterization instead.

Listing a Customer's Licenses

Authenticated users can list the licenses they own along with how many activations remain. A correlated subquery counts the activations per license so the front end can render a clear entitlement view.

// src/routes/licenses.js (first half)
export default async function licenseRoutes(fastify) {
  fastify.get('/', { onRequest: [fastify.authenticate] }, async (request) => {
    const { rows } = await fastify.pg.query(
      `SELECT l.id, l.license_key, l.status, l.issued_at, l.expires_at,
              l.max_activations, p.name AS product_name,
              (SELECT count(*) FROM activations a WHERE a.license_id = l.id)
                AS used_activations
       FROM licenses l
       JOIN products p ON p.id = l.product_id
       WHERE l.user_id = $1
       ORDER BY l.issued_at DESC`,
      [request.user.id]
    );
    return { licenses: rows };
  });

  // ... validation and activation endpoints follow below
}

License Validation and Activation

This is the endpoint that shipped software calls at runtime. It is deliberately public, because the client application authenticates with the license key itself rather than with a user token. The request carries the license key and a stable machine identifier, plus optional metadata such as a hostname. The handler performs the full chain of checks: signature shape, existence, status, expiry, and activation limit. If the machine is new and a slot is available, it records the activation; if the machine is already known, it refreshes the last-seen timestamp so we can detect dormant installations later.

// src/routes/licenses.js (second half, inside licenseRoutes)
import { hasValidSignature } from '../lib/licenses.js';

const validateSchema = {
  body: {
    type: 'object',
    required: ['license_key', 'machine_id'],
    properties: {
      license_key: { type: 'string' },
      machine_id: { type: 'string', minLength: 4 },
      hostname: { type: 'string' }
    }
  }
};

fastify.post('/validate', { schema: validateSchema }, async (request, reply) => {
  const { license_key, machine_id, hostname } = request.body;

  // Reject structurally invalid keys before hitting the database
  if (!hasValidSignature(license_key, process.env.LICENSE_SIGNING_SECRET)) {
    return reply.code(400).send({ valid: false, reason: 'malformed_key' });
  }

  const result = await fastify.tx(async (client) => {
    const { rows } = await client.query(
      `SELECT id, status, max_activations, expires_at
       FROM licenses WHERE license_key = $1 FOR UPDATE`,
      [license_key]
    );
    const license = rows[0];

    if (!license) {
      return { code: 404, body: { valid: false, reason: 'not_found' } };
    }
    if (license.status !== 'active') {
      return { code: 403, body: { valid: false, reason: license.status } };
    }
    if (license.expires_at && new Date(license.expires_at) < new Date()) {
      // Reflect the expiry in the record so reporting stays accurate
      await client.query(
        `UPDATE licenses SET status = 'expired' WHERE id = $1`,
        [license.id]
      );
      return { code: 403, body: { valid: false, reason: 'expired' } };
    }

    // Is this machine already activated for this license?
    const existing = await client.query(
      `SELECT id FROM activations
       WHERE license_id = $1 AND machine_id = $2`,
      [license.id, machine_id]
    );

    if (existing.rows.length > 0) {
      await client.query(
        `UPDATE activations SET last_seen_at = now() WHERE id = $1`,
        [existing.rows[0].id]
      );
      return { code: 200, body: { valid: true, reason: 'already_activated' } };
    }

    // A new machine: enforce the activation limit
    const countResult = await client.query(
      `SELECT count(*)::int AS used FROM activations WHERE license_id = $1`,
      [license.id]
    );
    if (countResult.rows[0].used >= license.max_activations) {
      return { code: 409, body: { valid: false, reason: 'activation_limit_reached' } };
    }

    await client.query(
      `INSERT INTO activations (license_id, machine_id, hostname, ip_address)
       VALUES ($1, $2, $3, $4)`,
      [license.id, machine_id, hostname ?? null, request.ip]
    );
    return { code: 200, body: { valid: true, reason: 'activated' } };
  });

  reply.code(result.code).send(result.body);
});

Wrapping the whole check in a transaction with FOR UPDATE prevents a race where two machines activate simultaneously and both slip past the limit. The lock serializes them, so the last activation that would exceed the limit is correctly rejected.

Administrators occasionally need to revoke a license, for example after a chargeback. A guarded endpoint flips the status; because the validation endpoint checks status on every call, revocation takes effect the next time any installation phones home.

// src/routes/licenses.js (admin action, inside licenseRoutes)
fastify.post('/:id/revoke',
  { onRequest: [fastify.requireAdmin] },
  async (request, reply) => {
    const { rowCount } = await fastify.pg.query(
      `UPDATE licenses SET status = 'revoked' WHERE id = $1`,
      [request.params.id]
    );
    if (rowCount === 0) {
      return reply.code(404).send({ error: 'License not found' });
    }
    reply.send({ revoked: true });
  });

Trying the System End to End

With the schema migrated and the server running, you can exercise the full flow from the command line. Register a user, log in to obtain a token, and create a product with an admin account.

# Register and log in
curl -X POST http://localhost:3000/api/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"email":"buyer@example.com","password":"supersecret"}'

TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"buyer@example.com","password":"supersecret"}' \
  | node -e "process.stdin.on('data',d=>console.log(JSON.parse(d).token))")

# Start a checkout session for product 1
curl -X POST http://localhost:3000/api/checkout/session \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"product_id":1}'

To test the webhook locally without a real card charge, use the Stripe CLI to forward events to your machine. It also prints the webhook signing secret to place in your environment.

stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed

Once a license exists, simulate a client activation. Calling the same endpoint again with a different machine identifier consumes another slot, and exceeding the product's limit returns a conflict.

curl -X POST http://localhost:3000/api/licenses/validate \
  -H 'Content-Type: application/json' \
  -d '{"license_key":"ABCDE-FGHJK-MNPQR-1A2B3C","machine_id":"desktop-001"}'

Hardening for Production

The system as built is functional, but a few additions turn it into something you can safely operate. Add rate limiting to the authentication and validation routes with @fastify/rate-limit, since both are attractive targets for brute force and abuse. Store the Stripe event ids you have already processed in a dedicated table and skip duplicates explicitly, which makes idempotency independent of order status. Run the server behind TLS, either terminated at a reverse proxy or handled directly, so that license keys and tokens are never sent in the clear. Finally, schedule a periodic job that marks lapsed licenses as expired even when their software never checks in, so that reporting and metrics reflect reality rather than only reflecting activity.

It is also worth thinking about offline tolerance. Because each license key embeds an HMAC signature, a desktop client can validate the key's structure and cache the last successful server response for a grace period, allowing the application to keep running through short network outages while still enforcing the entitlement the next time connectivity returns.

Conclusion

We have built a complete license management backend on Node.js, Fastify, and PostgreSQL that spans the entire lifecycle of a sale: account creation, a product catalog, an online checkout backed by Stripe, atomic license issuance driven by a verified webhook, and a runtime validation endpoint that enforces activation limits and expiry. The design keeps its data model explicit, uses database transactions and row locks to stay correct under concurrency, and relies on Fastify's schema validation and plugin system to remain readable as it grows. From this foundation you can extend the system with subscription renewals, seat-based team licenses, or a self-service customer portal, all without disturbing the core entitlement logic that keeps your software protected.