Prima Get an API key

Prima API

Credits and auto top-up

A prepaid balance, a hosted checkout to add to it, and a standing instruction that tops it up when it falls below a line you set.

Planned Decided and written down. Not built, or built and not yet released. Do not integrate against it until the card says live.

What it does

The balance is prepaid. Every call is debited against it, and a call the balance will not cover is refused with a 402 carrying insufficient_credit, whose message names the top-up URL.

A new account gets a one-time welcome grant of 5,000 Sparks. Once, at signup, not monthly.

Topping up is a hosted checkout: POST /credits/topup returns a checkout_url, the browser goes there as a full-page redirect, and settlement arrives by webhook. No card field appears anywhere in our own interface.

Auto top-up is one standing instruction per tenant: a threshold in Sparks, a tier to buy, and a monthly ceiling. When the balance crosses the threshold downward, the tier is charged to the card on file, once per crossing.

Sending the same instruction again with enabled: false switches it off and keeps the card. DELETE forgets the card as well.

Inputs and outputs

GET /credits/tiers The amounts you can buy. Never hard-code this list.
POST /credits/topup {product_id, success_url, cancel_url, save_card} in, {order_id, status, checkout_url, amount_cents, currency, topup_url} back. save_card: true banks the card for auto top-up and charges the tier in the same checkout, and needs the session: with a key it is 403 session_required. A plain top-up, save_card: false, takes either credential, and with either it is the owner's or an admin's to make: the role is checked on the user the key or the session belongs to, so the owner's own key still opens a checkout and a member's key or session is 403 owner_required.
GET /credits/auto-topup Always 200 and always the same shape, whether or not an instruction exists. configured means a card is on file, not that a row exists: after a DELETE it reads false off a row that is still there. retry.status is idle, pending (with retry_after and a block_reason) or stalled.
DELETE /credits/auto-topup No body. Switches the instruction off and forgets the card. The same shape back, with configured: false. Session only; a key is 403 session_required.
PUT /credits/auto-topup {enabled, threshold_sparks, product_id, monthly_cap_cents}. amount_cents is copied from the tier and is never sent by the caller. Session only; a key is 403 session_required.
GET /credits/history Orders newest first, each with kind (topup or auto_topup), status, grant_status and the provider's transaction id. Keyset-paginated on an opaque cursor.
Balance Not on any of these. The balance is on /usage.
Units 1 Spark is 1,000 micro-CHF, so CHF 1 is 1,000 Sparks and a tier's amount_cents times 10,000 is its value in micro-CHF. credit_micro on a tier is that product, for display.

Configuration

Credential Two are accepted, and they are equal for reading only. A gateway key in X-API-Key reads every one of these paths and opens a plain top-up. PUT /credits/auto-topup, DELETE /credits/auto-topup and a top-up with save_card: true take a Labs session only: Authorization: Bearer <access token> or the access_token cookie, plus X-Workspace-ID when the person belongs to more than one workspace. A key on those three is refused with 403 session_required since alchemy_labs e428dab, before any role is looked at. The key is the credential that lives in env files and CI, and a leaked one must not be a lever on the card. Which credential is one question; which person is the next row.
Who may change it The workspace owner, or a member whose role in the workspace is admin. Anyone else is refused with 403 and the machine token owner_required, in the same envelope as session_required. That is the rule on the three /credits writes: POST /credits/topup with any body, PUT /credits/auto-topup and DELETE /credits/auto-topup. Owner means workspaces.owner_id, the column, so an owner without a workspace_users row passes; admin means an active workspace_users row whose role is owner or admin; member, viewer, desk_requester and any unknown role are refused. On the one write a key may make, a plain top-up, the role is checked on the key's own user. Reads are untouched: any active member's session and the key keep reading. Decided 2026-09-11 (A-1) and built: alchemy_labs 3d21356 on w1-auto-topup adds _require_billing_role, called after _require_labs_session on all three writes, backed by identity.user_may_change_billing. Not deployed.
Threshold threshold_sparks, at least 1 while the instruction is enabled. Switching off is enabled: false, never a threshold of zero.
Monthly ceiling monthly_cap_cents, at least the price of the chosen tier. It clears itself at the start of each month.
Card Banked by a top-up with save_card: true. Enabling auto top-up with no card on file is refused with a 409 no_card_on_file.
Sync state threshold_sync_status in the auto top-up response. While it reads pending or stuck, the threshold has not reached the ledger and nothing will fire, however green the toggle looks.

