License Server v1.25.0 · released 28 Oct 2026release notes →

Issue license keys automatically from Stripe Checkout

Last updated October 28, 2026

You sell your software through Stripe. Someone pays. Now they need a license key, and ideally they have it before they've switched back to the tab they came from.

The usual way to get there is a webhook handler of your own: receive Stripe's event, verify its signature, work out which product was bought, generate a key, store it, email it, and make sure Stripe retrying the event doesn't issue a second one. That's a small service to build, host and keep correct.

License Server does that part for you. You paste one URL into Stripe and tell License Server what each product should issue. From then on, every completed purchase issues a license and emails the buyer their key. Sell a subscription, and the same license stays valid for as long as it's paid for.

What happens on a purchase

  1. The buyer completes Stripe Checkout, through a Payment Link or a Checkout Session your own site creates.
  2. Stripe sends the event to License Server's callback URL.
  3. License Server checks Stripe's signature, works out which product the purchase was for, and issues a license with the terms you chose for it.
  4. The buyer gets an email with their key and a link to a page where they can see their license, free a machine, and download their license file.

Your storefront doesn't change. There's no code running on your side, and no key-generation logic to maintain.

To see it happen, the License demo app's Get a demo key button is a Stripe Payment Link set up exactly this way, in test mode. Pay with Stripe's test card and the key it issues is ready a moment later; the demo shows it on a page instead of emailing it.

What you need

  • A Stripe account, and a product sold through Stripe Checkout: either a Payment Link, or a Checkout Session you create. A Payment Link's mode follows its price: a one-time price makes a one-time purchase, and a recurring price makes a subscription (see Selling subscriptions below), which needs four or five more webhook events in step 2.
  • A License Server: 1.8.0 or later for refunds to revoke licenses, 1.9.0 or later for subscriptions, and 1.19.0 or later for refunds of subscription payments. A hosted account, or your own.

Setting it up

On a hosted account, you do all of it in the console, and the steps just below are the whole setup. Self-hosted, the console that ships with the bundle doesn't include this screen, so you make the same changes through the API: see Self-hosted: the same steps through the API.

On a hosted account

Everything here is in the console, under Settings → Storefront webhooks.

1. Connect Stripe. Click Connect Stripe. A Stripe webhook appears, marked Needs setup, with its Callback URL and a Copy button. It ignores purchases until step 3. Keep the console open in another tab.

2. Add the endpoint in Stripe. In the Stripe Dashboard, go to Developers → Webhooks and add a destination (an endpoint). Stripe asks for the events first. It lists them alphabetically, so charge.refunded comes before the checkout.session events. Select these four:

charge.refunded
checkout.session.async_payment_failed
checkout.session.async_payment_succeeded
checkout.session.completed

charge.refunded is what revokes a license when you refund the purchase (License Server 1.8.0+; see Refunds). If you sell subscriptions, select four more:

customer.subscription.created
customer.subscription.deleted
customer.subscription.updated
invoice.paid

