Skip to Content

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.space

These 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/plans

Response (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 a currency. There is no decimal monthly_price.
  • features is 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 id and interval. There is no annual_price field on a monthly plan — to offer both, read the two rows sharing a name. The limits are identical between them.
  • Limits are flat fields on the plan (max_projects, max_envs_per_project, …), not a nested limits object. null means unlimited.

Plan tiers and limits

PlanIntervalPriceProjectsEnvs/projectServicesProject usersAPI keysSupport
Freenone$011511community
Startermonthly$29.005325105email_48h
Starterannual$278.005325105email_48h
Teammonthly$79.001551002515email_chat_24h
Teamannual$758.001551002515email_chat_24h
Businessmonthly$199.00501050010050email_chat_12h
Businessannual$1,910.00501050010050email_chat_12h
Enterprisemonthly$499.00200252000500200priority_4h
Enterpriseannual$4,790.00200252000500200priority_4h
Enterprise+customCustomUnlimitedUnlimitedUnlimitedUnlimitedUnlimited24_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_UUID

Redirect 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.

StatusDescription
403Upgrading to paid plans is disabled in beta mode
404Plan 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/portal

Response (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/subscription

Response (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/status

Returns 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.

EndpointDescription
POST /me/downgrade/preflightBody {"to_plan_id": "..."}. Reports what would be over the target plan’s limits before you commit
POST /me/downgrade/confirmBody {"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-archivedArchive selected resources to get under a limit
POST /me/unarchiveRestore archived resources
GET /me/archived/summaryWhat is archived, by kind
GET /me/archived/services, /environments, /project-users, /api-keys, /service-floorsArchived 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/invoices

Response (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.

EndpointDescription
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/my sits under the /admin/features prefix 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