Billing
The Billing API is a thin facade over Stripe. Adjudon does not store
card data, does not run its own subscription state machine, and does
not bill anyone directly — the four endpoints on this resource
read plan metadata from environment variables, mint Stripe Checkout
or Customer Portal sessions, and surface the subscription state
Stripe webhooks have written back to Organization.billing. The
authoritative billing record lives in your Stripe dashboard;
/api/v1/billing is the read-and-redirect surface that wires the
Adjudon dashboard to it. Tested against API version v1. JWT auth on
every endpoint; mutations require org-level owner or admin
(checked against OrgMember.role, not the system-level User.role).
What this resource is and is not
The four endpoints on this page are:
GET /— the subscription state Stripe wrote back to the org via webhook plus the live month-to-date trace count.GET /plans— the plan metadata catalogue (names, prices, features) read from env vars so prices can change without a frontend deploy.POST /checkout— mints a Stripe Checkout Session with both the base recurring price and the metered overage price as line items.POST /portal— mints a Stripe Customer Portal session for self-service plan changes, payment-method updates, invoices, and cancellations.
Card data, invoices, dunning, refunds, proration, tax handling, and
PSD2/SCA flows are entirely Stripe's responsibility. Adjudon receives
state changes via the Stripe webhook at /api/stripe/webhook and
writes the resulting plan, status, and currentPeriodEnd to
Organization.billing. There is no Adjudon-native invoice object;
invoices are surfaced via the Customer Portal redirect.
Endpoints
GET /api/v1/billing
GET /api/v1/billing/plans
POST /api/v1/billing/checkout
POST /api/v1/billing/portal
The legacy unversioned mount /api/billing returns the same payloads
with a Deprecation header; migrate to /api/v1/billing.
Get current billing state
GET /api/v1/billing
Returns the active subscription plus this-month trace usage.
curl https://api.adjudon.com/api/v1/billing \
-H "Authorization: Bearer $ADJUDON_JWT"
Response (200 OK):
{
"success": true,
"data": {
"subscription": {
"plan": "scale",
"status": "active",
"currentPeriodEnd": "2026-06-01T00:00:00.000Z",
"cancelAtPeriodEnd": false,
"stripeCustomerId": "cus_PqJ8r2Yt..."
},
"usage": {
"traces": 48217,
"tracesLimit": 100000
},
"invoices": []
}
}
| Field | Type | Description |
|---|---|---|
subscription.plan | enum | sandbox, scale, governance, enterprise, custom |
subscription.status | string | Mirror of Stripe's subscription.status (active, past_due, canceled, trialing, …) |
subscription.currentPeriodEnd | string (ISO 8601) | When the current Stripe billing period closes |
subscription.cancelAtPeriodEnd | boolean | true after the user clicks Cancel in the portal but before period close |
subscription.stripeCustomerId | string | null | Stripe cus_* ID; null for Sandbox orgs that never checked out |
usage.tracesLimit | number | null | null for custom (unlimited) |
invoices | array | Reserved. Currently always []; use the Customer Portal for invoice history |
Errors: 401, 404 ORG_NOT_FOUND, 500 INTERNAL_ERROR.
List plans
GET /api/v1/billing/plans
Returns the plan catalogue. Prices come from PLAN_PRICE_* env vars
so the dashboard can display new pricing without redeploying. The
authoritative price lives in Stripe; this catalogue is a display-only
mirror.
| Plan | Monthly limit | Overage rate | Description |
|---|---|---|---|
| Sandbox | 10,000 | hard block | Solo developers & prototyping |
| Scale | 100,000 | €0.003 / trace | Early-stage teams |
| Governance | 500,000 | €0.001 / trace | Mid-market compliance |
| Enterprise | 2,000,000 | €0.0005 / trace | Large orgs, advanced AI governance |
| Custom | unlimited | custom contract | On-prem / private cloud |
Errors: 401, 500.
Create a Checkout session
POST /api/v1/billing/checkout
| Body field | Required | Description |
|---|---|---|
planId | yes | One of scale_monthly, scale_annual, governance_monthly, governance_annual, enterprise_monthly, enterprise_annual |
Mints a Stripe Checkout Session pre-configured with two line items:
the base recurring price (licensed, quantity 1) and the metered
overage price (no quantity — reported asynchronously via
stripeService.reportMeteredUsage once ingestion crosses the plan
limit). The session's client_reference_id is the Adjudon
organisation ID; the webhook handler reads it on
checkout.session.completed and stamps stripeCustomerId +
subscriptionId on the org.
curl -X POST https://api.adjudon.com/api/v1/billing/checkout \
-H "Authorization: Bearer $ADJUDON_JWT" \
-H "Content-Type: application/json" \
-d '{ "planId": "scale_annual" }'
Response: { "url": "https://checkout.stripe.com/c/pay/cs_live_..." }.
Redirect the browser to that URL; do not embed it.
Errors: 400 INVALID_PLAN, 401, 403 BILLING_FORBIDDEN,
404 ORG_NOT_FOUND, 500 CHECKOUT_FAILED.
Open the Customer Portal
POST /api/v1/billing/portal
Mints a Stripe Customer Portal session and returns the redirect URL. The portal handles every subscription mutation Adjudon does not expose directly: payment-method updates, plan changes, cancellations, invoice downloads, and tax-ID management.
curl -X POST https://api.adjudon.com/api/v1/billing/portal \
-H "Authorization: Bearer $ADJUDON_JWT"
Errors: 400 NO_STRIPE_CUSTOMER (org never checked out), 401,
403 BILLING_FORBIDDEN, 404, 500 PORTAL_FAILED.
Common gotchas
- The org-role gate is its own check. Mutations call
requireOrgAdmin, which readsOrgMember.role— the per-org membership role — not the system-levelUser.rolethe rest of the API uses. A user who is anadminon the workspace but amemberon the organisation cannot start checkout. SurfaceBILLING_FORBIDDENclearly in the UI. - Overage is metered, not pre-paid. The Checkout Session attaches
a metered price line item with no quantity. Adjudon reports usage
asynchronously via
stripe.billing.meterEvents.createwhenever ingestion exceeds the plan limit; Stripe meters it onto the next invoice. There is no API to read the current overage tally on this resource — check the Customer Portal invoice or GET /usage/current for the trace count. invoicesis always[]. The field is reserved on the response shape but the implementation never populates it. Treat the Customer Portal as the source of truth for invoice history; do not build an invoice list off this field.- Sandbox is a stripe-less plan. Sandbox orgs have no Stripe
customer record and no period end —
stripeCustomerIdisnull,currentPeriodEndfalls back to the first of next month, andPOST /portalreturns400 NO_STRIPE_CUSTOMER. Upgrade flows must go through Checkout, not Portal. - Stripe is the source of truth. Adjudon writes plan / status /
period-end on the
customer.subscription.*andcheckout.session.completedwebhook events; if a user cancels via the Portal, thecancelAtPeriodEndflag flips on the next webhook delivery, not on the Portal-redirect call. Build UI states that tolerate a brief window where the cached Adjudon state lags Stripe by a few hundred milliseconds.
See also
- Usage API — the trace-count meter that drives the metered overage Stripe bills against
- Plans & Features — the full feature-gate matrix per plan
- Sub-Processors — Stripe (Ireland) is the payment processor of record under EU SCCs
- Error Codes — the full error taxonomy