Skip to content

Checkout

POST
/api/billing/checkout
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.

organization_id
required
Organization Id
string format: uuid
Media type application/json
CheckoutRequest

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.

object
billing_cycle
Billing Cycle
string
default: monthly
Allowed values: monthly annual
coupon_code
Any of:
string
>= 1 characters <= 64 characters
mcu_device_count
Any of:
integer
>= 1 <= 100000
payment_method_id
Any of:
string
>= 1 characters <= 255 characters /^[A-Za-z0-9_-]+$/
product_codes
required
Product Codes
Array<string>
>= 1 items <= 16 items

Successful Response

Media type application/json
CheckoutResponse

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
results
required
Results
Array<object>
CheckoutLineItem

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
client_payload
Any of:
object
key
additional properties
any
coupon_applied
Coupon Applied
boolean
currency
Any of:
string
detail
Any of:
string
error
Any of:
string
final_unit_price_cents
Any of:
integer
ok
required
Ok
boolean
plan_code
required
Plan Code
string
product
Any of:
string
provider
Any of:
string
quantity
Any of:
integer
status
Any of:
string
subscription_id
Any of:
string format: uuid
Example
{
"results": [
{
"coupon_applied": false
}
]
}

Validation Error

Media type application/json
HTTPValidationError
object
detail
Detail
Array<object>
ValidationError
object
ctx
Context
object
input
Input
loc
required
Location
Array
msg
required
Message
string
type
required
Error Type
string
Example generated
{
"detail": [
{
"ctx": {},
"input": "example",
"loc": [
"example"
],
"msg": "example",
"type": "example"
}
]
}