Checkout
const url = 'https://example.com/api/billing/checkout?organization_id=2489E9AD-2EE2-8E00-8EC9-32D5F69181C0';const options = { method: 'POST', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"billing_cycle":"monthly","coupon_code":"example","mcu_device_count":1,"payment_method_id":"example","product_codes":["example"]}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}curl --request POST \ --url 'https://example.com/api/billing/checkout?organization_id=2489E9AD-2EE2-8E00-8EC9-32D5F69181C0' \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "billing_cycle": "monthly", "coupon_code": "example", "mcu_device_count": 1, "payment_method_id": "example", "product_codes": [ "example" ] }'Put this organization on a tier (§13).
The cart is a list for §12’s sake and the per-line result shape is
unchanged, but since §13.2 only ONE thing in it can be bought: a tier
plan_code. Every à la carte code comes back plan_not_purchasable. This
still answers 201 as soon as ANY line was bought, even alongside a refused
one — partial success is not an error to report as one, since the
successful line has already created a real resource. When nothing at all
was bought, the first line’s reason picks the status
(_failed_cart_status). Either way the body is the full per-line list.
The main-DB entitlement write lives here, not in the service.
checkout.py never touches the main database (its module docstring is
explicit), so a tier outcome that takes effect without a gateway
activation webhook to project it — a $0 tier, an accepted
upgrade/downgrade, a re-run against a tier the org is already paid up on —
comes back flagged requires_local_projection and is applied below.
Order is critical DB first (already committed by the service), main DB
second, so a crash between them under-serves rather than over-serves. A
failure of the second write is a real 500: every path that sets the flag
is idempotent, so the client’s retry — or, for a paid change, the
gateway’s own customer.subscription.updated — re-applies exactly the
same absolute state.
Retry safety is entirely the service’s (§10 / checkout.py): a resubmit
of a cart whose products are already live comes back as per-line
already_subscribed rather than a second charge, backstopped by
uq_subscriptions_one_live_per_product and a row lock on the buyer.
Nothing here may add a second guard on top — a duplicate check at this
layer would have to duplicate that lock to be correct, and a
non-serialized version of it would only widen the window it is trying to
close. checkout_cart is therefore called exactly once per request, with
no background task or retry wrapper around it.
Coupons are equally the service’s: one applied redemption per (coupon,
user) is a partial unique index, not an in-request check, so submitting
the same code again on a separate request — for a different product,
even a different organization — is refused coupon_already_redeemed on
its line. See coupons.redeem_coupon.
Authorizations
Section titled “ Authorizations ”Parameters
Section titled “ Parameters ”Query Parameters
Section titled “ Query Parameters ”Request Bodyrequired
Section titled “ Request Bodyrequired ”POST /api/billing/checkout — an à la carte cart.
product_codes are PLAN codes (base_ota, addon_monitoring, …), not
organization_entitlements.product values — §12 turned §2’s three fixed
bundles into one plan per product, so a cart is a list of plan codes and
the request-level name from §10 (plan_code) became plural. Unknown codes
are deliberately NOT rejected here: checkout_cart reports each one as its
own unknown_plan line, which keeps a cart with one bad code from
discarding the good ones. min_length=1 is the one shape rule enforced up
front, so that a NoPurchasableProductsError from the service
unambiguously means “non-empty cart, nothing sellable in it” (404) rather
than “empty cart” (422, raised right here by pydantic).
payment_method_id is the gateway’s own tokenized-card handle from
POST /billing/stripe/setup-intent (Stripe only; omitted for
razorpay/paypal per §10). It is forwarded verbatim to the gateway, so it
is constrained to an opaque-token character set — this is a request field
that becomes an argument to an external API call, and no legitimate token
contains anything else.
Responses
Section titled “ Responses ”Successful Response
POST /api/billing/checkout — one result per requested product code.
Returned at EVERY status, not only 201: a cart is per-product independent
(§12), so “monitoring bought, crash-reporting already subscribed” is a real
outcome that a bare {"detail": …} error body could not express. The
status code summarizes (201 when anything was bought, else the failing
line’s own code) and this body is where the client reads what actually
happened.
object
One cart line’s outcome — mirrors checkout.CheckoutLineResult.
ok decides which half of the fields is populated: a refused line carries
error/detail, a bought one carries the subscription and its client
payload. error is a wire-level string the console maps to copy —
CheckoutErrorCode values (already_subscribed, …) or, for a refused
coupon, a CouponRejectionReason value (coupon_already_redeemed, …).
object
Example
{ "results": [ { "coupon_applied": false } ]}Validation Error
object
object
object
Example generated
{ "detail": [ { "ctx": {}, "input": "example", "loc": [ "example" ], "msg": "example", "type": "example" } ]}