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
- 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).
- Stripe calls your
/api/stripe-webhookoncheckout.session.completed. - The route verifies the webhook signature, mints a license key
(
src/lib/license.ts), writes it into the Checkout Session's ownmetadata(no database — Stripe is the store), and emails it to the customer via WordPress'sdeliverLicenseKeyGraphQL mutation. - The Payment Link's
success_urlsends 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:
- Success page —
/license-success?session_id={CHECKOUT_SESSION_ID}shows a download button (uses the paid Stripe session). - Re-download —
/downloadwith theirhc1.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
- Generate the HeadlessNext Pro keypair (if you haven't already), using
the CLI tooling in that project:
node headlessnext/keys/generate-keypair.js. - Create two Stripe Payment Links for Connector Pro (or one if you only
sell lifetime): lifetime price and optional annual price. Same
success_urlas step 2 above —https://yourdomain.com/license-success?session_id={CHECKOUT_SESSION_ID}. - Register a webhook event on the same endpoint
(
https://yourdomain.com/api/stripe-webhook) — no second webhook needed, Stripe just needscheckout.session.completedonce. - Set env vars (lifetime + optional annual — both are one-time Stripe
Payment Links, not subscriptions):
Annual purchases mint anSTRIPE_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-----"hnp1.key withexpset one year ahead; lifetime purchases omitexp(perpetual). Customers renew by buying the annual link again — there is no auto-charge subscription. The private key value is the contents ofheadlessnext/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.