If the endpoint is at Stripe API version 2025-05-28.basil or later, also select invoice_payment.paid (License Server 1.19.0+). If you chose the latest API version when you created the endpoint, it is. It links each subscription payment to its refunds, so refunding a subscription payment can act on the license (see Refunds of subscription payments). Select it on this endpoint, the one whose URL contains /v1/webhooks/storefront/. Selecting it on another endpoint in the same Stripe account (your own app's, or a billing webhook) doesn't count. Test mode and live mode each need it.

Then click Continue. If Stripe asks what kind of destination, choose Webhook endpoint. Paste the callback URL from step 1 as the endpoint URL, and create it. Stripe shows you the endpoint's signing secret (whsec_...).

3. Save the signing secret. Back in the console, paste it into the Stripe webhook's Signing secret field and click Save secret. The webhook changes to Active. Every event is checked against that secret, the same way Stripe signs its own webhooks, so nobody can fake a purchase by calling the URL directly.

4. Tell License Server what each product issues. This is a mapping: which purchase it matches, and the license it should issue. Under the Stripe webhook's Product mappings, click Add mapping and fill in:

  • Reference type: Payment Link if you sell through a Payment Link. (Metadata (casazium_ref) is for Checkout Sessions your own site creates, and Price (subscription plans) or Product (subscription plans) for subscription plans; both are explained below.)
  • Payment Link ID: the link's plink_... ID. See Finding a Payment Link's ID just below: it is not the buy.stripe.com address.
  • Product ID: your product in License Server, the software the key unlocks (for example my-app), not Stripe's product.
  • Tier: for example pro.
  • Max activations (optional): how many machines one license can activate on. Leave it blank for 1.
  • Free inactive machines (optional): frees a seat held by a machine that has stopped checking in. See Choosing what each purchase issues.
  • Duration in days (optional): for example 365. Leave it blank and every license is perpetual; read the warning below first, since a mapping can't be edited later.
  • Limits (optional, JSON) and Notes (optional) can stay empty.

Click Add mapping. Add one for each Payment Link you sell through.

5. Tell buyers their key is coming (optional). After payment, Stripe's default confirmation page says nothing about a license. In the Payment Link's After payment settings, set a custom confirmation message, for example: "Your license key is on its way to your email."

That's the setup. Make a test purchase and the license appears in your license list, with the email already sent.

Test mode and live mode are separate

In live mode, a test purchase is a real charge. Stripe keeps test mode and live mode apart: each has its own endpoints, signing secrets and Payment Links, so its own plink_... IDs. To try it first, do steps 2 and 5 in Stripe's test mode and map the test Payment Links. The console takes a signing secret only while the webhook is still Needs setup, and allows one Stripe webhook that isn't disabled, so going live afterwards means disabling the test webhook, clicking Connect Stripe again (a new callback URL, for a live-mode endpoint), and recreating the mappings with the live Payment Links' IDs.

Selling subscriptions? Map each subscription price too, so the license follows plan and seat changes: see Plan changes and seats.

In the Stripe Dashboard, Payment Links are under Payments → Checkout → Payment links. Stripe moves its menus around, so if they aren't there, search the dashboard for "Payment links". Open the link you sell through. Its ID starts with plink_, and you'll find it in two places:

  • your browser's address bar, at the end of the page's address (.../payment-links/plink_...);
  • the Events list on the link's page.

Don't paste the buy.stripe.com address

The https://buy.stripe.com/... URL you share with buyers is not the Payment Link's ID. From License Server 1.16.0 and Console 1.12.0 a mapping with it is refused, with where to find the ID. Before that it was accepted but never matched a purchase: every sale through the link was recorded as unmapped and issued nothing. If you made one then, delete it and add it again with the plink_... ID.

Self-hosted: the same steps through the API

Self-hosted, call your own License Server with its admin API key. The examples use https://your-license-server.example.com as a stand-in for your server's address: put yours in its place (the self-hosted bundle answers on http://localhost:3001 on the machine running it).

Automating a hosted account

The same calls work on a hosted account, if you'd rather script them than use the console. Send them to https://license-cloud-api.casazium.com (not license-cloud.casazium.com, which is the console), and use your account's API key from the console's Settings → API access in place of $ADMIN_API_KEY. A hosted account never accepts ADMIN_API_KEY.

1. Connect Stripe:

curl -X POST https://your-license-server.example.com/v1/admin/storefront-webhooks \
  -H "Authorization: Bearer $ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "provider": "stripe" }'

The response's id makes the callback URL: https://your-license-server.example.com/v1/webhooks/storefront/{id}, with your server's address. The webhook starts out pending: it ignores purchases until step 3.

2. Add the endpoint in Stripe, exactly as in step 2 above, with that callback URL.

3. Save the signing secret:

curl -X POST https://your-license-server.example.com/v1/admin/storefront-webhooks/{id}/secret \
  -H "Authorization: Bearer $ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "secret": "whsec_..." }'

4. Add a mapping for each product:

curl -X POST https://your-license-server.example.com/v1/admin/storefront-webhooks/{id}/mappings \
  -H "Authorization: Bearer $ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "ref_kind": "payment_link",
    "external_ref": "plink_1UIaMlQu14RJmyaQ1x5Tw53t",
    "product_id": "my-app",
    "tier": "pro",
    "max_activations": 3,
    "duration_days": 365
  }'

external_ref is the Payment Link's plink_... ID, not its buy.stripe.com address: see Finding a Payment Link's ID. Step 5, the confirmation message, is set in Stripe either way.

Matching purchases to products

Each mapping matches a purchase one of two ways:

  • By Payment Link (reference type Payment Link in the console, "ref_kind": "payment_link" in the API). Use the Payment Link's plink_... ID: see Finding a Payment Link's ID. This is the simplest choice if you sell through Payment Links.
  • By metadata (Metadata (casazium_ref) in the console, "ref_kind": "metadata" in the API). If your own site creates Checkout Sessions, add a casazium_ref metadata value naming the product, and map that value instead:
