← All docs

Stripe catalog setup

Guide for Stripe products and prices. The app treats Stripe as the source of truth for plan definitions and syncs them into Django on server startup.

Overview

You do not create plans in Django admin. With STRIPE_SECRET_KEY set, the entrypoint runs seed_stripe_catalog (creates/updates Basic, Pro, Enterprise in Stripe) then sync_stripe_catalog (imports into Django). You can still create or edit products manually in the Dashboard; seed is idempotent and matches plans by metadata.code.

Before make start

Complete these steps in order:

  1. Set Stripe keys in server/.env and client/.env:

    # server/.env
    STRIPE_SECRET_KEY=sk_test_...
    # STRIPE_WEBHOOK_SECRET is set automatically by `make start`
    
    # client/.env — publishable key for Stripe.js / Elements
    STRIPE_PUBLISHABLE_KEY=pk_test_...
    
  2. Start the project — seed, catalog sync, and local webhook forwarding all run when STRIPE_SECRET_KEY is set:

    make start
    
  3. Verify — open Billing in the app or Django admin → Catalog products. You should see Basic, Pro, and Enterprise with monthly/yearly prices and features.

If STRIPE_SECRET_KEY is empty, startup skips seed/sync and webhook forwarding, and the billing page shows no plans.


Seed default products (API)

python manage.py seed_stripe_catalog creates or updates three plans via the Stripe API. No Dashboard setup is required for local/dev.

PlancodeMonthly (EUR)Yearly (EUR)Seats
BasicBASIC€9 / seat€90 / seat1–5
ProPRO€20 / seat€200 / seat5–15
EnterpriseENTERPRISE€50 / seat€500 / seat10–100

Prices use Stripe lookup keys (BASIC_MONTH_EUR, PRO_YEAR_EUR, …). Re-running seed updates product metadata/name and leaves matching prices unchanged. Amount changes create a new price and transfer the lookup key (Stripe prices are immutable).

Manual run (after the stack is up):

docker compose exec server python manage.py seed_stripe_catalog
docker compose exec server python manage.py sync_stripe_catalog

To customize plans, edit DEFAULT_STRIPE_CATALOG in server/billing/services/stripe_catalog_service.py, or manage products in the Dashboard (see below) and skip relying on the seeded amounts.


Create products in Stripe (manual)

Optional if you prefer the Dashboard over seed. In the Stripe Dashboard go to Product catalog → Products → Add product.

For each plan (e.g. Basic, Pro, Enterprise):

FieldGuidance
NameDisplay name shown in the app (e.g. Pro).
DescriptionOptional marketing copy; synced to the plan card.
ImageOptional; not used by the app today.
Product tax codeSet per your tax setup.
StatusKeep Active. Inactive products are not synced.

Leave Stripe’s built-in Features section empty. This project reads plan features from Metadata instead (see below).

The app expects multiple tiers with ascending capability. A typical setup:

Plancode metadatatier metadatamin_seatsmax_seats
BasicBASIC1015
ProPRO20515
EnterpriseENTERPRISE3010100

tier is a positive integer used to order plans and for future feature-gating logic. Higher number = higher tier.


Required product metadata

These keys apply to subscription plans (Basic / Pro / Enterprise). Other active Stripe products that only set code (for example a one-time storefront SKU) are skipped by sync_stripe_catalog and are not imported into CatalogProduct.

On each plan product, open More options → Metadata (or the Metadata panel on the product detail page) and add exactly these keys:

KeyRequiredFormatPurpose
codeYesUppercase string, unique per product (e.g. PRO)Stable internal identifier. Used in APIs, entitlements, and generated price codes.
tierYesInteger as string (e.g. 20)Plan rank. Higher tiers unlock more capability.
min_seatsYesPositive integer as string (e.g. 5)Minimum purchasable seats for the plan.
max_seatsYesPositive integer as string (e.g. 15)Maximum purchasable seats for the plan. Must be >= min_seats.
featuresYesJSON array of entitlement codesMachine-readable plan entitlements copied to workspace entitlements on subscribe.
plan_descriptionRecommendedJSON object of locale → bullet arraysLocalized “What’s included” copy shown on plan cards.

features format (entitlements)

Paste a JSON array of entitlement codes as a single metadata value — no extra outer quotes:

["manage_roles"]

Known entitlement codes today:

CodeUnlocks
manage_rolesCustom role create/edit/delete (still also requires the user’s role permission workspaces.manage_roles). Assigning existing roles to members is not gated by this entitlement.

Use [] for plans with no gated entitlements. The sync command also accepts comma-separated text as a fallback, but JSON is recommended.

