Issue license keys automatically from Stripe Checkout
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
- The buyer completes Stripe Checkout, through a Payment Link or a Checkout Session your own site creates.
- Stripe sends the event to License Server's callback URL.
- 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.
- 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 thebuy.stripe.comaddress. - 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.
Finding a Payment Link's ID
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'splink_...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 acasazium_refmetadata 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:
- 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. - 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.
- 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. - 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.basilor later: selectinvoice_payment.paid(step 2). Keepcharge.refunded.- From
2025-03-31.basilto before2025-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_quotarather than dropped. Self-hosted has no limit.
Where to go next
- Storefront auto-fulfillment: the full reference, including every delivery status.
- Licensing an Electron or Tauri desktop app: what the buyer's app does with the key once they have it.
- The License demo: an open-source Electron app that buys a test key this way and uses it.
- Self-hosted or hosted?: which License Server to run this on.
More in Guides, or the full documentation.