N
HeadlessCoreHeadless Core
Back to Home

Stripe Licensing (optional, off by default)

Automates the manual "run a CLI script, email the customer" flow in Licensing using a Stripe Payment Link. Read the security tradeoff in that doc before enabling this — the license-signing private key moves from an offline file to a server-side env var.

How it works

  1. A customer pays via a Stripe Payment Link (no code, hosted by Stripe — not the Checkout Sessions API, so there's no client-side Stripe.js and no publishable key needed).
  2. Stripe calls your /api/stripe-webhook on checkout.session.completed.
  3. The route verifies the webhook signature, mints a license key (src/lib/license.ts), writes it into the Checkout Session's own metadata (no database — Stripe is the store), and emails it to the customer via WordPress's deliverLicenseKey GraphQL mutation.
  4. The Payment Link's success_url sends the customer to /license-success?session_id={CHECKOUT_SESSION_ID}, which reads the key back from the session and displays it with a copy button, alongside the activation instructions.

Two independent delivery channels (page + email) so a WordPress mail outage doesn't strand a paying customer, and a customer closing the success page early still gets the key by email.

Setup

1. Generate a keypair (if you haven't already)

node scripts/generate-license-keypair.js

2. Create the Stripe Payment Link(s)

In the Stripe dashboard: Payment Links → New. Set the price for your pro tier. Under After payment, choose "Redirect customers to a website" and set the URL to:

https://yourdomain.com/license-success?session_id={CHECKOUT_SESSION_ID}

{CHECKOUT_SESSION_ID} is a literal template string Stripe fills in — type it exactly as shown.

Selling both a lifetime and a 1-year license? Create a second Payment Link with a second one-time Price (annual billing isn't needed — an "annual license" here just means the minted key has an expires date, not a recurring Stripe subscription). Both links point at the same webhook and success_url above; set STRIPE_PRICE_ID_ANNUAL (step 4) to the annual Price's ID so the webhook can tell them apart. Leave it unset if you only sell one price — every key stays lifetime, unchanged from before this existed.

3. Register the webhook

Stripe dashboard → Developers → Webhooks → Add endpoint:

  • URL: https://yourdomain.com/api/stripe-webhook
  • Event: checkout.session.completed

Copy the generated signing secret (whsec_...).

4. Set environment variables (Next.js)

STRIPE_SECRET_KEY="sk_live_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
LICENSE_SIGNING_PRIVATE_KEY_PEM="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
LICENSE_DELIVERY_SECRET="generate-with-openssl-rand--base64-32"
STRIPE_PRICE_ID_ANNUAL="price_..."  # optional, see step 2

LICENSE_SIGNING_PRIVATE_KEY_PEM is the contents of keys/license-private.pem — paste it as a single env var value (escape newlines as \n, or use your host's multiline-secret support if it has one).

5. Configure WordPress

Headless Core → Settings → Licensing → License Delivery Secret — set it to the same value as LICENSE_DELIVERY_SECRET above.

6. Test it

Use the Stripe CLI against your local dev server:

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

Confirm: the webhook returns 200, a license key shows up in your local mail catcher (e.g. Mailhog, or XAMPP's mail log), and visiting /license-success?session_id=<the triggered session id> displays the key and a Download product bundle button.

Product download (HeadlessCore purchases)

After payment, customers can download the full bundle (WordPress theme + Next.js starter + docs) two ways:

  1. Success page/license-success?session_id={CHECKOUT_SESSION_ID} shows a download button (uses the paid Stripe session).
  2. Re-download/download with their hc1. license key.

Both hit GET /api/download/product, which streams the latest productZip from releases/manifest.json. Build it before deploying:

node scripts/build-theme-release.js --version=1.0.1 --changelog="..."
node scripts/build-product-release.js --version=1.0.1

Commit nextjs-starter-kit/releases/ (including the ZIP) with your deploy. The purchase email also includes the session-scoped download URL when LICENSE_DELIVERY_SECRET and NEXT_PUBLIC_SITE_URL are set.

Selling a second product (HeadlessNext Connector Pro) through this same webhook

The webhook route also mints and delivers license keys for a completely separate, unrelated product: HeadlessNext Connector Pro, a WordPress plugin sold from a different repo (headlessnext-connector-pro). It uses its own key format (hnp1.<payload>.<sig>) and its own keypair — entirely independent of the hc1. format described above. Both purchases go through the same /api/stripe-webhook route and the same /license-success page; the code branches on which Stripe Price was purchased, and the success page tells the two products apart by the key's own prefix.

Setup

  1. Generate the HeadlessNext Pro keypair (if you haven't already), using the CLI tooling in that project: node headlessnext/keys/generate-keypair.js.
  2. Create two Stripe Payment Links for Connector Pro (or one if you only sell lifetime): lifetime price and optional annual price. Same success_url as step 2 above — https://yourdomain.com/license-success?session_id={CHECKOUT_SESSION_ID}.
  3. Register a webhook event on the same endpoint (https://yourdomain.com/api/stripe-webhook) — no second webhook needed, Stripe just needs checkout.session.completed once.
  4. Set env vars (lifetime + optional annual — both are one-time Stripe Payment Links, not subscriptions):
    STRIPE_PRICE_ID_HEADLESSNEXT_PRO="price_..."              # lifetime
    STRIPE_PRICE_ID_HEADLESSNEXT_PRO_ANNUAL="price_..."       # 1-year license
    HEADLESSNEXT_PRO_LICENSE_SIGNING_PRIVATE_KEY_PEM="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
    
    Annual purchases mint an hnp1. key with exp set one year ahead; lifetime purchases omit exp (perpetual). Customers renew by buying the annual link again — there is no auto-charge subscription. The private key value is the contents of headlessnext/keys/headlessnext-pro-private.pem.

Security tradeoff, same as step 4 above but worth repeating: this key currently lives only offline (gitignored, never committed, used by hand via headlessnext/keys/issue-license.js). Setting HEADLESSNEXT_PRO_LICENSE_SIGNING_PRIVATE_KEY_PEM moves it into this app's server environment — anyone who can read this app's env vars can mint valid HeadlessNext Connector Pro licenses. Only enable this once you've deployed somewhere you trust with that exposure, exactly as already true for LICENSE_SIGNING_PRIVATE_KEY_PEM.

Where the customer actually clicks "buy" is up to you — a marketing page, a link on another site, whatever — as long as it points at the Payment Link from step 2. No code changes are needed on that page; Stripe Payment Links are fully hosted checkout pages.

What this does NOT do

  • Install the key into a customer's WordPress for them. You don't have access to their WP instance — they still paste the key manually into Headless Core → Settings → Licensing, same as the fully-manual flow.
  • Handle refunds/chargebacks. A refunded purchase does not revoke the issued key — there's no persistence layer tracking issued keys against a denylist (this app is intentionally stateless). If you need revocation, you'd need to add one.
  • Support subscriptions/recurring billing. This flow assumes a one-time purchase for a perpetual or dated key. Recurring plans that need key rotation on renewal aren't handled.
  • Work across multiple tenants. If you're also running multi-tenant, this flow assumes a single seller — it doesn't route delivery per-tenant.

Rolling back

Unset STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET and the webhook route returns 501 and does nothing. Nothing else in the app depends on Stripe being configured.