Skip to content

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.

curl https://api.astrolinkers.com/v1/plans
{
  "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.

curl -H "Authorization: Bearer $TOKEN" \
  https://api.astrolinkers.com/v1/tenant/plan
{
  "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.