Issue license keys automatically from Lemon Squeezy
You sell your software through Lemon Squeezy. Someone buys it. Now they need a license key, and ideally they have it before they've switched back to the tab they came from.
License Server does that for you. You add one webhook in Lemon Squeezy and tell License Server what each product should issue. From then on, every completed order issues a license and emails the buyer their key, with no code on your side. Sell a subscription, and the same license stays valid for as long as it's paid for.
Why not Lemon Squeezy's own license keys?
Lemon Squeezy can generate license keys itself, and for some products that's all you need. Its License API activates, validates and deactivates keys, tracks instances, and expires keys with a product's license length or a lapsed subscription.
What its documentation doesn't cover is anything your app can check without calling Lemon Squeezy. License Server adds:
- Offline license files, signed with your own RSA key, which your app verifies with only a public key. No network call, and nothing to rate-limit.
- Usage limits per license, beyond machine counts.
- Update checks tied to the license: only an active, unexpired license for the product gets new releases, and the update manifest is signed so your app can verify it came from your server.
- A license portal where the buyer sees their license, frees a machine and downloads their license file.
- One licensing backend whichever storefront you use. Stripe, Lemon Squeezy and Paddle feed the same licenses, so moving between them, or selling through more than one, doesn't change your app.
If you only need "is this key valid?", Lemon Squeezy's built-in keys are simpler. If you need any of the above, read on.
What happens on an order
- The buyer completes Lemon Squeezy checkout.
- Lemon Squeezy sends an
order_createdwebhook to License Server. - License Server checks the webhook's signature, works out which product was bought, and issues a license with the terms you chose for it.
- The buyer gets an email with their key and a link to their license portal.
What you need
- A Lemon Squeezy store, and a product sold as a one-time purchase or a subscription (see Selling subscriptions below).
- A License Server, version 1.7.0 or later (1.8.0 or later for refunds to revoke licenses, 1.9.0 or later for subscriptions, 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 Lemon Squeezy. Click Connect Lemon Squeezy. A Lemon Squeezy webhook appears, marked Needs setup, with its Callback URL and a Copy button. It ignores orders until step 3. Keep the console open in another tab.
2. Choose a signing secret. Unlike Stripe, Lemon Squeezy doesn't generate one: you choose it, and use the same value in both places. It must be 16–40 characters. Under the webhook's Signing secret field, click Generate, then Copy.
3. Add the webhook in Lemon Squeezy. Go to Settings → Webhooks
and add a webhook with the callback URL, your signing secret, and two
events: order_created, and order_refunded so a refund revokes the
license (License Server 1.8.0+). If you sell subscriptions, select these
too — all of them:
subscription_created
subscription_updated
subscription_cancelled
subscription_resumed
subscription_expired
subscription_paused
subscription_unpaused
subscription_payment_success
subscription_payment_recovered
subscription_payment_refunded
subscription_payment_refunded (License Server 1.19.0+) is what a
refund of a subscription payment acts on.
Then, back in the console, click Save secret, with the same secret in the Signing secret field. The webhook changes to Active. Every order is checked against that secret, so nobody can fake a purchase by calling the URL directly.
Testing first?
Turn on test mode in Lemon Squeezy before adding the webhook. A webhook created in test mode receives only test orders, and you can pay with Lemon Squeezy's test card. When you're ready for live orders, add a second webhook in Lemon Squeezy with test mode off, using the same callback URL and signing secret: License Server needs only the one connection.
4. Tell License Server what each product issues. This is a mapping: which order it matches, and the license it should issue. Lemon Squeezy orders match by variant ID, the number Lemon Squeezy shows when you choose Copy Variant ID from a product's ⋯ menu (or Copy ID on one of its variants).
Under the webhook's Product mappings, click Add mapping, put the number in Variant ID, fill in Product ID (your product in License Server, not Lemon Squeezy's), Tier, and optionally Max activations, Duration in days and the rest, then click Add mapping. Read the warning below before leaving Duration in days blank, since a mapping can't be edited later.
That's the setup. Make a test purchase and the license appears in your license list, with the email already sent.
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 Lemon Squeezy:
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": "lemonsqueezy" }'
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 orders until step 3.
2. Choose a signing secret of 16–40 characters. This makes one:
openssl rand -hex 16
3. Add the webhook in Lemon Squeezy, exactly as in step 3 above, with that callback URL and secret. Then save the same secret in License Server:
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": "your-16-to-40-character-secret" }'
This switches the webhook on.
4. Add a mapping for each variant:
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": "variant",
"external_ref": "123456",
"product_id": "my-app",
"tier": "pro",
"max_activations": 3,
"duration_days": 365
}'
external_ref is the variant ID from step 4 above.
Matching orders to products
Each mapping matches an order by its variant
("ref_kind": "variant"): one mapping per variant you sell, such as
"Pro" and "Team". Variants of the same plan (monthly and yearly) can
issue the same license: give each its own mapping with the same terms.
Map by variant only, never by checkout custom data. A buyer can add custom data to any Lemon Squeezy checkout link, so it can't be trusted to say what they bought. The variant is what they paid for.
An order 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 order 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), optional usage limits, and how long it lasts
(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 order 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 order goes through: mappings can't be edited, only deleted and
recreated, and recreating one only affects future orders.
Selling subscriptions
From License Server 1.9.0, a subscription keeps one license for as long as it's paid for. Map the subscription's variant like any other product and select the subscription events in step 3. The buyer gets their key once, from the first order:
- Each renewal extends the same license to the subscription's next renewal date, 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 Lemon Squeezy retries. If the retries run out, 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. A resumed subscription carries on.
- A free trial, or a pause that keeps the service free, runs to its end plus the grace (a free pause with no resume date extends nothing). A pause that charges nothing and gives nothing doesn't extend it.
- When the subscription expires, so does the license. A customer who subscribes again gets a new one.
Enable the two payment events. Lemon Squeezy reports a subscription
as active even for changes that charge nothing, such as moving the
billing date, so License Server only extends a license when a payment
event confirms the renewal was paid. Without
subscription_payment_success and subscription_payment_recovered, a
subscription never extends. Until its first payment arrives, the license
keeps the mapping's terms, so setting duration_days (for example 35
for a monthly plan) is a sensible safety net; from the first payment,
the subscription takes over.
The grace period covers the gap between a renewal date and its payment arriving, and how long a customer keeps access while Lemon Squeezy retries a failed payment. 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 }'
Things to know: licenses from subscriptions sold before 1.9.0 aren't linked to them and don't change; set their expiry by hand. If your app uses offline license files, have it fetch a fresh file after each renewal. A mid-cycle upgrade doesn't move the expiry until the next renewal (its tier and machines change at once, from 1.13.0, below). Refunds of subscription payments are under Refunds of subscription payments.
Plan changes and seats
From License Server 1.13.0, the license follows the variant the customer is on: when they upgrade, downgrade, switch to yearly or change their number of seats, the license takes the new variant'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 variant a customer can be on, monthly and yearly, with
the same License Server product (
product_id). Different plans get different tiers or machines; the monthly and yearly variants of one plan get the same. - For per-seat plans, give the variant's mapping activations per unit (machines per seat) instead of max activations.
- Seats: the hosted checkout shows no quantity picker on a store's
default settings, so an order usually starts at 1 seat. Change the
seat count through Lemon Squeezy's API:
PATCH /v1/subscription-items/{id}with the newquantity(the item's ID isfirst_subscription_item.idonGET /v1/subscriptions/{id}). - Plans: change a subscription's variant through the API
(
PATCH /v1/subscriptions/{id}with the newvariant_id) or the dashboard. Lemon Squeezy's customer portal couldn't be tested: it's available only once a store is activated. - 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, at its next renewal. The steps are in Existing subscribers.
A worked example 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
product 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.
Retries, resends and refunds
If License Server is unreachable or the email fails, Lemon Squeezy retries the webhook three times, over about two and a half minutes. That's much shorter than Stripe's retries, so after an outage on your side, resend the webhook yourself from Lemon Squeezy's webhook page. A resend never creates a second license; if the license was issued but the email didn't go out, it sends the email again with the same key.
Resend within 7 days of the order. Lemon Squeezy's webhooks carry no timestamp, so a captured request would stay valid forever. License Server ignores any order more than 7 days old, so a replayed request can't issue a second license later. Past 7 days, use Resend email (below), or send the buyer their key yourself.
Order statuses decide what happens: paid and partially refunded orders
issue a license; failed and fraudulent orders are recorded as error;
pending and refunded orders are ignored.
Refunds (License Server 1.8.0+, with order_refunded enabled): a
full refund revokes the order's license — the default for every
webhook. To keep licenses after refunds, choose Keep it (record only)
under When a purchase is fully refunded in the console, or set
"refund_policy": "record" on the webhook self-hosted. Partial refunds
are only recorded. An order refunded before its license is issued never
gets one, and a refunded order is never emailed its key. A full refund
more than 7 days old (by its refunded_at) is ignored, for the same
replay reason as orders. A license file the buyer already downloaded
keeps working offline until it expires.
To test a refund in test mode, send it with a request to Lemon Squeezy's
refund API (for example with curl) rather than from the dashboard:
in our testing, refunding a test order from the Lemon Squeezy dashboard
failed, while the same refund through the API worked and revoked the
license.
Refunds of subscription payments
From License Server 1.19.0, a refund of a subscription payment follows
the same refund setting; a renewal's refund needs
subscription_payment_refunded (step 3). 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 order: 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 includes the first order after renewals, which before 1.19.0 revoked the license outright.
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, and a full
refund more than 7 days old is ignored, as for orders. Subscriptions
that started before you upgraded to 1.19.0 are never revoked by a
refund, not even of their first order; a renewal refund only rolls them
back. On a hosted account, console 1.15.0 shows each purchase's
refunds: a renewal refund (with its invoice, on License Server 1.21.2),
and one that rolled the license back reads
Refunded · shortened; one recorded because a later payment hasn't
been refunded reads Refunded · kept. Through the API, check
subscription_refund in the
deliveries endpoint.
The
storefront reference
has every rule.
Every order is listed with what happened to it: the console's Deliveries table on a hosted account, or the deliveries endpoint self-hosted.
A buyer who didn't get the email, or lost it: open the order's menu (⋯) in Deliveries and choose Resend email… (License Server 1.16.0, Console 1.12.0), with no time limit. It emails the key again to the buyer's address from the order, with a new link to their license portal; the link in the earlier email stops working. An order can be resent 3 times a day, at least 10 minutes apart. Self-hosted without the console, it's the resend endpoint.
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 order still issues its license,
but Casazium doesn't email it, and Deliveries marks it Issued ·
you deliver. To find a key, enter the order's ID (ls_order_ followed
by the order's identifier, not its order number, 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 orders 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 order 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. Lemon Squeezy doesn't redeliver an order 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 order (1.16.0+), or resend the
webhook from Lemon Squeezy within 7 days of the order, 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
PUBLIC_BASE_URL must be the server's public https address, because
the link in each email is built from it, and Lemon Squeezy needs to
reach the callback URL at that address. 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
- Subscriptions need License Server 1.9.0 and both payment events; plan and seat changes need 1.13.0.
- One Lemon Squeezy webhook per License Server account, alongside one each for Stripe and Paddle if you use them. 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.
- One license per order, from its first item.
- Your plan's license limit still applies. On a hosted plan, an
order 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.
- Verify a license offline: what the license file gives your app that a key alone doesn't.
- Issue license keys automatically from Stripe Checkout: the same setup for Stripe.
- Issue license keys automatically from Paddle: the same setup for Paddle.
More in Guides, or the full documentation.