Issue license keys automatically from Paddle
You sell your software through Paddle. Someone buys it. Paddle takes the payment, handles the tax and sends a receipt, and then it's your turn: Paddle Billing has no license keys, and its documentation leaves fulfillment to you, with a webhook handler you write and host.
License Server is that handler, already written. You add one notification destination in Paddle and tell License Server what each product should issue. From then on, every completed purchase issues a license and emails the buyer their key, a subscription keeps the same license valid for as long as it's paid for, and a refund revokes it. There's no code on your side.
What you get that Paddle doesn't do
- License keys, issued and emailed the moment a purchase completes, with a link to a license portal where the buyer sees their license, frees a machine and downloads their license file.
- 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.
- Activation and usage limits per license, set per product.
- Update checks tied to the license: only an active, unexpired license for the product gets new releases.
- 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.
What happens on a purchase
- The buyer completes Paddle checkout.
- Paddle sends a
transaction.completednotification to License Server. - License Server checks the notification's signature, works out which price or product was bought, and issues a license with the terms you chose for it.
- Paddle's notification names the buyer only by a customer ID, so License Server looks their email up in Paddle, with an API key that can only read customers.
- The buyer gets an email with their key and a link to their license portal. Paddle sends its own receipt separately.
What you need
- A Paddle Billing account, and a product with a one-time or recurring price. A sandbox account is free and is the place to try this first.
- A License Server, version 1.11.0 or later (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 Paddle. Click Connect Paddle, choose Sandbox or Live, and click Connect. A Paddle webhook appears, marked Needs setup, with its Callback URL and a Copy button. It ignores purchases until steps 2 and 3 are done. Keep the console open in another tab.
The environment can't be changed later. Paddle's notifications don't say whether they came from the sandbox or from your live account, so the webhook says it for them. That's why going live is a new webhook (below).
2. Add a notification destination in Paddle. In the same environment, go to Developer tools → Notifications → New destination. Choose the webhook type, paste the callback URL, and select these events:
transaction.completed
adjustment.created
adjustment.updated
transaction.completed issues licenses; the two adjustment events are
refunds and chargebacks, including (License Server 1.19.0+) refunds of
subscription renewals, so there's nothing more to add for those. If you
sell subscriptions, select these too —
all of them:
subscription.created
subscription.activated
subscription.updated
subscription.trialing
subscription.past_due
subscription.paused
subscription.resumed
subscription.canceled
It's worth adding api_key.expiring, api_key.expired and
api_key.revoked as well: they let License Server flag the API key
(step 3) the moment it stops working, rather than at the next purchase.
Save the destination, then copy its secret key from the
destination's details. It starts pdl_ntfset_. Back in the console,
paste it under Signing secret and click Save secret. Every
notification is checked against that secret, so nobody can fake a
purchase by calling the URL directly.
3. Create an API key that can only read customers. In Paddle, go to Developer tools → Authentication and create a new API key:
- Permissions: only Customers: read. License Server needs nothing else, and a key that can do less is a smaller loss if it leaks.
- Expiry: choose a custom date about a year out. Paddle's default is 90 days, which means replacing it four times a year, and "never expires" is a key that can read every customer's email forever. Put a reminder in your calendar a week before it expires.
Copy the key, paste it under API key in the console and click Save API key. License Server checks the key with Paddle before saving it, and refuses a live key on a sandbox webhook or the other way round. It's stored encrypted and never shown again. With the secret and the key both saved, the webhook changes to Active.
Paddle can't tell License Server whether a key has only Customers: read, so a key with more permissions is accepted too. Keeping it to one permission is up to you.
4. Tell License Server what each product issues. This is a
mapping: which purchase it matches, and the license it should issue.
Paddle purchases match by price ID (pri_…) or product ID
(pro_…), both shown on the product's page in Paddle's catalog.
Under the webhook's Product mappings, click Add mapping, choose Price or Product as the Reference type, put the ID in Paddle price ID or Paddle product ID, fill in Product ID (your product in License Server, not Paddle'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 on a one-time purchase, 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.
Testing in the sandbox
Everything above works the same in Paddle's sandbox, with Paddle's test cards. If you haven't sold through Paddle before, set a default payment link first (Checkout → Checkout settings): Paddle won't open a checkout without one. A hosted checkout (Checkout → Hosted checkouts) is the quickest way to make a test purchase.
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 Paddle, with "environment" set to "sandbox" or
"live":
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": "paddle", "environment": "sandbox" }'
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 steps 2 and 3 are done. As in the console, the
environment can't be changed later.
2. Add the notification destination in Paddle, exactly as in step 2 above, with that callback URL. Then save its secret key:
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": "pdl_ntfset_..." }'
3. Create the API key as in step 3 above, and save it:
curl -X POST https://your-license-server.example.com/v1/admin/storefront-webhooks/{id}/api-key \
-H "Authorization: Bearer $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "api_key": "pdl_sdbx_apikey_..." }'
The same checks apply: Paddle must accept the key, and it must be from the webhook's environment. With the secret and the key both saved, the webhook switches to active.
4. Add a mapping for each price or 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": "product",
"external_ref": "pro_01h...",
"product_id": "my-app",
"tier": "pro",
"max_activations": 3
}'
ref_kind is "price" for a pri_… ID or "product" for a pro_…
ID.
Matching purchases to products
A mapping matches by price ("ref_kind": "price") or by
product ("ref_kind": "product"). License Server tries the price
first, then the product:
- Map the product when all its prices should issue the same license, such as a monthly and a yearly price of one plan. One mapping covers both.
- Map a price when one price should issue something different from the rest of its product, such as a "Team" price with more seats.
There's a third kind, metadata: a casazium_ref value you put in a
transaction's custom data. It's only trusted on transactions you create
through Paddle's API, which needs your API key. Paddle.js lets the
buyer's browser set custom data on a checkout, so on any other checkout
a buyer could name a dearer product than the one they paid for. Hosted
checkout links can't carry custom data at all. Unless you create
transactions yourself, map by price or product.
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), 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 one-time purchase through it. That's right for a "buy it forever"
product, and wrong for one 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.
Subscriptions don't use it: their licenses follow the subscription.
Selling subscriptions
A subscription keeps one license for as long as it's paid for. Map its price or product like any other, and select the subscription events in step 2. The buyer gets their key once, from the first purchase:
- The first purchase is the first payment, so the license runs to the end of the first billing period, plus a grace period (7 days by default), straight away.
- Each renewal extends the same license when Paddle reports the
renewal's payment (
transaction.completed), never just because the subscription moved to a new period. Renewals send no email. - A plan change billed straight away, such as monthly to yearly, counts as a payment and extends the license to the new period's end. A change made with "do not bill" extends it at the next paid charge. The tier and seats follow the new plan either way, from 1.13.0 (below).
- If a payment fails, the subscription goes past due and the license keeps running to the end of what was paid, plus the grace. Paddle's retries can run longer than that: a customer who pays late lapses, then comes back when the recovered payment arrives. Raise the grace if you'd rather they kept access throughout.
- A pause works the same way: the license runs to the end of what was paid, plus the grace, and the charge on resuming extends it.
- A trial runs to its end, plus the grace.
- A cancellation keeps exactly what was paid, with no grace. When the subscription ends, so does the license; a customer who subscribes again gets a new one.
- A refund of a renewal (License Server 1.19.0+) takes back the time it paid for: see Refunds and chargebacks.
Set the grace 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 }'
If your app uses offline license files, have it fetch a fresh file after each renewal: a file carries the expiry from when it was exported.
Plan changes and seats
From License Server 1.13.0, the license follows the price the customer is on: when they upgrade, downgrade, switch to yearly or change their number of seats, the license takes the new price's tier, limits and number of machines, on the same key. The expiry moves only when a payment arrives; a change billed straight away counts as one.
To set it up:
- Map every price a customer can be on, monthly and yearly (or each
product, which covers all its prices), with the same License Server
product (
product_id). Map by price if a product also has recurring add-ons (the plan is the first item a mapping matches, and an add-on listed first would otherwise match the product mapping), or if you already have subscribers (Apply reaches them sooner through a price mapping). - For per-seat plans, give the mapping activations per unit (machines per seat) instead of max activations. The seats are the item's quantity.
- Plan and seat changes: Paddle's customer portal doesn't make
them. Use Paddle's API (
PATCH /subscriptions/{id}with the newitemsand aproration_billing_mode) or the dashboard. Billed straight away (prorated_immediately), a switch to yearly extends the expiry at once; withdo_not_billor a*_next_billing_periodmode, at the next paid charge. - Make sure the notification destination sends the
subscription.*events (step 2): plan changes arrive with them, and without them nothing follows the subscription at all. The console warns "Subscription events not received" an hour after a purchase with none. - 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 and what happens with fewer seats than machines are in Plan changes and seats.
Refunds and chargebacks
With the adjustment events selected, a full refund revokes the
purchase'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.
- A refund counts once Paddle approves it. Live refunds usually start as pending approval; the license is revoked when the approval arrives. In the sandbox, Paddle approves refunds automatically within about ten minutes.
- Full means everything the buyer paid. Paddle's dashboard refunds line items, and labels a refund of every item "partial"; License Server compares the refund with what the purchase was charged, so refunding the whole amount revokes like any full refund (License Server 1.12.2 or later, for purchases made on 1.12.2 or later). Refunding less than the whole amount is partial.
- A chargeback counts as a refund, and so does Paddle's early chargeback warning. If you win the chargeback, the license is not restored automatically: reinstate it by hand.
- Refunds of subscription payments (License Server 1.19.0+) take back what the refunded payment paid for. A full refund of the subscription's only paid payment that hasn't been refunded, usually the first purchase, revokes the license (a free trial's or a 100% discount's $0 charge doesn't count as paid). A full refund of a renewal, the latest payment, with paid ones before it that haven't been refunded, rolls the license 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. A refund of an older payment while a later payment hasn't been refunded, including the first purchase after renewals, is recorded only; before 1.19.0, that first-purchase refund revoked outright, and renewal refunds changed nothing.
- 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. Subscriptions that started before
you upgraded to 1.19.0 are never revoked by a refund, not even of
their first purchase; a renewal refund only rolls them back. License
Server doesn't cancel the subscription: to end a subscriber's access
for good, cancel it in Paddle. 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_refundin the deliveries endpoint; the storefront reference has every rule. - A license file the buyer already downloaded keeps working offline until it expires.
When the API key stops working
The API key is the one moving part Stripe and Lemon Squeezy don't have. If it expires, is revoked, or belongs to the wrong environment, License Server can't find the buyer's email. Purchases still issue their licenses; only the emails wait. The webhook is flagged in the console with what went wrong and what to do.
To catch up:
- Create a new key with only Customers: read, as in step 3, and save it in place of the old one.
- Click Send pending deliveries on the webhook (self-hosted:
POST /v1/admin/storefront-webhooks/{id}/send-pending). It sends up to 25 waiting licenses a run and says how many went out and how many still wait. Run it again until none do.
This step is needed because Paddle doesn't redeliver a notification License Server has already answered, and License Server answers as soon as the license is issued. Short Paddle outages need nothing from you: License Server asks Paddle to retry, and the retry sends the email.
From sandbox to live
The sandbox and your live Paddle account are separate: different products, prices, notification destinations and API keys. To go live:
- Disable the sandbox webhook in License Server. You can have one active Paddle webhook at a time.
- Connect a new one and choose Live.
- Repeat steps 2–4 in your live Paddle account: a live
notification destination, a live API key (it starts
pdl_live_apikey_), and mappings to your live price or product IDs.
If you set a webhook up in the wrong environment by mistake, the same steps fix it, and Send pending deliveries on the new webhook also picks up the purchases the old one couldn't email.
A buyer who lost the email, or never saw 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, 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. A purchase whose buyer email hasn't been fetched yet needs Send pending deliveries instead. One whose buyer email couldn't be fetched when it was issued, and was then sent by Send pending deliveries or Paddle's redelivery before 1.16.0, can't be resent: send that buyer their key yourself.
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 (paddle_txn_
followed by the transaction ID, 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).
The buyer's email is still fetched from Paddle with the webhook's API
key, so the lookup shows it; if Paddle was unreachable, it appears once
Paddle's retry fetches it. Send pending deliveries isn't offered on
a webhook set this way. 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. Send pending deliveries sends held purchases too, or use Resend email on one. 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, click Send pending deliveries 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 Paddle 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
- One license per purchase, from one item. On a subscription, that's its first recurring item a mapping matches (from 1.13.0; before it, the first recurring item); otherwise the first item in the cart.
- Imported subscriptions aren't licensed. A subscription imported into Paddle, for example from Paddle Classic, has no purchase to issue from. Issue those licenses by hand.
- A won chargeback doesn't restore the license (above).
- Past-due access lasts only the grace period, up to 30 days, even while Paddle is still retrying.
- After replacing a dead API key, send the pending deliveries. Paddle won't resend those notifications for you.
- Paddle Billing only. Paddle Classic isn't supported.
- One Paddle webhook per License Server account, alongside one each for Stripe and Lemon Squeezy if you use them. Mappings stay with the webhook they were made on, so a replacement needs them added again.
- 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.
- Verify a license offline: what the license file gives your app that a key alone doesn't.
- Issue license keys automatically from Stripe Checkout and from Lemon Squeezy: the same setup for the other storefronts.
More in Guides, or the full documentation.