Limits

  • Not enabled on this deployment yet. Until the ingress rules for /credits* are applied, every one of these paths answers 404 in Prima's own envelope, with code: null. Read that as "billing is not enabled here", not as an error and not as an empty tier list.
  • Three declined charges in a row disable the instruction and send mail. A charge whose outcome is unknown, for example a timeout, is not counted as a decline and does not send mail; it is reconciled against the payment provider before anything is retried.
  • A crossing that cannot be charged straight away, because of a cooldown, a reached monthly ceiling or an inactive tier, stays owed and is retried. After seven days unresolved the instruction is paused until a manual top-up, and says so.
  • Refunding an auto top-up does not cancel the standing instruction.
  • Enabling auto top-up never charges by itself. If the balance is already below the threshold, the crossing fires immediately and the charge follows within seconds.
  • A key cannot change or delete the standing instruction, and cannot bank a card. Since 2026-09-11 (alchemy_labs e428dab, on the auto top-up branch) PUT and DELETE /credits/auto-topup and save_card: true answer 403 session_required to an ai-scoped key, in the account service's envelope: type: permission_error, code: AUTHZ_001, and the token inside message. Reading the instruction with the key still works. A client that saved with the key before that commit has to save with the session now.
  • A session is not enough on its own: the writes are for the workspace owner or an admin member, and any other member is refused with 403 owner_required. The two 403s ask for different things. session_required is a credential failure, "not you right now": keep what the person was trying to save and send it again with a fresh session. owner_required is final for this person: show "ask the workspace owner or an admin", disable the write controls, and do not retry. Reads stay open to any active member and to the key.
  • The owner-or-admin rule covers every billing write on this surface: the three /credits writes and, since alchemy_labs 8b5361c, the subscription writes POST /sparks/checkout, POST /sparks/change and POST /sparks/cancel. Each takes the session first (a key is 403 session_required) and then the role (anyone but the owner or an admin is 403 owner_required). Reads are unchanged.
  • Errors on these paths come in the gateway envelope with the account service's own code (VAL_001, AUTHZ_001, RES_003 and so on), and the machine token (session_required, owner_required, no_card_on_file, cap_below_amount, threshold_out_of_range, unknown_credit_tier, topup_not_configured, invalid_cursor) sits inside message as the text 'error': '<token>'. Match it there. See the errors card.
  • With a Labs session instead of a key, a person who belongs to more than one workspace has to send X-Workspace-ID, or these paths answer 400.

Example

read the instruction with the key, change it with the session

curl https://api.prima.li/api/prima/v1/credits/auto-topup \
  -H "X-API-Key: $PRIMA_API_KEY"

curl -X PUT https://api.prima.li/api/prima/v1/credits/auto-topup \
  -H "Authorization: Bearer $LABS_ACCESS_TOKEN" \
  -H "X-Workspace-ID: $WORKSPACE_ID" \
  -H "Content-Type: application/json" \
  -d '{"enabled": true, "threshold_sparks": 2000,
       "product_id": "…", "monthly_cap_cents": 10000}'

