Menu
API reference
Everything the Saanora API accepts and returns: each endpoint, every request and response field with its type, default and limit, the three answer shapes, the response headers and every error code. To make your first call, start with the Quickstart and come back here for the details.
Base URL and endpoints
All requests go to https://api.saanora.com over HTTPS. Bodies are JSON, sent with Content-Type: application/json and limited to 1 MB.
| Method | Path | What it does |
|---|---|---|
| POST | /v1/decisions | Answers typed questions about one record, with a probability for every option. model is required. |
| POST | /v1/systemone | The same request and response for clients written for the open System One format, such as JevBench. model is optional and defaults to clara-1. |
| GET | /v1/models | Lists the live models you can pass as model. |
Every response, including errors, carries an x-request-id header. Error bodies repeat it as request_id. Quote it when you write to support@saanora.com.
503 model_warming_up, never charged, with a Retry-After header saying how many seconds to wait. Retry after that wait, and use a client timeout of 120 s, as in the examples below.Authentication
Every request needs a secret API key. Create one in the console under API keys and send it as a bearer token:
Authorization: Bearer sk_sa_...
Content-Type: application/jsonClients that cannot set Authorization can send the key as X-API-Key: sk_sa_... instead.
- Scope. Each key belongs to one project. Its requests count toward that project's usage and its optional monthly spend limit. Credit and rate limits are shared by the whole organization.
- Storage. Keys are stored only as SHA-256 hashes, so a key cannot be shown again after it is created.
- Revocation. A revoked key stops working within 60 seconds, because the gateway caches key lookups for 60 s.
- Errors. No key, or a value that is not an
sk_sa_key, returns 401missing_api_key. An unknown or revoked key returns 401invalid_api_key.
Create a decision
Send one record (state) and a set of typed questions about it. You get back, for every question, a probability over the options you defined. Answers are always one of your options, probabilities always sum to 1, and the model never writes free text.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
model | string | Yes | A decision model id from GET /v1/models, today clara-1. Missing: 400 missing_model. Unknown or not yet open (such as mark-1): 404 model_not_found. |
state | string or object | Yes | The record to decide about: a ticket, a conversation, a database row. An object is rendered compactly as text, so you can send a row as it is. Up to 16K tokens; a longer record is shortened in the middle, so the start and end are kept. English only. Missing: 400 missing_state. |
questions | object | Yes | Your question id → a question object. 1 to 32 questions on Tier 1, 64 from Tier 2. The ids come back as the keys of answers. Missing or empty: 400 missing_questions. Too many: 422 too_many_questions. |
mode | "fast" | "consistent" | No | Default fast. See Modes. Also accepted as a query parameter, ?mode=consistent; if both are set, the body wins. Any other value: 422 invalid_mode. |
A body over 1 MB returns 413 request_too_large; a body that is not JSON returns 400 invalid_json.
Question object
Each value in questions describes one question. Write instructions so it can be answered from the record alone.
| Field | Type | Applies to | Description |
|---|---|---|---|
type | "noul" | "choice" | "score" | All | Yes/no, one of several options, or a level on an ordered scale. Any other value: 422 invalid_question_type. |
instructions | string | All | The question itself, in plain English. |
criteria | object or array | All | noul: optional object describing yes and no. choice: required object, option id → short description, 2 to 255 options. score: required array of levels, lowest first, 2 to 255 levels. Wrong shape or count for choice or score: 422 invalid_criteria. |
threshold | number from 0 to 1 | noul | Optional. When set, the answer also carries a boolean decision. A value outside 0 to 1 is ignored. |
Yes/no questions (noul)
Use noul for anything with a yes or no answer. criteria is optional: add it when yes and no need defining, as an object with "true" and "false" keys ("yes" and "no" are also accepted). Add threshold to have the API apply your cut-off for you.
"refund": {
"type": "noul",
"instructions": "Is the customer asking for their money back?",
"criteria": {
"true": "The customer asks for a refund or a chargeback",
"false": "Any other request, including questions about the refund policy"
},
"threshold": 0.9
}Choice questions
Use choice when exactly one of several options applies. criteria maps each option id to a short description; the ids are what you get back. 2 to 255 options.
"intent": {
"type": "choice",
"instructions": "What does the customer want to do with their subscription?",
"criteria": {
"cancel": "End the subscription",
"upgrade": "Move to a bigger plan",
"support": "Get help with a problem, without changing the plan"
}
}Score questions
Use score for an ordered scale. criteria is an array of level descriptions, lowest first, with 2 to 255 levels. Levels are referred to by their index: 0 for the first, 1 for the next, and so on. Score scales are the least reliable question type, so when a decision can be phrased as yes/no or as a choice, prefer that.
"severity": {
"type": "score",
"instructions": "How severe is the business impact?",
"criteria": ["none", "low", "high", "critical"]
}Modes: fast and consistent
Both modes return the same response shape. The difference is how the record is read and what is billed.
fast (default) | consistent | |
|---|---|---|
| Reading | The record once; every question reuses that reading | The record again with each question, so each reading takes that question into account |
| Other questions in the request | Never change an answer | Never change an answer |
| Options changed or a question reworded | Answers can move more | Answers hold steadier |
| Billed tokens | The record plus each question, once | The tokens it reads: roughly the record times the number of questions |
| Tokens per minute | Counts the billed tokens | Counts the billed tokens, so it uses your TPM faster |
| Latency and cost | Lowest | Higher; cost grows with the number of questions |
| JevBench-mini | 81.8 | 83.6 |
For example, a 2,000-token record with 8 questions bills a little over 2,000 tokens in fast mode (about $0.001 at $0.50 per 1M) and roughly 16,000 tokens in consistent mode (about $0.008). In both modes each question is answered on its own, so adding or removing a question never changes the others. Use consistent when steadier probabilities are worth the extra tokens: thresholds, audits, and choice questions whose options change over time, such as tool menus.
Example request
Three questions, one of each type, about a support message. The Python and TypeScript versions check for errors and print each answer.
curl https://api.saanora.com/v1/decisions \
-H "Authorization: Bearer $SAANORA_API_KEY" \
-H "Content-Type: application/json" \
--max-time 120 \
-d '{
"model": "clara-1",
"state": "Customer writes: our nightly export has failed three nights in a row and the finance team needs it by 9am.",
"questions": {
"failing": { "type": "noul", "instructions": "Is a scheduled export currently failing?" },
"team": { "type": "choice", "instructions": "Which team should own this?",
"criteria": { "engineering": "Fixes broken product functionality",
"billing": "Handles invoices, charges and refunds" } },
"severity": { "type": "score", "instructions": "How severe is the business impact?",
"criteria": ["none", "low", "high", "critical"] }
}
}'Response
A successful request returns 200 OK with a decision object. This is the response to the example above:
{
"id": "dec_1eab8757a66cc4fba1e7d9f7",
"object": "decision",
"created": 1792431238,
"model": "clara-1",
"mode": "fast",
"answers": {
"failing": { "type": "noul", "noul": 0.996 },
"team": {
"type": "choice", "choice": "billing", "confidence": 0.317,
"probabilities": { "engineering": 0.341, "billing": 0.659 }
},
"severity": {
"type": "score", "score": 2.48, "confidence": 0.511,
"probabilities": { "0": 0.001, "1": 0.004, "2": 0.511, "3": 0.484 },
"legend": { "0": "none", "1": "low", "2": "high", "3": "critical" }
}
},
"usage": { "input_tokens": 191, "decisions": 3, "cost_usd": 0.000096 },
"latency_ms": 279.6
}| Field | Type | Description |
|---|---|---|
id | string | Unique id of this decision, starting dec_. |
object | string | Always decision. |
created | integer | When the decision was created, in Unix seconds. |
model | string | The model that answered, for example clara-1. |
mode | string | The mode that ran: fast or consistent. |
answers | object | One entry per question, keyed by your question ids. The shape depends on the question type: see the answer tables below. |
usage.input_tokens | integer | Billed input tokens. In consistent mode, everything read across all questions. The same number counts toward your tokens per minute. |
usage.decisions | integer | Number of questions answered. |
usage.cost_usd | number | Exactly what was deducted from your credit, in US dollars. |
latency_ms | number | Model time for this request, in milliseconds. Total time in the platform is in the saanora-processing-ms header. |
Clara costs $0.50 per 1M input tokens and $0 for output, with no per-decision fee. The example above billed 191 tokens: 191 × $0.50 / 1,000,000 = $0.0000955, rounded up to $0.000096. Each request's cost is rounded up to the nearest $0.000001. Before the model runs, a hold covering the worst case is placed on your credit; the exact cost is deducted afterward. If the hold cannot be covered, the request fails with 402 insufficient_credit and nothing runs. Failed requests are free. See Billing and credit.
Yes/no answer
| Field | Type | Description |
|---|---|---|
type | string | noul. |
noul | number, 0 to 1 | Probability that the answer is yes. The probability of no is 1 - noul. |
threshold | number | Only when the question set threshold: your value, echoed back. |
decision | boolean | Only when the question set threshold: noul >= threshold. |
The refund question above, with threshold 0.9, could come back as:
"refund": { "type": "noul", "noul": 0.93, "threshold": 0.9, "decision": true }noul is at most 0.199 or at least 0.801, never in between. A threshold anywhere from 0.2 to 0.8 therefore behaves exactly like 0.5. To separate confident answers from borderline ones, put your threshold above 0.8, for example 0.9 or 0.95, and send everything between 0.801 and it to review.Choice answer
| Field | Type | Description |
|---|---|---|
type | string | choice. |
choice | string | The most likely option id. Always one of your criteria keys. |
probabilities | object | Option id → probability, for every option. Sums to 1. |
confidence | number, 0 to 1 | (max p - 1/n) / (1 - 1/n), where n is the number of options. 1 when one option has all the probability, 0 when all options are equally likely, so it compares fairly across questions with different numbers of options. |
For the intent question above, with 3 options and 0.94 on support: (0.94 - 1/3) / (1 - 1/3) = 0.91.
"intent": {
"type": "choice",
"choice": "support",
"probabilities": { "cancel": 0.04, "upgrade": 0.02, "support": 0.94 },
"confidence": 0.91
}Score answer
| Field | Type | Description |
|---|---|---|
type | string | score. |
score | number | The expected level index: the sum of index × probability. Between 0 and the number of levels minus 1, and usually not a whole number. |
legend | object | Level index (as a string) → the level description you sent. |
probabilities | object | Level index → probability, for every level. Sums to 1. |
confidence | number, 0 to 1 | The probability of the most likely level. |
For the severity question in the example: 0 × 0.001 + 1 × 0.004 + 2 × 0.511 + 3 × 0.484 = 2.48, so the impact sits between "high" and "critical", nearly halfway. The most likely single level is the key with the highest probability, here "2" ("high").
"severity": {
"type": "score",
"score": 2.48,
"legend": { "0": "none", "1": "low", "2": "high", "3": "critical" },
"probabilities": { "0": 0.001, "1": 0.004, "2": 0.511, "3": 0.484 },
"confidence": 0.511
}Response headers
| Header | Meaning |
|---|---|
x-request-id | Unique id of this request. Also sent on errors. |
x-ratelimit-limit-requests | Requests per minute allowed for your organization. |
x-ratelimit-remaining-requests | Requests left in the current 60-second window. |
x-ratelimit-reset-requests | Time until a request slot frees up, for example 12s. |
x-ratelimit-limit-tokens | Input tokens per minute allowed for your organization. |
x-ratelimit-remaining-tokens | Input tokens left in the current 60-second window. |
saanora-processing-ms | Total time the request spent in the platform, in milliseconds. Subtract latency_ms to see the time outside the model. |
Retry-After | On 429 rate_limit_exceeded only: seconds to wait before retrying. |
The x-ratelimit-* and saanora-processing-ms headers come with every response that reached the model, successful or not. A 429 rate_limit_exceeded carries the x-ratelimit-* headers and Retry-After. Any other error carries only x-request-id.
List models
Returns the live models. Use it to check that a key works, or to confirm a model id before you send it. It needs the same API key as every other endpoint.
curl https://api.saanora.com/v1/models \
-H "Authorization: Bearer $SAANORA_API_KEY"The response:
{
"object": "list",
"data": [
{ "id": "clara-1", "object": "model", "owned_by": "saanora", "kind": "decision", "status": "live" }
]
}| Field | Type | Description |
|---|---|---|
object | string | Always list. |
data[].id | string | The id to pass as model. |
data[].object | string | Always model. |
data[].owned_by | string | Always saanora. |
data[].kind | string | What kind of model it is. Clara is a decision model, served by /v1/decisions and /v1/systemone. |
data[].status | string | The model's availability, for example live. |
Mark (mark-1), the language model behind Saanora Chat, is coming soon. Until it opens, requests to it return 404 model_not_found. See Models for benchmarks and known limitations.
System One compatibility
For clients written for the open System One format, such as JevBench. Point the client at https://api.saanora.com/v1/systemone and send your key in the Authorization header.
- The body is the same as
/v1/decisions, except thatmodelis optional and defaults toclara-1. - The response, headers, errors, rate limits and billing are the same as
/v1/decisions. modeworks the same way, in the body or as?mode=.
curl https://api.saanora.com/v1/systemone \
-H "Authorization: Bearer $SAANORA_API_KEY" \
-H "Content-Type: application/json" \
--max-time 120 \
-d '{
"state": "Customer writes: our nightly export has failed three nights in a row.",
"questions": {
"failing": { "type": "noul", "instructions": "Is a scheduled export currently failing?" }
}
}'Errors
Errors use standard HTTP status codes and a JSON body. Branch on error.code; message is written for people and can change.
{
"error": {
"type": "invalid_request_error",
"code": "invalid_criteria",
"message": "choice needs criteria with 2 to 255 options",
"param": "questions.team.criteria"
},
"request_id": "req_5b0c2e8f9a1d4c7b3e6f8a20"
}| Field | Type | Description |
|---|---|---|
error.type | string | The class of error, fixed by the status code (table below). |
error.code | string | The specific reason, such as invalid_criteria. |
error.message | string | A readable explanation of what to fix. |
error.param | string or null | The field that caused the error, such as questions.team.criteria, or null when no single field did. |
request_id | string | Same value as the x-request-id header. |
| Status | error.type |
|---|---|
| 400, 413, 422 | invalid_request_error |
| 401 | authentication_error |
| 402 | insufficient_credit |
| 404 | not_found_error |
| 429 | rate_limit_error |
| 502 | api_error |
| 503 | overloaded_error |
Error codes
| Status | code | Meaning |
|---|---|---|
| 400 | invalid_json | The body is not valid JSON. |
| 400 | missing_state | The body has no state field, or is not a JSON object. |
| 400 | missing_questions | questions is missing, is not an object, or is empty. |
| 400 | missing_model | No model on /v1/decisions. (/v1/systemone defaults to clara-1.) |
| 400 | wrong_endpoint | The model is not a decision model, so this endpoint does not serve it. |
| 401 | missing_api_key | No key was sent, or it is not an sk_sa_ key. |
| 401 | invalid_api_key | The key is unknown or has been revoked. |
| 402 | insufficient_credit | Your credit cannot cover the hold for this request. Nothing ran. |
| 404 | model_not_found | The model id does not exist or is not available yet (mark-1 until it opens). |
| 404 | not_found | The path does not exist, for example /v1/decision. A known path with the wrong method, such as GET /v1/decisions, returns 405 with the same error body. |
| 413 | request_too_large | The body is over 1 MB. |
| 422 | too_many_questions | More questions than your tier allows in one request (32 on Tier 1, 64 from Tier 2). |
| 422 | invalid_question_type | A question is not an object, or its type is not noul, choice or score. param is questions.<id>.type. |
| 422 | invalid_criteria | Choice criteria are not an object of 2 to 255 options, or score criteria are not a list of 2 to 255 levels. |
| 422 | invalid_mode | mode is not fast or consistent. |
| 400 or 422 | invalid_request | The model rejected a request that passed the checks above, for example noul criteria that are not an object. The message is the model's own reason. Fix the request before retrying. |
| 429 | rate_limit_exceeded | Your organization is over its requests or tokens per minute. Wait for Retry-After. |
| 429 | project_spend_limit | The project reached its monthly spend limit (UTC month). Retrying does not help: raise the limit or wait for the next month. |
| 502 | model_unavailable | The model could not answer. Never charged. Retry with backoff. |
| 503 | auth_unavailable | The key service is briefly unavailable. Retry with backoff. |
| 503 | billing_unavailable | The billing service is briefly unavailable. Retry with backoff. |
| 503 | catalog_unavailable | The model catalog is briefly unavailable. Retry with backoff. |
model_unavailable. Retry 429 rate_limit_exceeded, 502 and 503; fix the request, credit or spend limit before retrying anything else. Retry strategy is in Errors.Limits
| Limit | Value | When exceeded |
|---|---|---|
| Request body | 1 MB | 413 request_too_large |
Record (state) | About 16K tokens, less when a question has a long option list | No error: the middle is shortened and billed as read |
| Questions per request | 32 on Tier 1, 64 from Tier 2 | 422 too_many_questions |
| Choice options | 2 to 255 | 422 invalid_criteria |
| Score levels | 2 to 255 | 422 invalid_criteria |
| Requests per minute | By tier, table below | 429 rate_limit_exceeded |
| Tokens per minute | By tier, table below | 429 rate_limit_exceeded |
| Project monthly spend | Optional, set per project (UTC month) | 429 project_spend_limit |
| Credit | Your prepaid balance | 402 insufficient_credit |
Rate limits apply to the whole organization, across all projects, keys and models. Both are sliding 60-second windows. Tokens per minute counts billed tokens, the same number as usage.input_tokens; failed or refused requests do not count. Your tier rises automatically the moment both conditions are met.
| Tier | Qualifies at | Requests/min | Tokens/min | Questions/request |
|---|---|---|---|---|
| Tier 0 | Sign up (no API access; requests return 402) | — | — | — |
| Tier 1 | $5 paid | 300 | 300K | 32 |
| Tier 2 | $50 paid, 7+ days since first payment | 700 | 700K | 64 |
| Tier 3 | $250 paid, 14+ days since first payment | 1,500 | 1.5M | 64 |
| Tier 4 | $1,000 paid, 30+ days since first payment | 2,500 | 2.5M | 64 |
| Tier 5 | $2,500 paid, 60+ days since first payment | 5,000 | 5M | 64 |
Beyond Tier 5, write to support@saanora.com. More in Rate limits.