const session = await stripe.checkout.sessions.create({
  mode: 'payment',
  line_items: [{ price: 'price_1Q...', quantity: 1 }],
  success_url: 'https://example.com/thanks',
  metadata: { casazium_ref: 'my-app-pro-annual' },
});

Readable values like my-app-pro-annual also make the deliveries list easier to read than Stripe's generated IDs. When a purchase has both, License Server tries the casazium_ref mapping first and falls back to the Payment Link's.

A purchase that matches no mapping isn't lost: it's recorded as unmapped, so you can see it and issue the license by hand.

Choosing what each purchase issues

A mapping carries the same terms as issuing a license by hand: the product, a tier, how many machines it can activate on (Max activations, max_activations), optional usage limits, and how long it lasts (Duration in days, duration_days). Leave out max_activations and the license activates on one machine.

From License Server 1.14.0 a mapping can also free seats held by machines that stop being used: set Free inactive machines (lease_seconds), and a machine that hasn't checked in for that long gives up its seat when another machine needs one. See Seats that free themselves.

Leaving out duration_days issues licenses that never expire

A mapping without duration_days issues perpetual licenses, for every purchase through it. That's right for a one-time "buy it forever" product, and wrong for anything you sell by the year. Set it before the first purchase goes through: mappings can't be edited, only deleted and recreated, and recreating one only affects future purchases.

Selling subscriptions

From License Server 1.9.0, a subscription keeps one license for as long as it's paid for. Give the Payment Link a recurring price, map it like any other product, and select the subscription events in step 2. The buyer gets their key once, at the first purchase:

  • Each renewal extends the same license to the end of the period just paid for, plus a grace period (7 days by default). Renewals send no email.
  • If a payment fails, the license keeps running to the end of what was paid, plus the grace, while Stripe retries. If Stripe gives up, the license expires, and comes back if the customer pays later.
  • A cancellation keeps exactly what was paid: the license expires when the paid period does. If the customer changes their mind before then, it carries on.
  • A free trial runs to the trial's end, plus the grace.
  • When the subscription ends, so does the license. A customer who subscribes again gets a new one.
  • A refund of a subscription payment (License Server 1.19.0+) takes back the time that payment bought: see Refunds of subscription payments.

Only a payment extends a license. Stripe reports a subscription's next period before it charges for it, so License Server waits for invoice.paid: a license is never extended for a period that wasn't paid for. Until the first invoice is paid (usually seconds after checkout), the license is valid for the grace period (at least a day).

The grace period covers the gap between a period ending and the renewal's payment arriving. It's also how long a customer keeps access while Stripe retries a failed payment, which can run for weeks. Set it from 0 to 30 days under Subscriptions: grace period in the console, or self-hosted:

curl -X PATCH https://your-license-server.example.com/v1/admin/storefront-webhooks/{id} \
  -H "Authorization: Bearer $ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "subscription_grace_days": 14 }'

A subscription's license doesn't use the mapping's duration_days: its expiry comes from the subscription. If your app uses offline license files, have it fetch a fresh file after each renewal, since a file keeps the expiry it was exported with.

Plan changes and seats

From License Server 1.13.0, the license follows the plan the customer is on: when they upgrade, downgrade, switch to yearly or change their number of seats, the license takes the new plan's tier, limits and number of machines, on the same key. The expiry still moves only when a payment arrives.

To set it up:

  1. Map every price a customer can be on, monthly and yearly (in the console, reference type Price (subscription plans), price_...), with the same License Server product as the Payment Link mappings. Keep the Payment Link mappings: a checkout names only its Payment Link, so they issue the license, and the price mapping's terms replace theirs seconds later.
  2. For per-seat plans, give the price mapping activations per unit (machines per seat) instead of max activations, and turn on the Payment Link's option to let customers adjust the quantity.
  3. If customers change plans in Stripe's customer portal: list the prices they may switch between, allow quantity changes, turn on payment-method updates, and set it to invoice prorations immediately (always_invoice). This one matters: on Stripe's default, a customer who switches to yearly hasn't paid for the year yet, so they lose access at the end of the old month plus the grace. The portal offers one price per billing interval for each Stripe product, so two monthly plans need separate Stripe products.
  4. Already have subscribers? Their licenses don't change until you Apply the new mappings, and Apply reaches a subscription only once it has reported its plan. The steps are in Existing subscribers.

A worked example, scheduled downgrades, and what happens with fewer seats than machines are in Plan changes and seats.

To sell a fixed term without a subscription instead, sell a one-time price and set "duration_days": 365 on its mapping. The license then expires a year after purchase, and a renewal is the same product bought again.

Delayed payments, retries and duplicates

