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

Issue license keys automatically from Paddle

Last updated October 28, 2026

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

  1. The buyer completes Paddle checkout.
  2. Paddle sends a transaction.completed notification to License Server.
  3. 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.
  4. 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.
  5. 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:

  1. 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).
  2. For per-seat plans, give the mapping activations per unit (machines per seat) instead of max activations. The seats are the item's quantity.
  3. Plan and seat changes: Paddle's customer portal doesn't make them. Use Paddle's API (PATCH /subscriptions/{id} with the new items and a proration_billing_mode) or the dashboard. Billed straight away (prorated_immediately), a switch to yearly extends the expiry at once; with do_not_bill or a *_next_billing_period mode, at the next paid charge.
  4. 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.
  5. 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_refund in 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:

  1. Create a new key with only Customers: read, as in step 3, and save it in place of the old one.
  2. 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:

  1. Disable the sandbox webhook in License Server. You can have one active Paddle webhook at a time.
  2. Connect a new one and choose Live.
  3. 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_quota rather than dropped. Self-hosted has no limit.

Where to go next

More in Guides, or the full documentation.