$ guides / stripe

Custom Stripe webhooks

Host your own Stripe webhook and call the Developer API when the managed Commerce pipeline is not enough.

Guide

The supported way to sell licenses through Stripe is Commerce: paste keys in the dashboard, map prices to license templates, and AuthForge verifies webhooks, deduplicates events, issues keys, and can email buyers for you.

This guide is the fallback: integrate Stripe yourself by receiving webhooks on your server and calling POST /v1/licenses (and related endpoints). Reach for it when you need full control over Checkout sessions (custom line items, bundled purchases, bespoke metadata), or a flow Commerce does not cover yet.

Prerequisites

  • An AuthForge account with an app created
  • An AuthForge Developer API key
  • A Stripe account with API keys
  • A Node.js backend (examples use Express.js)

One-time purchases

Flow overview

sequenceDiagram
    participant Customer
    participant YourSite as Your Website
    participant Stripe
    participant YourServer as Your Server
    participant AF as AuthForge API

    Customer->>YourSite: Click "Buy License"
    YourSite->>Stripe: Create Checkout Session
    Stripe-->>Customer: Redirect to Checkout
    Customer->>Stripe: Complete payment
    Stripe->>YourServer: Webhook: checkout.session.completed
    YourServer->>AF: POST /v1/licenses
    AF-->>YourServer: License key
    YourServer->>Customer: Deliver key (email / success page)

1. Create a Stripe Checkout session

const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);

app.post("/api/create-checkout", async (req, res) => {
  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    payment_method_types: ["card"],
    line_items: [
      {
        price: "price_your_product_price_id",
        quantity: 1,
      },
    ],
    success_url: "https://yoursite.com/success?session_id={CHECKOUT_SESSION_ID}",
    cancel_url: "https://yoursite.com/pricing",
    metadata: {
      authforge_app_id: process.env.AUTHFORGE_APP_ID,
      license_duration: "365",      // days, or omit for lifetime
      max_hwid_slots: "1",
    },
  });

  res.json({ url: session.url });
});

2. Handle the Stripe webhook

const crypto = require("crypto");

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.sendStatus(400);
    }

    if (event.type === "checkout.session.completed") {
      const session = event.data.object;
      await handleCompletedCheckout(session);
    }

    res.sendStatus(200);
  }
);

3. Generate the license key

async function handleCompletedCheckout(session) {
  const { authforge_app_id, license_duration, max_hwid_slots } =
    session.metadata;

  // Idempotency: check if we've already created a license for this session
  const existingLicense = await db.getLicenseByStripeSession(session.id);
  if (existingLicense) {
    console.log("License already created for session", session.id);
    return;
  }

  // Calculate expiration
  const expiresAt = license_duration
    ? new Date(Date.now() + parseInt(license_duration) * 86400000).toISOString()
    : null;

  // Create license via AuthForge API
  const response = await fetch("https://api.authforge.cc/v1/licenses", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.AUTHFORGE_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      appId: authforge_app_id,
      quantity: 1,
      expiresAt,
      maxHwidSlots: parseInt(max_hwid_slots) || 1,
      label: `Stripe session ${session.id}`,
    }),
  });

  const data = await response.json();
  const licenseKey = data.licenses[0].licenseKey;

  // Store the mapping in your database
  await db.saveLicense({
    stripeSessionId: session.id,
    customerEmail: session.customer_details.email,
    licenseKey,
  });

  // Deliver the key to the customer
  await sendLicenseEmail(session.customer_details.email, licenseKey);
}

4. Deliver the key

Options for delivering the license key to the customer:

  • Email: Send an automated email with the key after payment.
  • Success page: Redirect to a page that displays the key (retrieve it by session ID).
  • Customer portal: Store keys in your database and let users view them in their account.
app.get("/success", async (req, res) => {
  const { session_id } = req.query;
  const license = await db.getLicenseByStripeSession(session_id);

  if (!license) {
    return res.status(404).send("License not found. Please check your email.");
  }

  res.render("success", { licenseKey: license.licenseKey });
});