Some payment methods, such as bank debits, settle after the buyer leaves checkout. Those purchases wait as awaiting_payment and issue the license once Stripe confirms the payment. That's why the setup listens for the two async_payment events.

Stripe retries webhooks it isn't sure were delivered, and you can resend one from the Stripe Dashboard. Neither creates a second license: each checkout issues at most one. If the license was issued but the email didn't go out, a retry sends the email again with the same key.

Every purchase is listed with what happened to it: the console's Deliveries table on a hosted account, or the deliveries endpoint self-hosted. Whether the license was issued and whether the email was sent are shown separately, because they can fail independently.

A buyer who didn't get the email, or lost it: open the purchase's menu (⋯) in Deliveries and choose Resend email… (License Server 1.16.0, Console 1.12.0). It emails the key again to the buyer's address from the purchase, with a new link to their license portal; the link in the earlier email stops working. A purchase can be resent 3 times a day, at least 10 minutes apart. Self-hosted without the console, it's the resend endpoint.

Refunds

From License Server 1.8.0, a full refund in Stripe revokes the purchase's license. It's the default for every webhook, including ones connected earlier, and it needs charge.refunded in the endpoint's events (step 2).

To keep licenses after refunds, choose Keep it (record only) under When a purchase is fully refunded in the console, or self-hosted:

curl -X PATCH https://your-license-server.example.com/v1/admin/storefront-webhooks/{id} \
  -H "Authorization: Bearer $ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "refund_policy": "record" }'

Either way:

  • Partial refunds are only recorded.
  • A purchase refunded before its license is issued never gets one — for example, a delayed payment refunded before it settled.
  • A refunded purchase is never emailed its key.
  • A full refund is final. If you reinstate a revoked license, Stripe resending the refund won't revoke it again.

Two limits: purchases made before you upgraded to 1.8.0 can't be matched to their refund (Stripe's refund event names only the payment, which License Server records from 1.8.0 on), so revoke those by hand. And a license file the buyer already downloaded keeps working offline until it expires; apps that check the key online see the revocation immediately.

Refunds of subscription payments

Before License Server 1.19.0, a refund of a subscription payment, even the first one, wasn't matched to the purchase and changed nothing. From 1.19.0 it follows the same refund setting. Under the default, a full refund takes back what that payment paid for:

  • The subscription's only paid payment that hasn't been refunded, usually the first: the license is revoked. A free trial's or a 100% coupon's $0 invoice doesn't count as paid.
  • A renewal, the latest payment, with paid ones before it that haven't been refunded: the license rolls back to the end of what's still paid for, with no grace. If that date has passed, the license is expired, not revoked, and a later renewal extends it again. A license you set to never expire by hand isn't changed.
  • An older payment, while a later payment hasn't been refunded: recorded only.

That can end a license you meant to keep, such as after a goodwill refund of a renewal. Keep it (record only) keeps subscription refunds manual too. Partial refunds stay recorded only. Subscriptions that started before you upgraded to 1.19.0 are never revoked by a refund; a renewal refund only rolls them back.

Matching a refund to its subscription payment depends on the endpoint's API version. Your webhook's api_version in GET /v1/admin/storefront-webhooks shows it once an event has arrived:

  • 2025-05-28.basil or later: select invoice_payment.paid (step 2). Keep charge.refunded.
  • From 2025-03-31.basil to before 2025-05-28.basil: these versions' events don't link a refund to its invoice, so refunds of subscription payments can't be matched. Stripe can't change an existing endpoint's API version, so replace the endpoint with a new one at the latest version, as in Moving a Stripe endpoint to a newer API version.
  • Before 2025-03-31.basil: nothing to add.