Do not use Stripe’s separate Features product field; only metadata features is read.

plan_description format (marketing bullets)

Paste a JSON object keyed by locale:

{"en":["Minimum 3 team members","Up to 10 team members","Unlimited workspaces"],"hr":["Najmanje 3 člana","Do 10 članova","Neograničeno radnih prostora"]}

The billing UI picks the active UI language (en / hr), falling back to en then the first available locale.

Example metadata (copy-paste)

Basic

KeyValue
codeBASIC
tier10
min_seats1
max_seats5
features[]
plan_description{"en":["Up to 5 team members","1 workspace","Email support"],"hr":["Do 5 članova","1 radni prostor","Podrška e-poštom"]}

Pro

KeyValue
codePRO
tier20
min_seats5
max_seats15
features["manage_roles"]
plan_description{"en":["Up to 15 team members","5 workspaces","Custom roles"],"hr":["Do 15 članova","5 radnih prostora","Prilagođene uloge"]}

Enterprise

KeyValue
codeENTERPRISE
tier30
min_seats10
max_seats100
features["manage_roles"]
plan_description{"en":["Up to 100 team members","Unlimited workspaces","Custom roles","Priority support"],"hr":["Do 100 članova","Neograničeni radni prostori","Prilagođene uloge","Prioritetna podrška"]}

If code is missing, sync derives one from the product name (e.g. Basic PlanBASIC_PLAN). Set code explicitly so price codes and entitlements stay predictable.


Create prices in Stripe

Add recurring prices on each product. The billing UI toggles between monthly and yearly intervals.

For each product, create:

PriceBilling periodNotes
MonthlyRecurring, every 1 monthMark one price as default if Stripe asks.
YearlyRecurring, every 1 yearEnables the annual toggle on the plan catalog.

Guidelines:

  • Use the same currency across all plans (e.g. EUR) unless you intentionally support multi-currency.
  • Keep prices Active. Removed or archived prices are deactivated in Django on the next sync.
  • Price nickname and lookup key in Stripe are optional; the app generates its own internal codes.

Internal price codes (generated on sync)

The sync command builds Django price codes automatically:

{PRODUCT_CODE}_{INTERVAL}_{CURRENCY}

Examples for product code PRO in EUR:

  • PRO_MONTH_EUR — monthly
  • PRO_YEAR_EUR — yearly

You do not configure these in Stripe; they appear in Django admin and subscription records after sync.


How the app uses synced data

Synced fieldWhere it appears
Product name, descriptionPlan cards on the Billing page
metadata.plan_descriptionCatalogProduct.plan_description“What’s included” list on each plan (localized)
metadata.featuresCatalogProduct.featuresEntitlement codes; copied to workspace entitlements on subscribe
Prices (amount, interval, currency)Plan pricing and checkout
metadata.codeCurrent-plan detection, entitlement product_code
metadata.tierCatalog ordering / future access rules
metadata.min_seats / metadata.max_seatsSeat picker bounds, invite gating, purchased seat validation

When a workspace subscribes, Stripe webhooks update Entitlement.features from the product’s effective features (Stripe-synced entitlement codes, unless overridden in Django admin via features_override). Creating/editing/deleting roles requires both the role permission workspaces.manage_roles and the entitlement code manage_roles. Assigning an existing role to a member only requires the role permission.


Syncing the catalog

Automatic (default)

On every server container start, after migrations:

[entrypoint] Seeding Stripe catalog (create/update default products)...
[entrypoint] Syncing Stripe catalog...

Skipped when STRIPE_SECRET_KEY is not set.

Manual

docker compose exec server python manage.py seed_stripe_catalog
docker compose exec server python manage.py sync_stripe_catalog

Django admin

Admin → Billing → Catalog productsSync from Stripe (top of the list).

Re-run sync after you change products, prices, or metadata in Stripe. Sync is idempotent: existing records are updated, missing Stripe prices are deactivated locally.


Local webhooks (Stripe CLI)

Catalog sync only imports products and prices. Live subscription state (who is on which plan, entitlements, invoices) comes from Stripe webhooks.

For local development, webhook forwarding is part of make start when STRIPE_SECRET_KEY is set — no ngrok, no Dashboard endpoint, no second terminal.

What make start does for billing

  1. Fetches the Stripe CLI signing secret (stripe listen --print-secret) and writes STRIPE_WEBHOOK_SECRET to server/.env.
  2. Starts the stack with Compose profile stripe, including a stripe-webhooks container.
  3. That container runs Stripe CLI listen and forwards events to http://server:8000/api/v1/billing/webhook/stripe/.
make start

Requires STRIPE_SECRET_KEY in server/.env. You do not need stripe login.

