Plans and usage¶
What a tier costs and allows, which tier your tenant is on, and how much of it you have spent. Four endpoints, all tenant-scoped: they answer about your tenant and never about anyone else's, because the tenant is taken from your credentials rather than from the URL.
GET /v1/plans — the catalog¶
The static list of tiers, ordered free → hobby → pro. No authentication and no tenant: it is the same answer for everybody, so it can back a pricing page.
{
"plans": [
{
"tier": "free",
"display_name": "Free",
"monthly_price_usd": 0.0,
"rate_limit_capacity": 60.0,
"rate_limit_refill_per_second": 2.0,
"llm_cost_cap_per_hour_usd": 0.0,
"status": "available",
"features": [
"Natal charts + talent profile",
"Template-based interpretations (no LLM rewrite)",
"Pair compatibility (synastry + Ashtakoota)",
"PDF reports",
"60 req/burst, 2 req/sec sustained",
"1000 requests / day, 10 000 / month",
"1 active API key"
]
}
]
}
| Field | Type | Notes |
|---|---|---|
tier |
enum | free, hobby, pro. The stable key — branch on this, not on display_name. |
display_name |
string | For a pricing page. |
monthly_price_usd |
float | 0.0 on Free. |
rate_limit_capacity |
float | Token-bucket size, i.e. the burst you may spend at once. See Rate limits. |
rate_limit_refill_per_second |
float | Sustained rate once the burst is gone. |
llm_cost_cap_per_hour_usd |
float or null |
Hourly ceiling on LLM spend. 0.0 on Free, which is why Free serves template interpretations and not LLM-rewritten ones. |
status |
enum | available or coming_soon. A coming_soon tier is listed but cannot be switched to — show it, do not offer it. |
features |
string[] | Marketing copy, in display order. Not a contract: do not parse it to decide what your integration may call. |
At the time of writing Hobby ($9) and Pro ($49) are coming_soon; only Free is available.
Read status rather than trusting that sentence.
GET /v1/tenant/plan — which tier you are on¶
Required: any valid credential. The tenant comes from the token.
{
"tenant_id": "tenant_01HZY...",
"display_name": "Acme Astrology",
"plan": { "tier": "free", "display_name": "Free", "…": "…" },
"plan_updated_at": "2026-05-17T09:12:44Z",
"created_at": "2026-04-02T18:30:01Z"
}
The tenant row is created on first read. A caller who has never had one gets a Free-tier tenant back rather than a 404, so there is no separate "sign up for the API" step.
POST /v1/tenant/plan — switch tier¶
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"plan_tier": "free"}' \
https://api.astrolinkers.com/v1/tenant/plan
Returns the same TenantPlanResponse as the GET.
| Outcome | Status | detail.reason |
|---|---|---|
| Switched | 200 | — |
plan_tier is not one of the three |
422 | invalid_plan_tier |
Tier is coming_soon, or is paid and you are not an admin |
403 | plan_not_available |
| Tenant row disappeared between the create and the update | 404 | tenant_not_found |
Downgrading to Free is self-service; moving to Hobby or Pro requires the admin scope.
That is deliberate while billing is not wired up: nothing should be able to put a tenant on a
paid tier except us.
GET /v1/tenant/usage — hourly totals¶
Required scope: keys:manage. Sums every active API key on the tenant.
| Parameter | Type | Default | Notes |
|---|---|---|---|
since |
datetime | until − 7 days |
ISO 8601 with an offset. A naive datetime is a 422. |
until |
datetime | now | Must be after since. |
The window may not exceed 90 days. Both usage endpoints reject a bad range with a 422:
detail.reason |
Meaning |
|---|---|
naive_datetime |
since or until arrived without a timezone offset. |
invalid_range |
until is not after since. |
range_too_large |
More than 90 days; detail.max_days says the limit. |
curl -H "Authorization: Bearer $TOKEN" \
"https://api.astrolinkers.com/v1/tenant/usage?since=2026-05-17T00:00:00Z&until=2026-05-18T00:00:00Z"
{
"since": "2026-05-17T00:00:00Z",
"until": "2026-05-18T00:00:00Z",
"total_requests": 184,
"total_errors": 3,
"buckets": [
{
"bucket_hour": "2026-05-17T12:00:00Z",
"requests": 184,
"errors_4xx": 2,
"errors_5xx": 1,
"latency_p95_ms": 42.5
}
]
}
| Field | Type | Notes |
|---|---|---|
bucket_hour |
datetime | Start of the hour, UTC. Hours with no traffic are absent, not zero-filled — fill the gaps yourself if you are drawing a chart. |
requests |
integer | Including the ones that failed. |
errors_4xx / errors_5xx |
integer | Kept apart because they mean different things: 4xx is usually your integration, 5xx is ours. |
latency_p95_ms |
float or null |
null when the bucket carries too few samples to have a meaningful p95. |
GET /v1/tenant/usage/endpoints — where the traffic went¶
Required scope: keys:manage. Same window parameters, plus:
| Parameter | Type | Default | Notes |
|---|---|---|---|
limit |
integer 1…100 |
20 |
How many route templates to return, busiest first. |
{
"since": "2026-05-17T00:00:00Z",
"until": "2026-05-18T00:00:00Z",
"total_requests": 184,
"total_errors": 3,
"buckets": [
{
"endpoint_path": "/v1/western/charts/{chart_id}/summary",
"requests": 128,
"errors_4xx": 1,
"errors_5xx": 0,
"latency_p95_ms": 38.25
}
]
}
endpoint_path is the route template, not a concrete URL: every chart id collapses into
{chart_id}, so the breakdown has as many rows as you have endpoints and not as many as you
have charts.
total_requests counts the whole window, so it can exceed the sum of the returned buckets
when limit cuts the tail off. That difference is your long tail, not a bug.
Per-key rather than per-tenant¶
The same two shapes exist scoped to a single API key —
GET /v1/api-keys/{key_id}/usage and GET /v1/api-keys/{key_id}/usage/endpoints. See
API keys. The tenant endpoints on this page are the sum over every active key.