The same list shows refund_matching: ok (no problem found), needs_api_upgrade (replace the endpoint) or needs_event (add invoice_payment.paid). From License Server 1.19.1, needs_event shows once this webhook's latest paid subscription invoice is 10 minutes old and its invoice_payment.paid hasn't arrived (1.19.0 waited a day). Only invoices charged automatically for more than $0 count (a free trial, a 100% coupon, or an invoice Stripe emails to the customer to pay instead doesn't count). After adding the event, resend that payment's invoice_payment.paid with the Stripe CLI:

stripe events resend <evt_id> --webhook-endpoint=<we_id>

The event's ID (evt_…) is on its page in Workbench → Events; the endpoint's ID (we_…) is on the endpoint's page. Add --live for a live-mode payment; Stripe resends events up to 30 days old. A refund from the last 7 days that was waiting then applies; for an older one, resend its latest charge.refunded too. If either event is more than 30 days old, Stripe can't resend it: change that license by hand. After you replace the endpoint, a refund of a payment made before isn't matched either. On a hosted account, console 1.15.0 shows refund_matching as Refunds not matched on the webhook, with these steps and a Replace signing secret button for the endpoint move, and shows each purchase's subscription refunds, including a first payment's. Through the API, check subscription_refund in the deliveries endpoint. Disputes aren't refunds, and change nothing. License Server doesn't cancel the subscription, so a rolled-back license comes back at the next renewal: cancel the subscription in Stripe to end access for good. The storefront reference has every rule.

What buyers see, and sending the key yourself

These need License Server 1.17.0 and Console 1.13.0.

Your product's name. The license email's subject (Your Acme Pro license) and text, and the license portal's title, name the product by its display name. Set it on the console's Products page: Edit next to the product, then Display name (the self-hosted bundle's console has this page too; through the API, PATCH /v1/admin/products/:id). Without one, buyers see the product ID, but only if it passes the same naming rules: letters, numbers, spaces and a little punctuation, and nothing that reads as a web or email address. A product ID like com.acme.pro (.pro is a real top-level domain) is left out, and the email's subject reads just "Your license". The Products page marks such a product: give it a display name.

The license file. If your app only takes the license key, turn off Offer the license file in the buyer portal on the same page. The portal then hides Download license file; your own exports through the API still work.

Sending the key yourself. If you'd rather email buyers from your own system, set the webhook's Sending the buyer their license to I deliver it myself, under Settings → Storefront webhooks (self-hosted: PATCH /v1/admin/storefront-webhooks/{id} with { "buyer_delivery": "vendor" }). Every purchase still issues its license, but Casazium doesn't email it, and Deliveries marks it Issued · you deliver. To find a key, enter the purchase's ID (its Checkout Session ID, cs_..., as Deliveries shows it) in Find a purchase by its checkout or order ID above Deliveries. It shows the license key, with Copy, and the buyer's email (self-hosted: the lookup endpoint). Switching also leaves to you the purchases not emailed yet. Resend email still works if a buyer says your email never arrived. Don't use this with Console 1.12 or older, which shows each such purchase as needing attention. The storefront reference has the details, including a portal link for your own email.

A held email. If Deliveries shows Held: daily limit - resend tomorrow, the account has sent 200 license emails in the last 24 hours; every license email counts, resends included. Stripe doesn't redeliver a purchase once License Server has answered it, so use Resend email once the limit allows. Held: buyer emails paused means Casazium has paused the account's buyer emails: the licenses are still issued, and support can tell you more.

Self-hosted: turn on email

A hosted account sends these emails already. A self-hosted License Server only logs them until you configure email: it warns at every start (Buyer license emails are off), and from License Server 1.15.1 the console shows each such purchase as Issued · email not sent. Once email is on, use Resend email on the purchase (1.16.0+), or resend its checkout.session.completed event from the Stripe Dashboard within about 30 days of the purchase, to email that buyer their key. Delivery uses Resend:

EMAIL_PROVIDER=resend
RESEND_API_KEY=re_...
EMAIL_FROM="My App <licenses@example.com>"
PORTAL_ENABLED=true
PUBLIC_BASE_URL=https://your-license-server.example.com

The license portal must be on when email is configured (the email links to it), and PUBLIC_BASE_URL must be the server's public https address, because the link in each email is built from it. If you run the self-hosted bundle, the portal is already on, and you set LICENSE_PUBLIC_URL in its .env instead of PUBLIC_BASE_URL.

From License Server 1.17.0, EMAIL_FROM must be a bare address or a name and an address, as above (My App <licenses@example.com>). Anything else stops the server from starting. Upgrade the console together with the server: the bundle pins License Server 1.25.0 with Console 1.16.1.

Limits worth knowing first

  • Stripe, Lemon Squeezy and Paddle only. Lemon Squeezy and Paddle have their own guides (Lemon Squeezy, Paddle); Gumroad isn't supported.
  • Subscriptions need License Server 1.9.0; plan and seat changes need 1.13.0, and a price mapping for each plan.
  • One Stripe webhook per License Server account. To replace it, disable the old one first. Mappings stay with the webhook they were made on, so plan to recreate them. Subscriptions carry over.
  • Mappings are added one at a time. That's fine for a handful of products, and tedious for a large catalog.
  • Your plan's license limit still applies. On a hosted plan, a purchase that would exceed it is recorded as over_quota rather than dropped. Self-hosted has no limit.

Where to go next

More in Guides, or the full documentation.