Skip to content
Skip to content

Storefront auto-fulfillment

Connect your storefront once, and every purchase after that issues a license and emails the buyer their key — with no integration code on your side. You paste one URL into Stripe, tell License Server which Payment Link sells which product, and the fulfillment happens without you.

Today that means Stripe Payment Links. The design is provider-agnostic, but Stripe is the only provider implemented, so nothing here applies to Gumroad, Paddle, Lemon Squeezy or a custom checkout yet.

Most people should set this up in the console — Settings → Storefront webhooks — rather than calling the API. The API reference documents every endpoint for the cases where you want automation or need to troubleshoot.

What this replaces, and what it does not

This removes the integration work, not the decisions. You still choose what each product issues — tier, seats, limits, duration — and those choices are per Payment Link, set once. Getting one of them wrong is quiet: every purchase through that mapping inherits it, and the buyer's license is already in their inbox before you notice. The duration_days warning below is the one that has actually caught someone out.

Setting it up

1. Connect. In the console, Settings → Storefront webhooks → Connect Stripe. You get a callback URL of the form {your-api-url}/v1/webhooks/storefront/{webhook_id} and a webhook in pending state. Nothing is fulfilled yet.

That webhook_id is not a secret. It is the routing part of a URL, exactly as a Stripe webhook endpoint URL is, and every route that accepts it checks server-side that it belongs to you.

2. Paste it into Stripe. Developers → Webhooks → Add endpoint, with the URL from step 1, listening for three events:

checkout.session.completed
checkout.session.async_payment_succeeded
checkout.session.async_payment_failed

Stripe then shows you a signing secret, once.

3. Save the signing secret back in the console. That flips the webhook to active, and it is the real trust boundary: every delivery is verified against it using the same scheme Stripe uses to sign its own webhooks.

4. Map a product. Tell License Server what a purchase should issue: which Payment Link (or casazium_ref metadata value), which product, which tier, and optionally seats, limits and duration.

You can have one active webhook per provider. Connecting a second Stripe webhook returns 409 — disable the first if you are replacing it.

What a purchase does

A buyer completes checkout. Stripe posts the event to your callback URL. License Server verifies the signature, finds the mapping that matches, and issues the license through the same internal path POST /issue-license uses — not a parallel implementation. The buyer gets an email with their key and, if the end-user portal is enabled, a link to manage it themselves.

Retries are safe in both directions. Stripe retrying the same checkout session never issues a second license — issuance is idempotent per session — and if the license was issued but the email failed, the retry redelivers the email without re-issuing the key.

duration_days defaults to perpetual

Omitting duration_days issues a license that never expires

This is the one default worth reading twice. A mapping with no duration_days issues perpetual licenses — no expiration, forever, for every purchase through it. There is no separate "make it expire" step later.

If you are selling an annual subscription, set duration_days on the mapping before the first purchase goes through it.

Mappings cannot be edited after creation — only created and deleted. To change a duration, delete the mapping and recreate it with the same reference. That affects future purchases only; licenses already issued keep the terms they were issued with. To correct one of those, set an expiry on the license directly with POST /admin/update-license-terms.

Watching it work

The console's Deliveries table is your record of what each purchase did, and it separates two things that fail independently:

  • Outcome — what happened to issuance: issued, processing, awaiting_payment (a delayed payment method settled after checkout; not terminal, a later event can still resolve it), unmapped (nothing matched that reference), over_quota, invalid_input, or error.
  • Delivery status — what happened to the email. A license can be issued with the email still pending, and the key is valid either way.

If an email is stuck, redeliver the event from your own Stripe dashboard. There is deliberately no resend button in the Casazium console: the platform never stores the buyer's email address, so it has nothing to resend to. That is a privacy choice, and the cost of it is this one extra step in Stripe.

Quotas and plans

There is no plan gate on this feature. It works on self-hosted and on both hosted tiers, and it is not an add-on.

The only ceiling is the license quota you already have — 5 active licenses on Free, 1,000 on Pro — which is the same quota every other way of issuing a license counts against. A purchase that would exceed it is recorded as over_quota rather than silently dropped.

Self-hosted: the email needs configuring

On a hosted account this is already done. Self-hosted, the confirmation email is only logged until you configure a mail provider:

EMAIL_PROVIDER=resend
RESEND_API_KEY=...
EMAIL_FROM="Your Product <licenses@yourdomain.com>"
PUBLIC_BASE_URL=https://your-license-api.com

EMAIL_REPLY_TO is optional. PUBLIC_BASE_URL is not optional for this feature — the callback URL and the portal link in each email are built from it. See .env.example in your release archive.

Known gaps

Worth knowing before you build a storefront around this:

  • Stripe only. No other provider is implemented.
  • No bulk import. Mappings are added one at a time — fine for a few products, tedious for a large catalog.
  • No in-console resend, for the privacy reason above.
  • Disconnect and reconnect is rough. Disabling a webhook and connecting a new one can strand an old Payment Link's mapping on the disabled webhook, where it cannot be moved to the new one. If you are replacing a webhook, expect to recreate mappings rather than migrate them.

Next steps