Billing setup
Radioso Enterprise’s plan card lets an account subscribe to a paid plan, buy a conversation top-up, and open the Stripe customer portal without an operator touching the console. All of it runs through Stripe Checkout and the Stripe customer portal; Radioso never stores a card number.
The top-up pack is sold on Satellite and Planet, never on Comet: a free account plus a few packs would undercut Satellite. Each pack’s conversations expire 12 months after purchase, with the oldest conversations spent first. POST /api/v1/ee/billing/checkout checks plan eligibility against the catalog before it creates a Stripe session, so a Comet account (or an account on a plan the catalog doesn’t list) gets 409 top_up_unavailable rather than a checkout URL.
Getting there takes three steps: run stripe:sync so your Stripe account matches the plan catalog, give Stripe Tax a head office address, and set the environment variables below. The same steps work for a Stripe sandbox and for live mode, so both accounts end up configured identically.
Environment variables
| Variable | Required | What it does |
|---|---|---|
STRIPE_SECRET_KEY | Yes | Your Stripe secret key. Missing this (or STRIPE_WEBHOOK_SECRET) turns billing off: the plan card’s GET /me reports configured: false and checkout, portal, and webhook requests return 503. |
STRIPE_WEBHOOK_SECRET | Yes | The signing secret of the billing webhook endpoint. stripe:sync --webhook-url creates the endpoint and hands you this value. Verifies that a webhook call actually came from Stripe. |
STRIPE_PLAN_METADATA_KEY | No | The Stripe product metadata key that names a plan. Defaults to plan. Only set this if plan collides with something else in your Stripe account; set it for stripe:sync too. |
A self-hosted install that never sets STRIPE_SECRET_KEY runs with billing off — the plan card stays hidden and every account keeps whatever plan an operator assigns through the usage-limits admin API.
Sync the catalog into Stripe
@radioso/plan-catalog (ee/packages/plan-catalog/src/plans.json) holds every number Radioso sells: plan prices, the conversation top-up, and the managed service. The stripe:sync command reads it and makes the Stripe account behind STRIPE_SECRET_KEY match it. It creates or corrects the products, the lookup-keyed prices, the account’s tax defaults, the customer portal configuration, and, when you pass a URL, the webhook endpoint. Run it as often as you like: once the account matches, it plans nothing.
Start with a dry run. It prints the mode, the account, and every change it would make, and changes nothing:
STRIPE_SECRET_KEY=sk_test_... pnpm --filter @radioso/enterprise-backend-module run stripe:syncStripe TEST mode
Account: acct_1Sandbox (Radioso Sandbox)
Catalog: 4 products, 6 prices, prices exclude tax (tax behavior exclusive)
Stripe Tax: status pending, 0 active registrations
Planned changes (12):
1. create product radioso_plan_satellite "Radioso Satellite", tax code txcd_10103001, metadata plan=satellite
...
5. create price satellite_month: €149 per month, tax exclusive, on radioso_plan_satellite
...
12. create customer portal configuration offering radioso_plan_satellite (satellite_month, satellite_year), radioso_plan_planet (planet_month, planet_year)
Warning: Stripe Tax is pending (missing: head_office). Checkout sends automatic_tax, so it fails until you set the head office address in Dashboard > Tax.
Dry run: nothing changed. Re-run with --apply to make these changes.The script builds the plan catalog and the backend module before it runs, so it always reads the catalog in your working tree.
When the plan looks right, apply it and create the webhook endpoint in the same run:
STRIPE_SECRET_KEY=sk_test_... pnpm --filter @radioso/enterprise-backend-module run stripe:sync \
--apply \
--webhook-url https://app.example.com/api/v1/ee/billing/webhook \
--webhook-secret-file "$HOME/stripe-webhook-secret-staging"Stripe shows a webhook endpoint’s signing secret once, in the response that creates the endpoint. --webhook-secret-file writes it to a new file with mode 0600, holding the bare secret and nothing else, ready for STRIPE_WEBHOOK_SECRET or your secret manager. The sync refuses to overwrite a file that already exists, and checks this before it changes anything. Without --webhook-secret-file, it prints the secret once under a “Store this now” line.
When an endpoint with that URL already exists, the sync corrects its events, turns it back on if it was disabled, and reminds you that its secret can’t be read back: STRIPE_WEBHOOK_SECRET has to hold it already, or you roll the secret in the Dashboard.
Live mode takes one more flag. A live key (sk_live_ or rk_live_) dry-runs like a test key, and --apply is refused until you add --live. --live itself is refused with a test key, so a copy-pasted command can’t apply to the wrong account:
STRIPE_SECRET_KEY=sk_live_... pnpm --filter @radioso/enterprise-backend-module run stripe:sync \
--apply --live \
--webhook-url https://app.example.com/api/v1/ee/billing/webhook \
--webhook-secret-file "$HOME/stripe-webhook-secret-live"| Flag | What it does |
|---|---|
| (none) | Dry run: print the plan and change nothing. |
--apply | Make the planned changes. |
--live | Required with --apply when the key is a live-mode key. Refused with a test key. |
--webhook-url <url> | Create or correct the billing webhook endpoint at this https:// URL. One URL per run. |
--webhook-secret-file <path> | Write a newly created endpoint’s signing secret to this new file (mode 0600). Needs --webhook-url. Relative paths resolve from the directory you ran the command in, not from inside the repository. |
The sync reads STRIPE_PLAN_METADATA_KEY the same way the backend does. A restricted key (rk_) works when it can write products, prices, webhook endpoints, customer portal configurations, and tax settings, and read tax registrations and account details. The output never includes the API key.
Changing a price
Edit the number in plans.json, then run the sync. For each price whose amount, interval, currency, or tax behavior changed, the sync creates a new price and moves the lookup key to it with transfer_lookup_key, then points the customer portal at it. The previous price stays active: subscribers on it keep renewing at the price they signed up for, and Radioso still maps them to the right plan through the product metadata. New checkouts use the new price straight away, with no Radioso configuration change.
Adding a plan
A new plan brings a Stripe product that older application revisions don’t know. Deploy the revision that carries the catalog entry to every region and drain older revisions before running the live sync. Until then, an older revision that receives a subscription event for a product carrying Radioso’s plan metadata answers with a retryable error, so Stripe redelivers the event after the rollout instead of it being dropped. Events for products without Radioso’s metadata are acknowledged.
Tax
Catalog prices exclude VAT. plans.json says so with "taxBehavior": "exclusive", and every price the sync creates carries tax_behavior: exclusive, so a customer on Satellite sees €149 plus their VAT at checkout. The sync also sets the account’s Stripe Tax defaults to tax behavior exclusive and tax code txcd_10103001 (software as a service, business use), and gives every product that tax code.
Two Stripe Tax settings stay with you in the Stripe Dashboard:
- Head office address (Dashboard → Tax). Checkout requests
automatic_tax, and Stripe rejects those sessions until Stripe Tax isactive, which takes a head office address. The dry run prints the Stripe Tax status and warns while it is anything other thanactive. - Tax registrations (Dashboard → Tax → Registrations). Stripe collects tax only where you have registered. The dry run prints the active-registration count and warns at zero.
Products, prices, and lookup keys
Radioso looks up every Stripe price by its lookup key, never by price ID. That is what lets a price change ride on transfer_lookup_key without touching Radioso configuration. stripe:sync creates all of these; the tables are the reference for what it creates and for checking an account by hand.
| Product ID | Name | Plan metadata |
|---|---|---|
radioso_plan_satellite | Radioso Satellite | plan = satellite |
radioso_plan_planet | Radioso Planet | plan = planet |
radioso_topup | Radioso conversation top-up (300 conversations) | none |
radioso_managed_service | Radioso managed service | none |
The sync owns the products at these ids: it creates them where they’re missing and keeps their name, tax code, and plan metadata matching the catalog. On an account where one of the lookup keys below already sits on a different, hand-created product, the dry run lists that key moving to the sync’s product, naming the price it replaces — review that line before you --apply.
| Lookup key | Product | Interval |
|---|---|---|
satellite_month | radioso_plan_satellite | Monthly |
satellite_year | radioso_plan_satellite | Annual |
planet_month | radioso_plan_planet | Monthly |
planet_year | radioso_plan_planet | Annual |
topup_300 | radioso_topup | One-time |
managed_month | radioso_managed_service | Monthly |
The amount behind each key comes from the catalog, in EUR and excluding VAT, so what a customer pays lines up with what Radioso grants.
Product metadata
Radioso resolves a Stripe price back to a plan through the price’s product, not the price itself — every price on a product, current or grandfathered, maps to the same plan. The sync sets plan = satellite on radioso_plan_satellite and plan = planet on radioso_plan_planet, and puts the value back if it drifts. The top-up and managed-service products carry no plan key, because buying them changes no plan. The free plan has no Stripe product, since nothing is purchased to get it.
The sync manages only the plan key (or your STRIPE_PLAN_METADATA_KEY); any other metadata you add to these products stays as you set it.
If a subscription event carries a price whose product has no plan metadata, or names a plan Radioso doesn’t recognize, Radioso logs a warning and leaves the account’s plan untouched rather than guessing.
Customer portal configuration
Stripe opens every portal session with the account’s default portal configuration, and the sync keeps that configuration in line with the catalog:
- Customers can update their email, billing address, tax ID, and name.
- Invoice history and payment method updates are on.
- Cancellation takes effect at the end of the billing period.
- Customers can switch between the Satellite and Planet monthly and annual prices. Prorations are invoiced immediately (
always_invoice): a plan grants its quota the moment it starts, so an upgrade is charged when it happens.
If the account has no portal configuration yet, the sync creates one. When Stripe doesn’t make that new configuration the default, the sync warns: open Settings → Billing → Customer portal in the Dashboard, click Save once to create the default, and run the sync again so the default carries these settings.
Radioso opens the portal with a return_url back to the dashboard, so a customer who cancels or changes plans there lands back on their Plan & usage page.
Webhook endpoint
Radioso receives subscription and invoice changes at:
https://<your-app-base-url>/api/v1/ee/billing/webhookstripe:sync --webhook-url registers that URL, subscribed to exactly the events Radioso acts on:
checkout.session.completedcustomer.subscription.updatedcustomer.subscription.deletedinvoice.paidinvoice.payment_failed
It pins the endpoint to the Stripe API version Radioso’s Stripe library reads (the dry run prints it when it plans the endpoint), so event payloads arrive in the shape Radioso parses. Stripe can’t change an existing endpoint’s API version. When an endpoint registered by hand uses a different, specific version, the sync warns; delete that endpoint in the Dashboard, run the sync with --webhook-url to create it again, and store the new signing secret. When an endpoint has no version pinned, it follows the account’s default API version, which may already be current; the sync notices this instead of warning, and points you at Dashboard > Developers > Webhooks to check the default rather than telling you to delete anything.
Radioso verifies every webhook call against STRIPE_WEBHOOK_SECRET before reading the payload; a call with a missing or invalid signature gets a 400 and never reaches account state.
Each webhook delivery is idempotent on Stripe’s own event ID — replaying the same event through stripe listen or Stripe’s dashboard retry is a no-op the second time, not a double credit or a double plan change.
If you run more than one region (for example a separate EU stack) on one Stripe account, run the sync once per region with that region’s --webhook-url and its own --webhook-secret-file. Stripe delivers to every endpoint you register, and each region resolves its own accounts with its own signing secret.
Auto top-up
Auto top-up charges a subscription customer for a conversation top-up pack the moment the account’s usage reaches limit_reached or grace_exhausted, instead of waiting for someone to buy one by hand. It rides the webhook endpoint registered above: the charge resolves through invoice.paid and invoice.payment_failed, the same two events the sync already subscribes the endpoint to for the subscription itself, so turning auto top-up on needs no change to the webhook registration or another stripe:sync run.
Radioso tells an auto-topped-up invoice apart from the subscription’s own renewal invoice by its metadata (radioso_kind: auto_top_up), checked before any account state changes. A declined charge voids the invoice, turns auto top-up off for that account, and leaves the subscription itself untouched — it never sets the account past due.
The charge itself is idempotent end to end, keyed on the pack’s own row id: creating the invoice, attaching the pack’s price, finalizing, and paying each carry their own Stripe idempotency key derived from that id. A network error, timeout, or other ambiguous failure during that sequence leaves the attempt in place rather than marking it failed, since Stripe may already have charged the card; a sweep a few minutes later replays the exact same sequence, so Stripe either confirms the existing result or completes it, never both and never a second charge. The price-attachment step also checks the draft invoice’s own line items before adding one, so it recognizes a line a prior attempt already created rather than relying on the idempotency key alone. Stripe keeps an idempotency key for 24 hours, so re-driving stops at 20 hours: past that, Radioso voids the draft and marks the pack failed without turning auto top-up off, since giving up is Radioso’s own call, not Stripe’s.
Turn on Stripe’s own payment receipt emails too (Dashboard → Settings → Customer emails → Successful payments and Failed payments), so a customer gets Stripe’s receipt alongside the account-level notice Radioso sends. See Enterprise usage limits for the account-facing side: the plan card toggle, the monthly pack cap, and the sweep that evaluates enabled accounts.
Billing notices
Radioso emails an account’s active owners and admins on four events: a plan change (from checkout or a subscription update), a subscription ending (back to the catalog’s default plan), a failed subscription payment, and a failed auto top-up charge. Owners and admins only, by design — never the Stripe customer’s billing email, which can outlive its owner’s membership on the account. Each notice carries a link back to the account’s usage page and goes out once the triggering webhook event has committed, as a best-effort send that never blocks or fails the webhook response.
Stripe’s own receipt and payment-failure emails, configured in the customer-emails setting above, cover the payment side; Radioso’s notices cover what changed on the account.
Test mode
Stripe test mode works exactly like live mode from Radioso’s side: run stripe:sync with your test secret key, and use that key and the test endpoint’s signing secret in the environment. The Stripe CLI’s stripe listen --forward-to https://<your-app-base-url>/api/v1/ee/billing/webhook is the fastest way to exercise a full checkout-to-webhook round trip against a local stack, including a test EU VAT ID for reverse charge and a test coupon code for allow_promotion_codes. While you forward events that way, set STRIPE_WEBHOOK_SECRET to the signing secret stripe listen prints.
What Radioso never stores
Radioso keeps a Stripe customer ID, subscription ID, price ID, billing status, and the email address that started checkout — nothing about payment methods or card numbers. Cardholder data stays in Stripe end to end, whether the customer pays through Checkout or manages their subscription through the portal.
Common failure modes
- Checkout fails with a tax error: Stripe Tax is not
active. Set the head office address in Dashboard → Tax; the next dry run showsStripe Tax: status active. Refusing to change a LIVE Stripe account without --live: the key is a live-mode key. Review the dry run, then add--livenext to--apply.--live was given but STRIPE_SECRET_KEY is a TEST key:--liveand the secret key disagree about which account this is. Remove--live, or pointSTRIPE_SECRET_KEYat your live-mode key.Refusing to overwrite $HOME/stripe-webhook-secret-staging: the secret file already exists. Pass a path that doesn’t exist yet; the sync changed nothing.--webhook-url must be an absolute https:// URL: the URL isn’thttps://. Stripe only delivers tohttps://endpoints.- The portal shows no plan switching: the configuration the sync manages is not the default. Save the customer portal settings once in the Dashboard and run the sync again.
- The dry run warns about the webhook API version: the endpoint was registered with a different, specific version. Delete it in the Dashboard, run the sync with
--webhook-urlto recreate it, and store the new signing secret. - The dry run notices the webhook’s API version instead of warning: the endpoint has no version pinned and follows the account default. Check that default in Dashboard > Developers > Webhooks; it may already be current.
Read next
- Enterprise usage limits — how a plan becomes per-account caps, and how to assign a plan by hand.