Skip to content

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

NameInTypeRequiredDescription
org_idpathstring (uuid)yes

Responses

StatusDescriptionBody
200Successful ResponseBillingResponse
422Validation ErrorHTTPValidationError

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

NameInTypeRequiredDescription
org_idpathstring (uuid)yes

Request body (required)

FieldTypeRequiredDescription
cancel_at_period_endbooleanyes

Responses

StatusDescriptionBody
200Successful ResponseCancellationResponse
422Validation ErrorHTTPValidationError

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 /billing reports 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 its change_id, which keeps one logical writer with two possible confirmations. See services/billing/plan_change.py.

Refusals:

  • unknown plan_code404;
  • 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_plan short-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

NameInTypeRequiredDescription
org_idpathstring (uuid)yes

Request body (required)

FieldTypeRequiredDescription
plan_codestringyesPlan to switch to, e.g. team.

Responses

StatusDescriptionBody
200Successful ResponseChangePlanResponse
422Validation ErrorHTTPValidationError

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_code404, 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_sub after 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

NameInTypeRequiredDescription
org_idpathstring (uuid)yes

Request body (required)

FieldTypeRequiredDescription
pack_codestringnoOne-off credit pack to buy, e.g. pack_1k.
plan_codestringnoPlan to subscribe to, e.g. pro.

Responses

StatusDescriptionBody
200Successful ResponseCheckoutResponse
422Validation ErrorHTTPValidationError

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

NameInTypeRequiredDescription
org_idpathstring (uuid)yes

Responses

StatusDescriptionBody
200Successful ResponsePortalResponse
422Validation ErrorHTTPValidationError