Source of truth

  • philosophers_stone/operations/decisions/0036-prepaid-credits-auto-topup-and-balance-events.md (amended 2026-09-11 (7))
  • philosophers_stone/operations/decisions/0036-prepaid-credits-auto-topup-and-balance-events.md § 4 and § 4a (the tenant endpoints, the standing instruction, the three-strike disable, the pending crossing and the seven-day pause)
  • philosophers_stone/operations/decisions/0036-prepaid-credits-auto-topup-and-balance-events.md § 5 (`save_card`, the tiers, and why a refund does not cancel the instruction)
  • philosophers_stone/operations/decisions/0036-prepaid-credits-auto-topup-and-balance-events.md § 7 (ingress; why every `/credits*` path answers 404 today)
  • prima/prima/errors.py (the 402 `insufficient_quota` / `insufficient_credit` with the top-up URL in the message)
  • prima/prima/app.py (the route-miss 404 carrying `code: null`)
  • alchemy_labs/backend/api/routers/primaapi_router.py, branch `w1-auto-topup` (the topup response fields, the auto top-up request and response shapes, `configured` as "a card is on file", the `retry` block, the validation order on PUT)
  • alchemy_labs/backend/api/routers/primaapi_router.py, branch `w1-auto-topup` (`_require_labs_session`: `auth_method == "jwt"` or 403 `session_required`, called by `put_auto_topup`, `delete_auto_topup` and `credits_topup` when `save_card` is true; `get_auto_topup` and the other reads do not call it)
  • alchemy_labs/backend/api/routers/primaapi_router.py, branch `w1-auto-topup` @ 3d21356 (`_require_billing_role`: 403 `owner_required` with the hint "only the workspace owner or an admin can change billing", called after `_require_labs_session` on `put_auto_topup` and `delete_auto_topup`, and on `credits_topup` for every body; the reads do not call it; `sparks_checkout`, `sparks_change` and `sparks_cancel` call neither guard at 47d1840)
  • alchemy_labs/backend/tests/prima_credits/test_auto_topup_endpoints.py, branch `w1-auto-topup` (the owner without a membership row writes; an admin writes; a plain member is refused on every write and nothing moves; an inactive admin row is not an admin; a plain member still reads; a key cannot bank a card but may top up; `BILLING_ROLES` is pinned to the `Role` enum)
  • alchemy_labs/backend/services/primaapi/identity.py (`resolve_gateway_identity`: `X-API-Key` with the `ai` scope, or a Labs session with `X-Workspace-ID`; 400 when the workspace is ambiguous; `_user_has_workspace_access`: the owner or any active `workspace_users` row, the read-side check; `user_may_change_billing` and `BILLING_ROLES = ("owner", "admin")`, the write-side predicate since 3d21356: a `workspaces.owner_id` match or an active `workspace_users` row whose role is in that tuple, one text-SQL query)
  • philosophers_stone/operations/decisions/0036-prepaid-credits-auto-topup-and-balance-events.md § 4, "Who may change the instruction" (amended (5): the two credentials are equal for reading and not for writing) and "Which member may change it" (amended (7), A-1 decided 2026-09-11: owner by `workspaces.owner_id` or an active `owner`/`admin` membership row; the order of the guards; a plain top-up with the key is role-checked on the key's user; `owner_required` is final for this user)
  • philosophers_stone/operations/prima-onboarding-phase2-report.md § 5.A, the A-1 block (decided 2026-09-11: every billing write is for the workspace owner or an admin role, any other member is 403 `owner_required`; the W1 guard, the W6 message and this wording follow from it)
  • philosophers_stone/operations/prima-onboarding-phase2b-report.md § 4.2 (A-1 as it stood before 3d21356: the session check only, the owner or any active member; the one W1 commit it named is 3d21356)
  • alchemy_labs/backend/services/workspace/models.py (`workspaces.owner_id`; `workspace_users.role`, a `String(50)` whose documented values are `owner`, `admin`, `member`, `viewer`; `desk_requester` is a real value below `viewer`)
  • alchemy_labs/backend/services/workspace/isolation.py (`verify_workspace_access(required_role="admin")`: the role hierarchy the guard's predicate composes, `viewer` below `member` below `admin` below `owner`, unknown below all) and alchemy_labs/backend/services/auth/rbac_service.py (`is_workspace_admin`: the same membership test elsewhere in Labs, not what the guard calls; it does not read `owner_id`, so on its own it would refuse an owner without a membership row)
  • alchemy_labs/backend/app_fastapi.py (`http_exception_handler`: the envelope on these paths, its `code`, and the token inside `message`)
  • alchemy_labs/backend/core/sparks_config.py (`MICRO_PER_SPARK` = 1,000)
  • philosophers_stone/operations/prima-onboarding-implementation-plan.md § D7 (the welcome grant is one-time)
  • philosophers_stone/platform/primaapi.md § Self-serve signup (the 5,000 Sparks welcome grant, delivered at signup)