Chat completions
POST https://app.syntic.ai/v1/chat/completionsSend a conversation and get back the assistant’s next message. The reply arrives as a stream of server-sent events unless the request sets "stream": false. When you supply tools, the reply may instead ask your code to run one or more functions.
Request headers
| Header | Required | Value |
|---|---|---|
Authorization | Yes | Bearer <key>. See Authentication. |
Content-Type | Yes | application/json |
Origin | Only for keys with an origin allowlist | The calling site’s origin. Browsers set it automatically. |
Request body
| Field | Type | Required | Details |
|---|---|---|---|
messages | array of message objects | Yes | The conversation so far, oldest first. 1 to 500 items. An empty or missing array returns 400 messages_required. |
model | string | No | A Syntic model name, for example "Amara S1.2" or "Amara Quick". Names must match exactly, including spaces and capitals. A missing name uses the default model, Amara S1.2. Today an unknown name does too; not live yet — on develop: an unknown name returns 404 model_not_found. See Models overview. |
stream | boolean | No | Defaults to true. Set false to get a single JSON object. |
max_tokens | integer | No | The most tokens the reply may contain. When the cap is reached, finish_reason is length. |
tools | array of tool objects | No | Functions the model may ask you to call. |
tool_choice | string or object | No | "auto" lets the model decide, "none" forbids tool calls, and {"type": "function", "function": {"name": "..."}} asks for a specific function. |
Sampling settings have no effect. temperature, top_p, n, presence_penalty and frequency_penalty are accepted so existing code does not break, but they are removed before the request reaches the model. Every request returns exactly one choice.
Other fields are ignored. In particular, response_format, seed, stop, logprobs and user are not passed on.
Message object
| Field | Type | Used on | Description |
|---|---|---|---|
role | string | all | system, user, assistant or tool. |
content | string | all | The text of the message. May be empty or null on an assistant message that only carries tool_calls. |
tool_calls | array | assistant | The calls the model asked for in an earlier reply. Return them unchanged in the next request. |
tool_call_id | string | tool | The id of the tool call this result answers. |
Put instructions in a single system message at the start of the array. The API places a short Syntic identity instruction ahead of it; your text follows that instruction unchanged. See What the API adds to your request.
Tool object
| Field | Type | Description |
|---|---|---|
type | string | Always "function". |
function.name | string | The name the model uses when it calls the function. Letters, digits, underscores and hyphens are the safe choice. |
function.description | string | A plain-language explanation the model reads to decide when to call it. |
function.parameters | object | A JSON Schema object describing the arguments. |
Examples
A single JSON reply
curl https://app.syntic.ai/v1/chat/completions \
-H "Authorization: Bearer $SYNTIC_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "Amara S1.2",
"stream": false,
"max_tokens": 300,
"messages": [
{ "role": "system", "content": "You write product descriptions for a garden centre." },
{ "role": "user", "content": "A frost-hardy lavender in a 2-litre pot." }
]
}'A streamed reply
Leave stream out (or set it to true) and read the body as it arrives. curl -N turns off output buffering so you see each event as it lands:
curl -N https://app.syntic.ai/v1/chat/completions \
-H "Authorization: Bearer $SYNTIC_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "Amara Quick",
"messages": [{ "role": "user", "content": "Give me three names for a bakery van." }]
}'Parsing code for Python and JavaScript is on Stream format.
A tool call
Offer the model a function. If it decides to use it, the reply carries tool_calls instead of text:
curl https://app.syntic.ai/v1/chat/completions \
-H "Authorization: Bearer $SYNTIC_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "Amara S1.2",
"stream": false,
"tools": [{
"type": "function",
"function": {
"name": "check_stock",
"description": "Look up how many units of a product are in a given warehouse.",
"parameters": {
"type": "object",
"properties": {
"sku": { "type": "string", "description": "Product code, e.g. LAV-2L" },
"warehouse": { "type": "string", "enum": ["leeds", "bristol"] }
},
"required": ["sku", "warehouse"]
}
}
}],
"messages": [{ "role": "user", "content": "Do we have LAV-2L in Leeds?" }]
}'A typical reply:
{
"id": "chatcmpl-...",
"object": "chat.completion",
"created": 1767225600,
"model": "...",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "tc_01",
"type": "function",
"function": { "name": "check_stock", "arguments": "{\"sku\":\"LAV-2L\",\"warehouse\":\"leeds\"}" }
}
]
},
"finish_reason": "tool_calls"
}
],
"usage": { "prompt_tokens": 142, "completion_tokens": 27, "total_tokens": 169 }
}Run the function yourself, then send the whole conversation back with the assistant message (unchanged) and one tool message per call:
{
"model": "Amara S1.2",
"stream": false,
"tools": [ "...same tools as before..." ],
"messages": [
{ "role": "user", "content": "Do we have LAV-2L in Leeds?" },
{ "role": "assistant", "content": "", "tool_calls": [ "...exactly as received..." ] },
{ "role": "tool", "tool_call_id": "tc_01", "content": "{\"units\": 38}" }
]
}arguments is a JSON string, not an object; parse it before use and be ready for it to be invalid. The format of id is not fixed, so treat it as an opaque string. A complete loop is in Write a tool loop and Tool calling.
Response: stream: false
200 OK, Content-Type: application/json
{
"id": "chatcmpl-...",
"object": "chat.completion",
"created": 1767225600,
"model": "...",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "Bring year-round fragrance to borders and patios..." },
"finish_reason": "stop"
}
],
"usage": { "prompt_tokens": 58, "completion_tokens": 91, "total_tokens": 149 }
}| Field | Type | Description |
|---|---|---|
id | string | Identifier of the completion. Use x-syntic-request-id when contacting support. |
object | string | Always chat.completion. |
created | integer | Unix time in seconds. |
model | string | The model identifier reported for this completion. Do not use it to detect a model fallback; read the x-syntic-model-downgraded header instead. |
choices | array | Always one item. |
choices[].index | integer | Always 0. |
choices[].message.role | string | Always assistant. |
choices[].message.content | string or null | The generated text. Empty or null when the reply only holds tool calls. |
choices[].message.tool_calls | array | Present when the model asks you to run functions. Each item has id, type: "function" and function with name and arguments (a JSON string). |
choices[].finish_reason | string | stop (the model finished), length (hit max_tokens or the context window) or tool_calls (waiting for tool results). |
usage.prompt_tokens | integer | Input tokens, including the Syntic identity instruction. |
usage.completion_tokens | integer | Output tokens. |
usage.total_tokens | integer | The sum of the two. |
When part of the prompt is served from cache, usage can also include a cached_tokens count. Cached input is billed at the lower cached rate shown on Pricing.
Response headers
| Header | Description |
|---|---|
x-syntic-request-id | Unique id of the request. |
x-syntic-billing-source | subscription or api_credit. |
x-syntic-model-downgraded | Present only when the requested model is not included in your plan; holds the name of the model that answered. |
Response: streamed
200 OK, Content-Type: text/event-stream, with the same x-syntic-* headers.
Each event is a data: line holding a chat.completion.chunk object, followed by a blank line. Text arrives in choices[0].delta.content; tool calls arrive in pieces in choices[0].delta.tool_calls. A chunk near the end carries usage, and the stream closes with data: [DONE]. If something fails after the stream has started, a final event {"error": "stream_error", "detail": "..."} is sent before the connection closes.
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":1767225600,"model":"...","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":1767225600,"model":"...","choices":[{"index":0,"delta":{"content":"Crumb & Co"},"finish_reason":null}]}
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":1767225600,"model":"...","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":61,"completion_tokens":24,"total_tokens":85}}
data: [DONE]The full event reference, including how to assemble tool calls, is on Stream format.
Model selection and fallback
| You send | Model that answers | Header |
|---|---|---|
No model | Default model (Amara S1.2) | none |
| A name that does not exist | Default model | none |
| A model your plan includes | That model | none |
| A model your plan does not include | Default model | x-syntic-model-downgraded |
| A model that needs standard data retention, from a zero-data-retention organization | None | 400 model_unavailable |
Not live yet — on develop: the second and fourth rows change. A name that does not exist returns 404 model_not_found, and a model your plan does not include returns 403 model_not_in_plan; the x-syntic-model-downgraded header is retired. See Errors.
Until then an unknown name silently falls back, so check the spelling of model names carefully. The list of names, prices and plan requirements is on Models overview.
Billing
Each request is metered by its usage. Tokens come first from your organization’s plan allowance; once that is used up they are charged to the usage-credit wallet at the model’s retail rate, if credits are turned on. The x-syntic-billing-source header tells you which applied.
If you close a streamed connection early, the tokens already generated are still billed. When the final usage chunk never arrived, the count is estimated from the text that was sent.
See Usage & cost and Pricing.
Errors
Every error uses the error envelope: a type fixed by the HTTP status, a machine-readable code, a message, param and request_id.
{
"error": {
"type": "invalid_request_error",
"code": "too_many_messages",
"message": "The request has too many messages.",
"param": null,
"request_id": "req_6f1c2a9e0b7d4c3a8e5f1b2d",
"max_messages": 500
}
}| Status | type | code values |
|---|---|---|
| 400 | invalid_request_error | invalid_json, messages_required, invalid_request, context_length_exceeded, model_unavailable (provisional, not in the public catalog) |
| 401 | authentication_error | unauthenticated, invalid_api_key, revoked_api_key, expired_api_key, invalid_token, token_revoked |
| 402 | insufficient_quota | quota_exhausted |
| 403 | permission_error | forbidden, public_key_rejected, origin_not_allowed, insufficient_scope, org_membership_required, account_disabled, org_unavailable, plan_required |
| 404 | not_found_error | model_not_found (on develop, not live yet) |
| 405 | invalid_request_error | method_not_allowed, endpoint_not_supported |
| 413 | invalid_request_error | payload_too_large, too_many_messages |
| 429 | rate_limit_error | rate_limited, model_busy, capacity_busy |
| 500 | api_error | internal_error, server_misconfigured |
| 502 | api_error | upstream_error, upstream_unavailable; stream_error inside a stream |
| 503 | service_unavailable_error | authorization_unavailable, usage_check_unavailable, plan_check_unavailable, capacity_busy, service_unavailable |
| 504 | timeout_error | upstream_timeout |
Each code has its own page in the error code index; the full rules are on Error codes.
Limits
| Limit | Value |
|---|---|
| Request body | About 2 MB |
| Messages per request | 500 |
| Context window | 262,144 tokens (prompt plus reply) |
| Requests per minute | 600 per API key |
More on Limits.
Planned additions
Chat completions stays the stable public surface. New capabilities are added to this same request body rather than to new endpoints, and every new field will be validated: it is either honoured or rejected with a clear 400 naming the field, never silently dropped.
| Field or change | Purpose | Spec |
|---|---|---|
stop, user, metadata | Stop strings, end-user id, your own tags | Request parameters |
temperature, top_p, top_k honoured per model | Sampling, with 400 unsupported_parameter where a model cannot honour them | Sampling |
Fixed finish_reason list | Adds stop_sequence, refusal, content_filter, pause | Stop reasons |
response_format | Guaranteed JSON, optionally matching a schema | JSON output |
image_url / image_file content parts | Image input | Vision |
file content parts | PDF and document input | PDF and documents |
file_id references | Reuse uploaded files | Files API |
citations on documents, message.annotations | Answers that point to their sources | Citations |
prompt_cache_key, usage.prompt_tokens_details.cached_tokens | Control and report prompt caching | Prompt caching |
stream_options.include_usage, keep-alives, error envelope | Streaming polish | Streaming |
reasoning | Step-by-step thinking with an effort or budget | Reasoning |
context_management | Server-side clearing and compaction | Context management |
parallel_tool_calls, function.strict | Tool-call control | Parallel tool calls, Strict tool arguments |
Built-in tools in tools (syntic_web_search, …) | Tools that Syntic runs for you | Web search |
mcp_servers | Connect remote MCP servers | MCP connector |
POST /v1/chat/completions/count_tokens | Count input tokens without running the model | Token counting |
Error envelope {error:{type, code, message, param}, request_id} | One error shape across all endpoints | Error format |
A second request shape built on typed content blocks is specified on Messages. Both shapes share one engine, billing and limits.