Contents
Models
GETList the models this key can useGETOne model, with the forms to call itPredictions
POSTClassify one record, flat responsePOSTClassify one or many recordsReference
AuthenticationError codesRate limitingSchemasAI Model Trainer API
Classify text with a model you trained in StayCharted AI Model Trainer.
Version 1.0.0 · Base URL https://amt.staycharted.com · OpenAPI 3.1 · OpenAPI 3.0 for connector builders
Two endpoints. You train a model in the StayCharted app — by uploading labelled examples — and then call it from your own code, or from an automation platform, to classify new text.
Getting a key. Sign in to StayCharted and issue one yourself under API keys. The full key is shown once, in the response that creates it, and never again. Keys are issued from a signed-in session, not from this API: a key that can mint further keys cannot really be revoked, because whoever held it has already made another.
What counts against your plan. Predictions are counted per record and drawn from one monthly budget shared with spreadsheet fills in the app — the same model doing the same work on the same hardware, so it is the same unit. A request refused by validation costs nothing.
Rate limits are separate, and counted in records per minute for the whole account. Every key on the account draws on one budget, and a request of 100 records costs a hundred times one of a single record — the limit exists to protect the server, so it is measured in the work that arrives, not in the number of envelopes it arrives in.
Batch vs bulk. One request carries up to 100 records. That makes this the right tool for a steady trickle — classify a ticket as it arrives, enrich a row as it is created. For tens of thousands of rows, upload the file in the app instead: it runs asynchronously on hardware sized for it, and costs you one upload rather than thousands of calls.
Errors. Every error response is JSON and carries a stable code. Branch on code, never on the human-readable error string, which is written for people and may be reworded. A refusal caused by your plan additionally carries entitlement, limit, used, resetsAt and upgrade, so a client can tell "wait until the cycle resets" from "this plan will never allow it".
Authentication
Authorization: Bearer <your key>. Issue and revoke keys in the StayCharted app; up to ten may be active at once, so an integration can be rotated without downtime.
GET /api/v1/models HTTP/1.1
Host: amt.staycharted.com
Authorization: Bearer amt_live_…
Endpoints
Models
GET /api/v1/models— List the models this key can useGET /api/v1/models/{id}— One model, with the forms to call it
Predictions
POST /api/v1/models/{id}/classify— Classify one record, flat responsePOST /api/v1/models/{id}/predict— Classify one or many records
Models
The models on your account that are deployed and answering.
GET /api/v1/models
List the models this key can use
Only deployed models appear. A model that has been trained but not published is not callable and is not listed.
Cheap enough to be the endpoint an integration uses to verify a key — it touches no model and spends no predictions. It has its own small allowance, 60 requests a minute per workspace, separate from the records-a-minute limit on predictions.
Responses
200 — The deployed models on this account.
| Field | Type | Required | Notes |
|---|---|---|---|
| workspace | object | yes | The workspace this key belongs to. Integrations use it to label a saved connection, so two keys can be told apart. |
| workspace.id | string (uuid) | no | |
| workspace.name | string | null | no | The workspace's name, as the app's workspace switcher shows it. |
| workspace.owner | string | null | no | Who shared the workspace: their name, or their email address when they have not given a name, as the app's switcher shows it. Null when it cannot be read. |
| models | array of object | yes | |
| models[].id | string (uuid) | yes | |
| models[].name | string | yes | |
| models[].custom | boolean | yes | True for a Dedicated AI Model, false for a Standard Model. Both are called the same way. |
| models[].modality | string | yes | What the model reads. An image model takes a link to a picture (or imageBase64), and some also read a text column. (one of text, image) |
| models[].inputs | array of string | yes | For a text model, the columns it was trained on: the field names to send when input is an object. A record that sends none of them is refused with api.input_fields_missing. Empty for an image model. |
| models[].readsText | boolean | no | Whether the model reads text. Always true for a text model. For an image model, whether it also reads a message with each picture: if true, send {"image": "<link>", "text": "..."}; if false, send the link alone. |
| models[].answer | object | yes | What every answer from this model carries. single: one category, in label. list: several values from one column, in labels. record: one value per column, in fields, keyed by the names listed here. Every answer also has label, confidence and needsReview. |
| models[].answer.shape | string | yes | (one of single, list, record) |
| models[].answer.fields | array of string | yes | The answer's column names, as trained. |
Example — two
{
"workspace": {
"id": "2fd16021-706c-4608-b488-9d0c267c7f28",
"name": "Support",
"owner": "Sam Patel"
},
"models": [
{
"id": "8f14e45f-ceea-467a-9b2c-1d5c2b0e7a11",
"name": "Support tickets",
"custom": true,
"modality": "text",
"inputs": [
"Subject",
"Body"
],
"answer": {
"shape": "record",
"fields": [
"Category",
"Assignment group"
]
}
},
{
"id": "3c59dc04-8e88-4950-9f2b-7a1c2a1e0b02",
"name": "Product feedback",
"custom": false,
"modality": "text",
"inputs": [
"Feedback"
],
"answer": {
"shape": "single",
"fields": [
"Area"
]
}
}
]
}
Errors: 401, 402, 403, 429
401 — The key is missing, malformed, unknown or revoked. Deliberately one answer for all four: distinguishing them would confirm to a stranger which keys exist.
Example — missing
{
"error": "Send your API key as: Authorization: Bearer <key>.",
"code": "api.key_missing"
}
Example — invalid
{
"error": "That API key is not valid.",
"code": "api.key_invalid"
}
402 — Your plan will not allow this. Two causes, and a client should treat them differently: plan.feature_not_included means the API is not on this plan at all and retrying will never help, while plan.prediction_limit means the month's predictions are spent and resetsAt says when they come back.
Example — plan.feature_not_included
{
"error": "Your plan does not include the Prediction API. The model is still available in the app.",
"code": "plan.feature_not_included",
"entitlement": "predictionApi",
"limit": null,
"used": null,
"resetsAt": null,
"upgrade": {
"planId": "essentials",
"planName": "Essentials",
"monthlyPriceCents": 6900,
"currency": "usd",
"newLimit": true
}
}
Example — plan.prediction_limit
{
"error": "This needs 40 predictions and 12 remain this billing period.",
"code": "plan.prediction_limit",
"entitlement": "predictionsPerMonth",
"limit": 50000,
"used": 49988,
"resetsAt": "2026-10-01T00:00:00.000Z",
"upgrade": {
"planId": "business",
"planName": "Business",
"monthlyPriceCents": 29900,
"currency": "usd",
"newLimit": 250000
}
}
403 — The account is suspended. Its data and its plan are intact, but it cannot spend anything until support reactivates it.
{
"error": "This account is suspended. Contact support to reactivate it.",
"code": "account.suspended"
}
429 — Too much sent this minute for your plan. Counted in RECORDS and for the whole account — every key draws on one budget, so adding keys does not add throughput. The allowance refills continuously rather than resetting on the minute, and the bucket always holds at least one full-size request, so a 100-record call is never permanently impossible even on a plan whose per-minute rate is lower than that. A request that does not fit is refused whole; no part of it is processed and none of it is counted against the monthly budget.
| Header | Meaning |
|---|---|
| X-RateLimit-Limit | Records a minute allowed on this plan, for the whole account. Every key on the account draws on this one budget. |
| X-RateLimit-Remaining | Records that can be sent right now. Refills continuously at the rate above rather than resetting on the minute, and holds at least one full request, so a request of the maximum size is always eventually accepted. A request that does not fit is refused whole — no part of it is processed — with Retry-After giving the seconds until it will. |
| Retry-After | Seconds until enough allowance has refilled for THIS request. There is no window to wait out — a smaller request may well succeed sooner. |
{
"error": "Rate limit reached — 60 records a minute on your plan, and this request is 100. Try again in 40s.",
"code": "api.rate_limited",
"retryAfterSeconds": 24
}
GET /api/v1/models/{id}
One model, with the forms to call it
The same description as in the list, plus two JSON Schemas built from it: inputSchema, the fields to send as input to classify, and answerSchema, what classify returns. Tools that build their forms from a schema use these; everything else can use inputs and answer.
Shares the model list's allowance. A model that is not published, or that this plan cannot call, is not found.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | The model's id, from GET /api/v1/models. |
Responses
200 — The model.
A Model, plus the two schemas an integration builds its forms from.
| Field | Type | Required | Notes |
|---|---|---|---|
| id | string (uuid) | yes | |
| name | string | yes | |
| custom | boolean | yes | |
| modality | string | yes | (one of text, image) |
| inputs | array of string | yes | |
| readsText | boolean | no | |
| answer | object | yes | |
| answer.shape | string | no | (one of single, list, record) |
| answer.fields | array of string | no | |
| inputSchema | object | yes | JSON Schema of the input object for classify. |
| answerSchema | object | yes | JSON Schema of what classify returns for this model. |
{
"id": "8f14e45f-ceea-467a-9b2c-1d5c2b0e7a11",
"name": "Support tickets",
"custom": false,
"modality": "text",
"inputs": [
"Subject",
"Body"
],
"answer": {
"shape": "record",
"fields": [
"Category",
"Assignment group"
]
},
"inputSchema": {
"type": "object",
"properties": {
"Subject": {
"type": "string",
"title": "Subject"
},
"Body": {
"type": "string",
"title": "Body"
}
}
},
"answerSchema": {
"type": "object",
"properties": {
"label": {
"type": "string",
"title": "Answer"
},
"confidence": {
"type": "number",
"title": "Confidence (0 to 1)"
},
"needsReview": {
"type": "boolean",
"title": "Needs review"
},
"fields": {
"type": "object",
"title": "Answer fields",
"properties": {
"Category": {
"type": "string",
"title": "Category"
},
"Assignment group": {
"type": "string",
"title": "Assignment group"
}
}
}
}
}
}
Errors: 401, 402, 403, 404, 429
401 — The key is missing, malformed, unknown or revoked. Deliberately one answer for all four: distinguishing them would confirm to a stranger which keys exist.
Example — missing
{
"error": "Send your API key as: Authorization: Bearer <key>.",
"code": "api.key_missing"
}
Example — invalid
{
"error": "That API key is not valid.",
"code": "api.key_invalid"
}
402 — Your plan will not allow this. Two causes, and a client should treat them differently: plan.feature_not_included means the API is not on this plan at all and retrying will never help, while plan.prediction_limit means the month's predictions are spent and resetsAt says when they come back.
Example — plan.feature_not_included
{
"error": "Your plan does not include the Prediction API. The model is still available in the app.",
"code": "plan.feature_not_included",
"entitlement": "predictionApi",
"limit": null,
"used": null,
"resetsAt": null,
"upgrade": {
"planId": "essentials",
"planName": "Essentials",
"monthlyPriceCents": 6900,
"currency": "usd",
"newLimit": true
}
}
Example — plan.prediction_limit
{
"error": "This needs 40 predictions and 12 remain this billing period.",
"code": "plan.prediction_limit",
"entitlement": "predictionsPerMonth",
"limit": 50000,
"used": 49988,
"resetsAt": "2026-10-01T00:00:00.000Z",
"upgrade": {
"planId": "business",
"planName": "Business",
"monthlyPriceCents": 29900,
"currency": "usd",
"newLimit": 250000
}
}
403 — The account is suspended. Its data and its plan are intact, but it cannot spend anything until support reactivates it.
{
"error": "This account is suspended. Contact support to reactivate it.",
"code": "account.suspended"
}
404 — No published model with that id on this account.
{
"error": "No such model.",
"code": "api.model_not_found"
}
429 — Too much sent this minute for your plan. Counted in RECORDS and for the whole account — every key draws on one budget, so adding keys does not add throughput. The allowance refills continuously rather than resetting on the minute, and the bucket always holds at least one full-size request, so a 100-record call is never permanently impossible even on a plan whose per-minute rate is lower than that. A request that does not fit is refused whole; no part of it is processed and none of it is counted against the monthly budget.
| Header | Meaning |
|---|---|
| X-RateLimit-Limit | Records a minute allowed on this plan, for the whole account. Every key on the account draws on this one budget. |
| X-RateLimit-Remaining | Records that can be sent right now. Refills continuously at the rate above rather than resetting on the minute, and holds at least one full request, so a request of the maximum size is always eventually accepted. A request that does not fit is refused whole — no part of it is processed — with Retry-After giving the seconds until it will. |
| Retry-After | Seconds until enough allowance has refilled for THIS request. There is no window to wait out — a smaller request may well succeed sooner. |
{
"error": "Rate limit reached — 60 records a minute on your plan, and this request is 100. Try again in 40s.",
"code": "api.rate_limited",
"retryAfterSeconds": 24
}
Predictions
POST /api/v1/models/{id}/classify
Classify one record, flat response
One record in, one answer out, with nothing nested.
predict is the endpoint for code. This one is for everything that is not code — Zapier, Make, Power Automate, Salesforce Flow, a spreadsheet formula. Those tools bind a field to a response value by picking it from a list, and predictions[0].label is awkward or impossible to express in most of them.
So every value here is a top-level key: label, confidence, modelId, modelName. That is the whole difference. Same auth, same plan limits, same monthly budget, same one record spent.
There is no usage field because it would always be 1.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | The model's id, from GET /api/v1/models. |
Request body (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
| input | one of | yes | The text to classify, or an object of the fields the model reads (FieldsInput). For an IMAGE model: a link to the picture (http or https), or {"imageBase64": "..."} for a picture sent in the request (under 10 MB — send a link for anything larger). A picture model that also reads text (readsText) takes {"image": "<link>", "text": "..."} or {"imageBase64": "...", "text": "..."}. |
{
"input": "My invoice charged me twice this month"
}
Responses
200 — The answer, flat.
| Header | Meaning |
|---|---|
| X-RateLimit-Limit | Records a minute allowed on this plan, for the whole account. Every key on the account draws on this one budget. |
| X-RateLimit-Remaining | Records that can be sent right now. Refills continuously at the rate above rather than resetting on the minute, and holds at least one full request, so a request of the maximum size is always eventually accepted. A request that does not fit is refused whole — no part of it is processed — with Retry-After giving the seconds until it will. |
Deliberately flat — every value a top-level key, so a no-code tool can bind to it without walking an array.
| Field | Type | Required | Notes |
|---|---|---|---|
| label | string | null | yes | The category. Null only if the model returned nothing usable. |
| confidence | number | null | yes | 0 to 1. Null when the engine does not report one. |
| modelId | string (uuid) | yes | |
| modelName | string | yes | |
| error | string | no | Image models only, and only when this picture could not be used: why, as a code — not_found, forbidden, unreachable, timeout, too_large, web_page, not_an_image, unsupported_format, too_small, corrupt, blocked_address, not_a_link. The label and confidence are null, and no image is counted for it. |
| message | string | no | The same reason in plain words, for showing to a person. |
| noPhoto | boolean | no | Text + pictures models only. The answer came from the text alone, with no picture. |
| pictureProblem | string | no | Text + pictures models only. A picture was sent but could not be used (same codes as error); the answer came from the text. |
| needsReview | boolean | no | True when the confidence is below the model's review cutoff: the answer a person should check before anything acts on it. Present on every answer with a label. On plans with the Review Queue, these answers also wait there for your team. |
| labels | array of string | no | Models whose answer is a list (several values in one column, such as the groups that can open a file): the values, sorted. label holds the same values joined with "; ". |
| fields | object | no | Models whose answer has several columns (such as GL account and cost center): each column's value, by the column's name. label holds the values joined with " | ". |
{
"label": "Billing",
"confidence": 0.94,
"modelId": "8f14e45f-ceea-467a-9b2c-1d5c2b0e7a11",
"modelName": "Support tickets"
}
Errors: 400, 401, 402, 403, 404, 409, 429, 503
400 — No text was sent.
{
"error": "Send \"input\" with the text to classify.",
"code": "api.input_missing"
}
401 — The key is missing, malformed, unknown or revoked. Deliberately one answer for all four: distinguishing them would confirm to a stranger which keys exist.
Example — missing
{
"error": "Send your API key as: Authorization: Bearer <key>.",
"code": "api.key_missing"
}
Example — invalid
{
"error": "That API key is not valid.",
"code": "api.key_invalid"
}
402 — Your plan will not allow this. Two causes, and a client should treat them differently: plan.feature_not_included means the API is not on this plan at all and retrying will never help, while plan.prediction_limit means the month's predictions are spent and resetsAt says when they come back.
Example — plan.feature_not_included
{
"error": "Your plan does not include the Prediction API. The model is still available in the app.",
"code": "plan.feature_not_included",
"entitlement": "predictionApi",
"limit": null,
"used": null,
"resetsAt": null,
"upgrade": {
"planId": "essentials",
"planName": "Essentials",
"monthlyPriceCents": 6900,
"currency": "usd",
"newLimit": true
}
}
Example — plan.prediction_limit
{
"error": "This needs 40 predictions and 12 remain this billing period.",
"code": "plan.prediction_limit",
"entitlement": "predictionsPerMonth",
"limit": 50000,
"used": 49988,
"resetsAt": "2026-10-01T00:00:00.000Z",
"upgrade": {
"planId": "business",
"planName": "Business",
"monthlyPriceCents": 29900,
"currency": "usd",
"newLimit": 250000
}
}
403 — The account is suspended. Its data and its plan are intact, but it cannot spend anything until support reactivates it.
{
"error": "This account is suspended. Contact support to reactivate it.",
"code": "account.suspended"
}
404 — No model with that id on this account.
{
"error": "No such model.",
"code": "api.model_not_found"
}
409 — The model exists but cannot answer: it is not deployed yet (api.model_not_deployed — publish it in the app first), or it has to be retrained before it can answer again (api.model_needs_retraining — its owner retrains it in the app). Nothing was counted against your budget.
{
"error": "That model is not deployed yet.",
"code": "api.model_not_deployed"
}
429 — Too much sent this minute for your plan. Counted in RECORDS and for the whole account — every key draws on one budget, so adding keys does not add throughput. The allowance refills continuously rather than resetting on the minute, and the bucket always holds at least one full-size request, so a 100-record call is never permanently impossible even on a plan whose per-minute rate is lower than that. A request that does not fit is refused whole; no part of it is processed and none of it is counted against the monthly budget.
| Header | Meaning |
|---|---|
| X-RateLimit-Limit | Records a minute allowed on this plan, for the whole account. Every key on the account draws on this one budget. |
| X-RateLimit-Remaining | Records that can be sent right now. Refills continuously at the rate above rather than resetting on the minute, and holds at least one full request, so a request of the maximum size is always eventually accepted. A request that does not fit is refused whole — no part of it is processed — with Retry-After giving the seconds until it will. |
| Retry-After | Seconds until enough allowance has refilled for THIS request. There is no window to wait out — a smaller request may well succeed sooner. |
{
"error": "Rate limit reached — 60 records a minute on your plan, and this request is 100. Try again in 40s.",
"code": "api.rate_limited",
"retryAfterSeconds": 24
}
503 — The model cannot answer right now because a service it depends on is unavailable — usually for a minute or two during a restart. Nothing was counted against your budget. Retry after a short wait.
{
"error": "This model is temporarily unable to answer. Try again in a few minutes.",
"code": "api.model_unavailable"
}
POST /api/v1/models/{id}/predict
Classify one or many records
Send input with a single string, or inputs with an array of up to 100. predictions comes back as an array either way — a response whose type depends on the request's type is the kind of thing an integration gets wrong once and blames you for.
Answers are returned in the order they were sent. Every record in the request is counted, including ones the model answers with low confidence.
The whole request is checked against your remaining monthly budget before any of it runs, so a request that would cross the limit is refused entirely rather than half-served.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | The model's id, from GET /api/v1/models. |
Request body (application/json)
Exactly one of input or inputs.
Shape 1
| Field | Type | Required | Notes |
|---|---|---|---|
| input | one of | yes |
Shape 2
| Field | Type | Required | Notes |
|---|---|---|---|
| inputs | array of one of | yes | Text for a text model. For an IMAGE model, each item is a link to a picture or an ImageInput, and at most 20 of them (api.too_many_images): each is fetched and read inside the request. (minItems: 1, maxItems: 100) |
Example — One record
{
"input": "My invoice charged me twice this month"
}
Example — Several at once
{
"inputs": [
"My invoice charged me twice this month",
"The parcel arrived with a cracked screen"
]
}
Responses
200 — One prediction per input, in the order they were sent.
| Header | Meaning |
|---|---|
| X-RateLimit-Limit | Records a minute allowed on this plan, for the whole account. Every key on the account draws on this one budget. |
| X-RateLimit-Remaining | Records that can be sent right now. Refills continuously at the rate above rather than resetting on the minute, and holds at least one full request, so a request of the maximum size is always eventually accepted. A request that does not fit is refused whole — no part of it is processed — with Retry-After giving the seconds until it will. |
| Field | Type | Required | Notes |
|---|---|---|---|
| model | object | yes | |
| model.id | string (uuid) | yes | |
| model.name | string | yes | |
| predictions | array of object | yes | |
| predictions[].input | string | yes | Echoed back, so a batch can be matched up without relying on order alone. |
| predictions[].label | string | null | yes | Null when the model could not produce one. |
| predictions[].confidence | number | null | yes | 0 to 1. Null when the model does not report one. |
| predictions[].error | string | no | Image models only, and only when this picture could not be used: why, as a code — not_found, forbidden, unreachable, timeout, too_large, web_page, not_an_image, unsupported_format, too_small, corrupt, blocked_address, not_a_link. The label and confidence are null, and no image is counted for it. |
| predictions[].message | string | no | The same reason in plain words, for showing to a person. |
| predictions[].noPhoto | boolean | no | Text + pictures models only. The answer came from the text alone, with no picture. |
| predictions[].pictureProblem | string | no | Text + pictures models only. A picture was sent but could not be used (same codes as error); the answer came from the text. |
| predictions[].needsReview | boolean | no | True when the confidence is below the model's review cutoff: the answer a person should check before anything acts on it. Present on every answer with a label. On plans with the Review Queue, these answers also wait there for your team. |
| predictions[].labels | array of string | no | Models whose answer is a list (several values in one column, such as the groups that can open a file): the values, sorted. label holds the same values joined with "; ". |
| predictions[].fields | object | no | Models whose answer has several columns (such as GL account and cost center): each column's value, by the column's name. label holds the values joined with " | ". |
| usage | object | yes | |
| usage.predictions | integer | yes | Records counted against this month's budget by this request. |
Example — two
{
"model": {
"id": "8f14e45f-ceea-467a-9b2c-1d5c2b0e7a11",
"name": "Support tickets"
},
"predictions": [
{
"input": "My invoice charged me twice this month",
"label": "Billing",
"confidence": 0.94
},
{
"input": "The parcel arrived with a cracked screen",
"label": "Shipping",
"confidence": 0.71
}
],
"usage": {
"predictions": 2
}
}
Errors: 400, 401, 402, 403, 404, 409, 429, 503
400 — The request body is wrong. Checked before anything else, so these cost no predictions and no rate-limit budget.
Example — api.input_required
{
"error": "Send \"input\" with one string, or \"inputs\" with an array of them.",
"code": "api.input_required"
}
Example — api.too_many_inputs
{
"error": "One request carries at most 100 records — this one has 250. Send more requests, or fill a spreadsheet in the app for a large job.",
"code": "api.too_many_inputs"
}
Example — api.too_many_images
{
"error": "One request carries at most 20 pictures — this one has 50. Send more requests, or fill a file in the app for a large job.",
"code": "api.too_many_images"
}
Example — api.input_invalid
{
"error": "Every input must be a non-empty string, or an object of the fields the model reads.",
"code": "api.input_invalid"
}
Example — api.input_fields_missing
{
"error": "None of the fields this model reads were sent. It reads: \"Name\", \"Path\", \"Owner\".",
"code": "api.input_fields_missing"
}
Example — api.text_input_invalid
{
"error": "This model reads text: send each input as a string.",
"code": "api.text_input_invalid"
}
Example — api.image_input_invalid
{
"error": "This model reads pictures: send each input as a link to the picture (http:// or https://), or as {\"imageBase64\": \"...\"}.",
"code": "api.image_input_invalid"
}
Example — api.image_too_large
{
"error": "A picture sent in the request must be under 10 MB on its own, or 1.5 MB each when several are sent together — send a link for a larger one.",
"code": "api.image_too_large"
}
401 — The key is missing, malformed, unknown or revoked. Deliberately one answer for all four: distinguishing them would confirm to a stranger which keys exist.
Example — missing
{
"error": "Send your API key as: Authorization: Bearer <key>.",
"code": "api.key_missing"
}
Example — invalid
{
"error": "That API key is not valid.",
"code": "api.key_invalid"
}
402 — Your plan will not allow this. Two causes, and a client should treat them differently: plan.feature_not_included means the API is not on this plan at all and retrying will never help, while plan.prediction_limit means the month's predictions are spent and resetsAt says when they come back.
Example — plan.feature_not_included
{
"error": "Your plan does not include the Prediction API. The model is still available in the app.",
"code": "plan.feature_not_included",
"entitlement": "predictionApi",
"limit": null,
"used": null,
"resetsAt": null,
"upgrade": {
"planId": "essentials",
"planName": "Essentials",
"monthlyPriceCents": 6900,
"currency": "usd",
"newLimit": true
}
}
Example — plan.prediction_limit
{
"error": "This needs 40 predictions and 12 remain this billing period.",
"code": "plan.prediction_limit",
"entitlement": "predictionsPerMonth",
"limit": 50000,
"used": 49988,
"resetsAt": "2026-10-01T00:00:00.000Z",
"upgrade": {
"planId": "business",
"planName": "Business",
"monthlyPriceCents": 29900,
"currency": "usd",
"newLimit": 250000
}
}
403 — The account is suspended. Its data and its plan are intact, but it cannot spend anything until support reactivates it.
{
"error": "This account is suspended. Contact support to reactivate it.",
"code": "account.suspended"
}
404 — No model with that id on this account.
{
"error": "No such model.",
"code": "api.model_not_found"
}
409 — The model exists but cannot answer: it is not deployed yet (api.model_not_deployed — publish it in the app first), or it has to be retrained before it can answer again (api.model_needs_retraining — its owner retrains it in the app). Nothing was counted against your budget.
{
"error": "That model is not deployed yet.",
"code": "api.model_not_deployed"
}
429 — Too much sent this minute for your plan. Counted in RECORDS and for the whole account — every key draws on one budget, so adding keys does not add throughput. The allowance refills continuously rather than resetting on the minute, and the bucket always holds at least one full-size request, so a 100-record call is never permanently impossible even on a plan whose per-minute rate is lower than that. A request that does not fit is refused whole; no part of it is processed and none of it is counted against the monthly budget.
| Header | Meaning |
|---|---|
| X-RateLimit-Limit | Records a minute allowed on this plan, for the whole account. Every key on the account draws on this one budget. |
| X-RateLimit-Remaining | Records that can be sent right now. Refills continuously at the rate above rather than resetting on the minute, and holds at least one full request, so a request of the maximum size is always eventually accepted. A request that does not fit is refused whole — no part of it is processed — with Retry-After giving the seconds until it will. |
| Retry-After | Seconds until enough allowance has refilled for THIS request. There is no window to wait out — a smaller request may well succeed sooner. |
{
"error": "Rate limit reached — 60 records a minute on your plan, and this request is 100. Try again in 40s.",
"code": "api.rate_limited",
"retryAfterSeconds": 24
}
503 — The model cannot answer right now because a service it depends on is unavailable — usually for a minute or two during a restart. Nothing was counted against your budget. Retry after a short wait.
{
"error": "This model is temporarily unable to answer. Try again in a few minutes.",
"code": "api.model_unavailable"
}
Error codes
Every error is JSON with a stable code. Branch on the code; the error sentence is for people and may be reworded.
| HTTP | Code | What it means |
|---|---|---|
| 400 | api.image_input_invalid | This model reads pictures: send each input as a link to the picture (http:// or https://), or as {"imageBase64": "..."}. |
| 400 | api.image_too_large | A picture sent in the request must be under 10 MB on its own, or 1.5 MB each when several are sent together — send a link for a larger one. |
| 400 | api.input_fields_missing | None of the fields this model reads were sent. It reads: "Name", "Path", "Owner". |
| 400 | api.input_invalid | Every input must be a non-empty string, or an object of the fields the model reads. |
| 400 | api.input_missing | Send "input" with the text to classify. |
| 400 | api.input_required | Send "input" with one string, or "inputs" with an array of them. |
| 400 | api.text_input_invalid | This model reads text: send each input as a string. |
| 400 | api.too_many_images | One request carries at most 20 pictures — this one has 50. Send more requests, or fill a file in the app for a large job. |
| 400 | api.too_many_inputs | One request carries at most 100 records — this one has 250. Send more requests, or fill a spreadsheet in the app for a large job. |
| 401 | api.key_invalid | That API key is not valid. |
| 401 | api.key_missing | Send your API key as: Authorization: Bearer <key>. |
| 402 | plan.feature_not_included | Your plan does not include the Prediction API. The model is still available in the app. |
| 402 | plan.prediction_limit | This needs 40 predictions and 12 remain this billing period. |
| 403 | account.suspended | This account is suspended. Contact support to reactivate it. |
| 404 | api.model_not_found | No such model. |
| 409 | api.model_not_deployed | That model is not deployed yet. |
| 429 | api.rate_limited | Rate limit reached — 60 records a minute on your plan, and this request is 100. Try again in 40s. |
| 503 | api.model_unavailable | This model is temporarily unable to answer. Try again in a few minutes. |
Rate limiting
Too much sent this minute for your plan. Counted in RECORDS and for the whole account — every key draws on one budget, so adding keys does not add throughput.
The allowance refills continuously rather than resetting on the minute, and the bucket always holds at least one full-size request, so a 100-record call is never permanently impossible even on a plan whose per-minute rate is lower than that. A request that does not fit is refused whole; no part of it is processed and none of it is counted against the monthly budget.
| Header | Meaning |
|---|---|
| X-RateLimit-Limit | Records a minute allowed on this plan, for the whole account. Every key on the account draws on this one budget. |
| X-RateLimit-Remaining | Records that can be sent right now. Refills continuously at the rate above rather than resetting on the minute, and holds at least one full request, so a request of the maximum size is always eventually accepted. A request that does not fit is refused whole — no part of it is processed — with Retry-After giving the seconds until it will. |
| Retry-After | On a 429: seconds until enough allowance has refilled for this request. |
Schemas
Model
| Field | Type | Required | Notes |
|---|---|---|---|
| id | string (uuid) | yes | |
| name | string | yes | |
| custom | boolean | yes | True for a Dedicated AI Model, false for a Standard Model. Both are called the same way. |
| modality | string | yes | What the model reads. An image model takes a link to a picture (or imageBase64), and some also read a text column. (one of text, image) |
| inputs | array of string | yes | For a text model, the columns it was trained on: the field names to send when input is an object. A record that sends none of them is refused with api.input_fields_missing. Empty for an image model. |
| readsText | boolean | no | Whether the model reads text. Always true for a text model. For an image model, whether it also reads a message with each picture: if true, send {"image": "<link>", "text": "..."}; if false, send the link alone. |
| answer | object | yes | What every answer from this model carries. single: one category, in label. list: several values from one column, in labels. record: one value per column, in fields, keyed by the names listed here. Every answer also has label, confidence and needsReview. |
| answer.shape | string | yes | (one of single, list, record) |
| answer.fields | array of string | yes | The answer's column names, as trained. |
ModelDetail
A Model, plus the two schemas an integration builds its forms from.
| Field | Type | Required | Notes |
|---|---|---|---|
| id | string (uuid) | yes | |
| name | string | yes | |
| custom | boolean | yes | |
| modality | string | yes | (one of text, image) |
| inputs | array of string | yes | |
| readsText | boolean | no | |
| answer | object | yes | |
| answer.shape | string | no | (one of single, list, record) |
| answer.fields | array of string | no | |
| inputSchema | object | yes | JSON Schema of the input object for classify. |
| answerSchema | object | yes | JSON Schema of what classify returns for this model. |
PredictRequest
Exactly one of input or inputs.
Shape 1
| Field | Type | Required | Notes |
|---|---|---|---|
| input | one of | yes |
Shape 2
| Field | Type | Required | Notes |
|---|---|---|---|
| inputs | array of one of | yes | Text for a text model. For an IMAGE model, each item is a link to a picture or an ImageInput, and at most 20 of them (api.too_many_images): each is fetched and read inside the request. (minItems: 1, maxItems: 100) |
FieldsInput
A record for a TEXT model, as the fields it was trained on, by column name: {"Name": "Q4 budget.xlsx", "Path": "/sites/Finance/Budget"}. The model reads them exactly as it read its training rows, so you don't write the Column: value text yourself. Nested objects are read with dotted names ({"owner": {"department": "HR"}} is the column owner.department), so a record exported from another system can be sent as it is. Fields the model doesn't read are ignored; a record with none of them is refused (api.input_fields_missing, which names the fields it reads).
A model trained on a SharePoint permission export reads Path. The item's location can be sent as Path, URL, webUrl or Resource Path, as a full URL or a server-relative path: {"webUrl": "https://contoso.sharepoint.com/sites/HR/Shared%20Documents/x.docx"} is read as /sites/HR/Shared Documents/x.docx, the way the export was read in training.
ImageInput
A picture sent in the request, for an image model. Each picture the model reads counts once against Images / month as well as once against the prediction budget; a link that cannot be fetched counts against neither.
For a model that reads text with its pictures ("text + pictures"), also send text: the text columns it was trained on, as Column: value lines. Send the picture as image (a link) or imageBase64, or send text alone for a message with no picture; the answer then says "noPhoto": true.
| Field | Type | Required | Notes |
|---|---|---|---|
| imageBase64 | string | no | The picture file (JPEG, PNG, WebP, GIF, BMP or TIFF), base64-encoded. Under 10 MB when it is the only picture in the request, 1.5 MB each when several are sent together. |
| image | string | no | A link to the picture (http or https). Text + pictures models. |
| text | string | no | The text read with the picture. Text + pictures models. |
ClassifyResponse
Deliberately flat — every value a top-level key, so a no-code tool can bind to it without walking an array.
| Field | Type | Required | Notes |
|---|---|---|---|
| label | string | null | yes | The category. Null only if the model returned nothing usable. |
| confidence | number | null | yes | 0 to 1. Null when the engine does not report one. |
| modelId | string (uuid) | yes | |
| modelName | string | yes | |
| error | string | no | Image models only, and only when this picture could not be used: why, as a code — not_found, forbidden, unreachable, timeout, too_large, web_page, not_an_image, unsupported_format, too_small, corrupt, blocked_address, not_a_link. The label and confidence are null, and no image is counted for it. |
| message | string | no | The same reason in plain words, for showing to a person. |
| noPhoto | boolean | no | Text + pictures models only. The answer came from the text alone, with no picture. |
| pictureProblem | string | no | Text + pictures models only. A picture was sent but could not be used (same codes as error); the answer came from the text. |
| needsReview | boolean | no | True when the confidence is below the model's review cutoff: the answer a person should check before anything acts on it. Present on every answer with a label. On plans with the Review Queue, these answers also wait there for your team. |
| labels | array of string | no | Models whose answer is a list (several values in one column, such as the groups that can open a file): the values, sorted. label holds the same values joined with "; ". |
| fields | object | no | Models whose answer has several columns (such as GL account and cost center): each column's value, by the column's name. label holds the values joined with " | ". |
NeedsReview
True when the confidence is below the model's review cutoff: the answer a person should check before anything acts on it. Present on every answer with a label. On plans with the Review Queue, these answers also wait there for your team.
AnswerLabels
Models whose answer is a list (several values in one column, such as the groups that can open a file): the values, sorted. label holds the same values joined with "; ".
AnswerFields
Models whose answer has several columns (such as GL account and cost center): each column's value, by the column's name. label holds the values joined with " | ".
Prediction
| Field | Type | Required | Notes |
|---|---|---|---|
| input | string | yes | Echoed back, so a batch can be matched up without relying on order alone. |
| label | string | null | yes | Null when the model could not produce one. |
| confidence | number | null | yes | 0 to 1. Null when the model does not report one. |
| error | string | no | Image models only, and only when this picture could not be used: why, as a code — not_found, forbidden, unreachable, timeout, too_large, web_page, not_an_image, unsupported_format, too_small, corrupt, blocked_address, not_a_link. The label and confidence are null, and no image is counted for it. |
| message | string | no | The same reason in plain words, for showing to a person. |
| noPhoto | boolean | no | Text + pictures models only. The answer came from the text alone, with no picture. |
| pictureProblem | string | no | Text + pictures models only. A picture was sent but could not be used (same codes as error); the answer came from the text. |
| needsReview | boolean | no | True when the confidence is below the model's review cutoff: the answer a person should check before anything acts on it. Present on every answer with a label. On plans with the Review Queue, these answers also wait there for your team. |
| labels | array of string | no | Models whose answer is a list (several values in one column, such as the groups that can open a file): the values, sorted. label holds the same values joined with "; ". |
| fields | object | no | Models whose answer has several columns (such as GL account and cost center): each column's value, by the column's name. label holds the values joined with " | ". |
PredictResponse
| Field | Type | Required | Notes |
|---|---|---|---|
| model | object | yes | |
| model.id | string (uuid) | yes | |
| model.name | string | yes | |
| predictions | array of object | yes | |
| predictions[].input | string | yes | Echoed back, so a batch can be matched up without relying on order alone. |
| predictions[].label | string | null | yes | Null when the model could not produce one. |
| predictions[].confidence | number | null | yes | 0 to 1. Null when the model does not report one. |
| predictions[].error | string | no | Image models only, and only when this picture could not be used: why, as a code — not_found, forbidden, unreachable, timeout, too_large, web_page, not_an_image, unsupported_format, too_small, corrupt, blocked_address, not_a_link. The label and confidence are null, and no image is counted for it. |
| predictions[].message | string | no | The same reason in plain words, for showing to a person. |
| predictions[].noPhoto | boolean | no | Text + pictures models only. The answer came from the text alone, with no picture. |
| predictions[].pictureProblem | string | no | Text + pictures models only. A picture was sent but could not be used (same codes as error); the answer came from the text. |
| predictions[].needsReview | boolean | no | True when the confidence is below the model's review cutoff: the answer a person should check before anything acts on it. Present on every answer with a label. On plans with the Review Queue, these answers also wait there for your team. |
| predictions[].labels | array of string | no | Models whose answer is a list (several values in one column, such as the groups that can open a file): the values, sorted. label holds the same values joined with "; ". |
| predictions[].fields | object | no | Models whose answer has several columns (such as GL account and cost center): each column's value, by the column's name. label holds the values joined with " | ". |
| usage | object | yes | |
| usage.predictions | integer | yes | Records counted against this month's budget by this request. |
Error
| Field | Type | Required | Notes |
|---|---|---|---|
| error | string | yes | For a person to read. May be reworded — do not match on it. |
| code | string | yes | Stable. Branch on this. |
PlanError
| Field | Type | Required | Notes |
|---|---|---|---|
| error | string | yes | For a person to read. May be reworded — do not match on it. |
| code | string | yes | Stable. Branch on this. |
| entitlement | string | null | no | Which plan limit refused this. |
| limit | integer | null | no | |
| used | integer | null | no | |
| resetsAt | string | null (date-time) | no | When the billing cycle rolls over and a monthly budget refills. Null for refusals that waiting will not fix. |
| upgrade | object | null | no | The cheapest plan that would actually raise this limit, or null when no plan would — worth checking before telling somebody to upgrade into the same wall. |
Generated from backend/openapi.yaml by scripts/build-api-reference.py. Do not edit by hand.