Skip to content

Admin Billing

Get Billing Catalog

Every plan and pack with its Polar product mapping, plus collisions.

null on a product id is a state, not a gap: it means the row is not sellable through Polar, which is correct for trial and enterprise and is what lets the checkout API refuse them without a special-case list.

collisions is what the database cannot enforce. It is advisory — the webhook resolver is the authority and its answer to an ambiguous product id is unresolved, i.e. a payment that lands nowhere until somebody fixes the mapping here.

Responses

StatusDescriptionBody
200Successful ResponseBillingCatalogResponse
422Validation ErrorHTTPValidationError

List Billing Events

The webhook ingress log, newest first, payloads included.

status_counts covers the whole table rather than the page, because the question this screen answers is “is any money stranded”, and a page of applied rows says nothing about the unresolved one behind it.

Payloads are returned whole. That is the point of storing them: when a customer says “I paid and got nothing”, this plus credit_ledger is the entire answer, and Polar’s own delivery log is not queryable from our side.

Parameters

NameInTypeRequiredDescription
limitqueryintegerno
offsetqueryintegerno
org_idquerystring (uuid)no
statusquerystringnoFilter to one BillingEventStatus value.

Responses

StatusDescriptionBody
200Successful ResponseBillingEventListResponse
422Validation ErrorHTTPValidationError

POST /api/admin/billing/events/{event_id}/replay

Section titled “POST /api/admin/billing/events/{event_id}/replay”

Replay Billing Event

Re-run a stored delivery’s effect.

Re-runs the effect, not the status stamp. Marking a row applied without moving any money would turn a stranded payment into an invisible one, which is strictly worse than leaving it stranded. Re-applying is safe because every writer underneath is idempotent on its own deterministic ledger key.

Refuses an applied row: that status is the only thing a redelivery is allowed to short-circuit on, and re-running it would be the one case the ledger keys are not protecting against a second human decision.

The effect itself lives in the webhook ingress. When that is not in the build this answers 503 naming the symbol it looked for, and touches nothing — no status change, no attempts bump — so the row stays exactly as diagnosable as it was.

Parameters

NameInTypeRequiredDescription
event_idpathstring (uuid)yes

Responses

StatusDescriptionBody
200Successful ResponseBillingEventReplayResponse
422Validation ErrorHTTPValidationError

List Credit Packs

Every pack, active or retired, in display order.

Responses

StatusDescriptionBody
200Successful ResponsePackProductRow[]
422Validation ErrorHTTPValidationError

Create Credit Pack

Create a credit pack. code must be unique.

Request body (required)

FieldTypeRequiredDescription
codestringyes
creditsnumber | stringyesCredits granted on payment. Must be positive and representable in NUMERIC(20, 6) — a pack worth zero credits is a mistake, not a state anyone chose.
is_activebooleanno
namestringyes
polar_product_idstring (uuid)no
price_currencystringnoISO 4217 alpha-3 the price is quoted in. Set it to whatever the Polar product is priced in — the figure above carries no currency of its own, and every surface that renders it reads this.
price_displaynumber | stringnoDisplay only. The price actually charged is the one on the Polar product; nothing bills from this column.
sort_orderintegerno

Responses

StatusDescriptionBody
201Successful ResponsePackProductRow
422Validation ErrorHTTPValidationError

Update Credit Pack

Replace a pack. Complete payload, like a plan write.

Editing credits does not retro-adjust anyone: a purchase already made wrote its own ledger row for the amount that was in force at the time, and the ledger is append-only. This changes what the next buyer gets.

Parameters

NameInTypeRequiredDescription
pack_idpathstring (uuid)yes

Request body (required)

FieldTypeRequiredDescription
codestringyes
creditsnumber | stringyesCredits granted on payment. Must be positive and representable in NUMERIC(20, 6) — a pack worth zero credits is a mistake, not a state anyone chose.
is_activebooleanno
namestringyes
polar_product_idstring (uuid)no
price_currencystringnoISO 4217 alpha-3 the price is quoted in. Set it to whatever the Polar product is priced in — the figure above carries no currency of its own, and every surface that renders it reads this.
price_displaynumber | stringnoDisplay only. The price actually charged is the one on the Polar product; nothing bills from this column.
sort_orderintegerno

Responses

StatusDescriptionBody
200Successful ResponsePackProductRow
422Validation ErrorHTTPValidationError

Delete Credit Pack

Delete a pack.

Deactivating (is_active=false) is nearly always the right move instead: a retired pack that stays in the table keeps an old purchase explainable, and a deleted one leaves a paid order.paid replay with no product to resolve against. Deletion stays available for a pack created by mistake and never sold.

Parameters

NameInTypeRequiredDescription
pack_idpathstring (uuid)yes

Responses

StatusDescriptionBody
204Successful Response
422Validation ErrorHTTPValidationError

Get Org Billing

This org’s external subscription, Polar’s verbatim status, and its link.

Two status columns, on purpose. external_status is what Polar said; status is what our period job and credit gates read. Conflating them would let a Polar state we have never heard of silently mean “active” or silently mean “blocked”, and both are wrong — so they are shown side by side and a disagreement is something a human can see.

Parameters

NameInTypeRequiredDescription
org_idpathstring (uuid)yes

Responses

StatusDescriptionBody
200Successful ResponseOrgBillingResponse
422Validation ErrorHTTPValidationError

PUT /api/admin/plans/{plan_id}/polar-products

Section titled “PUT /api/admin/plans/{plan_id}/polar-products”

Set Plan Polar Products

Register (or clear) a plan’s Polar product ids.

Sending null is a deliberate write meaning “not sellable”, not an omission — which is why this is its own endpoint rather than two more fields on the plan editor’s total-replace payload.

A duplicate id comes back as 409 rather than a 500: the partial unique index is the guard that stops two plans resolving one product ambiguously, and an operator hitting it deserves to be told which guard they hit.

Parameters

NameInTypeRequiredDescription
plan_idpathstring (uuid)yes

Request body (required)

FieldTypeRequiredDescription
polar_product_idstring (uuid)no
polar_product_id_annualstring (uuid)no

Responses

StatusDescriptionBody
200Successful ResponseBillingCatalogResponse
422Validation ErrorHTTPValidationError