Skip to content

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

VariableRequiredWhat it does
STRIPE_SECRET_KEYYesYour 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_SECRETYesThe 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_KEYNoThe 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:

bash
STRIPE_SECRET_KEY=sk_test_... pnpm --filter @radioso/enterprise-backend-module run stripe:sync
text
Stripe 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:

bash
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:

bash
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"
FlagWhat it does
(none)Dry run: print the plan and change nothing.
--applyMake the planned changes.
--liveRequired 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 is active, which takes a head office address. The dry run prints the Stripe Tax status and warns while it is anything other than active.
  • 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 IDNamePlan metadata
radioso_plan_satelliteRadioso Satelliteplan = satellite
radioso_plan_planetRadioso Planetplan = planet
radioso_topupRadioso conversation top-up (300 conversations)none
radioso_managed_serviceRadioso managed servicenone

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 keyProductInterval
satellite_monthradioso_plan_satelliteMonthly
satellite_yearradioso_plan_satelliteAnnual
planet_monthradioso_plan_planetMonthly
planet_yearradioso_plan_planetAnnual
topup_300radioso_topupOne-time
managed_monthradioso_managed_serviceMonthly

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:

plaintext
https://<your-app-base-url>/api/v1/ee/billing/webhook

stripe:sync --webhook-url registers that URL, subscribed to exactly the events Radioso acts on:

  • checkout.session.completed
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.paid
  • invoice.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 shows Stripe 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 --live next to --apply.
  • --live was given but STRIPE_SECRET_KEY is a TEST key: --live and the secret key disagree about which account this is. Remove --live, or point STRIPE_SECRET_KEY at 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’t https://. Stripe only delivers to https:// 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-url to 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.