Billing
GET /api/orgs/{org_id}/billing
Section titled “GET /api/orgs/{org_id}/billing”Get Billing
The org’s billing state, plus what it can buy.
This is the endpoint the UI is driven by, including immediately after a
checkout. Polar’s success redirect and Polar’s webhook race each other,
and both orders happen; the redirect carries a checkout id for support, and
the page polls here until subscription.plan_code is the plan that was
bought. Reading the plan off the redirect would show a customer a plan they
have not been provisioned for, or a stale one after they have.
plans and packs list only what is sellable — a Polar product id
is registered for it. That is why trial and enterprise never appear
here, with no list of plan codes anywhere in this module to keep in sync.
Member-level: a member sees the page and gets can_manage = false, so the
buttons can be disabled with a reason instead of the page 403-ing.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org_id | path | string (uuid) | yes |
Responses
| Status | Description | Body |
|---|---|---|
| 200 | Successful Response | BillingResponse |
| 422 | Validation Error | HTTPValidationError |
POST /api/orgs/{org_id}/billing/cancellation
Section titled “POST /api/orgs/{org_id}/billing/cancellation”Set Cancellation
Cancel at the end of the paid period, or undo that.
One endpoint with a boolean rather than /cancel and /uncancel,
because it sets one field on Polar’s side and a pair of verbs would be two
routes that can disagree about which one won.
Not a revocation and not an entitlement change. The customer paid for
this period and keeps every capability in it; what changes is whether it
renews. When it finally ends, Polar sends subscription.revoked and the
webhook lands the org on trial with its purchased credits intact (§6.4).
Like every other route here it writes nothing. Our
cancel_at_period_end column is written by the subscription.canceled /
subscription.uncanceled delivery, so this returns what Polar answered and
GET /billing catches up within seconds. A second writer for that flag
would be the same mistake as a second writer for the plan, with lower stakes
and no upside.
Unlike a plan switch this has no pending marker. A switch has just taken the customer’s money and leaves every visible number stale until the webhook lands, which is worth a column; a cancellation moves no money, changes no entitlement and no balance, so a stale flag for a few seconds costs a caller who ignores this response nothing at all.
OWNER or ADMIN only — it decides whether the org keeps paying.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org_id | path | string (uuid) | yes |
Request body (required)
| Field | Type | Required | Description |
|---|---|---|---|
| cancel_at_period_end | boolean | yes |
Responses
| Status | Description | Body |
|---|---|---|
| 200 | Successful Response | CancellationResponse |
| 422 | Validation Error | HTTPValidationError |
POST /api/orgs/{org_id}/billing/change-plan
Section titled “POST /api/orgs/{org_id}/billing/change-plan”Change Billing Plan
Switch a live subscription onto another plan, billed pro rata now.
This endpoint applies nothing. It calls Polar and returns. Our
plan_id, the entitlements it resolves and the credit movement all happen
when a Polar delivery confirms the switch.
That is not caution, it is the only correct shape. The grant key is scoped by
an identifier for this switch, precisely so a customer returning to a plan
they have held before (pro → team → pro) is granted for it rather than
refused by a once-ever key. An endpoint that moved the plan itself would
leave the confirming delivery arriving at an already-changed plan, where
change_plan short-circuits — and the identifier would never reach a grant
key at all. The customer would pay and receive nothing, silently. That is the
defect #497 fixed and #502’s handlers are built around.
What it does write is a marker naming the plan asked for and a
change_id. It does two jobs:
GET /billingreports the gap, so a customer who has just been charged a proration does not see a page where nothing has changed;- it is what lets a downgrade land at all. A downgrade’s proration is a
credit, not a charge, so it may never produce an
order.paid— and that event is the sole writer of a paid plan change. A subscription event is allowed to confirm the switch this marker names, keyed on itschange_id, which keeps one logical writer with two possible confirmations. Seeservices/billing/plan_change.py.
Refusals:
- unknown
plan_code→ 404; - a plan with no Polar product → 409 (
trial,enterprise); - the plan the org is already on → 409; Polar would bill a proration of
nothing and our
change_planshort-circuits anyway; - no live external subscription → 409, with checkout as the answer;
- the same switch already in flight → 409, so a double-click cannot buy two prorations. A switch to a different plan is allowed and replaces the marker: a customer changing their mind is not an error;
- Polar refusing (a currency change, an already-cancelled subscription) → 422 carrying its reason; Polar unavailable → 502.
OWNER or ADMIN only.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org_id | path | string (uuid) | yes |
Request body (required)
| Field | Type | Required | Description |
|---|---|---|---|
| plan_code | string | yes | Plan to switch to, e.g. team. |
Responses
| Status | Description | Body |
|---|---|---|
| 200 | Successful Response | ChangePlanResponse |
| 422 | Validation Error | HTTPValidationError |
POST /api/orgs/{org_id}/billing/checkout
Section titled “POST /api/orgs/{org_id}/billing/checkout”Create Checkout
Open a Polar checkout for one plan or one credit pack.
Returning a URL means the customer has somewhere to pay, nothing more.
No plan is changed, no credit is granted and nothing is written to
org_subscriptions here. All of that happens when the webhook lands, so
that a checkout the customer abandons — or one Polar later rejects — leaves
no trace on the org.
Refusals, and why each is the status it is:
- unknown
plan_code/pack_code→ 404, it does not exist; - a plan or pack with no Polar product registered → 409, it exists and
is deliberately not for sale (
trial,enterprise, a pack an operator has not finished registering); - a plan checkout while the org already has a live Polar subscription →
409, because a second subscription for one org collides with
ux_org_subscriptions_external_subafter the card is charged. Switching plans is a different operation with a different endpoint.
Pack checkouts are never refused for having a subscription: a top-up is a one-off purchase and an org may buy as many as it likes.
The buyer’s details are prefilled, and one person may buy for many orgs.
The Polar customer is the payer; the org this purchase is for travels in
the checkout metadata, which Polar copies onto the order and the
subscription. So a second org checked out by the same person reuses their
billing account — no second email to invent, nothing retyped — and the
delivery still resolves to the org named here. See
:func:~agentdepot_core.services.billing.polar_client.create_checkout_session.
OWNER or ADMIN only.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org_id | path | string (uuid) | yes |
Request body (required)
| Field | Type | Required | Description |
|---|---|---|---|
| pack_code | string | no | One-off credit pack to buy, e.g. pack_1k. |
| plan_code | string | no | Plan to subscribe to, e.g. pro. |
Responses
| Status | Description | Body |
|---|---|---|
| 200 | Successful Response | CheckoutResponse |
| 422 | Validation Error | HTTPValidationError |
POST /api/orgs/{org_id}/billing/portal
Section titled “POST /api/orgs/{org_id}/billing/portal”Create Portal
Mint a fresh customer-portal session for the org’s billing account.
Invoices, payment method and cancellation. Not plan changes — those are disabled on the Polar side, because a switch made in the portal produces no order id, and the grant key that keeps one payment from being applied twice is keyed on the order id. A customer who switched there would pay and receive nothing.
The token is short-lived, so this is called on the click and the URL is never prefetched or cached. An org that has never checked out has no Polar customer, which is answered from our own column without troubling Polar — a state to explain, not an error.
⚠️ The portal belongs to the payer, not to this org. Its scope is the Polar customer, and under the payer model one person’s customer bills for every org they bought for — so this link shows that person’s other orgs’ invoices and subscriptions too, and can cancel them. That is inherent in letting one card buy for many orgs; an org wanting its billing walled off needs a different payer. OWNER or ADMIN only, which is the only gate there is on it.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org_id | path | string (uuid) | yes |
Responses
| Status | Description | Body |
|---|---|---|
| 200 | Successful Response | PortalResponse |
| 422 | Validation Error | HTTPValidationError |