Billing API
Quazzar Space uses Stripe for payment processing. This reference covers listing plans, choosing one, reaching the customer portal, and retrieving invoices.
Base URL
https://app.quazzar.spaceThese endpoints are not under a /billing/public prefix. The customer-facing billing routes are mounted at the API root: /plans, /me/subscription, /me/invoices, /me/billing/portal, /subscriptions/choose. Everything except GET /plans requires an Authorization: Bearer <token> header — see the Authentication guide.
Subscription Plans
GET /plans
Public — no token required. Returns every plan marked public.
curl https://app.quazzar.space/plansResponse (200 OK): a plain JSON array.
[
{
"id": "plan-uuid-team-monthly",
"name": "Team",
"interval": "monthly",
"price_cents": 7900,
"currency": "USD",
"perks_text": "For growing DevOps teams",
"max_projects": 15,
"max_envs_per_project": 5,
"max_services": 100,
"max_project_users": 25,
"max_api_keys_per_user": 15,
"support_level": "email_chat_24h",
"features": {
"crud_basic": true,
"crud_pro": true,
"crud_api": true,
"multi_cloud": true,
"providers": ["aws", "gcp", "azure", "hetzner", "ovh", "digitalocean"],
"auto_discovery": ["aws", "gcp", "azure"],
"monitoring": { "enabled": true, "integrations": ["prometheus", "grafana", "zabbix"] },
"users_included": 5,
"extra_user_price_cents": 1200,
"api_rate_limit": 5000,
"data_retention_days": 90,
"rbac_custom_roles_limit": 15,
"scheduler_ansible": false,
"ai_analysis": false
},
"is_public": true
}
]Three things to note about the shape:
- Prices are integer cents in
price_cents, with acurrency. There is no decimalmonthly_price. featuresis an object, not an array of strings. Most values are booleans, but some are numeric quotas (rbac_custom_roles_limit), some are strings (cost_estimate: "realtime_all_providers"), some are arrays (providers), and some are nested objects (monitoring).- Monthly and annual are separate plan records with their own
idandinterval. There is noannual_pricefield on a monthly plan — to offer both, read the two rows sharing aname. The limits are identical between them. - Limits are flat fields on the plan (
max_projects,max_envs_per_project, …), not a nestedlimitsobject.nullmeans unlimited.
Plan tiers and limits
| Plan | Interval | Price | Projects | Envs/project | Services | Project users | API keys | Support |
|---|---|---|---|---|---|---|---|---|
| Free | none | $0 | 1 | 1 | 5 | 1 | 1 | community |
| Starter | monthly | $29.00 | 5 | 3 | 25 | 10 | 5 | email_48h |
| Starter | annual | $278.00 | 5 | 3 | 25 | 10 | 5 | email_48h |
| Team | monthly | $79.00 | 15 | 5 | 100 | 25 | 15 | email_chat_24h |
| Team | annual | $758.00 | 15 | 5 | 100 | 25 | 15 | email_chat_24h |
| Business | monthly | $199.00 | 50 | 10 | 500 | 100 | 50 | email_chat_12h |
| Business | annual | $1,910.00 | 50 | 10 | 500 | 100 | 50 | email_chat_12h |
| Enterprise | monthly | $499.00 | 200 | 25 | 2000 | 500 | 200 | priority_4h |
| Enterprise | annual | $4,790.00 | 200 | 25 | 2000 | 500 | 200 | priority_4h |
| Enterprise+ | custom | Custom | Unlimited | Unlimited | Unlimited | Unlimited | Unlimited | 24_7_dedicated_1h |
Annual billing is a flat 20% off twelve monthly payments. Enterprise+ carries interval: "custom" and price_cents: 0; its price is agreed with sales rather than published.
The Free plan’s interval is none, not monthly. Both none and custom are treated as non-paid at checkout, which matters below.
Note that max_services is enforced per project and per environment on the same number — see Service limits.
Choose a Plan
POST /subscriptions/choose
curl -X POST https://app.quazzar.space/subscriptions/choose \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"plan_id": "plan-uuid-team-monthly"}'The body is plan_id alone. The interval is a property of the plan you name, so there is no billing_period field, and no success_url / cancel_url.
The response depends on whether the plan costs anything.
A free plan (price_cents: 0 or interval: "none") is applied immediately: existing active subscriptions are cancelled, the new one is created as active, and a zero-amount invoice is issued for the period.
{ "status": "ok", "applied": "free" }A paid plan creates a pending subscription — the current one keeps running until payment succeeds — and enqueues a background job that prepares a prorated mid-cycle checkout.
{ "job_id": "job-uuid" }Then poll for the checkout URL:
GET /subscriptions/checkout-url/{job_id}
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://app.quazzar.space/subscriptions/checkout-url/JOB_UUIDRedirect the customer to the URL this returns once the job has finished. Choosing the same paid plan twice reuses the existing pending subscription rather than creating a second one.
| Status | Description |
|---|---|
403 | Upgrading to paid plans is disabled in beta mode |
404 | Plan not found — unknown id, or the plan is not public |
Customer Portal
GET /me/billing/portal
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://app.quazzar.space/me/billing/portalResponse (200 OK):
{ "url": "https://billing.stripe.com/p/session/..." }The key is url, not portal_url. The session returns the customer to their profile page. 400 when Stripe is not configured (Billing portal is not enabled) or when beta mode is on (Billing portal is disabled in beta mode).
Current Subscription
GET /me/subscription
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://app.quazzar.space/me/subscriptionResponse (200 OK):
{
"plan": {
"id": "plan-uuid-team-monthly",
"name": "Team",
"interval": "monthly",
"price_cents": 7900,
"currency": "USD",
"max_projects": 15,
"max_services": 100,
"features": { "crud_pro": true }
},
"features": { "crud_pro": true }
}The body is the plan, with its features repeated at the top level for convenience. It does not describe the subscription row itself — there is no status, current_period_end, cancel_at_period_end, or usage block here. Usage lives in GET /me/limits/status.
404 No active subscription when there is none. That is a normal state, not an error condition — handle it rather than treating it as a failure.
If several active subscriptions exist for one account, the most recent by period start wins, deterministically.
Plan Limits and Usage
GET /me/limits/status
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://app.quazzar.space/me/limits/statusReturns the account’s limits alongside what it is actually using, and says whether anything is over:
{
"limits": { "services": 100, "project_users": 25, "api_keys": 15 },
"usage": { "services": 28, "project_users": 12, "api_keys": 3 },
"over": { "services": 0, "project_users": 0, "api_keys": 0 },
"over_total": 0,
"any_over": false,
"archive_window": { "until": null, "remaining_seconds": 0 },
"services_per_env": []
}services_per_env breaks the service count down per environment, which is what the per-environment cap is checked against.
GET /me/limits/overage-candidates lists what could be archived to get back under a limit.
Downgrades and Archiving
There is no POST /me/subscription/cancel. Moving to a smaller plan runs through a two-step flow so that resources over the new limits are dealt with explicitly rather than silently deleted.
| Endpoint | Description |
|---|---|
POST /me/downgrade/preflight | Body {"to_plan_id": "..."}. Reports what would be over the target plan’s limits before you commit |
POST /me/downgrade/confirm | Body {"to_plan_id": "..."}. Applies the downgrade and schedules limit enforcement two weeks out, so there is a grace window to archive what is over |
POST /me/mark-archived | Archive selected resources to get under a limit |
POST /me/unarchive | Restore archived resources |
GET /me/archived/summary | What is archived, by kind |
GET /me/archived/services, /environments, /project-users, /api-keys, /service-floors | Archived items of each kind |
Archived resources are excluded from the list endpoints elsewhere in the API — an archived environment does not appear in GET /environments, and archived services do not appear in GET /services.
To move to the Free plan, choose it with POST /subscriptions/choose; that cancels the active paid subscription as part of applying it.
Invoices
GET /me/invoices
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://app.quazzar.space/me/invoicesResponse (200 OK):
{
"items": [
{
"id": "inv-uuid-1",
"invoice_number": "QS-2026-0042",
"status": "paid",
"currency": "USD",
"subtotal_cents": 7900,
"tax_cents": 0,
"total_cents": 7900,
"issued_at": "2026-08-01T00:00:00Z",
"paid_at": "2026-08-01T00:05:00Z",
"pdf_url": "https://pay.stripe.com/invoice/..."
}
],
"total": 12
}Amounts are integer cents, split into subtotal_cents, tax_cents, and total_cents. total is the number of items returned. There is no pagination and no status filter — every invoice for the account is returned, so filter client-side. There are no period_start / period_end fields; the dates are issued_at and paid_at.
| Endpoint | Description |
|---|---|
GET /me/invoices/{invoice_id} | One invoice with its line items — each carrying item_type, plan_id, description, qty, and unit_price_cents. 404 Invoice not found for an invoice belonging to another account |
GET /invoice/{invoice_id} | The invoice rendered as HTML for printing |
Webhooks
POST /billing/stripe/webhook receives Stripe events. It is called by Stripe, not by your code, and is rate-limited to 120 requests per minute. In development, forward events with stripe listen --forward-to localhost:8000/billing/stripe/webhook and set STRIPE_WEBHOOK_SECRET to the secret it prints.
Feature Access
Your plan’s features object determines what the platform allows. Read it from GET /me/subscription (the features key), or from GET /admin/features/my, which returns {"features": {...}} for the caller’s active plan and answers {"features": {}} when there is no active subscription.
GET /admin/features/mysits under the/admin/featuresprefix but is the caller’s own view of their own plan; the other five/admin/features*routes are staff-only. There is no/features/me.
Using a feature the plan does not include returns 403 with a message naming what is missing.
Enterprise and Custom Plans
POST /enterprise/request
curl -X POST https://app.quazzar.space/enterprise/request \
-H "Content-Type: application/json" \
-d '{
"name": "Jane Developer",
"email": "[email protected]",
"phone": "+1-555-0100",
"message": "We need on-premise deployment for 400 services."
}'name, email, and message are required; message must be at least 10 characters. This reaches the sales team, who can also be contacted at [email protected].
Next Steps
- Errors & Permissions — status codes across the API
- Authentication — token management
- Projects API — creating projects within your limits
- Services API — services and infrastructure