Subscriptions

For recurring payments, you’ll generate a license when the subscription starts and revoke it when the subscription ends.

Events to handle

Stripe EventAuthForge Action
customer.subscription.createdCreate a license key
customer.subscription.deletedRevoke the license key
customer.subscription.updatedExtend expiry on renewal
invoice.payment_failedOptionally revoke or warn

Complete subscription handler

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) {
      return res.sendStatus(400);
    }

    switch (event.type) {
      case "customer.subscription.created": {
        const subscription = event.data.object;
        await handleSubscriptionCreated(subscription);
        break;
      }
      case "customer.subscription.deleted": {
        const subscription = event.data.object;
        await handleSubscriptionDeleted(subscription);
        break;
      }
      case "customer.subscription.updated": {
        const subscription = event.data.object;
        await handleSubscriptionUpdated(subscription);
        break;
      }
      case "invoice.payment_failed": {
        const invoice = event.data.object;
        await handlePaymentFailed(invoice);
        break;
      }
    }

    res.sendStatus(200);
  }
);

Subscription created; generate license

async function handleSubscriptionCreated(subscription) {
  // Guard against duplicate processing (Stripe retries webhooks)
  const existing = await db.getLicenseBySubscription(subscription.id);
  if (existing) return;

  const response = await fetch("https://api.authforge.cc/v1/licenses", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.AUTHFORGE_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      appId: process.env.AUTHFORGE_APP_ID,
      quantity: 1,
      expiresAt: new Date(subscription.current_period_end * 1000).toISOString(),
      maxHwidSlots: 1,
      label: `Stripe sub ${subscription.id}`,
    }),
  });

  const data = await response.json();
  const licenseKey = data.licenses[0].licenseKey;

  await db.saveSubscriptionLicense({
    stripeSubscriptionId: subscription.id,
    customerId: subscription.customer,
    licenseKey,
  });

  const customer = await stripe.customers.retrieve(subscription.customer);
  await sendLicenseEmail(customer.email, licenseKey);
}

Subscription deleted; revoke license

async function handleSubscriptionDeleted(subscription) {
  const record = await db.getLicenseBySubscription(subscription.id);
  if (!record) return;

  await fetch(
    `https://api.authforge.cc/v1/licenses/${record.licenseKey}`,
    {
      method: "PUT",
      headers: {
        Authorization: `Bearer ${process.env.AUTHFORGE_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ action: "revoke" }),
    }
  );
}

Subscription updated; extend expiry on renewal

async function handleSubscriptionUpdated(subscription) {
  if (subscription.status !== "active") return;

  const record = await db.getLicenseBySubscription(subscription.id);
  if (!record) return;

  await fetch(
    `https://api.authforge.cc/v1/licenses/${record.licenseKey}`,
    {
      method: "PUT",
      headers: {
        Authorization: `Bearer ${process.env.AUTHFORGE_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        action: "extend",
        expiresAt: new Date(subscription.current_period_end * 1000).toISOString(),
      }),
    }
  );
}

Payment failed; optional revocation

async function handlePaymentFailed(invoice) {
  const subscriptionId = invoice.subscription;
  if (!subscriptionId) return;

  const record = await db.getLicenseBySubscription(subscriptionId);
  if (!record) return;

  // Option 1: Revoke immediately
  // await revokeLicense(record.licenseKey);

  // Option 2: Send a warning email, revoke after grace period
  const customer = await stripe.customers.retrieve(invoice.customer);
  await sendPaymentFailedEmail(customer.email);
}

Idempotency

Stripe retries webhook deliveries on failure. Guard against duplicate license creation by:

  1. Storing the Stripe session/subscription ID alongside each license in your database.
  2. Checking for an existing record before creating a new license.
  3. Returning early if a license already exists for that payment.

Next steps