Credits and feedback
Audience
Integrators and MCP clients that need to read a credit balance, ask for more credits, buy credits, or send product feedback with a bearer API key.
TL;DR
GET /billing/credits— read the balance before starting work that costs credits.- On
402, display therecoverystring from the error. Do not compose your own wording. POST /support/credit-request— ask Thalus for credits. A person replies by email.POST /billing/credit-purchase-link— get a URL a human opens to buy credits now.POST /support/feedback— send product feedback.- None of these endpoints need a new scope. Any key you already hold works.
GET /billing/credits and POST /billing/credit-purchase-link are not in the generated API reference. They belong to the portal-annotated billing group, which the reference generator excludes. This page is their contract.
Scopes: nothing new to request
Every endpoint on this page authenticates with AuthSessionMiddleware — a session cookie or a bearer API key — and requires no integrator scope.
That is deliberate. An issued key carries a frozen scope array: the scopes are fixed when the key is minted and never widen. If these endpoints required a new scope, every key created before their release would return 403, so an agent that ran out of credits could not read its balance or ask for more with the key it already has. Keys you issued months ago work here today.
You still need a key that is valid and unrevoked. Missing or bad credentials return 401.
Read the credit balance
GET /billing/credits
Authorization: Bearer <rawKey>
{
"plan": "trial",
"status": "trial",
"credits": { "allowance": 1, "available": 1, "locked": 0, "used": 0 },
"assessmentCreditCost": 1,
"trialEndsAt": "2026-08-27T00:00:00.000Z",
"renewalAt": null,
"canPurchase": true
}
| Field | Meaning |
|---|---|
plan | trial, starter, team, enterprise, or internal |
status | Billing status of the org |
credits.allowance | Credits granted per period |
credits.available | Credits you can spend right now |
credits.locked | Held by assessments already running |
credits.used | Consumed this period |
assessmentCreditCost | Credits one assessment costs — compare against available before you start |
trialEndsAt | ISO timestamp, or null when not on a trial |
renewalAt | ISO timestamp of the next renewal, or null |
canPurchase | false for enterprise and internal orgs, which are billed by agreement |
Check available >= assessmentCreditCost before starting an assessment. That turns a mid-run 402 into a decision you make up front.
canPurchase is the gate on the purchase-link endpoint below: when it is false, self-serve buying is not available and the org is billed by agreement instead.
Handle 402 with the recovery string
InsufficientCreditsError and TrialExpiredError both return 402 and both carry a required recovery field: a sentence telling the caller what to do next.
{
"_tag": "air/InsufficientCreditsError",
"orgPid": "org_…",
"available": 0,
"required": 1,
"recovery": "Request more assessment credits from Thalus, or buy a 5-credit pack to continue now."
}
{
"_tag": "air/TrialExpiredError",
"orgPid": "org_…",
"recovery": "Your free trial has ended. Buy a 5-credit pack or move to the Team plan to run more assessments."
}
Display recovery verbatim. It is the same wording the AIR web app shows, so a person who sees it in your client and then opens the portal reads one consistent instruction. Composing your own wording means the two drift, and the server's copy changes when the commercial offer changes.
recovery is required, not optional — it is always present on both errors, so you never need a fallback string.
Ask Thalus for credits
POST /support/credit-request
Authorization: Bearer <rawKey>
Content-Type: application/json
{
"credits": 5,
"reason": "Assessing three more models before our launch review.",
"contactEmail": "dev@example.com"
}
| Field | Required | Notes |
|---|---|---|
credits | Yes | Integer, 1–100 |
reason | Yes | Up to 4000 characters. Staff read this |
contactEmail | No | Redirects the reply; see below |
context | No | Display-only triage hints — see Context |
{ "pid": "sup_…", "status": "open" }
Keep the pid if you want to refer to the request later. status is open on creation; staff move it to resolved or declined.
contactEmail is optional, and omitting it is usually right. A session-cookie caller is replied to at the signed-in user's address. A bearer API key belongs to a service account with no mailbox of its own, so the reply goes to the organization owner — the account that carries the billing relationship, which is the same rule that decides who receives a Stripe receipt. Send contactEmail only to redirect the reply somewhere else.
If your client drafts the request with an LLM, keep this field out of the model's reach. A model asked for an address supplies one it read earlier in the conversation, and the acknowledgement then reaches someone unrelated to the organization. Collect it from the person, or leave it out and let the owner receive the reply.
422 SupportContactEmailRequiredError is now returned only when the organization has no owner to reply to.
Thalus replies by email. No response time is promised.
Requesting is not buying
This endpoint records a request for a human to consider. It grants nothing. If you need credits immediately, use the purchase link below.
Buy credits without leaving the client
POST /billing/credit-purchase-link
Authorization: Bearer <rawKey>
{
"kind": "checkout",
"url": "https://checkout.stripe.com/c/pay/…",
"credits": 5,
"priceCents": 25000,
"provider": "stripe"
}
| Field | Meaning |
|---|---|
kind | checkout buys the Starter credit pack; portal manages an existing subscription |
url | Open this in a browser |
credits | Credits the pack grants, or null for a portal link |
priceCents | Price in minor units, or null for a portal link |
provider | Payment provider that issued the link |
This endpoint only creates a link. No money moves until a person completes checkout in a browser. That is what makes it safe to expose to an API key: your automation cannot spend money, it can only offer a human the option.
Branch on kind. A portal link means the org already has a subscription, so the right action is managing it rather than buying a pack.
Hand the url to a person — print it, or open it with your client's link affordance. Do not try to drive Stripe Checkout programmatically.
Returns 502 if the payment provider is unreachable, and 429 if you request links too quickly.
Send product feedback
POST /support/feedback
Authorization: Bearer <rawKey>
Content-Type: application/json
{
"category": "bug",
"message": "The tier rationale disagreed with the risk register on run asm_….",
"contactEmail": "dev@example.com"
}
| Field | Required | Notes |
|---|---|---|
category | Yes | bug, idea, praise, or other |
message | Yes | Up to 4000 characters |
contactEmail | No | Redirects the reply; see below |
context | No | See Context |
{ "pid": "sup_…", "received": true }
No email is sent to the submitter — feedback is one-way. The record still carries a reply address so the team can follow up: the signed-in user for a cookie caller, and the organization owner for an API key, which has no mailbox of its own. Send contactEmail only to point that somewhere else.
Have a person confirm the text before you send it. If your client drafts feedback with a model, show the draft and let the human edit and submit. Sending model-authored text as if a user wrote it makes the feedback channel useless to the team reading it.
Keep contactEmail out of the model's reach, for the reason given under credit requests: a model asked for an address offers one it read earlier in the conversation, and the follow-up then reaches the wrong person.
Feedback without a key
POST /support/feedback/public
Content-Type: application/json
{ "category": "other", "message": "Signup failed at the email step.", "contactEmail": "dev@example.com" }
Unauthenticated, for the case that matters most: someone whose signup or key creation failed, who therefore cannot authenticate. contactEmail is required here, and the response is { "received": true } with no pid — an unauthenticated caller has no way to look one up.
Limited to 5 requests per hour per request fingerprint. Exceeding that returns 429 with a retryAfter in seconds.
Context field
POST /support/feedback and POST /support/credit-request both accept an optional context object that helps staff triage without a round trip:
{
"context": {
"source": "mcp",
"client": { "name": "claude-code", "version": "2.1.0" },
"mcpAirVersion": "1.3.0",
"assessmentPid": "asm_…",
"projectPid": "prj_…"
}
}
Every field is optional. It is display-only and never trusted for authorization. The server snapshots the commercial facts it cares about — plan and credit balance at the moment of the request — from its own state, not from anything you send, so there is no point sending those.
Errors
| Status | When |
|---|---|
401 | Missing, invalid, or revoked key |
402 | Out of credits or trial ended — display recovery |
403 | Billing gate, or the org is billed by agreement (canPurchase: false) |
422 | A credit request whose organization has no owner to reply to, or a field failed validation |
429 | Rate limited — wait retryAfter seconds |
502 | Payment provider unreachable |
503 | Support intake or auth temporarily unavailable |
See Errors for the full matrix and retry guidance.