Product catalog API
The product catalog is the layer that holds our product decisions — the things Stripe must not own — beside the one thing Stripe alone owns (money). One row per sellable item (a plan or an add-on) carries its slug, display ordering, badge, visibility, whether it is reachable from self-serve checkout, the functional grants it confers, and the Stripe price ids that compose it. It never stores an amount: every euro shown to a customer is mirrored verbatim from Stripe (see the price mirror below), so the displayed price is by construction the price Stripe will charge.
Changing a catalog row takes effect immediately: the write invalidates the cached snapshot across all workers, and the next request reads the new values.
::: tip Admin UI
Platform admins see this catalog in the SPA under Account › Catalog (/catalog). The page is read-only for now. It shows every row with its resolved Stripe prices (for the wallet row: its top-up configuration and product binding, flagged "config invalid" / "product unbound" when it cannot sell), the Stripe sync status (GET /admin/v1/catalog/sync-status), and missing EN/DE copy (catalog.<kind>.<slug>.name / .description). It has a Resync now button (POST /admin/v1/catalog/resync). Approve prune… ({"allow_prune": true}) only appears when the last sync stopped at the prune breaker, and it asks for a typed confirmation, because the approval stays armed for 10 minutes. Editing rows is a later step.
:::
Wallet top-up configuration
Top-up amounts are not Stripe objects (decided 2026-09-25). The prepaid wallet sells any integer amount within a configured range; the configuration lives on the component-bound catalog row wallet_topup (kind='topup', stripe_component = wallet_topup):
Field (topup_config) |
Meaning |
|---|---|
currency |
EUR or USD (exact, case-sensitive) — the currency every top-up is charged in |
min_minor / max_minor |
the accepted range, integers in minor units: 100 ≤ min_minor ≤ max_minor ≤ 99999999 |
presets |
1–12 strictly ascending integers within the range — display shortcuts only, not a whitelist |
The seed (migration 0321) is EUR, 100 – 100000 (1 € – 1 000 €), presets [2500, 5000, 10000, 25000].
Stripe keeps exactly one product per account for top-ups: tagged metadata.product = ai-gateway and metadata.aiws_component = wallet_topup, with tax code txcd_10103001. It has no prices; every top-up is an inline amount on it (so it carries the tax code). The row is bound to that product when exactly one such product is mirrored, active, and has that tax code — otherwise nothing can be bought (topup_binding.reason: no_product, ambiguous_product, product_tax_code, mirror_unavailable, ambiguous_topup_row). A charge never trusts the mirror alone: the wallet re-reads the bound product live (60 s cache) before every top-up. The product is created per account with the operator script scripts/stripe/ensure_wallet_topup_product.sh (internal billing runbook).
The legacy per-price rows (topup_<price_id>, no stripe_component) were deleted by migration 0328.
These amounts are the one deliberate exception to "the catalog never stores the amount of a Stripe object": they are not prices of any Stripe object, so this row is their only owner.
Customer views and checkout
Every surface that shows a product or a price — the sign-up page, the upgrade calculator, the plans comparison, the trial screens, plan names — renders from this catalog; nothing is hard-coded. Two read-only views serve them, built by one presenter from an explicit field allowlist:
| View | Route | Who |
|---|---|---|
| Public | GET /admin/auth/signup/catalog |
anyone (sign-up page); 404 when self-serve is off |
| Workspace | GET /admin/v1/billing/catalog |
any signed-in user with a workspace |
Both return only visibility='public' rows: per plan slug, plan_value, display_order, badge,
self_serve, max_seats, grants, prices.{month,year} and checkout.{month,year}; per add-on
slug, display_order, badge, grants, prices; per top-up slug, currency, min_minor, max_minor,
and presets (an earlier release removed the one-release offers: [] compatibility field). A top-up entry appears only
while the wallet_enabled toggle is on and the offer resolves (valid config,
product bound); otherwise topups is []. The rendered bodies are cached per server node for 60 s
(cache keys catalog_public:v3:*, dropped on every catalog change, sync and wallet_enabled change). A price is
{unit_amount, currency, interval, tax_behavior}. Unlike the admin view below they carry no Stripe
price or product ids, lookup keys, nicknames, stripe_component, resolved diagnostics or
entitlement internals, and no internal or retired rows.
Shown == charged. A plan's prices.<interval> is exactly the Stripe slot that checkout charges
whenever checkout.<interval> is true. Checkout (sign-up, trial conversion, reactivation) takes the
plan slug, the interval, the number of seats and the price the page showed; it charges the
catalog's resolved slot for that interval with the seats as the quantity, and refuses (409
price_changed) if the price changed in between. The catalog is the only price authority:
no other table holds a price id, and a Stripe event's price is
mapped to a plan only through the catalog's binding (see Which price is charged). See Authentication — signup/checkout and
Billing — conversion checkout.
Stripe binding (stripe_component)
Stripe owns every payable offer (products, prices, top-ups); the catalog owns what Stripe cannot express (slug, ordering, badge, visibility, grants; copy lives in i18n). A catalog row is bound to its Stripe prices by component: stripe_component on the row equals metadata.aiws_component on the Stripe price. The same value is used in every Stripe account (int, beta, prod), so the catalog content is environment-independent.
Only our objects count: a Stripe product is ours when its metadata.product is ai-gateway; a price is ours when its own metadata.product is ai-gateway and its product is ours.
Each bound row carries a resolved view, computed from the mirror:
slots— recurring prices, one per (interval,currency). A slot is filled when exactly one active, eligible price exists; when several exist, the one holding a Stripe lookup_key wins; otherwise the slot is listed underunresolvedwith its candidate ids — never an arbitrary pick.offers— one-time prices of the component, ordered by currency, amount, id (display only; wallet top-ups are no longer sold from prices — see Wallet top-up configuration).ineligible— active prices of the component that cannot be shown, each with areason(for exampleinterval_count is not 1,usage_type is not licensed,currency_options set,product inactive,not ours: product untagged).
The resolved view is display data. Checkout charges a plan's resolved slot through the catalog's binding; a wallet top-up charges an amount on the bound top-up product (step 1b), confirmed live at charge time.
Base URL: https://<your-gateway-host>/admin/v1
Every endpoint requires the platform admin role (permission PLANS_MANAGE); a lower role is 403, an unauthenticated caller 401. Access is re-gated server-side — the client is never the authorization boundary.
::: info The catalog stores price IDS, never amounts
This is the load-bearing invariant. Amounts, currency, tiers and tax mode are read from Stripe and mirrored verbatim into stripe_price_cache; the catalog references a price only by its id. Adding an amount column to the catalog would reintroduce the "displayed price and charged price can drift" bug this layer exists to remove.
:::
List the catalog
Returns the resolved snapshot — all rows (including retired / internal, so the editor can see them), sorted by display_order then slug. Each entry:
[
{
"slug": "custom",
"kind": "plan",
"plan_value": "self_serve_custom",
"seat_scaled": 0,
"self_serve": 1,
"display_order": 20,
"badge": "popular",
"visibility": "public",
"grants": { "web_search": true },
"stripe_component": "seat",
"resolved": {
"slots": [
{ "interval": "month", "currency": "EUR",
"price": { "price_id": "price_...", "product_id": "prod_...", "currency": "EUR", "unit_amount": 1300,
"interval": "month", "lookup_key": "aiws_seat_monthly", "nickname": "Seat (monthly)",
"tax_behavior": "exclusive", "billing_scheme": "per_unit", "tiers_mode": null, "tiers": [] } }
],
"offers": [],
"unresolved": [],
"ineligible": []
},
"trial_credit_eur": null,
"entitlement": {
"budget_usd": 15,
"models_count": 2,
"seats": 5,
"web_search": true,
"workflows": false,
"connectors": false
}
}
]
grantsis a JSON object of feature flag → boolean.web_search/workflows/connectorsare derived from here (the plan row's grants plus any active add-on's grants); an empty object grants nothing. The EU-Gov add-on (eu_gov) row grantsmyra_hosted_models, which a self-serve tenant must hold to use Myra-hosted (provider-classmyra) models — enforced server-side in the model-routing allowlist (see tenant entitlements andeu_gov_model_not_allowedin error codes).self_serveandseat_scaledare0/1integers.trial_credit_eur(plan rows only; formerlyplan_config.trial_credit_eur): the credit in EUR a new trial workspace on this plan receives, frozen onto the workspace at sign-up;null= none advertised. Set on thetrialrow (seeded from the former plan value,3.00when that was empty). Other kinds do not carry the field.entitlementis present for plan rows with a knownplan_value,nullotherwise.stripe_componentis the Stripe binding (null= unbound).resolvedisnullfor an unbound row and when the mirror could not be read — unknown is never presented as "no prices" (seemirror_okin sync status).topup_config(kind='topup'rows) is the decoded top-up configuration, ornullwhen unset or invalid (an invalid stored value fails closed — no offer — and is logged).topup_bindingis{ "product_id": "prod_…" | null, "reason": null | "<token>" }for the component-bound topup row (the snapshot verdict the charge path starts from),nullon every other row.
Update a catalog row
Upserts the row named by slug (per-row, like the plan-config editor). The change is audited (product_catalog / create or update in the audit log; the before/after image includes the top-up config) and takes effect immediately (the snapshot cache is invalidated on success). To retire an item, set visibility to retired — there is no delete: a retired row keeps resolving for existing subscribers but drops out of the customer-facing lists.
Accepted body
| Field | Type | Rule |
|---|---|---|
slug |
string | Required. ^[a-z0-9_-]+$, ≤ 64 chars. The primary key. |
kind |
string | Required. plan, addon, or topup (a topup row must be bound by stripe_component). An existing row never changes kind. |
visibility |
string | Required. public, internal, or retired. |
self_serve |
integer | Required. 0 or 1 (0 = not reachable from Stripe checkout — trial, Enterprise). |
seat_scaled |
integer | Required. 0 or 1. |
display_order |
integer | Required. 0–100000. |
grants |
object | Required. A JSON object of string → boolean. May be empty ({} = grants nothing). |
plan_value |
string or null |
Optional. Links a plan row to its plan_config.plan (enforcement config). null for a pure add-on or the manual Enterprise identity. |
badge |
string or null |
Optional. e.g. popular. |
trial_credit_eur |
number or null |
Optional, plan rows only. 0–1000 with at most two decimals (e.g. 3 or 3.50). Absent keeps the stored value; null clears it (no trial credit advertised). |
| (retired) | — | The former Stripe price-id columns are retired: prices come only from the Stripe mirror via stripe_component. null or absent is ignored (a tab opened before the change keeps saving); a non-null value is refused (below). |
stripe_component |
string or null |
Optional. The Stripe binding: ^[a-z0-9_]{1,64}$ (lower-case). Absent keeps the stored value; null clears it. A component is bound to at most one row. |
topup_config |
object | Required on a topup row (absent or null → 400: a full upsert never clears the offer by omission); refused on any other kind. { "currency", "min_minor", "max_minor", "presets" } with the rules above — presets must be a JSON array (a string holding JSON is rejected). The same validator the read side applies. |
Every required field must be present: this is a full-row upsert, so an omitted visibility would silently un-retire a plan and an omitted grants would silently clear a grant — both are rejected (400), not defaulted.
Rejected (fail closed — nothing is persisted)
Every rejection is a 400 unless noted; on rejection the row is not written:
bodyis not a JSON object.- any required field is absent — absent and malformed are different answers, and an absent required field is refused, not defaulted.
slugempty, not^[a-z0-9_-]+$, or over 64 chars.kindnotplan/addon/topup;visibilitynotpublic/internal/retired;self_serve/seat_scalednot0/1;display_ordernot an integer in range.grantsis not a JSON object — including a JSON array (an empty[]is rejected explicitly, not treated as an empty object), a non-string key, or a non-boolean value.- a retired field with a value (absent,
nulland""count as not sent and are ignored) →{"error":"field_retired","field":"<name>","message":"The field '<name>' was removed. Reload the page."}. Retired:stripe_price_seat,stripe_price_seat_annual,stripe_price_flat,stripe_price_flat_annual. trial_credit_euron a row whosekindis notplan→400 invalid_field; on a plan row not a number in0–1000with at most two decimals (a string, a negative,1000.01,1.005) →400.- a
topuprow with aplan_valueor non-emptygrants. - a
topuprow withouttopup_config, or atopup_configthat breaks a rule (the error names the field:topup_config.currency,.min_minor,.max_minor,.presets[<i>], …); atopup_configon aplan/addonrow. stripe_componentis present but not a string/null, or does not match^[a-z0-9_]{1,64}$(upper case is rejected, not folded).stripe_componentis already bound to another catalog row.- an existing row would change
kind. - a
topuprow without astripe_component. - a second catalog edit is already in progress →
409(retry); a concurrent edit (another instance) claimed the same slug orstripe_componentbetween the checks and the write →409(retry).
The price mirror (third-party trust boundary)
The mirror (stripe_price_cache, stripe_product_cache) has exactly one writer: the sync (a webhook-triggered refetch of one object, or the full sync). A catalog PUT never writes it (retired the price-id columns and with them the PUT-time seeding) — a row is connected to Stripe only through its stripe_component, and its prices appear once the sync has mirrored them.
Mirrored facts: unit_amount (minor units; null for a tiered price), currency (upper-case), interval, interval_count, usage_type, tiers + tiers_mode, type (one_time / recurring), billing_scheme, tax_behavior, lookup_key, nickname, the component (from metadata.aiws_component), active, livemode, product_id, whether transform_quantity / custom_unit_amount are set, and whether currency_options configures a currency other than the price's own (Stripe always echoes the price's own currency there). Stripe responses are hostile input: every field is type-checked; metadata is accepted only within Stripe's documented bounds (≤ 50 keys, key ≤ 40, value ≤ 500 chars), names ≤ 255, ≤ 50 tiers with typed fields — an object outside them is rejected, never truncated.
Keeping the mirror equal to Stripe
The mirror follows Stripe without a deploy:
- Webhooks —
price.created|updated|deletedandproduct.created|updated|deleted(see webhooks) trigger a refetch of that one object from Stripe (the event body is never trusted beyond its id). A catalog event is always answered200; any failure only marks the mirror dirty. - Full sync — a cluster-single job (every 60 s it checks; it runs when the mirror is dirty, older than one hour, or never synced) lists our products and their prices and writes them (products with their tax code). It removes mirror rows that are no longer ours only when the whole listing completed, no object was rejected, and the removal stays under a safety breaker (at most half of the mirror, never every active price of a component). A mirrored product that is no longer ours (untagged in Stripe — e.g. the wallet top-up product switched off by ops) first gets its current facts written, so the top-up binding reports the loss in that same run; the next run removes the row. Prices a live subscription bills on (every non-terminal subscription row's price) are never removed, and a later untagging or re-tagging of such a price in Stripe does not unbind it: the mirror keeps its stored component, records the first time the price went missing from our listing (
untagged_at_ms) and raises one ops alert — move the subscription to a new price instead of re-tagging a price in use. The old untagged prices (the former per-plan and per-top-up prices) become removable; the first run that removes them usually trips the breaker once and needs one approval. A change of the top-up product binding is audited, and while the wallet is on a lost or moved binding raises onewallet_topup_bindingops alert (at most one per 30 minutes). A run that trips the breaker is reported aspartial; an operator approves one run past it withPOST /admin/v1/catalog/resync{"allow_prune": true}. - Pinning — every write checks that the configured Stripe key belongs to the deployment's pinned account and that each object's
livemodematches the pinned mode (liveortest). A mismatch refuses the run: nothing is written, nothing removed. - Every write is freshness-guarded: an older read never overwrites a newer one (a webhook refresh during a full sync survives it).
The mirror feeds display and the bindings; a charge never trusts it alone. Checkout sends price ids and Stripe re-derives the authoritative amount itself; a wallet top-up re-reads the bound product live.
Sync status
{
"account_id": "acct_...",
"mode": "test",
"last_attempt_at": 1790000000,
"last_success_at": 1790000000,
"last_result": "ok",
"last_error": null,
"prices_seen": 8,
"products_seen": 1,
"pruned": 0,
"summary": { "unresolved": 0, "ineligible": 0, "resolved": { "custom": { "entries": [ ... ], "truncated": false } } },
"dirty": false,
"backoff_until": null,
"last_resync_at": null,
"mirror_ok": true,
"topup_offer": { "ok": true, "reason": null },
"allow_prune": null,
"resync_gen": 42,
"last_run_gen": 42,
"clean_gen": 42
}
last_result is ok, partial (incomplete listing, a rejected object, or the prune breaker — last_error says which), refused (pin/account/mode mismatch), or failed. On an ok run, a non-null last_error is a warning, not a failure: a price a live subscription bills on could not be refreshed this run (it keeps its previous facts). After a non-ok run the job backs off (15 min, then 60 min); a resync, a restart, or a changed pin clears the backoff. mirror_ok is false when the mirror tables could not be read. topup_offer says whether a wallet top-up can be sold according to the catalog (config + product binding); reason is the refusal token otherwise (see Billing — Wallet).
Prune approval (allow_prune): null when none is recorded, otherwise { "until": <unix s>, "expires_in": <s> }. expires_in is computed on the database clock (never compare until with a browser clock); 0 means expired — no run that starts from now on can use it, but a run that started before until still may, so the approval stays reported for up to 10 minutes past until (exactly as long as such a run can still claim it). Revoke it with DELETE /admin/v1/catalog/allow-prune.
Run generations — how to tell whether your resync has run: the 202 of a resync carries resync_gen, the generation that request created. last_run_gen is the generation of the latest finished run (any result) and clean_gen that of the latest ok run. A run's generation is the one it read at its start, so:
last_run_gen >= resync_gen— a run that started after your request has finished (its result islast_result);clean_gen >= resync_gen— a successful run that started after your request has finished. Whether the mirror is up to date now additionally needsdirty == false, no warning inlast_error, andmirror_ok == true.
resync_gen in the status is the generation of the latest accepted resync (anyone's). All three are null until first written; an older release does not report them — fall back to comparing last_attempt_at (a run's end) with last_resync_at, which cannot tell which run served the request.
Resync
Marks the mirror dirty so the next tick (≤ ~60 s, any instance) runs a full sync, and returns 202 { "accepted": true, "allow_prune": <bool>, "resync_gen": <int | null> } (resync_gen: see run generations; null only if it could not be read — the resync was still accepted). The body is optional; allow_prune (boolean) approves one run past the prune breaker. It is one-shot: the first run that starts within 10 minutes and reaches its prune decision claims it (atomically, before it deletes anything — audited as product_catalog / allow_prune_consume, with breaker_tripped), whether or not the breaker trips; only if it trips does the approval let the run prune past it. A plain resync ({}) does not cancel an approval; DELETE /admin/v1/catalog/allow-prune does. An approval requested while a run is in progress is kept for the next run (a run only uses the approval it saw when it started). Audited (product_catalog / resync).
Rejected: a Content-Type other than application/json → 415; a body that is not a JSON object, or a non-boolean allow_prune → 400; a resync less than 60 s after the previous one (cluster-wide) → 429.
Revoke a prune approval
Disarms a recorded prune approval (platform admin, PLANS_MANAGE). No body is read. 200 { "revoked": true } when an approval was disarmed — audited (product_catalog / allow_prune_revoke, with the disarmed approval's generation and until); { "revoked": false } when none was recorded (idempotent — also when a run has already claimed it: a claimed approval is consumed and cannot be revoked; the claim happens before the run's first delete). A run that has not yet reached its prune decision then behaves exactly as without any approval: the breaker is all-or-nothing — the run removes rows only if its whole removal set stays within the breaker's limits (at most half of the mirror, never every active price of a component); a larger removal is held entirely again (partial, nothing removed). 500 when the state could not be read or written, or when the approval kept changing concurrently — retry. 401/403 without the permission. Cross-origin callers need a CORS preflight (non-simple method), which only the configured admin origin passes.
Which price is charged
The catalog is the only price authority (it retired every other copy — the former plan_config and catalog price-id columns, and the per-price startup gates that preceded it):
- Checkout (sign-up, trial conversion, reactivation) charges the plan's resolved slot — the component price the catalog resolves from the mirror for the chosen interval and currency — with the selected seats as the quantity. No slot →
503 checkout_unavailable, never a guessed price. - Provisioning after a paid checkout uses the plan recorded with the checkout session first.
- A subscription change event (
customer.subscription.updated) maps its price to a plan only through the catalog binding (a mirrored, tagged price of a plan's component). Self-serve customers cannot change plan, interval or seats (the billing portal offers no subscription update), so a plan-changing event only comes from an operator edit in the Stripe dashboard. When the price cannot be mapped the event is still acknowledged (its status, period and cancellation fields are applied) and the plan stays as it is; an audit row (subscription_tier_unresolved) and an ops alert name the reason —subscription_price_unbound(not bound to any plan, even after refetching it),subscription_tier_check_deferred(the catalog could not be read),catalog_plan_value_ambiguous(two plan rows share aplan_value— a configuration error). A price bound to an add-on is not a plan change. The fix is operational (tag the price or correct the catalog, then make Stripe emit a new event for the subscription — see the billing runbook); any later subscription event re-applies the check.
An empty grants object is valid and means the item confers no functional flags — a deliberate state, not an error.