EventPurpose
customer.subscription.createdNew subscription → entitlement
customer.subscription.updatedPlan changes, renewals, cancellation flags
customer.subscription.deletedSubscription ended (+ cancellation email if payment retries exhausted)
customer.deletedStripe customer removed → local billing cleanup
invoice.paidInvoice history
invoice.payment_failedFailed payment invoice record + branded recovery email
invoice.finalizedInvoice ready / updated

Failed payments (Dashboard)

Automatic retries and when access ends are controlled in Stripe, not in app code. Configure these in live (and test, if you want to exercise the flow):

  1. Settings → Billing → Manage failed payments — enable Smart Retries (or a custom retry schedule). For the cancellation email below, set the end state to Cancel the subscription (or mark unpaid). Entitlements stay active while the subscription is active, trialing, or past_due (grace access during dunning). Access drops when the subscription becomes canceled / unpaid.
  2. Settings → Customer emails — disable Stripe’s “Failed payment” (and any Stripe “subscription canceled” / unpaid notices you don’t want) so the workspace owner only receives the app’s branded emails. Keep receipt emails if you want.
  3. Hosted Invoice Page — leave enabled. The recovery email’s Retry payment button uses hosted_invoice_url.
  4. Production webhook endpoint — include at least the events in the table above (invoice.payment_failed, invoice.paid, invoice.finalized, customer.subscription.*). Local make start already forwards them.

Emails the app sends

WhenEmail
invoice.payment_failed on a renewal / mid-cycle invoice (not initial checkout)Recovery: amount, card last4, single Go to billing management CTA → /workspace/billing
Subscription moves from entitled (active / trialing / past_due) to canceled / unpaid after payment failure (cancellation_details.reason=payment_failed, or past_due → terminal)Cancellation: access ended, CTA to Billing to resubscribe

On the billing page past-due banner, users can Retry payment in-app (POST /billing/retry-payment/Invoice.pay) or Update payment information (setup intent; setting a new default card also retries the open invoice).

Voluntary cancels (cancellation_requested) do not send the payment-failed cancellation email.

Do not enable the Stripe Customer Portal for this flow; payment-method updates and retries stay in-app.

Verify delivery

make logs
# or only webhooks:
docker compose --profile stripe logs -f stripe-webhooks

After a test checkout or subscription change, you should see forwarded events in the stripe-webhooks logs, and entitlements/subscription status should update in the app.

Without webhook forwarding, checkout can still open in Stripe, but entitlements and subscription status in the app will not update.

Optional helpers

CommandPurpose
make stripe-webhooksRe-sync the signing secret and tail stripe-webhooks logs
./server/scripts/stripe-webhooks.sh --listenForeground listen outside Compose (debug)

Notes

  • Production/staging still needs a real HTTPS webhook endpoint in the Stripe Dashboard (or your deploy automation); the Compose service is for local development only.
  • The CLI signing secret is stable for a given Stripe test key; make start only rewrites .env when it changes.
  • If you add STRIPE_SECRET_KEY after the stack is already running, run make stop && make start so the secret is synced and the stripe-webhooks service starts.

Troubleshooting

SymptomLikely causeFix
Billing page: “No plans available”Empty catalog or sync skippedSet STRIPE_SECRET_KEY, restart server (seed + sync run automatically), or run the seed/sync commands manually
Features list empty on a planInvalid or missing plan_description metadataUse locale JSON object format; re-sync
Role management prompts to upgradePlan features missing manage_rolesAdd "manage_roles" to that product’s metadata features; re-sync
Yearly toggle missing pricesNo year interval price on productAdd a yearly recurring price in Stripe, re-sync
Wrong plan marked as currentcode mismatchEnsure metadata.code matches what entitlements expect; avoid renaming codes after launch
Sync errors on startupInvalid Stripe key or API errorCheck make logs; verify test vs live keys match your Stripe mode
Subscription stuck on “incomplete” / no entitlementWebhooks not forwarded locallySet STRIPE_SECRET_KEY, then make stop && make start; see Local webhooks
Webhook returns 400Host: server rejected, or stale/missing STRIPE_WEBHOOK_SECRETEntrypoint adds server to ALLOWED_HOSTS automatically; run make stop && make start. Check stripe-webhooks logs for [400] vs reconnect noise
No stripe-webhooks containerEmpty STRIPE_SECRET_KEYSet a Stripe test secret key in server/.env, then make start
Session expired, reconnecting... in stripe-webhooks logsNormal Stripe CLI idle reconnectIgnore unless events also return [400] / never show [200]

Logs: make logs or docker compose logs server — entrypoint lines are prefixed with [entrypoint].