Skip to content
Developer platformREST API · v1

API Documentation

Build, deploy, and observe Ainvented agents with a clear REST API. Start with one request, then add sessions, tools, files, tasks, and production controls as you need them.

Two-minute quickstart

Send your first message

Create an API key, keep it on your server, and make one chat-completion request.

  1. 1
    Create a key

    Open API Key Management and copy your key once. Never expose it in browser code.

  2. 2
    Call the API from your server
    Terminal
    export API_KEY="your_api_key_here"
    
    curl https://ainvented.com/api/v1/chat/completions \
      -H "Authorization: Bearer $API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "messages": [{"role": "user", "content": "Hello from Ainvented"}]
      }'
  3. 3
    Read the answer

    The generated text is in choices[0].message.content. Save the returned session_id when you want Ainvented to maintain conversation history.

Start here

Introduction

The Ainvented REST API gives your applications programmatic access to agents and their projects through a consistent interface. Use familiar HTTP patterns while Ainvented manages model access, sessions, tools, context, and observability.

API origin
https://ainvented.com
Authentication
A Bearer API key, sent from your server. Two key scopes.
Format
JSON in, JSON out. Streaming answers arrive as server-sent events.
Errors
One envelope for every failure. Error codes.
Where things are
Start here

Authentication

Ainvented v1 API requests use Bearer tokens unless an endpoint is explicitly marked public or browser-session only. Include your API key in the Authorization header:

HTTP header
Authorization: Bearer YOUR_API_KEY

Admin keys vs project-scoped keys

Every API key has one of two scopes. The scope is fixed at creation time and determines what the key can do.

Admin
Account-wide management key. Required for every endpoint under /v1/projects — listing, reading, updating, deleting, minting per-project keys, configuring webhooks, viewing consumption, and the partner payment surface (payments, payment methods, auto-recharge).
Project-scoped
Bound to one project. Can use only the runtime endpoints — /v1/chat/completions, /v1/tasks, /v1/vector-stores, /v1/workflows — which silently resolve to the bound project. Cannot read or modify anything under /v1/projects(no list, read, update, delete, key management, webhooks, consumption, or billing). Intended for partner-enabled accounts to give their clients a key that uses the product without exposing the project-management surface.
Start here

Rate Limits

API requests are rate limited using a token bucket algorithm. Rate limit information is included in response headers:

  • X-RateLimit-Limit: Maximum requests per time window
  • X-RateLimit-Remaining: Requests remaining in current window
  • X-RateLimit-Reset: Unix timestamp when limit resets

When rate limited, the API returns a 429 Too Many Requests response with a Retry-After header indicating seconds until reset.

Start here

Request Tracing & Observability

Every response from POST /v1/chat/completions — success or error, streaming or non-streaming — includes an X-Request-Id header (UUID). This id correlates the request with its usage events and, when the project is attached to an Observability project, with its captured trace. The header is purely additive — request and response bodies are unchanged.

  • Attach a chat project to an Observability project (sidebar → Observability) to see per-request traces: prompt, completion, model, tokens, cost, latency, TTFT and status.
  • Trace capture runs after the response is sent and never adds latency to your requests.
  • LLM-as-a-judge evaluations run against your project budget and appear in the project's consumption ledger like any other usage.
GET/api/v1/models#

List Models

List the model tiers this account can use. Uses the standard list-models shape, so a compatible client library or model picker populates itself with no Ainvented-specific code.

Authentication

Bearer token required in Authorization header

Response Schema

JSON type shapeExpand
{
  "object": "string",
  "data": [
    {
      "id": "string",
      "object": "string",
      "created": "number",
      "owned_by": "string"
    }
  ]
}
Fields6Expand
object
string
'list'
data
array
array — fields below
id
string
The model tier name to pass as `model` on /v1/chat/completions: 'ainvented-default', 'ainvented-low', 'ainvented-saga' or 'ainvented-epic'.
object
string
'model'
created
number
Unix timestamp of when the tier became available. Stable; it does not change between calls.
owned_by
string
'ainvented'

Code Examples

Every tier you may pass as `model`

curl https://ainvented.com/api/v1/models \
  -H "Authorization: Bearer $API_KEY"

# {
#   "object": "list",
#   "data": [
#     { "id": "ainvented-default", "object": "model", "created": 1735689600, "owned_by": "ainvented" },
#     { "id": "ainvented-low",     "object": "model", "created": 1751328000, "owned_by": "ainvented" },
#     { "id": "ainvented-saga",    "object": "model", "created": 1783036800, "owned_by": "ainvented" },
#     { "id": "ainvented-epic",    "object": "model", "created": 1783036800, "owned_by": "ainvented" }
#   ]
# }

Details

POST/api/v1/chat/completions#

Create Chat Completion

Generate a chat completion response using your configured chat project. Supports both streaming and non-streaming modes.

Authentication

Bearer token required in Authorization header

Request Schema

JSON type shapeExpand
{
  "model": "string",
  "messages": [
    {
      "role": "string",
      "content": "string | ContentPart[]"
    }
  ],
  "temperature": "number",
  "max_tokens": "number",
  "reasoning_effort": "string",
  "response_format": "object",
  "include_reasoning": "boolean",
  "reasoning": "object",
  "system_authority": "string",
  "stream": "boolean",
  "stream_options": "object",
  "project_id": "string",
  "safety_identifier": "string",
  "prompt_cache": "boolean",
  "prompt_cache_ttl_hours": "string|integer",
  "session_id": "string",
  "run_workflow": "boolean",
  "tools": "array",
  "modalities": "array",
  "image": "object",
  "connector_id": "string",
  "connector_to": "string",
  "connector_media_type": "string",
  "connector_media_url": "string",
  "connector_media_filename": "string",
  "connector_email_subject": "string",
  "tool_choice": "string|object"
}
Fields29Expand
model
stringoptional
Selects the chat model tier. Every tier answers to more than one name, case-insensitively, so a client with a single free-text "Model ID" box works whichever one you type: the standard model is 'ainvented-default', 'ainvented-lite', 'lite' or 'default'; the lower-cost model is 'ainvented-low' or 'low'; advanced reasoning is 'ainvented-saga' or 'saga'; the highest-capability model is 'ainvented-epic' or 'epic'. When omitted, the project's preconfigured default model is used (or 'ainvented-default' if the project has none). For compatibility with standard chat-completion clients, any other/legacy value is accepted (never rejected) and treated as 'ainvented-default'. The response echoes the CANONICAL name of the tier you selected (so 'epic' comes back as 'ainvented-epic'), never your raw string; pricing follows the selected tier (see the pricing page). GET /v1/models is the authoritative list.
messages
array
array — fields below
role
stringrequired
'system' | 'user' | 'assistant'. SIZE LIMIT: all `system` messages in a request are concatenated (in order, newline-separated) into the cached stable prefix. There is no longer a fixed character cap on any role; the whole request is instead fitted to the selected tier's INPUT TOKEN BUDGET, measured with the same tokenizer used for billing. Your `system` messages and your latest turn are the protected core and are never silently shortened: if they do not fit, the request is REFUSED with 400 context_length_exceeded (and is not billed) rather than answered from a truncated prompt. Lower-priority context (project files, workflow output) is trimmed first, and any trim at all is reported back on the `context_truncation` response field — nothing is dropped without telling you.
content
string | ContentPart[]required
Message content (text string or array of content parts for multimodal: `text`, `image_url`, `input_audio`, `video_url`).★ PUBLIC URL READING ("web search"): any public http(s) URL you include in the LAST user message's TEXT (e.g. 'summarize https://example.com/article') is automatically fetched and read by the assistant so it can answer about the page contents — no special parameter needed. CONTROLLED PER-PROJECT by the `web_search_enabled` setting (DEFAULT FALSE — opt in explicitly when you create or update the project — see POST / PUT /api/v1/projects). URLs typed in message text are NOT fetched unless the project sets web_search_enabled=true. SCOPE: detection runs on the message TEXT ONLY and is completely independent of file/image/audio handling — `image_url` content parts, attached project files, and audio are always forwarded regardless of this setting; only text-mentioned URLs are affected. data: URLs and images still go through `image_url` content parts. Adds no latency to plain text-only messages (URLs are fetched upstream only when present) and does not change billing. Works in non-streaming and streaming.★ A MESSAGE THAT IS ONLY A LINK TO AN IMAGE, AUDIO OR VIDEO FILE IS TREATED AS THAT FILE: when the whole content of the last user message is one http(s) link whose path ends in an accepted media extension (jpg, jpeg, png, gif, webp, bmp, tif, tiff, avif — wav, mp3, m4a, flac, ogg — mp4, m4v, mov, webm, mkv, 3gp, avi, mpg, mpeg, flv, wmv), the link is downloaded and attached exactly as if you had written it out as an `image_url` / `input_audio` / `video_url` content part, and it is billed exactly the same. A VIDEO link is promoted ONLY on a project with `web_search_enabled` = true (a video is downloaded, read and billed, so a project that keeps links unread — the default — gets its message as the text it was, nothing downloaded or billed). A promoted VIDEO that downloads but cannot be read is answered with a note saying so, never refused. The condition is strict: ONE link and nothing else — add a caption, a question, a second link or any other content part and the message keeps its current meaning (plain text, plus the URL reading above when the project enables it). A link that cannot be used — unreachable, private, oversized, or not the media its extension claimed — NEVER fails the request: the message stays exactly the text you sent, URL and all, and the turn runs precisely as it does today (including the URL reading above when your project enables it). No image or audio tokens are charged for a file that never arrived. For images and audio this is independent of `web_search_enabled` (a media link is an attachment, not a page to read); for a video it requires it (see above). It applies identically to streaming and non-streaming.
temperature
numberoptional
0.0 to 2.0, default: 1.0
max_tokens
numberoptional
Maximum tokens to generate. Forwarded to the model so generation STOPS at the cap (shorter responses arrive sooner); the reply is also clipped to it and `finish_reason` becomes 'length'. You are never billed for more completion tokens than the cap you set.
reasoning_effort
stringoptional
How much reasoning to spend on this turn: 'low', 'medium', 'high', 'xhigh' or 'max'. The same five levels work on EVERY model tier and are mapped onto what the serving model supports, so you never need to know which engine is behind a tier. Omitting the field uses the selected tier's standard effort, so existing clients are unaffected; it behaves identically on streaming and non-streaming requests. For drop-in compatibility, 'minimal' and 'none' are also accepted and treated as 'low', and an explicit null is treated as if the field were omitted — so a request written against a standard chat-completions client works unchanged. Any other value returns 400 invalid_request_error with param="reasoning_effort", rejected before any model is called, so an invalid value never costs you anything. Reasoning tokens are billed INSIDE completion_tokens (never additional) and are surfaced under usage.completion_tokens_details.reasoning_tokens when the serving engine reports them. Because a higher effort spends more of the same max_tokens budget thinking, a capped request can return LESS visible text at 'max' than at 'low' — raise max_tokens alongside the effort. PRECEDENCE: this request field > the project's `preconfigured_effort` > the tier's standard effort. Image-generating requests always run at the standard effort.
response_format
objectoptional
Structured output. { type: 'text' } (the default — nothing changes), { type: 'json_object' } (the reply is ONE valid JSON object), or { type: 'json_schema', json_schema: { name: string, schema: object (JSON Schema), strict?: boolean, description?: string } } (with strict: true the reply validates against your schema; with strict false or omitted the schema guides the reply and the reply is guaranteed to be a valid JSON object). Works on every model tier, including their backup models, streaming and non-streaming. Every reply is validated before it is released; an invalid one is regenerated (bounded), and if no attempt matches you get 502 response_format_failed. Combines with `tools`: a turn may still call a tool (tool_calls as usual) and any prose next to the call is dropped so `content` stays JSON or null. A reply that does not fit your own max_tokens is cut at the cap with finish_reason 'length' (incomplete JSON, billed at most the cap — raise max_tokens). Not allowed with modalities: ['image'] (an image-only reply has no text to format) — 400. An invalid value is 400 invalid_response_format with param 'response_format', decided before any model runs (never billed).
include_reasoning
booleanoptional
Whether the model's reasoning is returned. STREAMING: on by default — reasoning arrives as `delta.reasoning_content` chunks before the answer, so a client on a long-thinking turn sees progress instead of an empty stream; send false to turn it off. NON-STREAMING: off by default; send true to get it as `choices[0].message.reasoning_content`. Reasoning is never part of `content`, never stored with the conversation, and never billed separately (reasoning tokens were always inside completion_tokens). A turn whose model does not reason returns no reasoning, and a very short think may come without a summary (its answer arrives within a couple of seconds anyway). A non-boolean value is 400 invalid_reasoning_option.
reasoning
objectoptional
Alternative spelling some clients use: { exclude: true } is the same as include_reasoning: false. Other keys are ignored. A non-object value, or a non-boolean `exclude`, is 400 invalid_reasoning_option.
system_authority
stringoptional
How your `system` messages relate to the platform's own prompt. DEFAULT BEHAVIOUR: whenever a request carries a `system` message, that message is AUTHORITATIVE — the platform's conversational framing, tone paragraph and chat scaffolding are not layered on top of it. Your project's configured agent prompt, RAG results, URL-derived context, project files and the user's message all still apply, as do the rules that keep the underlying model unnameable. A request with NO `system` message is unaffected. Send 'merge' to pin the previous behaviour for a request (the platform prompt wraps your instructions), or 'exclusive' to state the default explicitly. An unrecognised value is ignored rather than rejected.
stream
booleanoptional
Enable streaming response, default: false
stream_options
objectoptional
streaming only — { include_usage: true } appends ONE final chunk before 'data: [DONE]' carrying the same `usage` object the non-streaming response returns. That chunk has an EMPTY choices array (choices: []) — the standard marker clients use to recognise it. Omit the field (or send anything other than exactly true) and the stream is byte-for-byte what it has always been, so existing streaming clients are unaffected. Costs no additional latency: the chunk is emitted after the last content delta.
project_id
stringoptional
Specific project ID (ignored if API key is scoped to a project)
safety_identifier
stringoptional
Accepted for drop-in compatibility with standard chat-completion clients, and IGNORED — sending it never changes the response, the billing or the latency, and it is never rejected. Abuse attribution on this platform is derived server-side from the authenticated project and account behind each request, so no client-supplied identifier is needed for it. The legacy `user` field is treated the same way. Per-END-USER attribution (distinguishing the individual people using YOUR application) is not currently exposed; if you need it, say so — the request field is reserved for exactly that.
prompt_cache
booleanoptional
default true — Prompt caching. When enabled (the default), repeated prompt prefixes across nearby requests are served from cache and those cached input tokens are billed at a heavily discounted cached rate (see Pricing), so your balance lasts longer at no quality cost. Set false to opt THIS request out — cached input tokens are then billed at the full input rate. PRECEDENCE: this request field > the project's `prompt_cache_enabled` setting > platform default (ON). Caching never changes the response text; the discount applies only when the provider reports a cache hit (not guaranteed for short prompts). Adds no latency.
prompt_cache_ttl_hours
string|integeroptional
Managed Prompt Cache: keep this project's stable prompt prefix (system/assistant instructions of at least 1,024 tokens) in a dedicated cache for a guaranteed lifetime, so EVERY request reusing it gets the cached-input discount deterministically (not best-effort). Allowed values: 1, 6, 12 or 24 (hours), "auto", or null — "auto"/null defer to the project's managed-cache setting for this request (same as omitting the field); any other value returns 400. The cached stable prefix INCLUDES your `system` role message(s) — so sending a large, unchanging system prompt bills it at the cached rate on every reuse instead of re-charging it each turn (different system prompts are cached separately and never collide). Billing: cached storage is charged at $1.50 per 1M tokens per hour (pro-rated per cached prefix) plus a one-time cache-write at $0.40 per 1M tokens; break-even guidance: the cache pays for itself above roughly 5 reuses of the prefix per hour. PRECEDENCE: this request field > the project's `prompt_cache_ttl_hours` setting. Requires prompt caching enabled (`prompt_cache:false` wins). Identical configuration and pricing across every model tier - switching models changes nothing.
session_id
stringoptional
Existing session ID for context continuity. If you already send the full conversation in `messages` (stateless usage — you manage history client-side), the server uses your transcript as the history and does NOT additionally load this session's stored history, so context is never duplicated. Send only the latest message + session_id to instead have the server supply the prior turns.
run_workflow
booleanoptional
Execute the project's configured workflow and inject results as context before generating the response. Default: false
tools
arrayoptional
Standard function-tool definitions for workflow override mode. When provided, only matching project workflows are executed. When omitted, all active project workflows auto-execute. An empty array disables all workflow execution. Format: [{ type: 'function', function: { name, description?, parameters? } }]. SPECIAL: a tool named 'image_generation' opts this request into image output (same as modalities: ['image']); its `parameters` are merged into the `image` block.★ TASKS (when the Tasks feature is enabled for your project): a tool whose `function.name` matches a stored Task (created via POST /api/v1/tasks) SELECTS that task for this interaction — standard tool selection. By default, selected tasks run ASYNCHRONOUSLY after the reply is flushed (zero TTFT/TTLT impact) and analyze the conversation history to extract structured data / classify intent; opting into `task_wait:true` instead runs them inline and adds their latency (see Result-delivery modes). A tools name matching no task/workflow/tool is ignored (no error). Read results via GET /api/v1/tasks/{id}/runs.
modalities
arrayoptional
Output modalities hint. Allowed values: 'text' | 'image'.★ IMAGE GENERATION IS STRICTLY OPT-IN — when this field is OMITTED, the endpoint stays on the fast text-only path with the lowest TTFT/TTLT (no image-generation work is triggered). A first-time caller will always feel the fastest possible response. Semantics: omitted → text-only fast path (default); ['text'] → same as omitted, treated as explicit text-only; ['image'] → OPT-IN: image only (no text reply); ['text','image'] → OPT-IN: text + image for every request. Images returned by the model are still surfaced in response.images (subject to your project's image-generation setting).
image
objectoptional
Image-generation parameters. Applied only when 'image' is in modalities or a tool named 'image_generation' is provided. Fields: { n?: 1..4 (default 1), size?: '256x256'|'512x512'|'1024x1024'|'1024x1536'|'1536x1024'|'1024x1792'|'1792x1024' (default '1024x1024'; the generated image has exactly the requested size and aspect ratio), quality?: 'standard'|'hd' (default 'standard'; hd renders more detail and bills more output tokens), style?: 'vivid'|'natural' (default 'vivid'), response_format?: 'url'|'b64_json' (default 'url'), remove_background?: boolean (default false; available when background removal is enabled for your project) }. PRECEDENCE: per-request image.* > your project defaults (Configure pane) > built-in defaults. Per-request always wins.★ REFERENCE/PRODUCT IMAGES: any images you attach in the last user message's content as `image_url` parts (base64 data: URLs or http(s) URLs) are forwarded to the image generator as references, so it edits/uses YOUR product instead of inventing one. Works in both streaming and non-streaming.
connector_id
stringoptional
UUID of an active chat connector to ALSO dispatch the AI reply through (Telegram, WhatsApp, Slack, Email/Resend, Gmail, SMTP, Telnyx SMS). The /v1/chat/completions response is still returned normally; the connector dispatch is best-effort and non-fatal — a dispatch failure is logged but does not change the HTTP response.
connector_to
stringoptional
Recipient address for the connector dispatch. Format depends on the channel: '+15551234567' for SMS / WhatsApp / Telnyx, channel id like 'C0123456' for Slack, chat id integer for Telegram, RFC-822 email for Resend / Gmail / SMTP. Required when connector_id is set.
connector_media_type
stringoptional
Type of media to attach to the connector dispatch. Allowed: 'image' | 'audio' | 'voice' | 'document' | 'video'. Default 'image'. Only consumed when connector_media_url is also set.
connector_media_url
stringoptional
Explicit media URL to attach to the connector dispatch. Wins over auto-attach. When this field is omitted AND the response produced images, response.images[0] is auto-attached (when auto-attach is enabled for your project).
connector_media_filename
stringoptional
Filename for documents dispatched via document-capable connectors (Telegram document, email attachments). Only consumed when connector_media_type='document'.
connector_email_subject
stringoptional
Subject line for email/Gmail/SMTP dispatch. Defaults to a generic 'AI assistant reply' subject when omitted.
tool_choice
string|objectoptional
Standard tool-selection control, also used to control Tasks (when the Tasks feature is enabled for your project). Values: 'auto' (run task-tools named in `tools` PLUS any matching on_message/on_intent project tasks — an on_intent task auto-fires when the conversation matches one of its declared intents, e.g. cancel_order), 'none' (run no tasks), 'required' (run ONLY the task-tools named in `tools`), or { type: 'function', function: { name } } (run ONLY that one task). COST-SAFE DEFAULT: auto-trigger expansion (firing unnamed on_message/on_intent tasks) happens when tool_choice is EXPLICITLY 'auto'. If tool_choice is omitted, only the tasks you NAME in `tools` fire — an empty `tools:[]` or omitting both fields runs NO tasks by default (so passing tools:[] to disable workflows never incurs surprise task spend). TRANSPARENT AUTO-FIRE (opt-in per project): a project can be configured so an OMITTED tool_choice behaves as 'auto' — then a plain chat-completion request (no tools/tool_choice at all) automatically runs the project's on_message/on_intent tasks, so existing clients need no changes. Contact us to enable it for your project. By default task selection + execution are resolved asynchronously AFTER the reply (zero TTFT/TTLT impact); only the opt-in task_wait:true runs them inline and adds latency.

Response Schema

JSON type shapeExpand
{
  "id": "string",
  "object": "string",
  "created": "number",
  "model": "string",
  "choices": [
    {
      "index": "number",
      "message": {
        "role": "string",
        "content": "string | null",
        "tool_calls": "array (only when the model calls inline function tools)",
        "reasoning_content": "string (only with include_reasoning: true, and only when the model reasoned)"
      },
      "finish_reason": "string"
    }
  ],
  "usage": {
    "prompt_tokens": "number",
    "completion_tokens": "number",
    "total_tokens": "number",
    "prompt_tokens_details": "object",
    "completion_tokens_details": "object"
  },
  "response": {
    "images": "string[]",
    "image_meta": "object[] (only present when images were produced)"
  },
  "session_id": "string",
  "context_truncation": "object (present ONLY when part of the request had to be left out)"
}
Fields23Expand
id
string
Unique completion ID
object
string
'chat.completion'
created
number
Unix timestamp
model
string
Model used
choices
array
array — fields below
index
number
Choice index
message
object
object — fields below
role
string
'assistant'
content
string | null
Generated text. null when the model emits tool_calls (function calling).
tool_calls
array (only when the model calls inline function tools)
[{ id: string, type: 'function', function: { name: string, arguments: string (JSON-encoded) } }]. Present with finish_reason 'tool_calls'.
reasoning_content
string (only with include_reasoning: true, and only when the model reasoned)
The model's reasoning for this turn. Never part of `content`.
finish_reason
string
'stop' | 'length' (the reply reached max_tokens — with response_format the JSON is then incomplete) | 'content_filter' | 'tool_calls' (when the model called one or more inline function tools)
usage
object
object — fields below
prompt_tokens
number
Input tokens billed for this turn, including your messages, any attached project context, and any in-turn auxiliary work (URL extraction, image captioning, transcription).
completion_tokens
number
Output tokens billed for this turn.
total_tokens
number
prompt_tokens + completion_tokens.
prompt_tokens_details
object
{ cached_tokens: number } — the portion of prompt_tokens served from the prompt cache and billed at the discounted cached rate. A SUBSET of prompt_tokens, never additional. 0 when prompt caching is disabled for the project or the request.
completion_tokens_details
object
{ reasoning_tokens: number } — reasoning/'thinking' tokens, ALREADY INCLUDED in completion_tokens (never additional). 0 means the serving engine does not report them separately; those tokens are still billed inside completion_tokens.
response
object
object — fields below
images
string[]
URLs (or data: URLs) of any images generated by the model. Empty array when no images were produced or image generation is disabled. Image URLs are NEVER inlined as markdown in choices[0].message.content; they are always returned in this dedicated field.
image_meta
object[] (only present when images were produced)
One entry per URL in `images`, same order. Fields per entry: { url: string, size: '256x256'|...|'1792x1024', format: string|null, bytes: number|null, source: string (origin of the image, e.g. 'background_removed'), request_id: string (uuid identifying the originating request), prompt_excerpt: string (first 80 chars of the originating prompt) }.
session_id
string
Session ID for future requests
context_truncation
object (present ONLY when part of the request had to be left out)
The request did not fit the selected tier's input budget, so some of it was not sent to the model. Fields: { truncated: true, input_tokens_dropped: number, attached_context_truncated: boolean (project files / workflow output were cut), system_messages_truncated: boolean (your `system` messages were cut), messages_truncated: boolean (your user/assistant turn was cut) }. Absent on every request that fitted, so this is purely additive. A request that cannot be trimmed at all is refused with 400 context_length_exceeded instead (see Error Codes) and is NOT billed.

Code Examples

Basic completion request with non-streaming response

import requests

api_key = "your_api_key_here"
url = "https://ainvented.com/api/v1/chat/completions"

headers = {
    "Authorization": f"Bearer {api_key}",
    "Content-Type": "application/json"
}

data = {
    "messages": [
        {"role": "user", "content": "Hello, how are you?"}
    ],
    "temperature": 0.7,
    "max_tokens": 150
}

response = requests.post(url, headers=headers, json=data)
result = response.json()
print(result["choices"][0]["message"]["content"])

Details

Model tiers

Select the chat model per request with the model field (or set a project default via preconfigured_model):

  • ainvented-default — the standard model.
  • ainvented-low — a lower-cost model. Best for high-volume, cost-sensitive turns.
  • ainvented-saga — an advanced reasoning model. Best for complex analysis and long-form work.
  • ainvented-epic — the highest-capability model. Best for the hardest reasoning tasks.
  • ainvented-lite and lite are additional names for the standard tier — same routing, same price as ainvented-default.
  • The bare words low, saga, epic, lite and default are accepted too (case-insensitively), for clients whose settings offer a single free-text "Model ID" box. GET /api/v1/models/{id} accepts every one of these spellings and resolves it to the canonical entry.
  • Omitting model uses the project's preconfigured default (or ainvented-default). Any unrecognized value is accepted and treated as ainvented-default — never rejected.
  • The response model field echoes the canonical name of the tier you selected — epic comes back as ainvented-epic, never your raw string. GET /api/v1/models is the authoritative list. Per-tier pricing is on the pricing page.

Select how much reasoning each request gets with the reasoning_effort field (or set a project default via preconfigured_effort): low, medium, high, xhigh or max. Omitting it keeps the tier's standard effort, so a higher effort is always something you opt into — it buys answer quality with time-to-first-token and completion tokens. It applies identically to streaming and non-streaming requests.

  • The same five levels work on every tier — each is mapped onto what the serving model actually supports, so you never have to know which engine is behind a tier.
  • For drop-in compatibility, reasoning_effort: "minimal" and "none" are also accepted and treated as low, and an explicit null is treated as if the field were omitted — so a request written against a standard chat-completions client works unchanged.
  • Any other value returns 400 invalid_request_error with param="reasoning_effort". It is rejected before the request reaches a model, so an invalid value never costs you anything.
  • Reasoning tokens are billed inside completion_tokens — never as an extra charge — and are reported under usage.completion_tokens_details.reasoning_tokens when the serving model breaks them out.
  • A higher effort spends more of the same max_tokens budget thinking, so a capped request can return less visible text at max than at low. Raise max_tokens alongside the effort.
cURL — request the low tier
curl https://ainvented.com/api/v1/chat/completions \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"model":"ainvented-low",
       "messages":[{"role":"user","content":"Summarize: the meeting is moved to 3pm Friday."}]}'
# -> response.model echoes the requested tier; a text-only, in-budget turn is served + billed as ainvented-low

Streaming Response

When stream: true is set, the response uses Server-Sent Events (SSE). Each chunk follows this schema:

Example
{
  "id": "string - Unique completion ID",
  "object": "string - 'chat.completion.chunk'",
  "created": "number - Unix timestamp",
  "model": "string - Model used",
  "choices": [
    {
      "index": "number - Choice index",
      "delta": {
        "role": "string (optional) - 'assistant' (first chunk only)",
        "content": "string (optional) - Incremental text content",
        "reasoning_content": "string (optional) - Incremental reasoning, streamed BEFORE the answer while the model thinks (on by default for streams; include_reasoning: false turns it off). Comes in its own chunks — never in the same chunk as `content` — so a client that ignores unknown delta keys sees exactly the stream it always did.",
        "tool_calls": "array (optional) - Inline function-calling deltas: [{ index: number, id: string, type: 'function', function: { name: string, arguments: string } }]. Accumulate by `index`; the stream ends with a finish_reason 'tool_calls' chunk.",
        "images": "string[] (optional) - Emitted in a dedicated chunk when the model generates one or more images. URLs (or data: URLs). Never inlined as markdown in delta.content.",
        "image_meta": "object[] (optional) - Emitted IMMEDIATELY AFTER the delta.images chunk when the caller opted in via modalities/image. Same per-entry shape as response.image_meta (url, size, format, bytes, source, request_id, prompt_excerpt). Old clients can safely ignore unknown delta.* keys."
      },
      "finish_reason": "string | null - 'stop' (final text chunk), 'tool_calls' (after inline function-call deltas), else null"
    }
  ],
  "session_id": "string (final chunk only) - Session ID for future requests",
  "context_truncation": "object (final chunk only, and only when part of the request had to be left out) - Same shape as the non-streaming `context_truncation` field. It rides the FINAL chunk because the upstream reports the trim after the generated text."
}

Streaming reasoning

On a reasoning turn the model can think for a while before the first word of the answer. A stream (stream: true) now carries that reasoning as it happens, in delta.reasoning_contentchunks, followed by the answer in delta.content as always — the convention coding agents and chat clients already render as "thinking". Nothing to enable.

  • Turn it off per request with include_reasoning: false (or reasoning: {"exclude": true}). Off, the stream is exactly what it was before.
  • Non-streaming requests return reasoning only when asked: include_reasoning: true adds choices[0].message.reasoning_content.
  • Reasoning is never part of content, never stored with the session, and never changes the bill: reasoning tokens were always counted inside completion_tokens (usage.completion_tokens_details.reasoning_tokens).
  • A turn whose model does not reason (low effort, a short answer) has no reasoning chunks. A higher reasoning_effort produces more of them.
  • While a stream has sent nothing for 15 seconds — a model thinking silently — an SSE comment line : keep-alive is written so proxies and client idle timers keep the connection open. Every SSE parser ignores comment lines.
Stream — reasoning, then the answer
data: {"id":"chatcmpl-…","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"reasoning_content":"The user wants the total. 3 boxes of 12 is 36…"},"finish_reason":null}]}

data: {"id":"chatcmpl-…","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"There are 36 items."},"finish_reason":null}]}

data: {"id":"chatcmpl-…","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: [DONE]

Structured output (response_format)

Ask for JSON and get JSON. response_format takes the standard three forms and works on every tier — including the backup model behind each tier — streaming or not:

  • {"type": "text"} — the default. The request is handled exactly as if the field were absent.
  • {"type": "json_object"} — the reply is one valid JSON object. Describe the keys you want in your messages.
  • {"type": "json_schema", "json_schema": {"name": …, "schema": {…}, "strict": true}} — the reply validates against your JSON Schema (types, required keys, enums, nested objects and arrays). Strict schemas follow the usual rules: every property listed in required and additionalProperties: false on each object; a schema that breaks them is a 400 invalid_response_format (param: response_format) that names every problem found — up to five, with where each is — so you fix them in one go. With strict false or omitted the schema guides the reply and you are guaranteed a valid JSON object.

Every reply is checked before it is released. A reply that does not match is generated again — a bounded number of times — and if none matches the request fails with 502 response_format_failed (retryable). The generations that were made are billed like any other turn; a request that is refused before reaching a model (an invalid response_format) costs nothing.

  • Streaming: the JSON arrives as ordinary delta.content chunks. Accumulate them and parse at the end.
  • With tools: the model may still call a tool; you get tool_calls as usual and any prose next to the call is dropped, so content is always JSON or null.
  • max_tokens: your cap wins over the JSON: a reply that does not fit is cut at the cap with finish_reason: "length" (incomplete JSON, billed at most the cap). Leave room.
  • Images: modalities: ["image"] (image-only) cannot be combined with a JSON format — 400.
  • The JSON is returned byte-for-byte as generated: no footer, link rewriting or truncation notes are added to a JSON reply.
cURL — extract fields as a strict schema
curl https://ainvented.com/api/v1/chat/completions \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"model":"ainvented-low",
       "messages":[{"role":"user","content":"Ana Gómez, 34, lives in Bogotá and works as an architect."}],
       "response_format":{"type":"json_schema","json_schema":{"name":"person","strict":true,
         "schema":{"type":"object","additionalProperties":false,
           "required":["name","age","city"],
           "properties":{"name":{"type":"string"},"age":{"type":"integer"},"city":{"type":"string"}}}}}}'
# -> choices[0].message.content = {"name":"Ana Gómez","age":34,"city":"Bogotá"}

Coding agents & other standard chat-completions clients

This endpoint is a drop-in target for any client that speaks the standard chat-completions protocol. Point the client's Base URL at https://ainvented.com/api/v1, paste an API key, and set the Model ID to one of the tiers above.

Agent clients differ from chat clients in one way that matters: they ship a largesystem prompt that defines a strict response protocol — "every reply must be exactly one tool call", replies formatted as tagged blocks — and they reject any reply that does not follow it, re-sending the whole conversation as a retry. For those clients the caller's system prompt must be the only thing shaping the answer, and it is:

  • Whenever a request carries a system message, that message is authoritative: the platform's conversational framing and tone rules are not layered on top of it. You do not have to ask for this and there is nothing to configure.
  • Your project's configured agent prompt still applies, ahead of your per-request instructions, so anything you set up in the project is preserved and your request wins on any point the two disagree about.
  • Native function tools work exactly as before, and tool_calls come back in the usual shape — the two are independent.
  • Send system_authority: "merge" on a request to pin the previous behaviour.

Screenshots and other images an agent attaches are accepted as image_url content parts in JPEG, PNG, GIF, WebP, BMP, TIFF or AVIF (an editor or browser screenshot is usually WebP). An unsupported format is a 400, not a retryable error — retrying the same image will not help; convert it first.

cURL — an agent-style request
curl https://ainvented.com/api/v1/chat/completions \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"model":"ainvented-epic",
       "stream":true,
       "stream_options":{"include_usage":true},
       "messages":[
         {"role":"system","content":"You are an agent. Every reply MUST be exactly one <tool> block and nothing else."},
         {"role":"user","content":"List the files in the project root."}
       ]}'

Session Management

Use the session_id parameter to maintain conversation context across requests:

  • First request — omit session_id; the response returns a new session ID.
  • Subsequent requests — include that session_id to continue the conversation.
  • Sessions are tied to your API key and keep the full conversation history.
  • Two ways to do multi-turn — pick one, don't combine them:
    • Stateless (recommended) — send the entire messages array (all prior turns + the new one) on every request. Your transcript IS the conversation history; the server does not add any stored history on top of it.
    • Stateful — send only the latest user message in messages plus the session_id; the server supplies the earlier turns from its store.
  • You don't need to do both: if messages already contains prior turns, the server automatically will not also re-load the stored session history, so context is never duplicated or double-counted toward usage. (Sending the full transcript AND relying on the stored history would otherwise repeat the same turns.)
  • /v1/chat/completions does not generate or expose chat session titles. (Title generation is suppressed upstream to minimize latency and cost.) Use the session_id returned in the response to correlate multi-turn conversations.

Project Selection

The API determines which project to use in this priority order:

  1. The API key's associated project (if scoped) — takes precedence over everything else.
  2. The project_id request field — only when the key is not scoped to a project.
  3. Your default project (first created).

Project Enhancements: Skills & Guardrails

Every request automatically inherits the active Skills and Guardrails configured for the project in the AI Chat UI — injected as system context on every call, with no extra request fields.

🎯 Skills

Instruction presets that shape the AI's behaviour on every response (e.g. "always respond in bullet points"). Set via the Skills button in AI Chat.

🛡️ Guardrails

Content/tone rules that are always enforced (e.g. "never mention competitors"). Set via the Guardrails button in AI Chat.

Workflow Execution in Chat

Set run_workflow: true to run the project's configured workflow before the AI responds. The workflow output is injected as context so the AI can reason over real-time data from your pipeline.

cURL — workflow-backed completion
curl https://ainvented.com/api/v1/chat/completions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [
      {"role": "user", "content": "Summarise the latest results"}
    ],
    "run_workflow": true
  }'
Python — workflow-backed completion
import requests

api_key = "your_api_key_here"
url = "https://ainvented.com/api/v1/chat/completions"

headers = {
    "Authorization": f"Bearer {api_key}",
    "Content-Type": "application/json"
}

# run_workflow: true triggers the project workflow before the AI responds.
# The workflow output is injected as context so the AI can reason over real data.
data = {
    "messages": [
        {"role": "user", "content": "Summarise the latest results"}
    ],
    "run_workflow": True
}

response = requests.post(url, headers=headers, json=data)
result = response.json()
print(result["choices"][0]["message"]["content"])

Tools Override (Selective Workflow Execution)

By default, all active project workflows run on every request and their output is injected as context. Use the tools field to control which run:

Default

No tools field → all active workflows auto-run (same as the chat UI).

Override

A tools array of names → only matching workflows run. Names look like workflow_<name>.

Disable

Pass "tools": [] → no workflows run for this request. Skills and guardrails still apply.

cURL — selective workflow execution
# Only execute specific workflows (override mode)
curl https://ainvented.com/api/v1/chat/completions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [
      {"role": "user", "content": "tell me about Croatia"}
    ],
    "tools": [
      {"type": "function", "function": {"name": "workflow_country_cities"}}
    ]
  }'
Python — selective workflow execution
import requests

api_key = "your_api_key_here"
url = "https://ainvented.com/api/v1/chat/completions"

headers = {
    "Authorization": f"Bearer {api_key}",
    "Content-Type": "application/json"
}

# Override mode: only execute specific workflow tools
# Pass an empty tools array to disable all workflow execution
data = {
    "messages": [
        {"role": "user", "content": "tell me about Croatia"}
    ],
    "tools": [
        {"type": "function", "function": {"name": "workflow_country_cities"}}
    ]
}

response = requests.post(url, headers=headers, json=data)
result = response.json()
print(result["choices"][0]["message"]["content"])
Guide

Tasks — async history analysis

Tasks are reusable, client-defined units that asynchronously analyze a conversation's history to extract structured data or classify intent — without ever affecting the chat reply's latency. A task is an instruction prompt + a JSON-Schema output_schema + a trigger. Manage them via the REST endpoints below, and select them per interaction with the completions tools/tool_choice params (a task is a function tool referenced by name). Available when the Tasks feature is enabled for your project.

Endpoints

POST/api/v1/tasks
Create a task. Body: name, instructions, trigger_type, output_schema, and optional trigger_config, history_scope, model, action, enabled, scope. model is the tier the task's runs are served on (ainvented-default, ainvented-low, ainvented-saga or ainvented-epic, aliases accepted as on /v1/chat/completions); omitted, the project's preconfigured_model applies, then the standard tier. Runs use the project's preconfigured_effort. (See Triggers and Limits below.)
GET/api/v1/tasks
List tasks. Filters: status, trigger_type, enabled, limit, after. Your global tasks are included by default — pass ?include_global=false for project-only, or ?scope=global for globals only. Each task carries scope.
GET/api/v1/tasks/{id}
Retrieve one task.
PUT/api/v1/tasks/{id}
Update a task — including pause/resume via enabled.
DELETE/api/v1/tasks/{id}
Delete a task. It stops running at once.
POST/api/v1/tasks/{id}/run
Run a task synchronously now and return the completed run with its result. Body: messages?, session_id?, project_id?. Used by the tool_call delivery mode and for run-now / testing. Runs regardless of the task's enabled flag (manual operator override — enabled:false only pauses automatic triggers); the run is billed and audited normally.
GET/api/v1/tasks/{id}/runs
Run history — each run has result, status, confidence, request_id, source_session_id, prompt_tokens/completion_tokens, completed_at. Filter to one conversation with ?session_id=…. (See Reading results.)

Triggers

A task's trigger_type decides when it can run:

  • manual — only runs when you explicitly name it (in tools) or call /run.
  • on_message — auto-runs on each interaction under tool_choice:'auto' (or on every request when transparent auto-fire is enabled for the project).
  • on_intent — trigger_config: { intents: [...] }. Under tool_choice:'auto' (or transparent auto-fire) the task auto-fires when the conversation matches one of its declared intents (e.g. cancel_order, refund). You can also name it explicitly in tools to force it on any turn.
  • schedule — trigger_config: { cron, timezone?, session_id? }.

Scope — project vs. global

scope defaults to 'project'. Set it to 'global' to make the task available to all of your projects (it's user-scoped, so project_id is null).

Selecting tasks from a completion

Name a task as a function tool — "tools":[{"type":"function","function":{"name":"<task name>"}}] — controlled by tool_choice (auto / none / required / {type:'function',function:{name}}). With both tools and tool_choice omitted, no tasks run by default (the cost-safe default). The project is resolved from your API key (or the project_id field) exactly like the rest of the endpoint — you never put a project id inside tools, and a task name resolves within the resolved project.

Inline function tools (standard function calling)

Separate from stored Tasks, you can use standard function calling: pass full function definitions inline on every request and let the model decide when to call them. Nothing is stored — if you don't send a definition on a request, it doesn't exist for that request.

  • Define functions in tools: {type:"function", function:{name, description, parameters /* JSON Schema */}}.
  • tool_choice: auto (model decides), none (never call), required (must call one), or {type:"function",function:{name}} (force one).
  • When the model calls a function, the reply has finish_reason:"tool_calls", message.content:null, and message.tool_calls[] (each with function.arguments as a JSON string). Parallel calls are supported.
  • You execute the function and continue the standard loop: resend with the prior {role:"assistant", tool_calls:[…]} and a {role:"tool", tool_call_id, name, content} result. The model then answers using the result.
  • Works with stream:true (tool calls arrive as delta.tool_calls then a final finish_reason:"tool_calls" chunk).
cURL — function-calling loop
# 1) Model decides to call get_weather
curl https://ainvented.com/api/v1/chat/completions \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"model":"ainvented-default",
       "messages":[{"role":"user","content":"Weather in Paris?"}],
       "tools":[{"type":"function","function":{"name":"get_weather",
         "description":"Get current weather",
         "parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}],
       "tool_choice":"auto"}'
# -> finish_reason "tool_calls"; message.tool_calls[0].function.arguments is a JSON string

# 2) Run the function yourself, then send the result back to finish the turn
curl https://ainvented.com/api/v1/chat/completions \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"model":"ainvented-default","messages":[
       {"role":"user","content":"Weather in Paris?"},
       {"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function",
         "function":{"name":"get_weather","arguments":"{\"city\":\"Paris\"}"}}]},
       {"role":"tool","tool_call_id":"call_1","name":"get_weather","content":"18C, sunny"}]}'
# -> a normal answer that uses "18C, sunny"

Result-delivery modes

How you get a task result back from /chat/completions:

Reading results & delivery (action)

After a completion selects a task, poll GET /api/v1/tasks/{id}/runs?session_id=… using the session_id the completion returned (it matches the run's source_session_id). A run with status:"success" is done. A run stuck running past ~10 min is auto-failed to error (timed_out) — just re-trigger.

Or have the result pushed when the run finishes via the task's action:

  • action.webhook_url — POSTs the structured payload (must be a public http(s) URL; private/loopback/metadata addresses are rejected for SSRF safety).
  • action.connector_id + action.connector_to — sends via a project chat connector (Telegram / WhatsApp / SMS / Email / Gmail / SMTP).
  • action.min_confidence — gates delivery (results below it are stored but not pushed).

Webhook takes precedence when both are set; delivery is best-effort and never affects the stored run. The delivered flag on each run reflects the outcome.

history_scope & limits

history_scope controls how much of the conversation the task reads:

  • { mode: 'full' } (default) — the entire transcript, with automatic map-reduce over long histories (bounded to the most recent ~144k tokens to cap per-run cost).
  • { mode: 'last_n', n: <1..1000> } — only the last N messages.
  • { mode: 'since', since: '<ISO>' } — falls back to full for the standard message shape (no per-message timestamps).

Limits: name ≤120 chars, unique per project (or per account for global tasks) and not a reserved name like image_generation; on a name clash the project-local task wins for that project. instructions ≤20000 chars; metadata ≤16 keys; output_schema nesting ≤32 levels. Unknown fields are rejected (strict).

Cost & quotas

Every task AI call is metered (one usage event per AI call); CRUD makes no AI call. Per-project guardrails cap active tasks and runs per minute & per day — over the cap, runs are skipped (no spend) and task creation returns 429 task_quota_exceeded.

Each run carries the tokens it was charged for on prompt_tokens and completion_tokens, so GET /api/v1/tasks/{id}/runs reconciles 1:1 with what the project's consumption reports for those runs. A run that never reached the model — refused by the budget gate, or a task that no longer exists — reports 0, which is exactly what it cost. A retried run reports the sum of its attempts.

Error codes

feature_disabled (403) — Tasks not enabled for this project.
task_not_found (404) — task id not found / not owned by your project.
invalid_request_error (400) — bad payload, invalid output_schema / cron / trigger_config. Includes schedule_disabled (schedule trigger is off) and invalid_schedule_scope (a global task can't use the schedule trigger).
task_quota_exceeded (429) — active-task cap reached.
duplicate_task_name (409) — a non-deleted task with that name already exists in the same scope (per project, or per account for global tasks). A deleted task's name becomes reusable.
Guide

Multimodal Content Support

The Chat Completions API supports multimodal content including base64-encoded images and audio files. You can send images and audio alongside text in your messages using the ContentPart array format.

Image Generation — Request

Image output is opt-in per request: add "image" to modalities (or a tool named image_generation) and describe the picture in the last user message. Everything else is the same chat-completions call, so any standard chat-completions SDK works unchanged. Size, quality and count live in the optional image block; anything you leave out falls back to the project's image defaults (the chat's Configure pane), then to 1024x1024, standard, one image.

cURL — one image
curl https://ainvented.com/api/v1/chat/completions \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{
    "modalities": ["image"],
    "image": { "size": "1024x1536", "quality": "standard" },
    "messages": [{"role": "user", "content": "A minimalist poster of a cat astronaut floating above the Moon, flat colors"}]
  }'
# -> choices[0].message.content is a short note ("Generated 1 image."),
#    response.images[0] is the picture, usage counts the image tokens (see below)
JavaScript — any standard chat-completions SDK
const res = await client.chat.completions.create({
  model: 'ainvented-default',
  modalities: ['image'],
  image: { size: '1024x1536' },            // optional: size, quality ('standard' | 'hd'), n (1-4)
  messages: [{ role: 'user', content: 'A minimalist poster of a cat astronaut floating above the Moon' }],
});
const [picture] = res.response.images;    // an https:// or data: URL — accept both
console.log(res.usage.completion_tokens_details.image_tokens);
Python — a standard SDK forwards the extra fields through extra_body
res = client.chat.completions.create(
    model="ainvented-default",
    extra_body={"modalities": ["image"], "image": {"size": "1024x1536"}},
    messages=[{"role": "user", "content": "A minimalist poster of a cat astronaut floating above the Moon"}],
)
picture = res.model_extra["response"]["images"][0]   # an https:// or data: URL
print(res.usage.completion_tokens_details.image_tokens)
  • image.size: 256x256, 512x512, 1024x1024, 1024x1536, 1536x1024, 1024x1792 or 1792x1024 — the picture comes back at exactly that size. quality: standard or hd (more detail, more output tokens). n: 1 to 4 pictures per call.
  • Reference images: attach pictures as image_url parts of the last user message and the model edits or uses them (a product shot, a logo) instead of inventing one; their input tokens are billed at the image-input rate.
  • Usage: an image turn's usage counts the pictures too — inside prompt_tokens / completion_tokens, itemised as prompt_tokens_details.image_tokens and completion_tokens_details.image_tokens — so what you read there is what the ledger charged. A text turn's usage is unchanged.
  • Where the picture is: response.images (or the streaming delta.images chunk). Each entry is a URL your client can load or fetch: today an inline data:image/png;base64,… URL for both response_format values, so treat https:// and data: alike. A 1024² standard image answers in about 12 s, an hd portrait in about 25 s.
  • image.remove_background answers a transparent PNG when background removal is enabled for the deployment; otherwise the flag is accepted and ignored.

Image Generation — Response Shape

When the model produces one or more images (e.g. a project configured for image generation, an image-modification workflow, or a text-to-image prompt), URLs to the generated images are returned in a dedicated field. Image URLs are never inlined as markdown in choices[0].message.content / delta.content.

Non-streaming (stream=false)

The top-level response includes a response.images array. Empty when no images were generated.

Non-streaming (stream=false)
{
  "id": "chatcmpl-1736541234567",
  "object": "chat.completion",
  "created": 1736541234,
  "model": "default",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "Here is the rendered image you requested."
      },
      "finish_reason": "stop"
    }
  ],
  "response": {
    "images": [
      "https://cdn.ainvented.com/generated/abc123.png",
      "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
    ]
  },
  "session_id": "sess_abc123"
}

Streaming (stream=true)

Generated images arrive in a dedicated chunk whose choices[0].delta is { images: string[] }. This chunk is interleaved with normal text-delta chunks; one chunk per batch of generated images. The final chunk has finish_reason: "stop" and a top-level session_id, followed by data: [DONE].

Streaming (stream=true)
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":1736541234,"model":"default","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}

data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":1736541234,"model":"default","choices":[{"index":0,"delta":{"content":"Here is the rendered image you requested."},"finish_reason":null}]}

data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":1736541234,"model":"default","choices":[{"index":0,"delta":{"images":["https://cdn.ainvented.com/generated/abc123.png"]},"finish_reason":null}]}

data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":1736541234,"model":"default","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"session_id":"sess_abc123"}

data: [DONE]

Content Types

Text Content

Text Content
{
  "type": "'text'",
  "text": "string - Text content"
}

Image Content (URL or Base64)

Image Content (URL or Base64)
{
  "type": "'image_url'",
  "image_url": {
    "url": "string - THREE ACCEPTED FORMS, detected automatically from the value: (1) an http(s) URL, (2) a data URL (data:image/<format>;base64,<data>), or (3) bare base64 with no prefix. Base64 can never begin 'http://' or 'data:' — ':' is outside the base64 alphabet — so the detection is exact, not a guess. Accepted formats: jpeg, png, gif, webp, bmp, tiff, avif — the same set for all three forms. Anything other than jpeg/png is converted losslessly on ingest at its ORIGINAL dimensions, so the same picture costs the same input tokens whichever format you send it in. An http(s) URL is identified from the BYTES it returns, not from its Content-Type header, so a correct image served as 'application/octet-stream' works and an HTML error page served as 'image/png' does not. That download is capped at the same size limit as an inline image and is abandoned as soon as it passes it. A URL must be publicly reachable: private, loopback and cloud-metadata addresses are refused (invalid_url). An unaccepted format or an oversized file is a 400 (unsupported_format / image_too_large) and is never billed; a value that is none of the three forms is a 400 (invalid_image_url); a URL that is simply unreachable is skipped and the rest of the message is answered. BILLING IS IDENTICAL ACROSS THE THREE FORMS — a linked image costs exactly what the same image costs inline. You can also skip this part entirely: a user message whose WHOLE content is one image link is promoted to exactly this part for you (see `messages[].content`).",
    "detail": "string (optional) - 'auto' | 'low' | 'high' - Image detail level for token calculation"
  }
}

Audio Content (URL or Base64)

Audio Content (URL or Base64)
{
  "type": "'input_audio'",
  "input_audio": {
    "data": "string - THREE ACCEPTED FORMS, detected automatically from the value, exactly as for image_url.url: (1) an http(s) URL to the audio file, (2) a data URL (data:audio/<format>;base64,<data>), or (3) bare base64. A URL is downloaded server-side, capped at the same size limit as inline audio, and identified from the BYTES it returns rather than from its Content-Type, so an HTML error page served as 'audio/mpeg' is refused (unsupported_format) instead of being transcribed and charged to you. A URL must be publicly reachable: private, loopback and cloud-metadata addresses are refused (invalid_url). An oversized file is a 400 (audio_too_large) and is never billed. If the audio was the ONLY content in the message and it could not be retrieved, the request is refused (media_unavailable) rather than answered with an empty prompt you would still be billed for. BILLING IS IDENTICAL ACROSS THE THREE FORMS — a linked clip costs exactly what the same clip costs inline. You can also skip this part entirely: a user message whose WHOLE content is one audio link is promoted to exactly this part for you (see `messages[].content`); a promoted link that cannot be retrieved is left as ordinary text instead of being refused.",
    "format": "string - Audio format: 'wav' | 'mp3' | 'm4a' | 'flac' | 'ogg'. REQUIRED with bare base64 (nothing else names the type). OPTIONAL with an http(s) URL or a data URL, which describe themselves — supply it anyway and it is still validated the same way."
  }
}

Video Content (URL, YouTube or Base64)

Video Content (URL, YouTube or Base64)
{
  "type": "'video_url'",
  "video_url": {
    "url": "string - FOUR ACCEPTED FORMS, detected automatically from the value: (1) an http(s) URL to the video file — downloaded server-side, capped, and identified from its BYTES; (2) a public YouTube link (youtube.com/watch, youtu.be, shorts, live, embed) — read by its address, nothing is downloaded; (3) a data URL (data:video/<format>;base64,<data>); (4) bare base64. Containers: mp4, mov, webm, mkv, 3gp, avi, mpeg, flv, wmv. Up to 50 MB per video and 4 videos per message, 50 MB for all of a message's videos together (configurable); up to the first 20 minutes of a video are read. EVERY MODEL TIER can answer about a video: the platform first turns it into a text record — summary, timestamped visual timeline with all on-screen text, and a verbatim transcript in the spoken language — that the tier you selected reads (a video never moves your request to another tier). BILLING: that record's cost is billed as INPUT tokens of the tier that served the turn, inside the same `usage.prompt_tokens` you already read — no separate line item. A video sent again in a LATER message of the same conversation is NOT parsed or billed again: earlier videos are read from the project's record cache (keep sending your full `messages` history as usual). Errors (400, never billed): invalid_video_url, invalid_url (private address), unsupported_format, video_too_large, too_many_videos, video_unavailable (an explicit link that could not be downloaded, or a YouTube link to a video that does not exist, was removed or is private — the message says which and how to fix it), video_content_rejected (content restrictions). 413 payload_too_large when the request is larger than the platform reads in one request — never billed, do not retry as is. 503 server_busy + Retry-After when too many large requests (over 8 MB — inline videos) are being processed at that moment — retry after the delay; nothing was read or billed. 503 video_processing_unavailable + Retry-After when video reading is momentarily unavailable — retry; nothing is billed. For a public YouTube video that still cannot be read, the same 503 says so: unlisted, age-restricted and members-only videos cannot be read. On a project with `web_search_enabled` = true you can also skip this part entirely: a user message whose WHOLE content is one link to a video file is promoted to exactly this part for you (see `messages[].content`); with it off (the default) such a link stays text."
  }
}
Video request (curl)
curl https://ainvented.com/api/v1/chat/completions \
  -H "Authorization: Bearer $AINVENTED_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "ainvented-low",
    "messages": [{
      "role": "user",
      "content": [
        { "type": "text", "text": "What happens in this clip? Quote what is said." },
        { "type": "video_url", "video_url": { "url": "https://cdn.example.com/clips/demo.mp4" } }
      ]
    }]
  }'

# A public YouTube link works the same way (nothing is downloaded):
#   { "type": "video_url", "video_url": { "url": "https://youtu.be/<id>" } }
# Inline: { "type": "video_url", "video_url": { "url": "data:video/mp4;base64,AAAAIGZ0eXBpc29t..." } }

URL or Base64 — one rule for both fields

image_url.url and input_audio.data each accept the same three forms, and the form is detected from the value you send — there is no extra parameter to set. Base64 can never begin http:// or data:, so the detection is exact.

URL or Base64 — one rule for both fields
{
  "model": "ainvented-default",
  "messages": [
    {
      "role": "user",
      "content": [
        { "type": "text", "text": "Describe the picture, then transcribe the audio." },

        // (1) http(s) URL — downloaded server-side, capped, identified by its bytes
        { "type": "image_url", "image_url": { "url": "https://example.com/photo.jpg" } },

        // (2) data URL — the classic inline form
        { "type": "image_url", "image_url": { "url": "data:image/png;base64,iVBORw0KGgo..." } },

        // (3) bare base64 — no prefix, type read from the bytes
        { "type": "image_url", "image_url": { "url": "iVBORw0KGgo..." } },

        // Audio takes the same three forms in input_audio.data.
        // "format" is OPTIONAL here: the downloaded bytes name the type.
        { "type": "input_audio", "input_audio": { "data": "https://example.com/note.mp3" } },

        // "format" is REQUIRED with bare base64 — nothing else names the type.
        { "type": "input_audio", "input_audio": { "data": "UklGRiQ...", "format": "wav" } }
      ]
    }
  ]
}

A message that is only a link

You do not have to build a content part at all. When the entire content of the last user message is a single http(s) link whose path ends in an accepted media extension, it is treated as that file: downloaded, size-capped, identified from its bytes, and attached exactly as if you had written out the image_url / input_audio part yourself. This is what a chat widget, a WhatsApp relay or a bot usually sends when a person shares a photo or a voice note.

A message that is only a link
# The whole message is the link — no content parts to build.
curl https://ainvented.com/api/v1/chat/completions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "ainvented-default",
    "messages": [{ "role": "user", "content": "https://example.com/photo.jpg" }]
  }'

# Identical to sending it the long way:
#   "content": [
#     { "type": "image_url",
#       "image_url": { "url": "https://example.com/photo.jpg" } }
#   ]

# A voice note works the same way:
#   "content": "https://example.com/nota.mp3"

# NOT promoted — the link has company, so it keeps its meaning:
#   "content": "mira esto https://example.com/photo.jpg"    <- text + URL reading
#   "content": "https://example.com/a.jpg https://example.com/b.jpg"
#   "content": "https://example.com/articulo"               <- no media extension

# A link that cannot be used is NOT an error. The message stays the text you
# sent and goes to the model as text, exactly as it does today:
#   "content": "https://example.com/borrado.jpg"   (404)
#   -> the model receives the string "https://example.com/borrado.jpg"
#   -> nothing is billed for the file that never arrived

Base64 Image Examples

Python - Image Analysis

Python
Python - Image Analysis
import requests
import base64

api_key = "your_api_key_here"
url = "https://ainvented.com/api/v1/chat/completions"

# Read and encode image
with open("image.jpg", "rb") as image_file:
    image_data = base64.b64encode(image_file.read()).decode('utf-8')

headers = {
    "Authorization": f"Bearer {api_key}",
    "Content-Type": "application/json"
}

data = {
    "messages": [
        {
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": "What's in this image?"
                },
                {
                    "type": "image_url",
                    "image_url": {
                        "url": f"data:image/jpeg;base64,{image_data}",
                        "detail": "high"
                    }
                }
            ]
        }
    ],
    "max_tokens": 300
}

response = requests.post(url, headers=headers, json=data)
result = response.json()
print(result["choices"][0]["message"]["content"])

TypeScript - Image Analysis

TypeScript
TypeScript - Image Analysis
const apiKey = "your_api_key_here";
const url = "https://ainvented.com/api/v1/chat/completions";

// Read and encode image (Node.js)
import fs from 'fs';
const imageBuffer = fs.readFileSync('image.jpg');
const imageBase64 = imageBuffer.toString('base64');

const response = await fetch(url, {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${apiKey}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    messages: [
      {
        role: "user",
        content: [
          {
            type: "text",
            text: "What's in this image?"
          },
          {
            type: "image_url",
            image_url: {
              url: `data:image/jpeg;base64,${imageBase64}`,
              detail: "high"
            }
          }
        ]
      }
    ],
    max_tokens: 300
  })
});

const result = await response.json();
console.log(result.choices[0].message.content);

cURL - Image Analysis

Bash
cURL - Image Analysis
# Portable on macOS and GNU/Linux
IMAGE_BASE64=$(base64 < image.jpg | tr -d '\n')

curl https://ainvented.com/api/v1/chat/completions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"messages\": [
      {
        \"role\": \"user\",
        \"content\": [
          {
            \"type\": \"text\",
            \"text\": \"What's in this image?\"
          },
          {
            \"type\": \"image_url\",
            \"image_url\": {
              \"url\": \"data:image/jpeg;base64,$IMAGE_BASE64\",
              \"detail\": \"high\"
            }
          }
        ]
      }
    ],
    \"max_tokens\": 300
  }"

Base64 Audio Examples

Python - Audio Transcription

Python
Python - Audio Transcription
import requests
import base64

api_key = "your_api_key_here"
url = "https://ainvented.com/api/v1/chat/completions"

# Read and encode audio
with open("audio.wav", "rb") as audio_file:
    audio_data = base64.b64encode(audio_file.read()).decode('utf-8')

headers = {
    "Authorization": f"Bearer {api_key}",
    "Content-Type": "application/json"
}

data = {
    "messages": [
        {
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": "Transcribe this audio"
                },
                {
                    "type": "input_audio",
                    "input_audio": {
                        "data": audio_data,
                        "format": "wav"
                    }
                }
            ]
        }
    ],
    "max_tokens": 500
}

response = requests.post(url, headers=headers, json=data)
result = response.json()
print(result["choices"][0]["message"]["content"])

TypeScript - Audio Transcription

TypeScript
TypeScript - Audio Transcription
const apiKey = "your_api_key_here";
const url = "https://ainvented.com/api/v1/chat/completions";

// Read and encode audio (Node.js)
import fs from 'fs';
const audioBuffer = fs.readFileSync('audio.wav');
const audioBase64 = audioBuffer.toString('base64');

const response = await fetch(url, {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${apiKey}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    messages: [
      {
        role: "user",
        content: [
          {
            type: "text",
            text: "Transcribe this audio"
          },
          {
            type: "input_audio",
            input_audio: {
              data: audioBase64,
              format: "wav"
            }
          }
        ]
      }
    ],
    max_tokens: 500
  })
});

const result = await response.json();
console.log(result.choices[0].message.content);

cURL - Audio Transcription

Bash
cURL - Audio Transcription
# Portable on macOS and GNU/Linux
AUDIO_BASE64=$(base64 < audio.wav | tr -d '\n')

curl https://ainvented.com/api/v1/chat/completions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"messages\": [
      {
        \"role\": \"user\",
        \"content\": [
          {
            \"type\": \"text\",
            \"text\": \"Transcribe this audio\"
          },
          {
            \"type\": \"input_audio\",
            \"input_audio\": {
              \"data\": \"$AUDIO_BASE64\",
              \"format\": \"wav\"
            }
          }
        ]
      }
    ],
    \"max_tokens\": 500
  }"

Browser-Based File Upload

When working in the browser, use the FileReader API to convert files to base64:

JavaScript - Browser File Upload

JavaScript
JavaScript - Browser File Upload
// Browser example — read the file locally, then send it to YOUR server.
// Your server adds the Ainvented API key and forwards the request securely.
const url = "/api/my-agent";

// Handle file input from user
const fileInput = document.querySelector('input[type="file"]');
fileInput.addEventListener('change', async (e) => {
  const file = e.target.files[0];
  
  // Read file as base64
  const reader = new FileReader();
  reader.onload = async () => {
    // Extract base64 data (remove data URL prefix)
    const base64Data = reader.result.split(',')[1];
    
    const response = await fetch(url, {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        messages: [
          {
            role: "user",
            content: [
              {
                type: "text",
                text: "What's in this image?"
              },
              {
                type: "image_url",
                image_url: {
                  url: `data:image/jpeg;base64,${base64Data}`,
                  detail: "high"
                }
              }
            ]
          }
        ],
        max_tokens: 300
      })
    });
    
    const result = await response.json();
    console.log(result.choices[0].message.content);
  };
  
  reader.readAsDataURL(file);
});

Combined Image and Audio Example

You can send multiple types of content in a single request for rich multimodal interactions:

Python - Combined Image and Audio

Python
Python - Combined Image and Audio
import requests
import base64

api_key = "your_api_key_here"
url = "https://ainvented.com/api/v1/chat/completions"

# Read and encode image
with open("chart.png", "rb") as image_file:
    image_data = base64.b64encode(image_file.read()).decode('utf-8')

# Read and encode audio
with open("narration.mp3", "rb") as audio_file:
    audio_data = base64.b64encode(audio_file.read()).decode('utf-8')

headers = {
    "Authorization": f"Bearer {api_key}",
    "Content-Type": "application/json"
}

data = {
    "messages": [
        {
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": "Analyze this chart and transcribe the audio narration"
                },
                {
                    "type": "image_url",
                    "image_url": {
                        "url": f"data:image/png;base64,{image_data}",
                        "detail": "high"
                    }
                },
                {
                    "type": "input_audio",
                    "input_audio": {
                        "data": audio_data,
                        "format": "mp3"
                    }
                }
            ]
        }
    ],
    "max_tokens": 1000
}

response = requests.post(url, headers=headers, json=data)
result = response.json()
print(result["choices"][0]["message"]["content"])

Validation and Error Handling

All multimodal content is validated before processing. The following validations are performed:

  • Base64 encoding: Data must contain only valid base64 characters (A-Z, a-z, 0-9, +, /, =)
  • Data URL format: Image data URLs must follow the format: data:image/<format>;base64,<data>
  • MIME type: Must be a supported image or audio format
  • Size limits: Content must not exceed configured size limits (20MB for images, 25MB for audio)
  • Required fields: All required fields must be present (e.g., 'data' and 'format' for audio)

Common Validation Errors

Invalid base64 encoding in image data

The base64 string contains invalid characters. Ensure proper encoding.

Malformed data URL: missing MIME type

Data URL must include MIME type: data:image/jpeg;base64,...

Image size (25.3MB) exceeds limit (20MB)

Reduce image size or compress before encoding.

Unsupported image format: svg+xml. Supported formats: jpeg, png, gif, webp, bmp, tiff, avif

Convert image to a supported format. This is a 400 — the request never reaches a model, so it is not billed and retrying the same image will not succeed.

Missing required field 'format' in input_audio

Audio content must include both 'data' and 'format' fields.

Token Estimation for Multimodal Content

Multimodal content consumes tokens based on content type and size:

Image Tokens

  • Low detail: Fixed 85 tokens per image
  • High detail: 85 + (number of 512px tiles × 170) tokens
  • Auto detail: Automatically determined based on image size

Audio Tokens

Estimated based on audio duration: approximately 2 tokens per second of audio. When duration cannot be determined, a conservative estimate is used based on file size.

Best Practices for Multimodal Content

  • Optimize file sizes: Compress images and audio before encoding to reduce token usage and improve response times
  • Use appropriate detail levels: Use 'low' detail for simple image recognition, 'high' for detailed analysis
  • Handle validation errors: Implement proper error handling for validation failures with retry logic
  • Monitor size limits: Stay well below size limits (80% threshold triggers warnings)
  • Mix content types: Combine text, images, audio and video in a single message for rich interactions
  • Backward compatibility: Standard HTTP/HTTPS image URLs continue to work alongside base64 data URLs
Build · 18 endpoints

Voice

Turn text into natural speech, design a voice from a description or clone one from a recording, and transcribe audio — in more than twenty languages, with catalog voices for English, Spanish, French, German, Italian, Portuguese, Russian, Korean, Japanese, Chinese and Hindi. Calls can run on their own or inside a voice project, which keeps a history you can play, download and list later.

Overview

  • Models. Speech: ainvented-voice (standard — the most natural, follows style directions) and ainvented-voice-low (low cost). Transcription: ainvented-transcribe. Clients built for the common speech and transcription APIs work unchanged: their model and voice names are accepted and mapped to these.
  • Voices. A catalog voice by its id (olivia, james, lena, chloe, sofia, jiwoo…, see GET /api/v1/audio/voices), or one of your project's own voices by the id design or cloning returned. Without voice, the text is read by the catalog voice of its own language. Any voice can speak any supported language; set language when the text is not in the voice's own language.
  • Voice projects. Pass voice_project_id on speech, design or transcription to keep the result in that project's history. Without it the call is billed the same and nothing is kept.
  • Keys. A project-scoped key works on its own project; an Admin key picks the project with project_id (or its first project), exactly as chat completions does.
cURL — speak a sentence
curl https://ainvented.com/api/v1/audio/speech \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"model":"ainvented-voice","voice":"olivia","input":"Hi, thanks for calling.","response_format":"mp3"}' \
  --output hello.mp3

Languages and voices

The catalog has a female and a male voice for English, Spanish, French, German, Italian, Portuguese, Russian, Korean, Japanese, Chinese and Hindi. Voices that name a region are built from a native speaker of it and keep that regional accent (native_accent: true in the voice list): British English, and Spanish from Mexico, Colombia, Argentina, Chile, Venezuela, the Caribbean and Spain. Speech can be generated in Arabic, Chinese, Danish, Dutch, English, Finnish, French, German, Greek, Hebrew, Hindi, Italian, Japanese, Korean, Malay, Norwegian, Polish, Portuguese, Russian, Spanish, Swahili, Swedish and Turkish; transcription recognizes about a hundred languages and detects the language by itself.

  • Any voice reads any language. Without language, the text is read in the language it is written in (an English sentence for a Spanish voice comes out as English, in that voice); when that is not clear — a single word, say — in the voice's own language. Send language to decide it yourself.
  • Another language stays clear. A native-accent, cloned or low-cost catalog voice reading a language other than its own is rendered with ainvented-voice on either model, because its accent would otherwise blur the words; it is billed as ainvented-voice and the X-Ainvented-Model header says so.
  • Native-accent voices and voices you cloned have one rendering, the one that keeps the speaker's accent and sounds most like them: both models give the same audio, billed as ainvented-voice-low (the X-Ainvented-Model header says so), and instructions are not applied to them (X-Ainvented-Instructions-Applied: false). Designed voices follow instructions on ainvented-voice.

Cost & metering

Speech and voice design are billed per character of text, transcription per second of audio (rounded up), cloning a voice is free (a clone sent without its language has it recognized from the recording, and those few seconds are billed), and catalog samples are free. Each call is charged once, to your project's budget when it has one or to your balance otherwise, and appears in consumption as component voice. A call refused by a budget, quota or balance check costs nothing, and a call that fails costs nothing. Every response reports what it consumed: speech in the X-Ainvented-Usage-Tokens header, the others in usage. These are billing tokens — the call's price expressed in tokens of your standard chat rate, the unit a project's token quota counts — not words or characters, so a short sentence can show a few thousand.

POST/api/v1/audio/speech#

Create speech

Speak `input` and answer the audio file itself (Content-Type per response_format). Billed per character.

Authentication

Bearer <api_key>

Request Schema

JSON type shapeExpand
{
  "input": "string",
  "model": "string",
  "voice": "string",
  "response_format": "string",
  "speed": "number",
  "instructions": "string",
  "language": "string",
  "voice_project_id": "string",
  "project_id": "uuid"
}
Fields9Expand
input
stringrequired
≤4096 characters — the text to speak
model
stringoptional
'ainvented-voice' (default) or 'ainvented-voice-low'
voice
stringoptional
a catalog voice id or one of your voice ids; default: the voice project’s default, else the catalog voice of the text’s language (olivia for English, lena for German, elena for Spanish…; the English one when the language has no voice)
response_format
stringoptional
'mp3' (default), 'opus', 'aac', 'flac', 'wav' or 'pcm'
speed
numberoptional
0.25 to 4.0, default 1.0
instructions
stringoptional
≤500 — style direction (“warm and reassuring”); standard model only, not for native-accent or cloned voices
language
stringoptional
ISO code of the TEXT ('en', 'en-GB', 'fr', 'de', 'es-MX'…) or 'auto'; default: the language the text is written in when that is clear, else the voice’s
voice_project_id
stringoptional
keep the result in this voice project’s history
project_id
uuidoptional
Admin keys: the project to bill

Response Schema

JSON type shapeExpand
{
  "body": "the audio bytes",
  "X-Request-Id": "header",
  "X-Ainvented-Model": "header",
  "X-Ainvented-Characters": "header",
  "X-Ainvented-Usage-Tokens": "header",
  "X-Ainvented-Speed-Applied": "header",
  "X-Ainvented-Instructions-Applied": "header",
  "X-Ainvented-Generation-Id": "header",
  "X-Ainvented-Voice": "header"
}
Fields9Expand
body
the audio bytes
X-Request-Id
header
the request id
X-Ainvented-Model
header
the model that served (billed)
X-Ainvented-Characters
header
characters billed
X-Ainvented-Usage-Tokens
header
usage booked for the call
X-Ainvented-Speed-Applied
header
'true' | 'false'
X-Ainvented-Instructions-Applied
header
'true' | 'false' (some voices speak only in their own style)
X-Ainvented-Generation-Id
header
the history entry (only with voice_project_id)
X-Ainvented-Voice
header
the voice that read it (the catalog voice of the text’s language when no voice was sent)

Code Examples

curl https://ainvented.com/api/v1/audio/speech \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"input":"Your order has shipped.","voice":"olivia","response_format":"wav","speed":1.15,"instructions":"cheerful"}' \
  --output order.wav

Details

Errors

  • 400 invalid_model, invalid_input, input_too_long, invalid_language, invalid_speed, invalid_response_format — nothing was billed.
  • 404 voice_not_found — the voice id is not in the catalog nor one of this project's voices.
  • 402 budget_exhausted, token_limit_exhausted, insufficient_balance — refused before any work.
  • 409 voice_project_archived — the voice project no longer accepts work.
  • 503 voice_unavailable / 504 voice_timeout — retry; a failed call is not billed.
POST/api/v1/audio/transcriptions#

Create a transcription

Transcribe an audio file (multipart). Billed per second of audio. The recording is not stored.

Authentication

Bearer <api_key>

Request Schema

JSON type shapeExpand
{
  "file": "binary",
  "model": "string",
  "language": "string",
  "prompt": "string",
  "response_format": "string",
  "timestamp_granularities[]": "string",
  "voice_project_id": "string",
  "project_id": "uuid"
}
Fields8Expand
file
binaryrequired
mp3, wav, m4a, ogg, webm or flac, up to 25 MB
model
stringoptional
'ainvented-transcribe'
language
stringoptional
ISO code hint ('es', 'en'…); omitted = detected
prompt
stringoptional
≤1000 — names and terms to spell right
response_format
stringoptional
'json' (default), 'text', 'srt', 'vtt' or 'verbose_json'
timestamp_granularities[]
stringoptional
repeatable — 'segment' and/or 'word' ('word' needs verbose_json)
voice_project_id
stringoptional
keep the transcript in this voice project’s history
project_id
uuidoptional
Admin keys: the project to bill

Response Schema

JSON type shapeExpand
{
  "text": "string",
  "language": "string (verbose_json)",
  "duration": "number (verbose_json)",
  "segments": "[{ id, start, end, text }] (verbose_json)",
  "words": "[{ word, start, end }] (verbose_json with word granularity)",
  "usage": "{ type: \"duration\", seconds, total_tokens } (json forms)"
}
Fields6Expand
text
string
the transcript
language
string (verbose_json)
the detected or given language
duration
number (verbose_json)
seconds
segments
[{ id, start, end, text }] (verbose_json)
words
[{ word, start, end }] (verbose_json with word granularity)
usage
{ type: "duration", seconds, total_tokens } (json forms)

Code Examples

curl https://ainvented.com/api/v1/audio/transcriptions \
  -H "Authorization: Bearer $API_KEY" \
  -F file=@interview.mp3 -F response_format=srt
# The language is detected; add -F language=de to set it.
GET/api/v1/audio/voices#

List voices

The catalog plus your project's own voices. Filter with ?language=en (or en-GB), ?source=catalog|custom. Free.

Authentication

Bearer <api_key>

Response Schema

JSON type shapeExpand
{
  "object": "'list'",
  "data": "[{ id, object: 'voice', name, description, language, locale (catalog), gender (catalog), style (catalog), source: 'catalog'|'clone'|'design', tiers, preview_available, native_accent (catalog: built from a native speaker, keeps its regional accent) }]",
  "languages": "string[]",
  "project_id": "uuid"
}
Fields4Expand
object
'list'
data
[{ id, object: 'voice', name, description, language, locale (catalog), gender (catalog), style (catalog), source: 'catalog'|'clone'|'design', tiers, preview_available, native_accent (catalog: built from a native speaker, keeps its regional accent) }]
languages
string[]
languages speech can be generated in
project_id
uuid
the project whose voices are listed

Code Examples

curl "https://ainvented.com/api/v1/audio/voices?language=en" -H "Authorization: Bearer $API_KEY"
GET/api/v1/audio/voices/{id}#

Read a voice

One catalog voice by id, or one of your voices. POST to the same path renames or describes one of your voices ({ name?, description? }); DELETE removes it.

Authentication

Bearer <api_key>

GET/api/v1/audio/voices/{id}/preview#

Hear a catalog voice

A short sample of a catalog voice (audio bytes). Free.

Authentication

Bearer <api_key>

POST/api/v1/audio/voices/design#

Design a voice

A voice from a written description. Answers a billed sample; with save: true the voice is kept and its id works as `voice` in speech.

Authentication

Bearer <api_key>

Request Schema

JSON type shapeExpand
{
  "description": "string",
  "language": "string",
  "text": "string",
  "save": "boolean",
  "name": "string",
  "voice_project_id": "string",
  "project_id": "uuid"
}
Fields7Expand
description
stringrequired
10–1000 — who is speaking: age, gender, tone, accent
language
stringoptional
the sample's language, default 'en'
text
stringoptional
≤1000 — the sentence the sample reads
save
booleanoptional
keep the voice
name
stringoptional
≤120 — its name when kept
voice_project_id
stringoptional
project_id
uuidoptional

Response Schema

JSON type shapeExpand
{
  "voice": "Voice | null",
  "saved": "boolean",
  "save_error": "{ code, message }",
  "audio": "{ data: base64, format, mime_type }",
  "characters": "number",
  "usage": "object",
  "generation_id": "string | null"
}
Fields7Expand
voice
Voice | null
the kept voice
saved
boolean
save_error
{ code, message }optional
only when saving failed; the sample is still returned and billed
audio
{ data: base64, format, mime_type }
characters
number
usage
object
generation_id
string | null

Code Examples

curl https://ainvented.com/api/v1/audio/voices/design \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"description":"A woman in her thirties, warm and clear, calm and reassuring","language":"en","save":true,"name":"Claire"}'
# For an authentic regional accent, use a catalog voice from that country (native_accent: true)
# or clone a native speaker: an accent written in the description is only approximate.
POST/api/v1/audio/voices/clone#

Clone a voice

Keep a recorded voice as one of your project's voices (multipart). Free when you name the language it speaks. You must have the speaker's permission; delete it at any time.

Authentication

Bearer <api_key>

Request Schema

JSON type shapeExpand
{
  "file": "binary",
  "name": "string",
  "consent": "'true'",
  "description": "string",
  "language": "string",
  "voice_project_id": "string",
  "project_id": "uuid"
}
Fields7Expand
file
binaryrequired
5–30 s of one person speaking clearly, up to 10 MB
name
stringrequired
≤120
consent
'true'required
you confirm the speaker's permission
description
stringoptional
≤1000
language
stringoptional
the language the recording speaks (ISO 639-1). It decides how the voice reads every other language. Omitted or 'auto': recognized from the recording, and those few seconds are billed
voice_project_id
stringoptional
project_id
uuidoptional

Response Schema

JSON type shapeExpand
{
  "id": "string",
  "object": "'voice'",
  "name": "string",
  "source": "'clone'",
  "language": "string | null",
  "created_at": "ISO"
}
Fields6Expand
id
string
use as `voice`
object
'voice'
name
string
source
'clone'
language
string | null
created_at
ISO

Code Examples

curl https://ainvented.com/api/v1/audio/voices/clone \
  -H "Authorization: Bearer $API_KEY" \
  -F file=@sam.mp3 -F name="Sam" -F language=en -F consent=true
# Leave language out to have it recognized from the recording (those few seconds are billed).
POST/api/v1/audio/voices/{id}#

Rename a voice

Rename or describe one of your voices. Catalog voices are read-only (400 voice_read_only).

Authentication

Bearer <api_key>

Request Schema

JSON type shapeExpand
{
  "name": "string",
  "description": "string | null"
}
Fields2Expand
name
stringoptional
≤120
description
string | nulloptional
≤1000
DELETE/api/v1/audio/voices/{id}#

Delete a voice

Deletes one of your voices and its recording. Audio already generated with it stays in your history.

Authentication

Bearer <api_key>

Response Schema

JSON type shapeExpand
{
  "id": "string",
  "object": "'voice'",
  "deleted": "true"
}
Fields3Expand
id
string
object
'voice'
deleted
true
POST/api/v1/voice-projects#

Create a voice project

A workspace with its own history and defaults. Free.

Authentication

Bearer <api_key>

Request Schema

JSON type shapeExpand
{
  "name": "string",
  "description": "string",
  "default_voice": "string",
  "default_model": "'ainvented-voice' | 'ainvented-voice-low'",
  "default_language": "string",
  "default_format": "'mp3'|'opus'|'aac'|'flac'|'wav'|'pcm'",
  "project_id": "uuid"
}
Fields7Expand
name
stringrequired
1–120
description
stringoptional
≤1000
default_voice
stringoptional
default_model
'ainvented-voice' | 'ainvented-voice-low'optional
default_language
stringoptional
default_format
'mp3'|'opus'|'aac'|'flac'|'wav'|'pcm'optional
project_id
uuidoptional
Admin keys

Response Schema

JSON type shapeExpand
{
  "id": "string",
  "object": "'voice_project'",
  "name": "string",
  "description": "string|null",
  "project_id": "uuid",
  "status": "'active'|'archived'",
  "default_voice": "string|null",
  "default_model": "string|null",
  "default_language": "string|null",
  "default_format": "string|null",
  "generation_count": "number",
  "created_at": "ISO",
  "updated_at": "ISO"
}
Fields13Expand
id
string
object
'voice_project'
name
string
description
string|null
project_id
uuid
status
'active'|'archived'
default_voice
string|null
default_model
string|null
default_language
string|null
default_format
string|null
generation_count
number
created_at
ISO
updated_at
ISO

Code Examples

curl https://ainvented.com/api/v1/voice-projects \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"Support line","default_voice":"olivia","default_language":"en"}'
GET/api/v1/voice-projects#

List voice projects

?status=active (default)|archived|all, ?limit (≤100), ?cursor, ?order=asc|desc.

Authentication

Bearer <api_key>

Response Schema

JSON type shapeExpand
{
  "object": "'list'",
  "data": "VoiceProject[]",
  "next_cursor": "string | null"
}
Fields3Expand
object
'list'
data
VoiceProject[]
next_cursor
string | null
GET/api/v1/voice-projects/{id}#

Read a voice project

One voice project, with its generation_count.

Authentication

Bearer <api_key>

POST/api/v1/voice-projects/{id}#

Update a voice project

Rename, describe or change its defaults (send null to clear a default). 409 when archived.

Authentication

Bearer <api_key>

Request Schema

JSON type shapeExpand
{
  "name": "string",
  "description": "string|null",
  "default_voice": "string|null",
  "default_model": "string|null",
  "default_language": "string|null",
  "default_format": "string|null"
}
Fields6Expand
name
stringoptional
description
string|nulloptional
default_voice
string|null
default_model
string|null
default_language
string|null
default_format
string|null
DELETE/api/v1/voice-projects/{id}#

Archive a voice project

Stops new work in it; the history stays readable. Idempotent.

Authentication

Bearer <api_key>

Response Schema

JSON type shapeExpand
{
  "id": "string",
  "object": "'voice_project'",
  "status": "'archived'",
  "deleted": "true"
}
Fields4Expand
id
string
object
'voice_project'
status
'archived'
deleted
true
GET/api/v1/voice-projects/{id}/generations#

List a voice project's history

Newest first. ?kind=speech|design|transcription, ?limit, ?cursor.

Authentication

Bearer <api_key>

Response Schema

JSON type shapeExpand
{
  "object": "'list'",
  "data": "[{ id, object: 'voice_generation', kind, input, transcript, voice, model, language, format, units, unit: 'character'|'second', cost_micros, duration_ms, has_audio, audio_url, details, created_at }]",
  "next_cursor": "string | null"
}
Fields3Expand
object
'list'
data
[{ id, object: 'voice_generation', kind, input, transcript, voice, model, language, format, units, unit: 'character'|'second', cost_micros, duration_ms, has_audio, audio_url, details, created_at }]
next_cursor
string | null
GET/api/v1/voice-projects/{id}/generations/{generationId}#

Read a history entry

One entry, including transcript segments. DELETE on the same path removes it and its stored audio (the charge stays on your ledger).

Authentication

Bearer <api_key>

DELETE/api/v1/voice-projects/{id}/generations/{generationId}#

Remove a history entry

Removes the entry and its stored audio.

Authentication

Bearer <api_key>

Response Schema

JSON type shapeExpand
{
  "id": "string",
  "object": "'voice_generation'",
  "deleted": "true"
}
Fields3Expand
id
string
object
'voice_generation'
deleted
true
GET/api/v1/voice-projects/{id}/generations/{generationId}/audio#

Download generated audio

The stored audio of a speech or design entry, as an attachment named for what it is (project, voice and the first words: anuncio-verano-sofia-este-verano-refrescate.mp3). Free (it was billed when generated).

Authentication

Bearer <api_key>

POST/api/v1/workflows/execute#

Execute Workflow

Execute a workflow project programmatically with custom input parameters. The workflow runs using the same logic as the UI, returning results formatted with node labels as keys.

Authentication

Bearer token required in Authorization header. API key must be scoped to a workflow project.

Request Schema

JSON type shapeExpand
{
  "project_id": "string",
  "input_vars": "object",
  "images": "string[]",
  "audios": "string[]",
  "videos": "string[]",
  "urls": "string[]",
  "url_inputs": "array",
  "external_apis": "array",
  "model": "string",
  "reasoning_effort": "string"
}
Fields10Expand
project_id
stringoptional
Workflow project ID (ignored if API key is scoped to a project)
input_vars
objectoptional
Key-value pairs for workflow input variables
images
string[]optional
Array of base64-encoded image strings
audios
string[]optional
Array of base64-encoded audio strings
videos
string[]optional
Array of videos (data:video/<format>;base64,... or bare base64; up to 4 videos, 50 MB in total) for the nodes wired to a Video Input node. Each is read as a text record (summary, on-screen text, transcript) and billed as input tokens of the node's tier. A video LINK goes in `urls` / `url_inputs` instead — it is read the same way. Refused before anything runs or is billed: 400 invalid_type, invalid_value (a link), too_many_videos, invalid_base64 / empty_base64 (data that is not base64), video_too_large. A video the platform cannot read (not a video, or no video track) fails the run with 400 unsupported_media_type (413 payload_too_large; 503 video_processing_unavailable + Retry-After means retry) and is not billed.
urls
string[]optional
Array of URL strings the platform reads for the run. A link inside a private network (localhost, 10.x, 169.254.x, …) is refused with 400 invalid_url before anything runs; one whose name resolves there is not read.
url_inputs
arrayoptional
Array of URL input objects with nodeId, customLabel, urls, and enabled fields. Each url follows the `urls` rule: one inside a private network is refused with 400 invalid_url.
external_apis
arrayoptional
Array of external API configuration objects. Each url must be a public http(s) address: one that points inside a private network (localhost, 10.x, 169.254.x, …) is refused with 400 invalid_url before anything runs, and one whose name resolves there, or that redirects there, fails the run with 400 invalid_url.
model
stringoptional
Model tier for this run: 'ainvented-epic' | 'ainvented-saga' | 'ainvented-default' | 'ainvented-low' (the bare words 'epic', 'saga', 'lite' and 'low' are accepted too). Acts as this run's WORKFLOW DEFAULT: it moves every node whose author left it on the default, and leaves explicitly pinned nodes on their pin — so one caller cannot silently downgrade the node a workflow was built around. Omit to use the workflow's own default, then the project's. An unrecognised value is a 400, never a silent downgrade.
reasoning_effort
stringoptional
How much the model reasons before answering: 'low' | 'medium' | 'high' | 'xhigh' | 'max'. Same precedence as `model`. Higher effort produces more reasoning tokens, which are billed as output tokens. Omit to use each node's tier default.

Response Schema

JSON type shapeExpand
{
  "success": "boolean",
  "output": "object",
  "execution_time_ms": "number"
}
Fields3Expand
success
boolean
Execution success status
output
object
Workflow output with node labels as keys
execution_time_ms
number
Execution duration in milliseconds

Code Examples

Basic workflow execution with input variables and URLs

curl https://ainvented.com/api/v1/workflows/execute \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input_vars": {
      "search_query": "AI news",
      "max_results": 10
    },
    "urls": ["https://example.com/feed"]
  }'

Details

Input Parameters

Workflow input parameters match the format used in the UI's "Input Parameters" modal. All parameters are optional, but at least one should be provided:

  • input_vars: Object with key-value pairs for workflow variables
  • images: Array of base64-encoded image strings
  • audios: Array of base64-encoded audio strings
  • videos: Array of base64-encoded videos (or data:video/… URLs) for nodes wired to a Video Input node — each is read as a text record and billed as input tokens of the node's tier
  • urls: Array of URL strings for web scraping or data fetching (a link to a video file, or a public YouTube link, is read as a video)
  • url_inputs: Array of URL input objects with node-specific configuration
  • external_apis: Array of external API configuration objects — each url must be a public http(s) address; one inside a private network (or resolving or redirecting there) is refused with 400 invalid_url

Choosing a model

A workflow is not one kind of work. “Pull the invoice number out of this PDF” and “write the customer's reply” have different value and different cost, so each node in a workflow can run on its own model tier and its own reasoning effort, set in the functions‑workflow‑builder.

The optional model and reasoning_effort fields set the default for this run. They move every node whose author left it on the default and leave explicitly pinned nodes alone — so passing "model": "low"makes the cheap steps cheaper without quietly downgrading the one step the workflow exists for.

  • Precedence, highest first: the node's own pin → this request → the workflow default → the project default → ainvented-default.
  • Accepted models: ainvented-epic, ainvented-saga, ainvented-default (also ainvented-lite / lite), ainvented-low. The bare words epic, saga, low work too.
  • Accepted efforts: low, medium, high, xhigh, max. Reasoning tokens are billed as output tokens, so a higher effort costs more.
  • An unrecognised value returns 400. An empty string means “not selected” and is accepted.
  • A node that sends images is always served on the vision-capable standard model and billed at that rate, whatever tier was requested.
  • A tier this deployment has not enabled is served — and billed — as the standard tier. GET /v1/models lists what is available to you.

Output Format

The workflow output is formatted with node labels as keys, matching the format displayed in the UI toaster. Each node's output is accessible by its label:

Workflows may include Files and Multimodal RAG context nodes (configured in the functions‑workflow‑builder). They enrich downstream nodes with file URLs or vector‑store search results and are billed to your project as audited usage. They are internal to the workflow and do not change this endpoint's request or response contract.

Output
{
  "success": true,
  "output": {
    "Web Scraper": {
      "articles": [
        {"title": "AI Breakthrough", "url": "..."},
        {"title": "ML Advances", "url": "..."}
      ]
    },
    "Summarizer": {
      "summary": "Recent AI developments include..."
    }
  },
  "execution_time_ms": 2341
}

Project Selection

The API determines which workflow project to execute based on this priority order:

  1. API key's associated project (if configured) - Takes precedence over all other options
  2. project_id parameter (if API key is not scoped to a project)
  3. Your default project (first created workflow project)

Rate Limiting

Workflow execution uses the same rate limiting as the chat completions endpoint. Rate limit headers are included in all responses:

  • X-RateLimit-Limit: Maximum requests per time window
  • X-RateLimit-Remaining: Requests remaining in current window
  • X-RateLimit-Reset: Unix timestamp when limit resets

Error Responses

Workflow execution can return the following error codes:

Error Responses
{
  "error": {
    "message": "string - Human-readable error message",
    "type": "string - Error type (authentication_error, permission_denied, validation_error, etc.)",
    "code": "string - Specific error code"
  }
}
  • 401 Unauthorized: Invalid, missing, or revoked API key
  • 403 Forbidden: Project doesn't belong to your account or is disabled
  • 404 Not Found: Workflow project doesn't exist
  • 422 Validation Error: Invalid input parameters or malformed JSON
  • 429 Too Many Requests: Rate limit exceeded
  • 500 Server Error: Workflow execution failed or internal error

Example Success Response

Example Success Response
{
  "success": true,
  "output": {
    "Web Scraper": {
      "articles": [
        {"title": "AI Breakthrough", "url": "https://example.com/article1"},
        {"title": "ML Advances", "url": "https://example.com/article2"}
      ]
    },
    "Summarizer": {
      "summary": "Recent AI developments include breakthrough in neural networks..."
    }
  },
  "execution_time_ms": 2341
}

Example Error Response

Example Error Response
{
  "error": {
    "message": "Invalid API key. Please check your API key and try again.",
    "type": "invalid_request_error",
    "code": "authentication_error"
  }
}
Reference · 8 endpoints

Projects

Manage chat projects with their assigned API keys, two independent quotas (cents-budget and token-units quota), currency, alert thresholds, and hard-cap policies. Every monetized AI call routes through both quotas — when hard_cap is true and consumed >= budget, the gate refuses with 402 insufficient_balance; when token_hard_capis true and projected token usage would exceed token_limit, the gate refuses with 402 insufficient_token_quota— both checks happen before any billable work begins, so over-budget requests are never charged. The token check runs first; the response code lets clients tell the two refusals apart.

Shell variables used in project examples
export BASE="https://ainvented.com"
export API_KEY="your_admin_api_key"
export PID="your_project_id"
# The account-wide bulk endpoints below refuse a project-scoped key with 403, so their
# examples name the variable ADMIN_KEY to make that requirement obvious. Same value.
export ADMIN_KEY="$API_KEY"
POST/api/v1/projects#

Create chat project

Creates a chat project, its initial API key, and its initial budget in a single transaction. The full API key is returned ONCE; subsequent reads expose only the partial key.

Authentication

Bearer <api_key> — must be an Admin key (no project binding). See the Authentication section above for the scope rules.

Request Schema

JSON type shapeExpand
{
  "name": "string",
  "type": "\"chat\" | \"function\" | \"canvas\"",
  "budget_cents": "integer cents",
  "currency": "\"USD\" | \"EUR\" | \"GBP\" | \"CAD\" | \"AUD\"",
  "alert_thresholds": "integer[]",
  "hard_cap": "boolean",
  "token_limit": "integer",
  "token_hard_cap": "boolean",
  "web_search_enabled": "boolean",
  "prompt_cache_enabled": "boolean",
  "prompt_cache_ttl_hours": "string|integer|null",
  "preconfigured_model": "'ainvented-default' | 'ainvented-low' | 'ainvented-saga' | 'ainvented-epic'",
  "preconfigured_effort": "'low' | 'medium' | 'high' | 'xhigh' | 'max'"
}
Fields13Expand
name
stringrequired
1..40
type
"chat" | "function" | "canvas"optional
default "chat" — also accepts legacy integers 1=chat, 2=function, 3=canvas. Any other value returns 400 invalid_request_error with param="type".
budget_cents
integer centsoptional
default 0
currency
"USD" | "EUR" | "GBP" | "CAD" | "AUD"optional
default "USD"
alert_thresholds
integer[]optional
strictly ascending, max 20, each 1..100, default [50,80,95,100]
hard_cap
booleanoptional
default true — refuse the request when consumed >= budget
token_limit
integeroptional
default 0 — max tokens this project can consume. 0 = unlimited.
token_hard_cap
booleanoptional
default false — when true, refuse with 402 insufficient_token_quota once consumed_tokens + estTokens > token_limit
web_search_enabled
booleanoptional
DEFAULT FALSE — "web search". When true, public http(s) URLs the end-user types in their message TEXT are fetched and read by the assistant in /v1/chat/completions. Set true to enable: only message-text URLs are affected — attached project files, `image_url` parts, and audio are always forwarded either way. Does not change billing.
prompt_cache_enabled
booleanoptional
default true — prompt caching for this project. When enabled (the default), repeated prompt prefixes are served from cache and billed at the discounted cached rate, lowering consumption at no quality cost. Set false to opt this project out — its requests bill cached input tokens at the full input rate. A per-request `prompt_cache` field overrides this per call.
prompt_cache_ttl_hours
string|integer|nulloptional
Managed Prompt Cache for this project, a single unified selector: 1, 6, 12 or 24 (fixed hours), "auto" (hand the lifetime to the optimizer, which continuously picks the best TTL to maximize this project's net cache savings), or null to disable (default). Keeps the project prompt prefix (>=1,024 tokens) cached for a guaranteed lifetime so every reuse bills at the cached rate. Storage: $1.50 per 1M tokens/hour + $0.40 per 1M one-time write. Same options and pricing for both models.
preconfigured_model
'ainvented-default' | 'ainvented-low' | 'ainvented-saga' | 'ainvented-epic'optional
default 'ainvented-default' — the project's DEFAULT chat model tier, used by /v1/chat/completions when the caller omits the `model` field, and by every turn the project serves that cannot name a model per request: channel (connector) messages, scheduled tasks, evaluation judges without a tier of their own, vector-store answers, the builder's AI helpers, and workflow nodes with no node or workflow selection. An explicit request `model` always overrides this. 'ainvented-low' is the lower-cost tier (see the pricing page).
preconfigured_effort
'low' | 'medium' | 'high' | 'xhigh' | 'max'optional
the project's DEFAULT reasoning effort, used by /v1/chat/completions when the caller omits the `reasoning_effort` field and by the same project-served turns as `preconfigured_model` (connectors, scheduled tasks, judges, vector-store answers, the builder's AI helpers, workflow nodes with no selection of their own). An explicit request `reasoning_effort` always overrides this. Omit it and the project stays on the selected tier's standard effort.

Response Schema

JSON type shapeExpand
{
  "id": "uuid",
  "name": "string",
  "type": "integer",
  "type_name": "\"chat\" | \"function\" | \"canvas\"",
  "api_key": {
    "id": "uuid",
    "key": "full key",
    "partialKey": "last 4 chars"
  },
  "budget": {
    "budget_cents": "number",
    "consumed_cents": "number",
    "remaining_cents": "number",
    "remaining_pct": "number",
    "currency": "string",
    "hard_cap": "boolean",
    "alert_thresholds": "number[]",
    "fired_thresholds": "number[]",
    "token_limit": "number",
    "consumed_tokens": "number",
    "remaining_tokens": "number",
    "remaining_tokens_pct": "number",
    "token_hard_cap": "boolean",
    "fired_token_thresholds": "number[]"
  },
  "web_search_enabled": "boolean",
  "prompt_cache_enabled": "boolean",
  "prompt_cache_ttl_hours": "string|integer|null",
  "preconfigured_model": "string",
  "preconfigured_effort": "string|null",
  "created_at": "ISO timestamp"
}
Fields29Expand
id
uuid
name
string
type
integer
DEPRECATED. Kept for backward compatibility with pre-2026-05 clients. Use type_name instead.
type_name
"chat" | "function" | "canvas"
canonical project kind.
api_key
object
object — fields below
id
uuid
key
full key
returned only once
partialKey
last 4 chars
budget
object
object — fields below
budget_cents
number
consumed_cents
number
remaining_cents
number
remaining_pct
number
currency
string
hard_cap
boolean
alert_thresholds
number[]
fired_thresholds
number[]
token_limit
number
consumed_tokens
number
remaining_tokens
number
remaining_tokens_pct
number
token_hard_cap
boolean
fired_token_thresholds
number[]
web_search_enabled
boolean
the effective value of the project you just created: false unless you explicitly sent web_search_enabled:true. Omitting the field creates the project with URL reading OFF and writes no settings row, and this field reports that.
prompt_cache_enabled
boolean
effective value (true unless explicitly set to false)
prompt_cache_ttl_hours
string|integer|null
managed-cache value: "auto" (optimizer-managed), a fixed TTL in hours, or null (not configured)
preconfigured_model
string
effective default model tier ('ainvented-default' unless created with 'ainvented-low')
preconfigured_effort
string|null
effective default reasoning effort ('low' | 'medium' | 'high' | 'xhigh' | 'max'), or null when the project inherits the tier's standard effort
created_at
ISO timestamp

Code Examples

Create a chat project with a $250 budget AND a 1 000 000-token hard cap. Using the canonical string for `type`.

curl -X POST $BASE/api/v1/projects \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "qa-prod-1",
    "type": "chat",
    "budget_cents": 25000,
    "currency": "USD",
    "alert_thresholds": [50, 80, 95, 100],
    "hard_cap": true,
    "token_limit": 1000000,
    "token_hard_cap": true
  }'
GET/api/v1/projects#

List projects for the authenticated user

Returns the projects owned by the API key's user, with a budget summary per row. Without query parameters it returns every non-deleted project, newest first (the original behaviour, unchanged). Add any of the query parameters below to switch to the PAGED mode — sorted and filtered on the server, one page at a time — which is what the Partner Console's projects table uses and what an account with thousands of projects should use.

Authentication

Bearer <api_key> — must be an Admin key (no project binding).

Request Schema

JSON type shapeExpand
{
  "limit": "integer 1..200 (query, optional; default 50)",
  "offset": "integer >= 0 (query, optional; default 0)",
  "sort": "\"name\" | \"created_at\" | \"expiration_date\" | \"consumed_cents\" | \"budget_cents\" | \"remaining_cents\" | \"consumed_tokens\" (query, optional; default \"created_at\")",
  "dir": "\"asc\" | \"desc\" (query, optional)",
  "q": "string, max 80 (query, optional)",
  "status": "\"active\" | \"disabled\" | \"all\" (query, optional; default \"active\")"
}
Fields6Expand
limit
integer 1..200 (query, optional; default 50)
page size. Sending it (or any parameter below) selects the paged mode.
offset
integer >= 0 (query, optional; default 0)
rows to skip.
sort
"name" | "created_at" | "expiration_date" | "consumed_cents" | "budget_cents" | "remaining_cents" | "consumed_tokens" (query, optional; default "created_at")
dir
"asc" | "desc" (query, optional)
default "asc" for name, "desc" for everything else.
q
string, max 80 (query, optional)
case-insensitive substring of the project name, or a prefix of the project id.
status
"active" | "disabled" | "all" (query, optional; default "active")
"active" matches the unpaged mode (non-deleted projects only).

Response Schema

JSON type shapeExpand
{
  "data": [
    {
      "id": "uuid",
      "name": "string",
      "type": "integer",
      "type_name": "\"chat\" | \"function\" | \"canvas\"",
      "disabled": "boolean",
      "created_at": "ISO",
      "expiration_date": "ISO",
      "prompt_cache_ttl_hours": "string|integer|null",
      "budget": {
        "budget_cents": "number",
        "consumed_cents": "number",
        "currency": "string",
        "hard_cap": "boolean",
        "token_limit": "number",
        "consumed_tokens": "number",
        "remaining_tokens": "number",
        "remaining_tokens_pct": "number",
        "token_hard_cap": "boolean"
      }
    }
  ],
  "page": "{ limit, offset, total, has_more }",
  "sort": "{ key, dir }",
  "filters": "{ q: string|null, status }"
}
Fields22Expand
data
array
array — fields below
id
uuid
name
string
type
integer
DEPRECATED. Use type_name.
type_name
"chat" | "function" | "canvas"
disabled
boolean
created_at
ISO
expiration_date
ISO
paged mode only (the renewal date)
prompt_cache_ttl_hours
string|integer|null
managed-cache value: "auto" (optimizer-managed), a fixed TTL in hours, or null (off)
budget
object
object — fields below
budget_cents
number
consumed_cents
number
currency
string
hard_cap
boolean
token_limit
number
consumed_tokens
number
remaining_tokens
number
remaining_tokens_pct
number
token_hard_cap
boolean
page
{ limit, offset, total, has_more }
paged mode only
sort
{ key, dir }
paged mode only: the sort actually applied
filters
{ q: string|null, status }
paged mode only

Code Examples

curl $BASE/api/v1/projects -H "Authorization: Bearer $API_KEY"
PUT/api/v1/projects/cache-ttl#

Bulk: set the managed-cache value on all (or selected) projects

Batch-applies one Managed Prompt Cache value to every project of your account — or to a chosen subset — in a single call, the API twin of the partner console's 'apply to all projects' button. Accepts a fixed TTL, 'auto' (hand every project to the optimizer), or null (off). Projects without a settings row get one created. Safe at any scale: a project whose prompts are never reused mints no cache and pays no storage (creation requires the prefix to be seen 3 times within 10 minutes). Audit log emits one project.prompt_cache_ttl.set row per project whose value actually changed.

Authentication

Bearer <api_key> — must be an Admin key (no project binding).

Request Schema

JSON type shapeExpand
{
  "prompt_cache_ttl_hours": "string|integer|null",
  "project_ids": "array"
}
Fields2Expand
prompt_cache_ttl_hours
string|integer|nullrequired
1, 6, 12 or 24 hours, "auto" (optimizer-managed on every project), or null to disable on every project. Same semantics, minimum prompt size (1,024 tokens) and pricing ($1.50 per 1M tokens/hour storage + $0.40 per 1M one-time write) as the per-project setting.
project_ids
arrayoptional
up to 1000 project UUIDs to scope the apply to. OMIT to apply to every non-deleted project of your account (the default, and the only behaviour before this field existed). Ids you do not own are simply not matched, and duplicates are collapsed, so `updated` always reflects the projects actually written.

Response Schema

JSON type shapeExpand
{
  "updated": "integer",
  "prompt_cache_ttl_hours": "string|integer|null"
}
Fields2Expand
updated
integer
number of projects the value was applied to
prompt_cache_ttl_hours
string|integer|null
the applied value

Code Examples

curl -X PUT $BASE/api/v1/projects/cache-ttl \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prompt_cache_ttl_hours": "auto"}'
PUT/api/v1/projects/defaults#

Bulk: set the default model and reasoning effort on all (or selected) projects

Batch-applies a default model and/or a default reasoning effort to every project of your account — or to a chosen subset — in a single call, instead of one PUT per project. Both fields are the values used when a request omits `model` / `reasoning_effort`; a request that names either is unaffected. Send at least one of the two (an empty patch returns 400). Projects without a settings row get one created. BILLING: the default model decides which tier's input, cached-input and output rate an unnamed-model request is billed at, and a higher default effort spends more output tokens per turn — on a request that sets its own max_tokens, that cap bounds the visible answer only and thinking tokens are billed on top of it. Channel (connector) messages, scheduled tasks, evaluation judges that name no tier of their own, vector-store answers and the builder's AI helpers cannot name a model or an effort per request, so BOTH defaults apply to them: they are served on the project's default tier at the project's default effort and billed at that tier's rates. A workflow follows its own selection first (a node's pin, then the workflow default) and falls back to the project default. Pinning a premium tier therefore changes what serves every turn of the project, not only its price. Audit log emits one project.preconfigured_model.set and/or project.preconfigured_effort.set row per project whose stored value actually changed, so every repricing stays attributable to the account that made it; re-applying a value a project already holds writes no row.

Authentication

Bearer <api_key> — must be an Admin key (no project binding).

Request Schema

JSON type shapeExpand
{
  "preconfigured_model": "string",
  "preconfigured_effort": "string|null",
  "project_ids": "array"
}
Fields3Expand
preconfigured_model
stringoptional
"ainvented-default", "ainvented-low", "ainvented-saga" or "ainvented-epic". "ainvented-default" clears any override back to the standard tier.
preconfigured_effort
string|nulloptional
"low", "medium", "high", "xhigh" or "max", or null to clear the override so each project inherits its tier's own default effort.
project_ids
arrayoptional
up to 1000 project UUIDs to scope the apply to. OMIT to apply to every non-deleted project of your account. Ids you do not own are simply not matched, and duplicates are collapsed, so `updated` always reflects the projects actually written.

Response Schema

JSON type shapeExpand
{
  "updated": "integer",
  "preconfigured_model": "string",
  "preconfigured_effort": "string|null"
}
Fields3Expand
updated
integer
number of projects the value was applied to
preconfigured_model
string
echoed only when preconfigured_model was included in the request
preconfigured_effort
string|null
echoed only when preconfigured_effort was included in the request

Code Examples

curl -X PUT $BASE/api/v1/projects/defaults \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"preconfigured_model": "ainvented-low", "preconfigured_effort": "medium"}'
GET/api/v1/projects/{projectId}#

Read a project (with counts)

Returns the project, its full budget snapshot, and component counts (connectors, skills, workflows). A deleted project returns 404.

Authentication

Bearer <api_key> — must own the project.

Response Schema

JSON type shapeExpand
{
  "id": "uuid",
  "name": "string",
  "type": "integer",
  "type_name": "\"chat\" | \"function\" | \"canvas\"",
  "disabled": "boolean",
  "created_at": "ISO",
  "budget": "BudgetSnapshot",
  "preconfigured_model": "string",
  "default_model": "string",
  "web_search_enabled": "boolean",
  "prompt_cache_enabled": "boolean",
  "prompt_cache_ttl_hours": "string|integer|null",
  "preconfigured_effort": "string|null",
  "counts": {
    "connector": "number",
    "skill": "number",
    "workflow": "number"
  }
}
Fields17Expand
id
uuid
name
string
type
integer
DEPRECATED. Use type_name.
type_name
"chat" | "function" | "canvas"
disabled
boolean
created_at
ISO
budget
BudgetSnapshot
preconfigured_model
string
the effective default model for this project, as the neutral alias ("ainvented-default" | "ainvented-low" | "ainvented-saga" | "ainvented-epic"): the value you wrote as preconfigured_model on create / update / bulk (or "ainvented-default" when none was set).
default_model
string
the same value under its original read-side name, kept for existing readers. This is the READ-side name of the value written as preconfigured_model by create, update and the bulk defaults endpoint.
web_search_enabled
boolean
whether public URLs typed in a message are fetched for this project
prompt_cache_enabled
boolean
the caching on/off switch; false bills every input token at the full rate
prompt_cache_ttl_hours
string|integer|null
the project's managed-cache value: "auto" (optimizer-managed), a fixed TTL in hours, or null (not configured / off)
preconfigured_effort
string|null
the project's default reasoning effort ('low' | 'medium' | 'high' | 'xhigh' | 'max'), omitted when it inherits the tier's standard effort
counts
object
object — fields below
connector
number
skill
number
workflow
number

Code Examples

curl $BASE/api/v1/projects/$PID -H "Authorization: Bearer $API_KEY"
PUT/api/v1/projects/{projectId}#

Update a project

Patches name, budget_cents, currency, alert_thresholds, hard_cap, disabled, token_limit, token_hard_cap, web_search_enabled, prompt_cache_enabled, prompt_cache_ttl_hours, and preconfigured_effort. Currency cannot be changed once consumed_cents > 0 (returns 409). Managed cache is a single unified selector: setting prompt_cache_ttl_hours to 'auto' hands the lifetime to the optimizer; a number/null sets a fixed TTL and turns auto off — there is no separate flag and no manual/auto interlock. Updating alert_thresholds re-arms BOTH fired_thresholds and fired_token_thresholds; updating token_limit re-arms fired_token_thresholds. Lowering token_limit below current consumed_tokens is allowed and is not retroactive — the next request is evaluated against the new limit. Empty patch returns 400. Audit log emits project.token_limit.set and project.token_limit.cap_toggle, project.web_search.toggle, project.prompt_caching.toggle, project.prompt_cache_ttl.set, and project.preconfigured_effort.set, when the corresponding fields change.

Authentication

Bearer <api_key> — must own the project.

Request Schema

JSON type shapeExpand
{
  "name": "string",
  "budget_cents": "integer",
  "currency": "\"USD\" | \"EUR\" | \"GBP\" | \"CAD\" | \"AUD\"",
  "alert_thresholds": "integer[]",
  "hard_cap": "boolean",
  "disabled": "boolean",
  "token_limit": "integer",
  "token_hard_cap": "boolean",
  "web_search_enabled": "boolean",
  "prompt_cache_enabled": "boolean",
  "prompt_cache_ttl_hours": "string|integer|null",
  "preconfigured_model": "'ainvented-default' | 'ainvented-low' | 'ainvented-saga' | 'ainvented-epic'",
  "preconfigured_effort": "'low' | 'medium' | 'high' | 'xhigh' | 'max' | null"
}
Fields13Expand
name
stringoptional
1..40
budget_cents
integeroptional
currency
"USD" | "EUR" | "GBP" | "CAD" | "AUD"optional
alert_thresholds
integer[]optional
strictly ascending, max 20
hard_cap
booleanoptional
disabled
booleanoptional
token_limit
integeroptional
≥ 0 — 0 means unlimited
token_hard_cap
booleanoptional
web_search_enabled
booleanoptional
enable/disable fetching of public URLs typed in message TEXT ("web search") for this project. Only message-text URLs are affected; files, image_url parts, and audio are unaffected.
prompt_cache_enabled
booleanoptional
enable/disable prompt caching for this project. When false, requests bill cached input tokens at the full input rate.
prompt_cache_ttl_hours
string|integer|nulloptional
the unified Managed Prompt Cache selector: set a fixed TTL (1|6|12|24), "auto" (hand the lifetime to the optimizer), or null to disable. $1.50 per 1M tokens/hour storage while active. Selecting a fixed value/off turns auto off and vice-versa — no interlock.
preconfigured_model
'ainvented-default' | 'ainvented-low' | 'ainvented-saga' | 'ainvented-epic'optional
set the project's DEFAULT chat model tier (used when /v1/chat/completions omits `model`). 'ainvented-default' clears any override back to the standard model.
preconfigured_effort
'low' | 'medium' | 'high' | 'xhigh' | 'max' | nulloptional
set the project's DEFAULT reasoning effort (used when /v1/chat/completions omits `reasoning_effort`). null clears any override back to the tier's standard effort.

Response Schema

JSON type shapeExpand
{
  "id": "uuid",
  "name": "string",
  "disabled": "boolean",
  "budget": "BudgetSnapshot (includes token fields)",
  "web_search_enabled": "boolean",
  "prompt_cache_enabled": "boolean",
  "prompt_cache_ttl_hours": "string|integer|null",
  "preconfigured_model": "string",
  "preconfigured_effort": "string|null"
}
Fields9Expand
id
uuid
name
string
disabled
boolean
budget
BudgetSnapshot (includes token fields)
web_search_enabled
boolean
echoed only when web_search_enabled was included in the patch
prompt_cache_enabled
boolean
echoed only when prompt_cache_enabled was included in the patch
prompt_cache_ttl_hours
string|integer|null
echoed only when included in the patch ("auto" | fixed hours | null)
preconfigured_model
string
echoed only when preconfigured_model was included in the patch
preconfigured_effort
string|null
echoed only when preconfigured_effort was included in the patch ('low' | 'medium' | 'high' | 'xhigh' | 'max' | null)

Code Examples

Raise budget, tighten alerts, and switch to a 500 000-token hard cap.

curl -X PUT $BASE/api/v1/projects/$PID \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "budget_cents": 50000,
    "alert_thresholds": [25, 50, 75, 100],
    "token_limit": 500000,
    "token_hard_cap": true
  }'
DELETE/api/v1/projects/{projectId}#

Delete a project

Deletes the project, revokes all of its API keys and deactivates all of its webhooks. Idempotent: a second call returns 410. Subsequent reads return 404.

Authentication

Bearer <api_key> — must own the project.

Code Examples

curl -X DELETE $BASE/api/v1/projects/$PID -H "Authorization: Bearer $API_KEY"
Reference · 6 endpoints

Payments

Collect payments from your end-clients into ainvented's Stripe account. By default the equivalent USD amount is credited to your user balance; optionally set credit_target: "project_budget"to credit a project budget instead. USD only. Refunds happen via the Stripe dashboard and are mirrored read-only — they never debit your balance. You can also configure per-project auto-recharge(below) to top a project budget up automatically when it runs low.

POST/api/v1/projects/{projectId}/payments#

Create a payment

Creates a Stripe PaymentIntent on ainvented's Stripe account and returns a client_secret your frontend uses with Stripe.js. Amount is the smallest USD unit (cents) and must be between 50 ($0.50, Stripe's USD floor) and 99,999,999 ($999,999.99). EASIEST INTEGRATION: pass hosted_checkout:true to get a checkout_url instead — redirect your customer to it and you need NO Stripe key at all (not even the publishable key). For amounts ≥ $10, that checkout_url is OUR customer checkout page, which also collects the customer's email (Stripe emails them a receipt for this payment and for every later auto-reload charge), lets them set up auto-reload (toggle ON by default, they can opt out) and return to your site after paying — you don't have to send anything extra. See hosted_checkout + auto_reload below.

Authentication

Bearer <admin_api_key> — must be an account-wide Admin key, must own the project, and your account must be approved for payment collection.

Request Schema

JSON type shapeExpand
{
  "amount_cents": "integer",
  "currency": "\"USD\" (literal; any other value → 400)",
  "description": "string",
  "idempotency_key": "string",
  "credit_target": "\"user_balance\" | \"project_budget\" . Embedded and under-$10 hosted flows default to \"user_balance\". For backward compatibility, the AInvented-hosted $10+ checkout defaults to this path project's \"project_budget\". An explicit value is always honored.",
  "budget_project_id": "uuid",
  "hosted_checkout": "boolean . true → response returns a checkout_url; redirect your customer there. You need NO Stripe key, not even the publishable key. BY DEFAULT (amount_cents ≥ $10) this returns OUR customer checkout page with a required email field (so Stripe receipts the payment and every later auto-reload charge) and an opt-out auto-reload toggle, so your customers can set up auto-reload without you sending anything. success_url/cancel_url are honored on that page as a \"Return to site\" button + a back link (they no longer force the plain Stripe page). The only case that stays on the plain Stripe-hosted page is amount_cents < $10 (too small to carry auto-reload). To keep our page but start the toggle OFF (opt-in), send auto_reload:{enabled:false}; to customize the default amounts, send your own auto_reload object.",
  "success_url": "url",
  "cancel_url": "url",
  "auto_reload": "object"
}
Fields10Expand
amount_cents
integerrequired
50..99,999,999
currency
"USD" (literal; any other value → 400)
description
stringoptional
max 280 chars — shows in Stripe dashboard
idempotency_key
stringoptional
max 255 chars — permanently identifies one payment inside this project. Retrying the same request returns that payment; use a new key for a new payment.
credit_target
"user_balance" | "project_budget" . Embedded and under-$10 hosted flows default to "user_balance". For backward compatibility, the AInvented-hosted $10+ checkout defaults to this path project's "project_budget". An explicit value is always honored.optional
budget_project_id
uuidoptional
the project budget to credit when credit_target="project_budget". Defaults to this path project; a different project must be one you also own (else 403/404).
hosted_checkout
boolean . true → response returns a checkout_url; redirect your customer there. You need NO Stripe key, not even the publishable key. BY DEFAULT (amount_cents ≥ $10) this returns OUR customer checkout page with a required email field (so Stripe receipts the payment and every later auto-reload charge) and an opt-out auto-reload toggle, so your customers can set up auto-reload without you sending anything. success_url/cancel_url are honored on that page as a "Return to site" button + a back link (they no longer force the plain Stripe page). The only case that stays on the plain Stripe-hosted page is amount_cents < $10 (too small to carry auto-reload). To keep our page but start the toggle OFF (opt-in), send auto_reload:{enabled:false}; to customize the default amounts, send your own auto_reload object.optional
default false
success_url
urloptional
hosted_checkout only — where the customer returns after paying. On our checkout page it becomes a "Return to site" button; on the plain Stripe page it is a redirect ({CHECKOUT_SESSION_ID} substituted).
cancel_url
urloptional
hosted_checkout only — a "back" link on our checkout page / a redirect on the Stripe page.
auto_reload
objectoptional
configure project auto-reload alongside this top-up: { enabled?: boolean (defaults true — on the checkout page the auto-reload toggle starts ON so the customer opts OUT; send enabled:false to offer it opt-in), threshold_cents?, recharge_cents?, payment_method_id? }. Behavior depends on the flow: (a) EMBEDDED (no hosted_checkout) → the config is applied server-side immediately and echoed as auto_reload.applied; money/card fields merge onto any existing config. (b) HOSTED_CHECKOUT for $10+ → checkout_url points at OUR customer checkout page: the CUSTOMER reviews/adjusts the settings, the choice is persisted before payment, card saving follows that choice, and auto-reload is applied only after payment succeeds. The email they enter there becomes the receipt address for every off-session recharge, so an auto-reload charge is never silent. The response returns auto_reload {applied:false, pending:true, defaults:{…}}. For the hosted page the top-up and enabled reload amount each have a $10.00 minimum. Omitted on an embedded or under-$10 flow ⇒ no auto_reload key; omitted on a $10+ hosted flow ⇒ the established default-on hosted offer. A config-write failure never reverses a successful payment.

Response Schema

JSON type shapeExpand
{
  "payment_id": "uuid",
  "stripe_client_secret": "pi_*_secret_*",
  "checkout_url": "the page to redirect the customer to (hosted_checkout=true only). For amounts ≥ $10 this is OUR checkout page",
  "checkout_session_id": "cs_*",
  "stripe_payment_intent_id": "pi_*",
  "status": "PaymentStatus enum (REQUIRES_PAYMENT_METHOD, PROCESSING, SUCCEEDED, ...)",
  "amount_cents": "integer",
  "currency": "\"USD\"",
  "description": "string | null",
  "credit_target": "\"user_balance\" | \"project_budget\"",
  "credited_budget_project_id": "uuid | null",
  "created_at": "ISO timestamp",
  "idempotent_replay": "boolean",
  "auto_reload": "object | absent"
}
Fields14Expand
payment_id
uuid
stripe_client_secret
pi_*_secret_*
one-shot, bound to this PaymentIntent. (embedded flow only; absent when hosted_checkout=true)
checkout_url
the page to redirect the customer to (hosted_checkout=true only). For amounts ≥ $10 this is OUR checkout page
https://<app>/checkout/<token> (auto-reload + Return-to-site); for < $10 it is a Stripe-hosted https://checkout.stripe.com/… page.
checkout_session_id
cs_*
the Stripe Checkout Session id (hosted_checkout=true only)
stripe_payment_intent_id
pi_*
the Stripe PI id
status
PaymentStatus enum (REQUIRES_PAYMENT_METHOD, PROCESSING, SUCCEEDED, ...)
amount_cents
integer
currency
"USD"
description
string | null
credit_target
"user_balance" | "project_budget"
where the payment will be credited on success
credited_budget_project_id
uuid | null
the funded project budget (null for user_balance)
created_at
ISO timestamp
idempotent_replay
boolean
true when this request replayed an existing payment
auto_reload
object | absent
present only when you sent auto_reload. Embedded flow: { applied: boolean, config?: {…}, error?: {…} } (applied=false = payment succeeded but config not written, explicit). Hosted-checkout flow: { applied:false, pending:true, defaults:{…} } — the customer sets it on the checkout_url page and it is applied on payment completion.

Code Examples

Easiest integration: get a checkout_url and redirect your customer. For $10+ they land on OUR checkout page — enter an email (Stripe receipts every charge to it), pay + (by default) opt into auto-reload — then a "Return to site" button sends them to success_url. You send nothing extra; you never touch Stripe.

curl -X POST $BASE/api/v1/projects/$PID/payments \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount_cents": 5000,
    "currency": "USD",
    "credit_target": "project_budget",
    "hosted_checkout": true,
    "success_url": "https://your-app.com/thanks",
    "cancel_url": "https://your-app.com/cart"
  }'
# → { "payment_id": "...",
#     "checkout_url": "https://ainvented.com/checkout/<token>",   # OUR page (amount ≥ $10)
#     "auto_reload": { "applied": false, "pending": true,
#                      "defaults": { "enabled": true, "threshold_cents": 1000, "recharge_cents": 2000 } }, ... }
# Redirect your customer to checkout_url. No publishable key, no Stripe.js.
# (Want the toggle OFF by default? add  "auto_reload": {"enabled": false}.
#  Amounts under $10 return a plain Stripe checkout_url with no auto-reload.)
GET/api/v1/projects/{projectId}/payments#

List payments for a project

Returns payments ordered by created_at DESC. Supports pagination + status / date filters.

Authentication

Bearer <api_key> — must own the project AND your account must be approved for payment collection.

Request Schema

JSON type shapeExpand
{
  "query.status": "optional PaymentStatus enum (REQUIRES_PAYMENT_METHOD|PROCESSING|REQUIRES_ACTION|SUCCEEDED|FAILED|CANCELED|REFUNDED)",
  "query.limit": "integer 1..200 (default 20)",
  "query.offset": "integer ≥ 0 (default 0)",
  "query.from": "ISO timestamp (inclusive)",
  "query.to": "ISO timestamp (exclusive)"
}
Fields5Expand
query.status
optional PaymentStatus enum (REQUIRES_PAYMENT_METHOD|PROCESSING|REQUIRES_ACTION|SUCCEEDED|FAILED|CANCELED|REFUNDED)
query.limit
integer 1..200 (default 20)
query.offset
integer ≥ 0 (default 0)
query.from
ISO timestamp (inclusive)
query.to
ISO timestamp (exclusive)

Response Schema

JSON type shapeExpand
{
  "data": [
    {
      "payment_id": "uuid",
      "amount_cents": "integer",
      "amount_received_cents": "integer (0 until succeeded)",
      "currency": "\"USD\"",
      "status": "PaymentStatus enum",
      "description": "string | null",
      "credit_target": "\"user_balance\" | \"project_budget\"",
      "credited_budget_project_id": "uuid | null",
      "stripe_payment_intent_id": "string",
      "error_code": "string | null",
      "error_message": "string | null",
      "created_at": "ISO",
      "paid_at": "ISO | null",
      "refunded_at": "ISO | null"
    }
  ],
  "page": {
    "limit": "integer",
    "offset": "integer",
    "has_more": "boolean"
  }
}
Fields19Expand
data
array
array — fields below
payment_id
uuid
amount_cents
integer
amount_received_cents
integer (0 until succeeded)
currency
"USD"
status
PaymentStatus enum
description
string | null
credit_target
"user_balance" | "project_budget"
credited_budget_project_id
uuid | null
the funded project budget
stripe_payment_intent_id
string
error_code
string | null
error_message
string | null
created_at
ISO
paid_at
ISO | null
refunded_at
ISO | null
page
object
object — fields below
limit
integer
offset
integer
has_more
boolean

Code Examples

curl "$BASE/api/v1/projects/$PID/payments?status=SUCCEEDED&limit=50" -H "Authorization: Bearer $API_KEY"
GET/api/v1/projects/{projectId}/payments/{paymentId}#

Read a single payment

Returns the full payment shape. Returns 404 if the payment is not in this project (cross-project lookups are not allowed).

Authentication

Bearer <api_key> — must own the project AND your account must be approved for payment collection.

Response Schema

JSON type shapeExpand
{
  "payment_id": "uuid",
  "project_id": "uuid",
  "amount_cents": "integer",
  "amount_received_cents": "integer",
  "currency": "\"USD\"",
  "status": "PaymentStatus enum",
  "description": "string | null",
  "stripe_payment_intent_id": "string",
  "error_code": "string | null",
  "error_message": "string | null",
  "created_at": "ISO",
  "paid_at": "ISO | null",
  "refunded_at": "ISO | null"
}
Fields13Expand
payment_id
uuid
project_id
uuid
amount_cents
integer
amount_received_cents
integer
currency
"USD"
status
PaymentStatus enum
description
string | null
stripe_payment_intent_id
string
error_code
string | null
error_message
string | null
created_at
ISO
paid_at
ISO | null
refunded_at
ISO | null

Code Examples

curl "$BASE/api/v1/projects/$PID/payments/$PMT_ID" -H "Authorization: Bearer $API_KEY"
POST/api/v1/projects/{projectId}/payment-methods#

Save a card (SetupIntent or hosted checkout)

Starts the save-card flow so a card can later back project auto-recharge. Default (embedded): ainvented creates a Stripe customer + SetupIntent server-side and returns a client_secret you confirm with Stripe.js + the PUBLIC publishable key. EASIEST: pass hosted_checkout:true to get a Stripe-hosted checkout_url (setup-mode) — redirect your customer there and you need NO Stripe key at all. Either way the saved pm_… attaches to your customer and is reusable across all your projects. You never touch ainvented's Stripe secret key.

Authentication

Bearer <api_key> — must own the project AND your account must be approved for payment collection.

Request Schema

JSON type shapeExpand
{
  "hosted_checkout": "boolean . true → returns a Stripe-hosted checkout_url (no client_secret, no publishable key needed).",
  "success_url": "url",
  "cancel_url": "url"
}
Fields3Expand
hosted_checkout
boolean . true → returns a Stripe-hosted checkout_url (no client_secret, no publishable key needed).optional
default false
success_url
urloptional
hosted_checkout only — redirect after the card is saved.
cancel_url
urloptional
hosted_checkout only — redirect on cancel.

Response Schema

JSON type shapeExpand
{
  "client_secret": "string",
  "checkout_url": "https://checkout.stripe.com/…",
  "customer_id": "string",
  "publishable_key": "string | null"
}
Fields4Expand
client_secret
string
embedded flow: confirm client-side with Stripe.js + publishable key (absent when hosted_checkout=true)
checkout_url
https://checkout.stripe.com/…
hosted flow: redirect your customer to save a card (hosted_checkout=true only)
customer_id
string
your ainvented Stripe customer id
publishable_key
string | null
the public pk_… to use for embedded confirmation

Code Examples

Redirect your customer to checkout_url to save a card; it then backs per-project auto-recharge.

curl -X POST "$BASE/api/v1/projects/$PID/payment-methods" \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"hosted_checkout": true, "success_url": "https://your-app.com/card-saved"}'
# → { "data": { "checkout_url": "https://checkout.stripe.com/c/pay/cs_...", "customer_id": "cus_..." } }
GET/api/v1/projects/{projectId}/payment-methods#

List saved cards

Returns the cards saved on your ainvented Stripe customer (so you can pick a pm_… for auto-recharge). Returns [] if you have not saved a card yet.

Authentication

Bearer <api_key> — must own the project AND be approved for payment collection.

Response Schema

JSON type shapeExpand
{
  "id": "string (pm_…)",
  "brand": "string | null",
  "last4": "string | null",
  "exp_month": "integer | null",
  "exp_year": "integer | null"
}
Fields5Expand
id
string (pm_…)
brand
string | null
last4
string | null
exp_month
integer | null
exp_year
integer | null

Code Examples

curl "$BASE/api/v1/projects/$PID/payment-methods" -H "Authorization: Bearer $API_KEY"
POST/api/v1/projects/{projectId}/auto-recharge#

Project auto-reload (create / get / update / disable)

Configure automatic top-up (auto-reload) of a PROJECT BUDGET. When the project’s remaining budget (budget_cents − consumed_cents) drops below threshold_cents, ainvented charges your saved card off-session for recharge_cents and credits the project budget. Four verbs: POST create/enable/partial-patch (enabled required; money/card fields merge onto any existing config; 201 new, 200 update); PUT upsert (full OR partial; money fields optional and merged; 200); GET returns the config (or null); DELETE turns it off (active=false). A lightweight toggle is just a POST with enabled=false or enabled=true. The payment_method_id (pm_…) must already be saved via POST /payment-methods. recharge_cents must be ≥ threshold_cents, and enabling requires a saved card. Each recharge is recorded as a project payment (credit_target=project_budget), is charge-idempotent per 60s window, and fires no AI call.

Authentication

Bearer <admin api_key> — must own the project AND be approved for payment collection. (Admin key required; project-scoped keys get 403 admin_key_required.)

Request Schema

JSON type shapeExpand
{
  "enabled": "boolean (POST: required",
  "threshold_cents": "integer",
  "recharge_cents": "integer (optional*, 1000..99,999,999",
  "payment_method_id": "string"
}
Fields4Expand
enabled
boolean (POST: required
the on/off toggle. PUT: optional, defaults true)
threshold_cents
integeroptional
0..99,999,999 — recharge when remaining budget falls below this. Merges onto the existing config when omitted.
recharge_cents
integer (optional*, 1000..99,999,999
minimum $10.00) — amount charged + credited on each recharge. *Required to persist a config; must be ≥ threshold_cents. Merges when omitted.
payment_method_id
stringoptional
*, pm_… — saved Stripe payment method. *Required to enable. Merges when omitted.

Response Schema

JSON type shapeExpand
{
  "project_id": "uuid",
  "threshold_cents": "integer",
  "recharge_cents": "integer",
  "active": "boolean",
  "payment_method_id": "string | null",
  "last_triggered_at": "ISO | null",
  "updated_at": "ISO | null"
}
Fields7Expand
project_id
uuid
threshold_cents
integer
recharge_cents
integer
active
boolean
payment_method_id
string | null
last_triggered_at
ISO | null
updated_at
ISO | null

Code Examples

curl -X POST "$BASE/api/v1/projects/$PID/auto-recharge" \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"enabled": true, "threshold_cents": 1000, "recharge_cents": 5000, "payment_method_id": "pm_..."}'
Guide

Webhook events

Subscribe via POST /api/v1/projects/{id}/webhooks with events: ['payment.succeeded', 'payment.failed'] in the body.

  • payment.succeeded — fired once when the PaymentIntent reaches the terminal succeeded state. Payload includes amount_received_cents, paid_at, stripe_payment_intent_id.
  • payment.failed — fired on each failed attempt (Stripe allows retries). Payload includes error_code, error_message.

No DELETE endpoint. Refunds are not supported via API. If you need to refund a customer, use the Stripe dashboard; the row's status will mirror to REFUNDED but your balance is NOT debited (manual reconciliation).

Reference · 22 endpoints

WhatsApp

Attach a customer's own WhatsApp Business account to one of your projects. You call one endpoint, get back a URL, and hand it to your customer — they sign in with Meta and pick their number. Neither you nor they ever type a WhatsApp access token, a phone number ID or a verify token; Ainvented obtains them from Meta server-side and stores them encrypted.

Once connected, a WhatsApp account appears on the project. What happens to the messages it receives depends on the account's mode, chosen when you mint the link: either the project's assistant answers by itself, or — the default for links minted through this API — every message is handed to your application through a webhook and you reply through the API, with nothing generated in between. All of these endpoints are reserved for reseller accounts and take an Admin API key.

Guide

Assistant mode vs API mode

mode: "assistant"mode: "api" (default here)
Who answersThe project's assistant, immediately, from the customer's number. Same behaviour as connecting from the AI Chat view.Your application. Nothing is generated: the message is stored, delivered to your webhooks, and you reply — or not — with POST /whatsapp/messages.
What it costs youEach inbound message is a priced assistant turn on the project, like a chat completion.No tokens are spent on the project for inbound or outbound messages. WhatsApp bills the customer's own account for conversations, as always.
HistoryLives with the chat session; not exposed here.GET /whatsapp/conversations and GET /whatsapp/messages hold every message in both directions, including failed sends.
SwitchingAny time, without reconnecting: PUT /whatsapp/accounts/{accountId} with the new mode. Accounts connected before modes existed are assistants until you switch them.
POST/api/v1/projects/{projectId}/whatsapp/connect#

Create a connect link

Mints a one-shot URL that walks your customer through Meta's Embedded Signup and attaches the resulting WhatsApp Business account to this project. Same shape as hosted checkout: you get a URL and hand it over — no SDK, no Meta app of your own. The link is single-use and expires after 7 days. The account it creates is in API mode unless you ask for an assistant. Send the customer's number with it: the link then finishes even if they close our page early, and they are never asked for it.

Authentication

Bearer <admin_api_key> — must be an account-wide Admin key (no project binding), must own the project, and the account must be a reseller.

Request Schema

JSON type shapeExpand
{
  "connector_name": "string",
  "return_url": "url",
  "mode": "\"api\" | \"assistant\" (optional, default \"assistant\"",
  "phone_number": "string"
}
Fields4Expand
connector_name
stringoptional
max 100 chars — label for the account that gets created. Omit it and the verified business name Meta returns is used instead, which is usually better than anything typed by hand.
return_url
urloptional
where the customer lands after connecting. Must be http or https; other schemes are rejected with 400. Omit it and they stay on a success card.
mode
"api" | "assistant" (optional, default "assistant"
what every connect link did before modes existed, so existing integrations keep working unchanged) — who answers on the connected number. "api": messages go to your webhooks and you reply through this API; "assistant": the project's assistant replies by itself.
phone_number
stringoptional
recommended — the WhatsApp Business number your customer is about to connect, with its country code ("+34 600 111 222"; spaces, dashes and dots are ignored; 8 to 15 digits). Meta reports a shared account, never which customer shared it: the number is how we tie that share to this link, so the connection completes even if the customer closes our page early, however many customers are connecting at once, and they are never asked for their number's last digits. Omit it and our page asks the customer for the number before sending them to Meta (recommended there too, not required).

Response Schema

JSON type shapeExpand
{
  "connect_url": "the URL to hand your customer",
  "connect_token": "the token embedded in that URL; use it to correlate with the list endpoint",
  "project_id": "uuid",
  "status": "\"pending\" on creation",
  "connector_name": "string | null",
  "return_url": "string | null",
  "mode": "\"api\" | \"assistant\"",
  "phone_number": "string | null",
  "expires_at": "ISO timestamp"
}
Fields9Expand
connect_url
the URL to hand your customer
https://<app>/connect/whatsapp/<token>
connect_token
the token embedded in that URL; use it to correlate with the list endpoint
project_id
uuid
the project the WhatsApp account will be attached to
status
"pending" on creation
connector_name
string | null
echoed back
return_url
string | null
echoed back
mode
"api" | "assistant"
the mode the account will be created in
phone_number
string | null
the number the link expects, normalised to E.164 ("+34600111222"), or null
expires_at
ISO timestamp
after this the link is dead and you must mint another

Code Examples

Send the returned connect_url to your customer however you like: an email, a button in your own dashboard, a QR code. It requires no Ainvented login on their side. Pass their number when you know it.

curl -X POST "$BASE/api/v1/projects/$PID/whatsapp/connect" \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "connector_name": "Acme Support",
    "phone_number": "+34 600 111 222",
    "return_url": "https://acme.example/settings/whatsapp?done=1"
  }'
# → { "connect_url": "https://ainvented.com/connect/whatsapp/DlhaNJ_Jyaf...",
#     "connect_token": "DlhaNJ_Jyaf...",
#     "status": "pending",
#     "phone_number": "+34600111222",
#     "expires_at": "2026-08-31T20:17:38.262Z" }

Details

Errors

  • 403 admin_key_required — the key is bound to a project. Everything under /v1/projects/** is account management and needs an Admin key.
  • 403 whatsapp_not_enabled — the account is not approved for the WhatsApp endpoints. Every route under /whatsapp/ returns this for a non-reseller account.
  • 403 project_disabled — the project was deleted.
  • 400 — bad project id, a return_url that is not http(s), or a mode other than the two above.
  • 400 invalid_phone_number — a phone_number that is not a number with its country code (8 to 15 digits). Nothing is minted.
  • 503 whatsapp_not_configured — WhatsApp onboarding is not switched on for this deployment. Never a client bug; retry later.
GET/api/v1/projects/{projectId}/whatsapp/connect#

Check connect links

Lists the connect links issued for this project, newest first, so you can tell whether a customer finished. Poll it after handing over a link, or read it on demand when your customer returns to your app.

Authentication

Bearer <admin_api_key> — same requirements as the POST.

Request Schema

JSON type shapeExpand
{
  "status": "\"pending\" | \"completed\" | \"failed\"",
  "limit": "integer",
  "offset": "integer"
}
Fields3Expand
status
"pending" | "completed" | "failed"optional
query — filter
limit
integeroptional
query, 1..200, default 20
offset
integeroptional
query, default 0

Response Schema

JSON type shapeExpand
{
  "data[].connect_token": "the token from the POST response",
  "data[].status": "\"pending\" (issued, unused) | \"completed\" (connected) | \"failed\" (an attempt failed; the link still works and they can retry) | \"expired\" (past expires_at)",
  "data[].connector_id": "uuid | null",
  "data[].connector_name": "string | null",
  "data[].mode": "\"api\" | \"assistant\"",
  "data[].phone_number": "string | null",
  "data[].error_message": "string | null",
  "data[].created_at": "ISO timestamp",
  "data[].expires_at": "ISO timestamp",
  "data[].completed_at": "ISO timestamp | null",
  "page": "{ limit, offset, has_more }"
}
Fields11Expand
data[].connect_token
the token from the POST response
correlate on this
data[].status
"pending" (issued, unused) | "completed" (connected) | "failed" (an attempt failed; the link still works and they can retry) | "expired" (past expires_at)
data[].connector_id
uuid | null
populated once status is "completed"; this is the WhatsApp account now live on the project (its account_id everywhere below)
data[].connector_name
string | null
data[].mode
"api" | "assistant"
the mode the link was minted with; links minted before modes existed read as "assistant"
data[].phone_number
string | null
the number the link expects (E.164): what you sent, or what the customer typed on our page; null for a link that carries none
data[].error_message
string | null
why the last attempt failed
data[].created_at
ISO timestamp
data[].expires_at
ISO timestamp
data[].completed_at
ISO timestamp | null
page
{ limit, offset, has_more }

Code Examples

curl "$BASE/api/v1/projects/$PID/whatsapp/connect?status=completed" \
  -H "Authorization: Bearer $ADMIN_KEY"

Details

GET/api/v1/projects/{projectId}/whatsapp/accounts#

List connected accounts

The WhatsApp accounts attached to this project, with their mode, how many people have written to each and how many of your webhooks fire for them. Inactive accounts are hidden unless asked for.

Authentication

Bearer <admin_api_key> — Admin key, project owner, reseller account. Same for every endpoint below.

Request Schema

JSON type shapeExpand
{
  "mode": "\"api\" | \"assistant\"",
  "include_inactive": "true"
}
Fields2Expand
mode
"api" | "assistant"optional
query — only accounts in that mode
include_inactive
trueoptional
query — also list deactivated accounts

Response Schema

JSON type shapeExpand
{
  "data[].id": "uuid",
  "data[].name": "string",
  "data[].mode": "\"api\" | \"assistant\"",
  "data[].display_phone_number": "string | null",
  "data[].verified_name": "string | null",
  "data[].phone_number_id": "string | null",
  "data[].business_account_id": "string | null",
  "data[].onboarded_via": "\"embedded_signup\" | \"manual\" | null",
  "data[].is_active": "boolean",
  "data[].token_expires_at": "ISO timestamp | null",
  "data[].needs_reconnect": "boolean",
  "data[].connected_at": "ISO timestamp",
  "data[].conversation_count": "integer",
  "data[].last_inbound_at": "ISO timestamp | null",
  "data[].webhook_count": "integer"
}
Fields15Expand
data[].id
uuid
the account_id used by every other endpoint
data[].name
string
data[].mode
"api" | "assistant"
data[].display_phone_number
string | null
the number as people see it
data[].verified_name
string | null
the business name WhatsApp verified
data[].phone_number_id
string | null
data[].business_account_id
string | null
data[].onboarded_via
"embedded_signup" | "manual" | null
data[].is_active
boolean
data[].token_expires_at
ISO timestamp | null
null means the authorization does not expire
data[].needs_reconnect
boolean
true once the authorization has expired; mint a new connect link
data[].connected_at
ISO timestamp
data[].conversation_count
integer
people who have written to this account (API mode only)
data[].last_inbound_at
ISO timestamp | null
data[].webhook_count
integer
your active webhooks that fire for this account

Code Examples

curl "$BASE/api/v1/projects/$PID/whatsapp/accounts?mode=api" \
  -H "Authorization: Bearer $ADMIN_KEY"
GET/api/v1/projects/{projectId}/whatsapp/accounts/{accountId}#

Read an account

One connected account with the same fields as the listing. 404 account_not_found for an account of another project — an account id never resolves across projects.

Authentication

Bearer <admin_api_key>

Response Schema

JSON type shapeExpand
{
  "…": "the same object as one row of the listing"
}
Fields1Expand
…
the same object as one row of the listing

Code Examples

curl "$BASE/api/v1/projects/$PID/whatsapp/accounts/$ACCOUNT_ID" \
  -H "Authorization: Bearer $ADMIN_KEY"
PUT/api/v1/projects/{projectId}/whatsapp/accounts/{accountId}#

Update an account

Rename an account, switch its mode, deactivate it, or give a manually configured account its WhatsApp Business Account id. Switching an account connected from the AI Chat view — or before modes existed — to API mode is how it stops answering by itself and starts reaching your webhooks; the customer does not reconnect. Every change is written to the project's audit log.

Authentication

Bearer <admin_api_key>

Request Schema

JSON type shapeExpand
{
  "name": "string",
  "mode": "\"api\" | \"assistant\"",
  "is_active": "boolean",
  "business_account_id": "string"
}
Fields4Expand
name
stringoptional
1..100 chars
mode
"api" | "assistant"optional
is_active
booleanoptional
false stops both directions: inbound messages are ignored and sends are refused with 409 account_inactive
business_account_id
stringoptional
numeric — the WhatsApp Business Account id of an account that was connected by hand (phone number id + token), which the template endpoints need. Settable once; stored only after WhatsApp confirms the account's own token can read that business account. Accounts connected through a connect link already carry it

Response Schema

JSON type shapeExpand
{
  "…": "the updated account, same shape as the listing"
}
Fields1Expand
…
the updated account, same shape as the listing

Code Examples

curl -X PUT "$BASE/api/v1/projects/$PID/whatsapp/accounts/$ACCOUNT_ID" \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "mode": "api" }'

Details

Errors

  • 400 — nothing to update, a mode other than the two, or a business_account_id that is not numeric.
  • 400 business_account_unverified — WhatsApp does not let the account's token read that business account; nothing is stored. Check the id in WhatsApp Manager, or reconnect the number through a connect link.
  • 409 business_account_already_set — the account already carries its business account id (every connect-link account does).
  • 404 account_not_found — not an account of this project.
  • 403 project_disabled — the project was deleted.
GET/api/v1/projects/{projectId}/whatsapp/conversations#

Who wrote in

One row per phone that has written to one of the project's API-mode accounts, most recent message first. A customer who writes to two of your numbers is two conversations. This is also the list of phones you may send free-form messages to.

Authentication

Bearer <admin_api_key>

Request Schema

JSON type shapeExpand
{
  "account_id": "uuid",
  "phone": "string",
  "since": "ISO datetime",
  "limit": "integer",
  "offset": "integer"
}
Fields5Expand
account_id
uuidoptional
query — one account only; 404 if it is not on this project
phone
stringoptional
query — one customer, in international format with or without the plus sign
since
ISO datetimeoptional
query — conversations with an inbound message at or after this moment
limit
integeroptional
query, 1..200, default 20
offset
integeroptional
query, default 0

Response Schema

JSON type shapeExpand
{
  "data[].id": "uuid",
  "data[].account_id": "uuid",
  "data[].phone": "string",
  "data[].profile_name": "string | null",
  "data[].first_inbound_at": "ISO timestamp | null",
  "data[].last_inbound_at": "ISO timestamp | null",
  "data[].last_outbound_at": "ISO timestamp | null",
  "data[].inbound_count": "integer",
  "data[].outbound_count": "integer",
  "data[].last_message": "{ direction: \"inbound\" | \"outbound\", preview: string } | null",
  "page": "{ limit, offset, has_more }"
}
Fields11Expand
data[].id
uuid
usable as conversation_id when reading messages
data[].account_id
uuid
data[].phone
string
digits in international format, no plus sign; use it as `to` when replying
data[].profile_name
string | null
the name the customer chose on WhatsApp
data[].first_inbound_at
ISO timestamp | null
data[].last_inbound_at
ISO timestamp | null
data[].last_outbound_at
ISO timestamp | null
data[].inbound_count
integer
data[].outbound_count
integer
successful sends only
data[].last_message
{ direction: "inbound" | "outbound", preview: string } | null
page
{ limit, offset, has_more }

Code Examples

curl "$BASE/api/v1/projects/$PID/whatsapp/conversations?account_id=$ACCOUNT_ID&since=$(date -u +%Y-%m-%dT00:00:00Z)" \
  -H "Authorization: Bearer $ADMIN_KEY"
GET/api/v1/projects/{projectId}/whatsapp/messages#

Read messages

Every message on the project's API-mode accounts, in both directions, newest first: what customers sent, what you sent, and what failed to send. Every inbound message is stored BEFORE any webhook delivery is attempted, so this is your fallback when your endpoint was down — filter on since and order=asc to catch up. GET /whatsapp/messages/{messageId} returns one message with its full payload.

Authentication

Bearer <admin_api_key>

Request Schema

JSON type shapeExpand
{
  "account_id": "uuid",
  "conversation_id": "uuid",
  "phone": "string",
  "direction": "\"inbound\" | \"outbound\"",
  "type": "text | image | audio | video | document | sticker | location | contacts | reaction | interactive | button | order | template | unsupported | unknown",
  "status": "received | sent | delivered | read | failed",
  "since": "ISO datetime",
  "until": "ISO datetime",
  "order": "\"desc\" (default) | \"asc\"",
  "include_payload": "true",
  "limit": "integer",
  "offset": "integer"
}
Fields12Expand
account_id
uuidoptional
query
conversation_id
uuidoptional
query
phone
stringoptional
query — messages from or to this customer
direction
"inbound" | "outbound"optional
query
type
text | image | audio | video | document | sticker | location | contacts | reaction | interactive | button | order | template | unsupported | unknownoptional
query
status
received | sent | delivered | read | failedoptional
query
since
ISO datetimeoptional
query — on the moment the message happened on WhatsApp
until
ISO datetimeoptional
query
order
"desc" (default) | "asc"
include_payload
trueoptional
query — include the message exactly as WhatsApp delivered it (inbound) or exactly as it was sent (outbound): the contact cards of a "contacts" message, the coordinates of a "location", the id of the button or row pressed in an "interactive" reply, an "order", a "reaction"…
limit
integeroptional
query, 1..200, default 20
offset
integeroptional
query, default 0

Response Schema

JSON type shapeExpand
{
  "data[].id": "uuid",
  "data[].account_id": "uuid",
  "data[].conversation_id": "uuid",
  "data[].direction": "\"inbound\" | \"outbound\"",
  "data[].wa_message_id": "string | null",
  "data[].from": "string",
  "data[].to": "string",
  "data[].type": "string",
  "data[].text": "string | null",
  "data[].media": "{ id, mime_type, sha256, filename?, voice?, animated? } | null",
  "data[].status": "\"received\" for inbound; \"sent\" → \"delivered\" → \"read\", or \"failed\", for outbound",
  "data[].error": "{ code, message } | null",
  "data[].sent_at": "ISO timestamp",
  "data[].delivered_at": "ISO timestamp | null",
  "data[].read_at": "ISO timestamp | null",
  "data[].webhook": "{ status: \"none\" | \"pending\" | \"delivered\" | \"failed\", delivered_at } for inbound messages; null for outbound",
  "data[].api_key_id": "uuid | null",
  "data[].created_at": "ISO timestamp",
  "data[].referral": "object | null",
  "data[].payload": "object",
  "page": "{ limit, offset, has_more }"
}
Fields21Expand
data[].id
uuid
data[].account_id
uuid
data[].conversation_id
uuid
data[].direction
"inbound" | "outbound"
data[].wa_message_id
string | null
WhatsApp's own id; pass it as reply_to to quote the message
data[].from
string
phone digits
data[].to
string
phone digits
data[].type
string
see the type filter
data[].text
string | null
body of a text, caption of a media message, title of a pressed button or list row, name of a location, emoji of a reaction, names on a contact card. A summary: the whole content of those types is in payload
data[].media
{ id, mime_type, sha256, filename?, voice?, animated? } | null
id is what GET /whatsapp/media/{id} takes
data[].status
"received" for inbound; "sent" → "delivered" → "read", or "failed", for outbound
data[].error
{ code, message } | null
why an outbound message failed
data[].sent_at
ISO timestamp
when it happened on WhatsApp
data[].delivered_at
ISO timestamp | null
data[].read_at
ISO timestamp | null
data[].webhook
{ status: "none" | "pending" | "delivered" | "failed", delivered_at } for inbound messages; null for outbound
data[].api_key_id
uuid | null
the key that sent an outbound message
data[].created_at
ISO timestamp
data[].referral
object | null
when the customer wrote in from a Click-to-WhatsApp ad or the WhatsApp button of a Facebook / Instagram post: source_type ("ad" | "post"), source_id, source_url, headline, body, media_type, image_url or video_url and, for ads, ctwa_clid (the click id to attribute the conversation and the sale in Meta's tools). Exactly as WhatsApp delivered it; null on every other message
data[].payload
object
only with include_payload=true (always present in webhook events)
page
{ limit, offset, has_more }

Code Examples

curl "$BASE/api/v1/projects/$PID/whatsapp/messages?direction=inbound&since=2026-09-03T08:00:00Z&order=asc&limit=200" \
  -H "Authorization: Bearer $ADMIN_KEY"
POST/api/v1/projects/{projectId}/whatsapp/messages#

Send a message

Sends a message from one of the project's API-mode accounts. A free-form message (text, media or interactive) can only go to a phone that has written to that account, and WhatsApp itself refuses it once 24 hours have passed since the customer's last message. A template message may go to any phone and opens the conversation. The message is stored either way — a refused send is a message with status "failed" and its reason — and nothing is generated on your behalf: you decide every word. Interactive messages — reply buttons, a pick-one list, a call-to-action link, a request for the customer's location — take the interactive object in the shape WhatsApp itself uses; the customer's answer arrives as an inbound message of type "interactive" whose payload carries the id of the button or row they pressed.

Authentication

Bearer <admin_api_key> — also subject to the key's rate limit (429 rate_limit_exceeded with Retry-After).

Request Schema

JSON type shapeExpand
{
  "account_id": "uuid",
  "to": "string",
  "type": "\"text\" (default) | \"image\" | \"audio\" | \"video\" | \"document\" | \"sticker\" | \"template\" | \"interactive\"",
  "text": "string (max 4096)",
  "media_url": "https url",
  "filename": "string",
  "template": "{ name, language, components? }",
  "interactive": "{ type, header?, body, footer?, action }",
  "reply_to": "string",
  "preview_url": "boolean"
}
Fields10Expand
account_id
uuid
the account to send from; must be active and in API mode
to
string
recipient in international format; the plus sign and spaces are optional
type
"text" (default) | "image" | "audio" | "video" | "document" | "sticker" | "template" | "interactive"
text
string (max 4096)
the body of a text message; the caption of an image, video or document
media_url
https url
where WhatsApp fetches the media from; required for the media types
filename
stringoptional
document name shown to the recipient
template
{ name, language, components? }
required for type "template"; an approved template of the customer's WhatsApp Business account
interactive
{ type, header?, body, footer?, action }
required for type "interactive". type: "button" (action.buttons: 1-3 × { type: "reply", reply: { id ≤256, title ≤20 } }, unique ids), "list" (action.button ≤20 — the label that opens the list — and action.sections: 1-10 × { title?, rows: [{ id ≤200, title ≤24, description? ≤72 }] }, at most 10 rows in all, unique ids), "cta_url" (action: { name: "cta_url", parameters: { display_text ≤20, url } }) or "location_request_message" (action: { name: "send_location" }). body.text ≤1024; footer.text ≤60; header: { type: "text", text ≤60 } — or, for button and cta_url, { type: "image" | "video" | "document", <type>: { link: https url } }
reply_to
stringoptional
wa_message_id of the message being answered; renders as a quoted reply
preview_url
booleanoptional
text only — let WhatsApp render a preview of the first link in the body

Response Schema

JSON type shapeExpand
{
  "…": "the stored outbound message, same shape as a row of GET /whatsapp/messages, status \"sent\", HTTP 201"
}
Fields1Expand
…
the stored outbound message, same shape as a row of GET /whatsapp/messages, status "sent", HTTP 201

Code Examples

curl -X POST "$BASE/api/v1/projects/$PID/whatsapp/messages" \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "account_id": "'$ACCOUNT_ID'",
    "to": "+34 600 111 222",
    "text": "Your order 4521 ships tomorrow. Anything else we can help with?",
    "reply_to": "wamid.HBhMMzQ2MDAxMTEyMjIVAgASGBQzQTZDMEE5MDQ1QjE4MDU1RkMyMwA="
  }'
# → 201 { "id": "…", "direction": "outbound", "status": "sent", "wa_message_id": "wamid.…", … }

Details

Errors

  • 400 — the offending field is named in error.param: a text message without text, a media type without an https media_url, a template type without template, a to that is not a phone; for an interactive message the whole path (interactive.action.buttons.0.reply.title) — a fourth button, a title over 20 characters, an eleventh row, a media header on a list, two rows with the same id — so WhatsApp's own refusal (131009) is never the first you hear of it.
  • 404 conversation_not_found — a free-form message to a phone that never wrote to this account. Send a template instead.
  • 404 account_not_found — not an account of this project.
  • 409 account_not_api_mode — the assistant answers on this account; switch it with the PUT above.
  • 409 account_inactive — deactivated account.
  • 409 account_reauthorization_required — the customer's authorization expired or was revoked. Mint a new connect link and have them reconnect; the account keeps its id and history.
  • 502 whatsapp_send_failed — WhatsApp refused the message; details.whatsapp_code says why and details.retryable whether waiting helps.
  • 502 whatsapp_unreachable — the WhatsApp API did not answer in time. Nothing was sent; retry.
  • 429 rate_limit_exceeded — your key's bucket is empty; honour Retry-After.
GET/api/v1/projects/{projectId}/whatsapp/media/{mediaId}?account_id={accountId}#

Download media

The bytes of an image, audio, video, document or sticker a customer sent. Inbound messages carry media.id; fetching it needs the account's authorization, which never leaves the server, so this endpoint fetches it for you and streams the file back with its content type. Media stays available on WhatsApp for a limited time — download what you need to keep.

Authentication

Bearer <admin_api_key>

Request Schema

JSON type shapeExpand
{
  "account_id": "uuid"
}
Fields1Expand
account_id
uuidrequired
query — the account the message arrived on

Response Schema

JSON type shapeExpand
{
  "body": "the file bytes",
  "Content-Type": "the media's type, e.g. image/jpeg, audio/ogg, application/pdf",
  "Content-Length": "bytes",
  "X-WhatsApp-Media-Sha256": "hex digest reported by WhatsApp, when available"
}
Fields4Expand
body
the file bytes
Content-Type
the media's type, e.g. image/jpeg, audio/ogg, application/pdf
Content-Length
bytes
X-WhatsApp-Media-Sha256
hex digest reported by WhatsApp, when available

Code Examples

curl "$BASE/api/v1/projects/$PID/whatsapp/media/$MEDIA_ID?account_id=$ACCOUNT_ID" \
  -H "Authorization: Bearer $ADMIN_KEY" -o photo.jpg

Details

Errors

  • 404 media_not_found — unknown id, or the file has expired on WhatsApp.
  • 413 media_too_large — over the size limit (25 MB by default).
  • 409 account_reauthorization_required — the account's authorization is no longer valid.
  • 502 whatsapp_unreachable / whatsapp_media_failed — WhatsApp did not return the file; retry.
GET/api/v1/projects/{projectId}/whatsapp/accounts/{accountId}/templates#

List templates

The message templates of one connected account, read live from its WhatsApp Business Account: name, language, category, review status, components and quality. A template is the only message WhatsApp lets you send to a phone that has not written in, or after the 24-hour window has closed (POST /whatsapp/messages with type "template"), so this is where you check what is approved before sending. Nothing is cached on Ainvented: WhatsApp is the source of truth. Pages are cursor-based — pass next_cursor back as after.

Authentication

Bearer <admin_api_key> — reseller-only, like every route under /whatsapp/. Works for accounts in either mode: templates belong to the business account, not to the way the number answers.

Request Schema

JSON type shapeExpand
{
  "status": "\"APPROVED\" | \"PENDING\" | \"REJECTED\" | \"PAUSED\" | \"DISABLED\" | \"IN_APPEAL\" | \"PENDING_DELETION\" | \"DELETED\" | \"LIMIT_EXCEEDED\"",
  "category": "\"MARKETING\" | \"UTILITY\" | \"AUTHENTICATION\"",
  "language": "string",
  "name": "string",
  "search": "string",
  "limit": "integer 1-100",
  "after": "string"
}
Fields7Expand
status
"APPROVED" | "PENDING" | "REJECTED" | "PAUSED" | "DISABLED" | "IN_APPEAL" | "PENDING_DELETION" | "DELETED" | "LIMIT_EXCEEDED"optional
query, case-insensitive
category
"MARKETING" | "UTILITY" | "AUTHENTICATION"optional
query, case-insensitive
language
stringoptional
query — a WhatsApp language code such as "es" or "en_US"
name
stringoptional
query — exact template name
search
stringoptional
query, max 200 — matches the name or the text of the template
limit
integer 1-100optional
query, default 25
after
stringoptional
query — the next_cursor of the previous page

Response Schema

JSON type shapeExpand
{
  "data[].id": "string",
  "data[].account_id": "uuid",
  "data[].name": "string",
  "data[].language": "string",
  "data[].status": "\"APPROVED\" | \"PENDING\" | \"REJECTED\" | \"PAUSED\" | \"DISABLED\" | \"IN_APPEAL\" | \"PENDING_DELETION\" | \"DELETED\" | \"LIMIT_EXCEEDED\"",
  "data[].category": "\"MARKETING\" | \"UTILITY\" | \"AUTHENTICATION\"",
  "data[].sub_category": "string | null",
  "data[].components": "array",
  "data[].parameter_format": "\"POSITIONAL\" | \"NAMED\" | null",
  "data[].message_send_ttl_seconds": "integer | null",
  "data[].quality": "{ score: \"GREEN\" | \"YELLOW\" | \"RED\" | \"UNKNOWN\", updated_at: ISO timestamp | null, reasons } | null",
  "data[].rejected_reason": "string | null",
  "data[].previous_category": "string | null",
  "data[].correct_category": "string | null",
  "page.limit": "integer",
  "page.cursor": "string | null",
  "page.next_cursor": "string | null",
  "page.has_more": "boolean"
}
Fields18Expand
data[].id
string
WhatsApp's template id; the same id the whatsapp.template.* webhook events carry
data[].account_id
uuid
the account it was read through
data[].name
string
lowercase letters, digits and underscores; what you send with type "template"
data[].language
string
one template name can exist in several languages, each with its own id and status
data[].status
"APPROVED" | "PENDING" | "REJECTED" | "PAUSED" | "DISABLED" | "IN_APPEAL" | "PENDING_DELETION" | "DELETED" | "LIMIT_EXCEEDED"
data[].category
"MARKETING" | "UTILITY" | "AUTHENTICATION"
data[].sub_category
string | null
data[].components
array
the HEADER / BODY / FOOTER / BUTTONS objects exactly as WhatsApp stores them, example values included
data[].parameter_format
"POSITIONAL" | "NAMED" | null
how the variables in the text are written ({{1}} or {{name}})
data[].message_send_ttl_seconds
integer | null
how long WhatsApp keeps trying to deliver a message of this template
data[].quality
{ score: "GREEN" | "YELLOW" | "RED" | "UNKNOWN", updated_at: ISO timestamp | null, reasons } | null
WhatsApp's quality rating, driven by how recipients react
data[].rejected_reason
string | null
why WhatsApp rejected it, when it did
data[].previous_category
string | null
set when WhatsApp re-filed the template under another category
data[].correct_category
string | null
the category WhatsApp thinks it should have
page.limit
integer
page.cursor
string | null
the after you passed
page.next_cursor
string | null
pass it as after for the next page; null on the last one
page.has_more
boolean

Code Examples

Only an APPROVED template can be sent. Filter on it before offering the list to your own users.

curl "$BASE/api/v1/projects/$PID/whatsapp/accounts/$ACCOUNT_ID/templates?status=approved&limit=50" \
  -H "Authorization: Bearer $ADMIN_KEY"
# → { "data": [ { "id": "1011820805234855", "name": "order_update", "language": "es", "status": "APPROVED",
#                 "category": "UTILITY", "components": [ { "type": "BODY", "text": "Hola {{1}}, tu pedido {{2}} sale mañana." } ], … } ],
#     "page": { "limit": 50, "cursor": null, "next_cursor": null, "has_more": false } }

Details

Errors shared by every template endpoint

  • 404 account_not_found — the account is not on this project.
  • 409 account_inactive — the account is switched off; templates are managed on live accounts only.
  • 409 account_reauthorization_required — the account's authorization has expired; mint a connect link and have the customer reconnect.
  • 409 business_account_unknown — the account was connected by hand and carries no WhatsApp Business Account id. Set it once with PUT /whatsapp/accounts/{accountId} and business_account_id (it is stored only after WhatsApp confirms the account's own token can read it), or reconnect the number through a connect link, which records it by itself.
  • 400 whatsapp_template_refused — WhatsApp refused the request; error.message carries its own explanation (a name already in use, a variable without an example value, a category that does not fit the text…) and error.details its whatsapp_code / whatsapp_subcode.
  • 404 template_not_found — no template with that id on this account. An id from another account answers this too, whatever the token could reach.
  • 403 whatsapp_permission_denied — the account's token cannot manage templates; reconnect through a connect link.
  • 429 whatsapp_rate_limited — WhatsApp is throttling template operations on this account (it caps creations per hour); retry after Retry-After. 429 rate_limit_exceeded is your own key's limit, as on every write.
  • 502 whatsapp_template_failed / whatsapp_unreachable — WhatsApp did not answer or answered with an outage; error.details.retryable is true. On a create, read the list before retrying: the template may exist.
POST/api/v1/projects/{projectId}/whatsapp/accounts/{accountId}/templates#

Create a template

Submits a message template to WhatsApp for review on the account's business account. WhatsApp reviews it asynchronously — usually within minutes, sometimes longer: the template comes back with status PENDING, and the outcome (APPROVED or REJECTED with a reason) reaches you as a whatsapp.template.status webhook event and on GET. Nothing is generated on your behalf: the text is yours, and WhatsApp judges it against its template policies.

Authentication

Bearer <admin_api_key> — subject to the key's rate limit (429 rate_limit_exceeded with Retry-After).

Request Schema

JSON type shapeExpand
{
  "name": "string",
  "category": "\"MARKETING\" | \"UTILITY\" | \"AUTHENTICATION\"",
  "language": "string",
  "components": "array",
  "app_id": "string",
  "allow_category_change": "boolean",
  "parameter_format": "\"POSITIONAL\" | \"NAMED\"",
  "message_send_ttl_seconds": "integer"
}
Fields8Expand
name
stringrequired
lowercase letters, digits and underscores, max 512. Unique per language on the account; a deleted name cannot be reused for 30 days
category
"MARKETING" | "UTILITY" | "AUTHENTICATION"required
case-insensitive — WhatsApp prices conversations by it and rejects a text that does not fit the category
language
stringrequired
a WhatsApp language code such as "es", "es_MX" or "en_US"
components
arrayrequired
1-10 — the parts of the message, passed to WhatsApp as given. A BODY is required. Each has a type: HEADER (format TEXT / IMAGE / VIDEO / DOCUMENT / LOCATION), BODY (text, max 1024 chars, variables as {{1}} or {{name}}), FOOTER (text), BUTTONS (buttons[]: QUICK_REPLY, URL, PHONE_NUMBER, COPY_CODE…). Every variable needs an example value (example.body_text / example.header_text) or WhatsApp refuses the template. An IMAGE, VIDEO or DOCUMENT header needs its sample: give example.header_url (a public https URL of a JPEG, PNG, MP4 or PDF — fetched and uploaded for you in this same call) or example.header_handle (the handle of a sample uploaded beforehand with POST …/templates/media). Missing both is a 400 naming components.N.example.header_handle
app_id
stringoptional
only for an account you configured by hand with your own token, when a header carries example.header_url: the numeric id of the app that issued the token (as in POST …/templates/media)
allow_category_change
booleanoptional
let WhatsApp file the template under the category it judges correct instead of rejecting it
parameter_format
"POSITIONAL" | "NAMED"optional
how the variables are written; POSITIONAL unless said otherwise
message_send_ttl_seconds
integeroptional
-1 or 30…2592000 — how long WhatsApp keeps trying to deliver a message of this template; -1 is the category default

Response Schema

JSON type shapeExpand
{
  "…": "the template, same shape as a row of GET /templates, with status \"PENDING\" (or already \"APPROVED\" for some utility texts WhatsApp clears at once), HTTP 201"
}
Fields1Expand
…
the template, same shape as a row of GET /templates, with status "PENDING" (or already "APPROVED" for some utility texts WhatsApp clears at once), HTTP 201

Code Examples

The example values are what WhatsApp's reviewers see in place of the variables; the template is refused without them.

curl -X POST "$BASE/api/v1/projects/$PID/whatsapp/accounts/$ACCOUNT_ID/templates" \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "order_update",
    "category": "UTILITY",
    "language": "es",
    "components": [
      { "type": "BODY",
        "text": "Hola {{1}}, tu pedido {{2}} sale mañana. ¿Necesitas algo más?",
        "example": { "body_text": [["Ana", "4521"]] } },
      { "type": "FOOTER", "text": "Acme" },
      { "type": "BUTTONS", "buttons": [ { "type": "QUICK_REPLY", "text": "Sí, gracias" } ] }
    ]
  }'
# → 201 { "id": "1011820805234855", "name": "order_update", "language": "es", "status": "PENDING", "category": "UTILITY", … }
# later, on your webhook: { "event": "whatsapp.template.status", "data": { "template": { "id": "1011820805234855", … }, "status": "APPROVED", … } }

Details

What WhatsApp enforces

  • Review is asynchronous. Listen to whatsapp.template.status or poll GET /templates/{templateId}; do not send a template until it is APPROVED — a send of a pending or rejected template is refused with whatsapp_send_failed.
  • One name, several languages. Creating the same name in a second language is a second template with its own id and its own review; send it with the matching template.language.
  • Limits are the account's. WhatsApp caps how many templates a business account may hold and how many can be created per hour; the refusal comes back as whatsapp_template_refused or whatsapp_rate_limited.
  • Quality follows the template. Recipients who block or report the number lower its quality; a RED template can be PAUSED and then DISABLED by WhatsApp — both arrive as whatsapp.template.status events, and whatsapp.template.quality reports the rating changes themselves.
  • Nothing is billed by Ainvented. A template call is free at WhatsApp and unpriced here; every create, edit and delete is written to the project's audit log with the key that made it.
POST/api/v1/projects/{projectId}/whatsapp/accounts/{accountId}/templates/media#

Upload a header sample

The sample file a template with an IMAGE, VIDEO or DOCUMENT header is reviewed against. WhatsApp takes it through its own upload and hands back a handle; this endpoint does that upload with the account's authorization — which never leaves the server — and answers with the handle, plus the HEADER component ready to paste into POST /templates. If the sample sits at a public URL you can skip this call: put it in the header's example.header_url when creating or editing the template and the upload happens there. Hand the file over as multipart/form-data (a file part) or as a public https url this server fetches. WhatsApp accepts JPEG and PNG (up to 5 MB), MP4 (16 MB) and PDF (100 MB); this server buffers up to 25 MB by default. A handle is tied to the account's app and expires after a while at WhatsApp, so upload right before creating the template.

Authentication

Bearer <admin_api_key> — subject to the key's rate limit (429 rate_limit_exceeded with Retry-After).

Request Schema

JSON type shapeExpand
{
  "file": "multipart part",
  "url": "https url (JSON body, instead of file)",
  "file_name": "string",
  "mime_type": "string",
  "app_id": "string"
}
Fields5Expand
file
multipart part
the sample bytes, with its Content-Type (image/jpeg, image/png, video/mp4 or application/pdf)
url
https url (JSON body, instead of file)
a public address this server fetches the sample from; must not resolve to a private network
file_name
stringoptional
both forms — the name WhatsApp files the sample under; the part's or the URL's last segment unless given
mime_type
stringoptional
both forms — overrides the type the part or the URL declares
app_id
stringoptional
only for an account you configured by hand with your own token: the numeric id of the app that issued it. Accounts connected through a connect link upload on Ainvented's app and must not send it

Response Schema

JSON type shapeExpand
{
  "handle": "string",
  "account_id": "uuid",
  "format": "\"IMAGE\" | \"VIDEO\" | \"DOCUMENT\"",
  "mime_type": "string",
  "size": "integer",
  "file_name": "string",
  "header_component": "{ type: \"HEADER\", format, example: { header_handle: [handle] } }"
}
Fields7Expand
handle
string
WhatsApp's upload handle; put it in example.header_handle of the HEADER component
account_id
uuid
format
"IMAGE" | "VIDEO" | "DOCUMENT"
the header format this sample stands for
mime_type
string
size
integer
bytes uploaded
file_name
string
header_component
{ type: "HEADER", format, example: { header_handle: [handle] } }
the component, ready to paste (HTTP 201)

Code Examples

HANDLE=$(curl -s -X POST "$BASE/api/v1/projects/$PID/whatsapp/accounts/$ACCOUNT_ID/templates/media" \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -F "file=@hero.jpg;type=image/jpeg" | jq -r .handle)

curl -X POST "$BASE/api/v1/projects/$PID/whatsapp/accounts/$ACCOUNT_ID/templates" \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "spring_sale",
    "category": "MARKETING",
    "language": "es",
    "components": [
      { "type": "HEADER", "format": "IMAGE", "example": { "header_handle": ["'$HANDLE'"] } },
      { "type": "BODY", "text": "Hola {{1}}, llegó la primavera: 20% en todo hasta el domingo.", "example": { "body_text": [["Ana"]] } }
    ]
  }'

Details

Errors

  • 400 — no file part and no url; an empty file (media_empty); a url that is not public https, or that could not be fetched (sample_unreachable); app_id_required for an account you configured by hand; a malformed app_id.
  • 415 unsupported_media_type — not JPEG, PNG, MP4 or PDF.
  • 413 media_too_large — over WhatsApp's cap for the format, or over this server's buffer.
  • 404 account_not_found · 409 account_inactive / account_reauthorization_required / business_account_unknown — the same account rules as every template call.
  • 403 whatsapp_permission_denied — the account's token cannot upload on that app (a hand-made account: check app_id).
  • 502 whatsapp_template_failed / whatsapp_unreachable — WhatsApp did not take the file; retry.
  • 503 whatsapp_upload_unavailable — uploads are not configured on this server.
GET/api/v1/projects/{projectId}/whatsapp/accounts/{accountId}/templates/{templateId}#

Read a template

One template of the account, by the id WhatsApp gave it — the same id the whatsapp.template.* events carry, so a webhook delivery can be followed up with this call. The id is looked up under this account's business account first: another account's template answers 404 template_not_found.

Authentication

Bearer <admin_api_key>

Response Schema

JSON type shapeExpand
{
  "…": "the template, same shape as a row of GET /templates"
}
Fields1Expand
…
the template, same shape as a row of GET /templates

Code Examples

curl "$BASE/api/v1/projects/$PID/whatsapp/accounts/$ACCOUNT_ID/templates/$TEMPLATE_ID" \
  -H "Authorization: Bearer $ADMIN_KEY"
# → { "id": "…", "name": "order_update", "language": "es", "status": "REJECTED", "rejected_reason": "INVALID_FORMAT", … }
PUT/api/v1/projects/{projectId}/whatsapp/accounts/{accountId}/templates/{templateId}#

Edit a template

Changes the components, the category, the parameter format or the delivery TTL of one template. WhatsApp only edits a template that is APPROVED, REJECTED or PAUSED — never one PENDING or DISABLED — allows an approved template one edit a day and ten a month, and sends any change of the text back to review: the answer is the template as WhatsApp now holds it, usually PENDING again. The id is looked up under this account's business account first.

Authentication

Bearer <admin_api_key> — subject to the key's rate limit.

Request Schema

JSON type shapeExpand
{
  "components": "array",
  "category": "\"MARKETING\" | \"UTILITY\" | \"AUTHENTICATION\"",
  "parameter_format": "\"POSITIONAL\" | \"NAMED\"",
  "message_send_ttl_seconds": "integer"
}
Fields4Expand
components
arrayoptional
1-10, a BODY required — replaces every component; same rules as on creation. A media HEADER takes example.header_url (uploaded for you) or example.header_handle
category
"MARKETING" | "UTILITY" | "AUTHENTICATION"optional
parameter_format
"POSITIONAL" | "NAMED"optional
message_send_ttl_seconds
integeroptional
-1 or 30…2592000

Response Schema

JSON type shapeExpand
{
  "…": "the template after the change, same shape as a row of GET /templates"
}
Fields1Expand
…
the template after the change, same shape as a row of GET /templates

Code Examples

curl -X PUT "$BASE/api/v1/projects/$PID/whatsapp/accounts/$ACCOUNT_ID/templates/$TEMPLATE_ID" \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "components": [
      { "type": "BODY", "text": "Hola {{1}}, tu pedido {{2}} sale mañana.", "example": { "body_text": [["Ana", "4521"]] } }
    ]
  }'
# → 200 { "id": "…", "status": "PENDING", … }   ← reviewed again; the whatsapp.template.status event tells the outcome
DELETE/api/v1/projects/{projectId}/whatsapp/accounts/{accountId}/templates/{templateId}#

Delete a template

Deletes one template — that name in that language — from the account's business account. WhatsApp moves it to PENDING_DELETION (messages already sent with it still deliver for a while), and the name cannot be reused for 30 days. To delete a name in every language at once, call DELETE /whatsapp/accounts/{accountId}/templates?name={name} instead. The id is looked up under this account's business account first.

Authentication

Bearer <admin_api_key> — subject to the key's rate limit.

Response Schema

JSON type shapeExpand
{
  "deleted": "true",
  "account_id": "uuid",
  "id": "string | null",
  "name": "string",
  "language": "string | null"
}
Fields5Expand
deleted
true
account_id
uuid
id
string | null
the id deleted; null when deleting by name
name
string
language
string | null
null when deleting by name (every language went)

Code Examples

curl -X DELETE "$BASE/api/v1/projects/$PID/whatsapp/accounts/$ACCOUNT_ID/templates/$TEMPLATE_ID" \
  -H "Authorization: Bearer $ADMIN_KEY"
# → { "deleted": true, "account_id": "…", "id": "1011820805234855", "name": "order_update", "language": "es" }
POST/api/v1/projects/{projectId}/whatsapp/webhooks#

Register a webhook

Where inbound messages of the project's API-mode accounts are delivered, and optionally the delivery statuses of the messages you send. A hook may listen to every account of the project or to one account. It is a project webhook — same signature, retries and auto-disable rules as the budget hooks — and its secret is returned once, here.

Authentication

Bearer <admin_api_key>

Request Schema

JSON type shapeExpand
{
  "url": "https url",
  "account_id": "uuid | null",
  "events": "[\"whatsapp.message.received\", \"whatsapp.message.status\", \"whatsapp.template.status\", \"whatsapp.template.category\", \"whatsapp.template.quality\"]"
}
Fields3Expand
url
https urlrequired
public host; private and loopback addresses are refused
account_id
uuid | nulloptional
deliver only this account's events; omit or null for every account of the project
events
["whatsapp.message.received", "whatsapp.message.status", "whatsapp.template.status", "whatsapp.template.category", "whatsapp.template.quality"]optional
default ["whatsapp.message.received"]. The template events report WhatsApp's review outcome, a category change and a quality change of the account's message templates

Response Schema

JSON type shapeExpand
{
  "id": "uuid",
  "url": "string",
  "account_id": "uuid | null",
  "events": "string[]",
  "is_active": "boolean",
  "secret": "hex string",
  "created_at": "ISO timestamp",
  "updated_at": "ISO timestamp",
  "last_delivery_at": "ISO timestamp | null",
  "failure_count": "integer"
}
Fields10Expand
id
uuid
url
string
account_id
uuid | null
events
string[]
is_active
boolean
secret
hex string
shown ONCE; sign-check every delivery with it
created_at
ISO timestamp
updated_at
ISO timestamp
last_delivery_at
ISO timestamp | null
failure_count
integer
consecutive failed deliveries; the hook is disabled at 20

Code Examples

curl -X POST "$BASE/api/v1/projects/$PID/whatsapp/webhooks" \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://acme.example/whatsapp/inbound",
    "account_id": "'$ACCOUNT_ID'",
    "events": ["whatsapp.message.received", "whatsapp.message.status"]
  }'
# → 201 { "id": "…", "secret": "0f3a…", … }   ← store the secret now
GET/api/v1/projects/{projectId}/whatsapp/webhooks#

Manage webhooks

Lists the project's WhatsApp webhooks (budget hooks of the same project are not shown here, nor are these shown as WhatsApp hooks elsewhere). Each one is also reachable at /whatsapp/webhooks/{webhookId}: GET reads it, PUT changes url, events, account_id, is_active or rotates the secret, DELETE deactivates it, and POST /whatsapp/webhooks/{webhookId}/test sends a signed sample event with data.test = true so you can check your endpoint before real traffic.

Authentication

Bearer <admin_api_key>

Request Schema

JSON type shapeExpand
{
  "account_id": "uuid",
  "include_disabled": "true"
}
Fields2Expand
account_id
uuidoptional
query — the hooks that would fire for this account: its own and the project-wide ones
include_disabled
trueoptional
query

Response Schema

JSON type shapeExpand
{
  "data[]": "the webhook objects, without secrets"
}
Fields1Expand
data[]
the webhook objects, without secrets

Code Examples

curl -X PUT "$BASE/api/v1/projects/$PID/whatsapp/webhooks/$WEBHOOK_ID" \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "rotate_secret": true }'
# → 200 { …, "secret": "new hex" }
GET/api/v1/projects/{projectId}/whatsapp/traffic#

Traffic of a project

Messages per day on one project — received, sent and failed — zero-filled over the window, plus how many people wrote and how many numbers are active. One grouped query whatever the volume; the Partner Console draws its WhatsApp chart from it.

Authentication

Bearer Admin API key — reseller accounts only.

Request Schema

JSON type shapeExpand
{
  "from": "string",
  "to": "string",
  "group_by": "string",
  "account_id": "string"
}
Fields4Expand
from
stringoptional
ISO datetime or YYYY-MM-DD. Defaults to 30 days before to.
to
stringoptional
ISO datetime or YYYY-MM-DD (a bare date is inclusive of its whole day). Defaults to now. The window may not exceed 90 days.
group_by
stringoptional
"day" (default) or "account". "account" adds a per-number breakdown.
account_id
stringoptional
Narrow the report to one number.

Response Schema

JSON type shapeExpand
{
  "project_id": "string",
  "window": {
    "from": "string",
    "to": "string"
  },
  "group_by": "string",
  "totals": {
    "inbound": "integer",
    "outbound": "integer",
    "total": "integer",
    "failed": "integer",
    "active_conversations": "integer",
    "accounts": "integer"
  },
  "series": [
    {
      "bucket": "string",
      "inbound": "integer",
      "outbound": "integer",
      "total": "integer",
      "failed": "integer"
    }
  ],
  "by_account": [
    {
      "account_id": "string",
      "project_id": "string",
      "name": "string | null",
      "display_phone_number": "string | null",
      "inbound": "integer",
      "outbound": "integer",
      "total": "integer",
      "failed": "integer"
    }
  ]
}
Fields27Expand
project_id
string
The project.
window
object
object — fields below
from
string
ISO
to
string
ISO
group_by
string
What was asked for.
totals
object
object — fields below
inbound
integer
Messages received in the window.
outbound
integer
Messages sent (through the API or by the assistant).
total
integer
failed
integer
Messages whose status is failed.
active_conversations
integer
People who wrote in the window.
accounts
integer
Active numbers on the project.
series
array
array — fields below
bucket
string
YYYY-MM-DD (UTC)
inbound
integer
outbound
integer
total
integer
failed
integer
by_account
array
array — fields below
account_id
string
project_id
string
name
string | null
display_phone_number
string | null
inbound
integer
outbound
integer
total
integer
failed
integer

Code Examples

curl "$BASE/api/v1/projects/$PID/whatsapp/traffic?from=2026-08-01&to=2026-08-31&group_by=account" \
  -H "Authorization: Bearer $API_KEY"
GET/api/v1/whatsapp/accounts#

Every number, all projects

Every WhatsApp number connected to any of your projects, in one paged list with its project, its mode and how many people wrote to it. The account-wide counterpart of the per-project list — one call for a thousand projects, never one per project.

Authentication

Bearer Admin API key — reseller accounts only.

Request Schema

JSON type shapeExpand
{
  "mode": "string",
  "include_inactive": "boolean",
  "project_ids": "string",
  "limit": "integer",
  "offset": "integer"
}
Fields5Expand
mode
stringoptional
"api" or "assistant".
include_inactive
booleanoptional
Include deactivated numbers.
project_ids
stringoptional
Comma-separated project UUIDs (at most 1000) to narrow the list. Ids you do not own simply do not match.
limit
integeroptional
1..200, default 50.
offset
integeroptional
default 0.

Response Schema

JSON type shapeExpand
{
  "data": [
    {
      "id": "string",
      "project_id": "string",
      "name": "string",
      "mode": "string",
      "display_phone_number": "string | null",
      "verified_name": "string | null",
      "is_active": "boolean",
      "needs_reconnect": "boolean",
      "conversation_count": "integer",
      "last_inbound_at": "string | null",
      "webhook_count": "integer",
      "project": {
        "id": "string",
        "name": "string | null"
      }
    }
  ],
  "page": {
    "limit": "integer",
    "offset": "integer",
    "has_more": "boolean"
  }
}
Fields19Expand
data
array
array — fields below
id
string
project_id
string
name
string
mode
string
"api" | "assistant"
display_phone_number
string | null
verified_name
string | null
is_active
boolean
needs_reconnect
boolean
conversation_count
integer
last_inbound_at
string | null
webhook_count
integer
Active whatsapp.* hooks that would fire for this number.
project
object
object — fields below
id
string
name
string | null
page
object
object — fields below
limit
integer
offset
integer
has_more
boolean

Code Examples

curl "$BASE/api/v1/whatsapp/accounts?mode=api&limit=100" \
  -H "Authorization: Bearer $API_KEY"
PUT/api/v1/whatsapp/accounts#

Bulk mode / active

Set who answers — and whether a number is active — on every WhatsApp number of your projects in one call, or on a selection by project and/or by number. One transaction; one whatsapp.account.update audit row (marked bulk) per number whose stored value actually changes; a re-apply writes nothing.

Authentication

Bearer Admin API key — reseller accounts only.

Request Schema

JSON type shapeExpand
{
  "mode": "string",
  "is_active": "boolean",
  "project_ids": "string[]",
  "account_ids": "string[]"
}
Fields4Expand
mode
stringoptional
"api" or "assistant". At least one of mode / is_active is required.
is_active
booleanoptional
project_ids
string[]optional
1..1000 project UUIDs. Absent = every non-deleted project of the account.
account_ids
string[]optional
1..1000 number ids, further narrowing the selection.

Response Schema

JSON type shapeExpand
{
  "matched": "integer",
  "updated": "integer",
  "mode": "string",
  "is_active": "boolean"
}
Fields4Expand
matched
integer
Numbers in scope.
updated
integer
Numbers whose stored value changed.
mode
stringoptional
Echoed when sent.
is_active
booleanoptional
Echoed when sent.

Code Examples

curl -X PUT "$BASE/api/v1/whatsapp/accounts" \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{ "mode": "api", "project_ids": ["$PID"] }'
GET/api/v1/whatsapp/traffic#

Traffic, all projects

The same report as the per-project traffic, across every project you own (or a project_ids selection), with an optional per-project breakdown. Every figure comes from a grouped query on the message index — never from message rows — so it costs the same for one project as for a thousand.

Authentication

Bearer Admin API key — reseller accounts only.

Request Schema

JSON type shapeExpand
{
  "from": "string",
  "to": "string",
  "group_by": "string",
  "project_ids": "string",
  "account_id": "string"
}
Fields5Expand
from
stringoptional
ISO datetime or YYYY-MM-DD. Defaults to 30 days before to.
to
stringoptional
ISO datetime or YYYY-MM-DD. Defaults to now. At most 90 days after from.
group_by
stringoptional
"day" (default), "project" or "account".
project_ids
stringoptional
Comma-separated project UUIDs (at most 1000).
account_id
stringoptional
Narrow the report to one number.

Response Schema

JSON type shapeExpand
{
  "project_id": "null",
  "project_count": "integer",
  "window": {
    "from": "string",
    "to": "string"
  },
  "group_by": "string",
  "totals": {
    "inbound": "integer",
    "outbound": "integer",
    "total": "integer",
    "failed": "integer",
    "active_conversations": "integer",
    "accounts": "integer"
  },
  "series": [
    {
      "bucket": "string",
      "inbound": "integer",
      "outbound": "integer",
      "total": "integer",
      "failed": "integer"
    }
  ],
  "by_project": [
    {
      "project_id": "string",
      "name": "string | null",
      "inbound": "integer",
      "outbound": "integer",
      "total": "integer",
      "failed": "integer"
    }
  ],
  "by_account": [
    {
      "account_id": "string",
      "project_id": "string",
      "name": "string | null",
      "display_phone_number": "string | null",
      "inbound": "integer",
      "outbound": "integer",
      "total": "integer",
      "failed": "integer"
    }
  ]
}
Fields35Expand
project_id
null
project_count
integer
Projects in scope.
window
object
object — fields below
from
string
ISO
to
string
ISO
group_by
string
totals
object
object — fields below
inbound
integer
outbound
integer
total
integer
failed
integer
active_conversations
integer
accounts
integer
series
array
array — fields below
bucket
string
YYYY-MM-DD (UTC)
inbound
integer
outbound
integer
total
integer
failed
integer
by_project
array
array — fields below
project_id
string
name
string | null
inbound
integer
outbound
integer
total
integer
failed
integer
by_account
array
array — fields below
account_id
string
project_id
string
name
string | null
display_phone_number
string | null
inbound
integer
outbound
integer
total
integer
failed
integer

Code Examples

curl "$BASE/api/v1/whatsapp/traffic?group_by=project" \
  -H "Authorization: Bearer $API_KEY"
POST/api/v1/whatsapp/webhooks#

Webhook on every project

Register one webhook URL on every project you own (or a project_ids selection) in a single call. One secret is minted for the whole batch and returned once, so an application that receives every customer's messages at one endpoint verifies every delivery with one key. A project that already has an active project-wide hook on that URL is left alone and reported as reused.

Authentication

Bearer Admin API key — reseller accounts only.

Request Schema

JSON type shapeExpand
{
  "url": "string",
  "events": "string[]",
  "project_ids": "string[]"
}
Fields3Expand
url
stringrequired
https URL; private, loopback and link-local addresses are refused.
events
string[]optional
whatsapp.message.received (default) and/or whatsapp.message.status.
project_ids
string[]optional
1..1000 project UUIDs. Absent = every non-deleted project of the account.

Response Schema

JSON type shapeExpand
{
  "secret": "string | null",
  "created": "integer",
  "reused": "integer",
  "webhooks": [
    {
      "id": "string",
      "project_id": "string",
      "url": "string",
      "events": "string[]",
      "is_active": "boolean",
      "reused": "boolean",
      "created_at": "string"
    }
  ]
}
Fields11Expand
secret
string | null
The one secret shared by every hook created — shown ONCE. null when nothing was created.
created
integer
reused
integer
webhooks
array
array — fields below
id
string
project_id
string
url
string
events
string[]
is_active
boolean
reused
boolean
created_at
string

Code Examples

curl -X POST "$BASE/api/v1/whatsapp/webhooks" \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{ "url": "https://acme.example/whatsapp/inbound", "events": ["whatsapp.message.received", "whatsapp.message.status"] }'
Guide

Webhook payload & signature

Every delivery is a JSON POST with the headers X-Ainvented-Event, X-Ainvented-Delivery (the event id) and X-Ainvented-Signature: sha256= followed by the hex HMAC-SHA256 of the raw body under your webhook's secret. Verify it before trusting a payload. Answer with any 2xx within 8 seconds; anything else is retried once, 2 seconds later, and the message is marked webhook.status = "failed" for you to read back. Deliveries are at-least-once and idempotent on the message id, so a duplicate event carries the same data.message.id.

Delivery
POST https://acme.example/whatsapp/inbound
X-Ainvented-Event: whatsapp.message.received
X-Ainvented-Delivery: 3f6c1a2e-…
X-Ainvented-Signature: sha256=9b1f…

{
  "id": "3f6c1a2e-…",
  "event": "whatsapp.message.received",
  "occurred_at": "2026-09-03T10:15:02.114Z",
  "project_id": "…",
  "data": {
    "message": {
      "id": "…", "account_id": "…", "conversation_id": "…",
      "direction": "inbound", "wa_message_id": "wamid.…",
      "from": "34600111222", "to": "15551111111",
      "type": "image", "text": "is this the blue one?",
      "media": { "id": "1042…", "mime_type": "image/jpeg", "sha256": "…" },
      "status": "received", "error": null,
      "sent_at": "2026-09-03T10:15:00.000Z", "delivered_at": null, "read_at": null,
      "webhook": { "status": "pending", "delivered_at": null },
      "api_key_id": null, "created_at": "2026-09-03T10:15:02.010Z",
      "referral": null,
      "payload": { "from": "34600111222", "id": "wamid.…", "timestamp": "1788776100", "type": "image",
                   "image": { "id": "1042…", "mime_type": "image/jpeg", "sha256": "…", "caption": "is this the blue one?" } }
    },
    "account": { "id": "…", "name": "Acme Support", "display_phone_number": "+1 555 111 1111", "phone_number_id": "…" },
    "conversation": { "id": "…", "phone": "34600111222", "profile_name": "Ana", "inbound_count": 3, "outbound_count": 2, "…": "…" }
  }
}

data.message.payload is the message exactly as WhatsApp delivered it (since 2026-09-16, on every event — the same object include_payload=true returns). The first-class fields summarise; the payload carries the whole content of the types that do not fit a text: the phones, emails and organisation of a contacts card, the coordinates of a location, the id of the button or row pressed in an interactive reply (payload.interactive.button_reply.id / list_reply.id), a button tapped on a template, a reaction with the id of the message it reacts to, an order with its items — and any field WhatsApp adds later, without waiting for us to model it. data.message.referral is the ad or post the customer came from, or null.

A whatsapp.message.status event carries data.status (sent, delivered, read or failed) and the outbound data.message it refers to, already updated.

The template events carry data.template (id, name, language) and data.account (with its business_account_id). whatsapp.template.status adds data.status (APPROVED, REJECTED, PAUSED, DISABLED, PENDING_DELETION…), data.reason and, on a pause or a disable, data.info and data.disabled_at; whatsapp.template.category adds previous_category, new_category and correct_category; whatsapp.template.quality adds previous_quality_score and new_quality_score. Deliveries are idempotent on the template, the new state and the moment WhatsApp reported it. Follow any of them up with GET /whatsapp/accounts/{accountId}/templates/{templateId}.

Template review outcome
{
  "id": "8a1d…", "event": "whatsapp.template.status", "occurred_at": "2026-09-10T18:43:36.000Z", "project_id": "…",
  "data": {
    "template": { "id": "1011820805234855", "name": "order_update", "language": "es" },
    "status": "APPROVED", "reason": null, "disabled_at": null, "info": null,
    "account": { "id": "…", "name": "Acme Support", "display_phone_number": "+1 555 111 1111", "phone_number_id": "…", "business_account_id": "…" }
  }
}
Node — verify a delivery
# Node — verify a delivery
const crypto = require('crypto');
const expected = 'sha256=' + crypto.createHmac('sha256', WEBHOOK_SECRET).update(rawBody).digest('hex');
const ok = expected.length === signature.length &&
  crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
Guide

What your customer goes through

The page you send them is hosted by Ainvented and needs no account. They sign in with Meta, choose or create a WhatsApp Business account, and verify a phone number. Meta asks them for a payment method: as a technology provider there is no credit line to share, so WhatsApp bills each customer directly for their own message usage. If the link carries their number, the page shows it and goes straight to Meta; otherwise it asks them for the number first (recommended, not required).

Limits worth designing around

  • Give us the number. Meta tells us an account was shared, not by whom. With phone_number on the link, the share is matched to it exactly — the connection completes even if your customer closes our page before it finishes, however many customers are connecting at the same time. Without it, only the page they keep open can finish the link, and they may be asked for the last digits of their number.
  • The number must be free. A phone number already registered on the WhatsApp Business API cannot be onboarded, and your customer must be able to receive Meta's verification code on it.
  • One number, one project. A number already active on a different project is refused with phone_number_in_use.
  • Reconnecting is the same call. If the same number comes back to the same project, the existing connector is refreshed in place rather than duplicated.
  • The access token does not expire, so your customers are never asked to reconnect on a schedule.
  • 24-hour window. WhatsApp only allows free-form replies within 24 hours of the end user's message. An assistant answers immediately so this rarely bites it; in API mode, a reply that comes later than that is refused with whatsapp_send_failed (code 131047) and only a template reopens the conversation.
  • Templates are managed here too. Create them with POST /whatsapp/accounts/{accountId}/templates, learn the review outcome from the whatsapp.template.status event, then send them by name with type: "template". An account connected by hand needs its business_account_id set once (PUT /whatsapp/accounts/{accountId}) before its templates can be managed.
Reference · 3 endpoints

Project API keys

Each project may have multiple API keys, each scoped to that project. Keys are hashed with Argon2id; only the last 4 characters (partialKey) are stored for display. The full key is returned exactly once at creation.

POST/api/v1/projects/{projectId}/api-keys#

Create an API key under a project

Creates a new ApiKey row scoped to the project. Returns the full key once.

Authentication

Bearer <api_key> — must own the project.

Request Schema

JSON type shapeExpand
{
  "label": "string"
}
Fields1Expand
label
stringrequired
1..100, trimmed

Response Schema

JSON type shapeExpand
{
  "id": "uuid",
  "key": "full key",
  "partialKey": "last 4 chars",
  "label": "string",
  "projectId": "uuid",
  "createdAt": "ISO"
}
Fields6Expand
id
uuid
key
full key
returned only once
partialKey
last 4 chars
label
string
projectId
uuid
createdAt
ISO

Code Examples

curl -X POST $BASE/api/v1/projects/$PID/api-keys \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "label": "prod-server-1" }'
GET/api/v1/projects/{projectId}/api-keys#

List API keys for a project

Returns active keys by default. Append ?include_revoked=true to also see revoked keys.

Authentication

Bearer <api_key> — must own the project.

Response Schema

JSON type shapeExpand
{
  "keys": [
    {
      "id": "uuid",
      "label": "string",
      "partialKey": "last 4 chars",
      "projectId": "uuid",
      "createdAt": "ISO",
      "lastUsedAt": "ISO | null",
      "revokedAt": "ISO | null",
      "requestCount": "number",
      "tokenUsage": "string (BigInt as string)"
    }
  ],
  "total": "number"
}
Fields11Expand
keys
array
array — fields below
id
uuid
label
string
partialKey
last 4 chars
projectId
uuid
createdAt
ISO
lastUsedAt
ISO | null
revokedAt
ISO | null
requestCount
number
tokenUsage
string (BigInt as string)
total
number

Code Examples

curl "$BASE/api/v1/projects/$PID/api-keys?include_revoked=true" -H "Authorization: Bearer $API_KEY"
DELETE/api/v1/projects/{projectId}/api-keys/{keyId}#

Revoke an API key

Sets revokedAt on the key. Subsequent calls using that key return 401. Already-revoked → 410. Cross-project mismatch → 404. Usage history is retained.

Authentication

Bearer <api_key> — must own the project.

Code Examples

curl -X DELETE $BASE/api/v1/projects/$PID/api-keys/$KEY_ID -H "Authorization: Bearer $API_KEY"
Reference · 1 endpoint

Consumption

Aggregates token + cost usage across your project. Each billable sub-call (completion, workflow, skill, assistant, connector) is recorded as its own usage event, so the reported totals always match what your project actually consumed.

GET/api/v1/projects/{projectId}/consumption#

Read consumption breakdown

Returns a window of token + cost usage with a per-component breakdown and a time series. Window may not exceed 90 days. Defaults to the last 30 days UTC.

Authentication

Bearer <api_key> — must own the project.

Request Schema

JSON type shapeExpand
{
  "from": "ISO timestamp",
  "to": "ISO timestamp",
  "group_by": "\"day\" | \"component\" | \"model\"",
  "component_type": "\"completion\" | \"workflow\" | \"skill\" | \"assistant\" | \"connector\"",
  "model": "\"ainvented-default\" | \"ainvented-low\" | \"ainvented-saga\" | \"ainvented-epic\" | \"(none)\""
}
Fields5Expand
from
ISO timestampoptional
default now-30d
to
ISO timestampoptional
default now
group_by
"day" | "component" | "model"optional
default "day"
component_type
"completion" | "workflow" | "skill" | "assistant" | "connector"optional
filters the whole response to one component
model
"ainvented-default" | "ainvented-low" | "ainvented-saga" | "ainvented-epic" | "(none)"optional
filters the whole response to one model

Response Schema

JSON type shapeExpand
{
  "project_id": "uuid",
  "currency": "string",
  "budget_cents": "number",
  "consumed_cents": "number",
  "remaining_cents": "number",
  "remaining_pct": "number",
  "budget_micros": "number",
  "consumed_micros": "number",
  "remaining_micros": "number",
  "exhausted": "boolean",
  "hard_cap": "boolean",
  "alert_thresholds": "number[]",
  "fired_thresholds": "number[]",
  "token_limit": "number",
  "consumed_tokens": "number",
  "remaining_tokens": "number",
  "remaining_tokens_pct": "number",
  "token_hard_cap": "boolean",
  "fired_token_thresholds": "number[]",
  "window": {
    "from": "ISO",
    "to": "ISO"
  },
  "totals": {
    "prompt_tokens": "number",
    "completion_tokens": "number",
    "total_tokens": "number",
    "cached_tokens": "number",
    "cost_cents": "number",
    "cost_micros": "number"
  },
  "by_component": {
    "completion": {
      "events": "number",
      "prompt_tokens": "number",
      "completion_tokens": "number",
      "cached_tokens": "number",
      "total_tokens": "number",
      "cost_cents": "number"
    },
    "workflow": "...same shape...",
    "skill": "...",
    "assistant": "...",
    "connector": "..."
  },
  "tokens_by_component": {
    "completion": "number",
    "workflow": "number",
    "skill": "number",
    "assistant": "number",
    "connector": "number"
  },
  "by_model": {
    "ainvented-default": {
      "events": "number",
      "prompt_tokens": "number",
      "completion_tokens": "number",
      "cached_tokens": "number",
      "total_tokens": "number",
      "cost_cents": "number",
      "cost_micros": "number"
    },
    "ainvented-low": "...same shape...",
    "(none)": "...auxiliary calls with no billed model..."
  },
  "series": [
    {
      "bucket": "string",
      "prompt_tokens": "number",
      "completion_tokens": "number",
      "cached_tokens": "number",
      "total_tokens": "number",
      "cost_cents": "number",
      "cost_micros": "number"
    }
  ]
}
Fields66Expand
project_id
uuid
currency
string
budget_cents
number
consumed_cents
number
remaining_cents
number
remaining_pct
number
budget_micros
number
exact budget (10000 micros = 1 cent)
consumed_micros
number
EXACT consumption; consumed_cents is floor(consumed_micros/10000)
remaining_micros
number
exact remaining (clamped ≥ 0)
exhausted
boolean
true when the hard-cap budget is fully spent (matches the 402 gate)
hard_cap
boolean
alert_thresholds
number[]
fired_thresholds
number[]
token_limit
number
consumed_tokens
number
remaining_tokens
number
remaining_tokens_pct
number
token_hard_cap
boolean
fired_token_thresholds
number[]
window
object
object — fields below
from
ISO
to
ISO
totals
object
object — fields below
prompt_tokens
number
input tokens
completion_tokens
number
output tokens
total_tokens
number
cached_tokens
number
input tokens served from cache (a subset of prompt_tokens, billed at the discounted cached rate)
cost_cents
number
cost_micros
number
EXACT cost; cost_cents is floor(cost_micros/10000)
by_component
object
object — fields below
completion
object
object — fields below
events
number
prompt_tokens
number
input
completion_tokens
number
output
cached_tokens
number
cache-served input (subset of prompt_tokens)
total_tokens
number
cost_cents
number
workflow
...same shape...
skill
...
assistant
...
connector
...
tokens_by_component
object
object — fields below
completion
number
total tokens for completion events in the window
workflow
number
skill
number
assistant
number
connector
number
by_model
object
object — fields below
ainvented-default
object
object — fields below
events
number
prompt_tokens
number
input tokens for this model
completion_tokens
number
output tokens for this model
cached_tokens
number
cache-served input for this model
total_tokens
number
cost_cents
number
cost_micros
number
exact cost for this model
ainvented-low
...same shape...
(none)
...auxiliary calls with no billed model...
series
array
array — fields below
bucket
string
prompt_tokens
number
input
completion_tokens
number
output
cached_tokens
number
cache-served input
total_tokens
number
cost_cents
number
cost_micros
number
exact cost for the bucket

Code Examples

Last 7 days, grouped by component, only workflow events.

FROM=$(node -p "new Date(Date.now() - 7*864e5).toISOString()")
curl "$BASE/api/v1/projects/$PID/consumption?from=$FROM&group_by=component&component_type=workflow" \
  -H "Authorization: Bearer $API_KEY"
GET/api/v1/projects/{projectId}/balance#

Read a project's authoritative balance

Single source of truth for 'does this key/project have money?'. Money is reported at floored-cent precision (budget_cents/consumed_cents/remaining_cents, backward-compatible) AND exact micro precision (budget_micros/consumed_micros/remaining_micros; 10000 micros = 1 cent). The `exhausted` flag is computed from the SAME comparison the request-time gate uses, so polling this never disagrees with the 402 a key actually receives. IMPORTANT: a project-scoped hard-cap key spends ONLY this project budget — it never falls back to the user balance. Not admin-gated: a project-scoped key may read its own balance.

Authentication

Bearer <api_key> — must own the project.

Response Schema

JSON type shapeExpand
{
  "project_id": "uuid",
  "budget_cents": "number",
  "consumed_cents": "number",
  "remaining_cents": "number",
  "budget_micros": "number",
  "consumed_micros": "number",
  "remaining_micros": "number",
  "exhausted": "boolean",
  "remaining_pct": "number",
  "currency": "string",
  "hard_cap": "boolean",
  "token_limit": "number",
  "consumed_tokens": "number",
  "remaining_tokens": "number",
  "token_hard_cap": "boolean"
}
Fields15Expand
project_id
uuid
budget_cents
number
consumed_cents
number
floor(consumed_micros/10000)
remaining_cents
number
budget_micros
number
consumed_micros
number
exact
remaining_micros
number
exhausted
boolean
true when consumed_micros >= budget_micros for a hard cap
remaining_pct
number
currency
string
hard_cap
boolean
token_limit
number
consumed_tokens
number
remaining_tokens
number
token_hard_cap
boolean

Code Examples

Check whether a project-scoped key still has budget before relying on it.

curl "$BASE/api/v1/projects/$PID/balance" -H "Authorization: Bearer $API_KEY"
Reference · 3 endpoints

Promotions

Promo codes and referral share links. GET /api/promotions/resolve is fully public (it powers the /promo/<slug> landing pages) and returns a display-safe offer summary. Redeeming a code and reading your referral stats are browser-session endpoints: they authenticate with the session cookie of your signed-in ainvented account — not available with API keys. Money fields follow the platform conventions: offer credit amounts are USD dollars; caps, minimums and commission totals are integer cents.

GET/api/promotions/resolve#

Resolve a promo code or share-link slug

Resolves a promo code or a share-link landing slug to a display-safe offer summary. Lookups always answer HTTP 200 with { valid, reason, offer } so a landing page can render the outcome honestly — including why an offer is not redeemable ('not_found' | 'inactive' | 'not_started' | 'expired' | 'fully_redeemed'). offer is null only when reason is 'not_found'. The only 400 is omitting both query parameters. Rate-limited per IP (30 requests/minute).

Authentication

None — public endpoint. No API key and no session required.

Request Schema

JSON type shapeExpand
{
  "code": "string",
  "slug": "string"
}
Fields2Expand
code
stringoptional
Query parameter. The promo code to resolve (case-insensitive). One of code or slug is required — omitting both returns 400 invalid_request.
slug
stringoptional
Query parameter. The share-link landing slug to resolve (compared lowercased). When both code and slug are provided, slug wins.

Response Schema

JSON type shapeExpand
{
  "valid": "boolean",
  "reason": "string | null",
  "offer": {
    "code": "string",
    "offerKind": "string",
    "creditAmount": "number | null",
    "discountPercent": "number | null",
    "discountCapCents": "integer | null",
    "minTopupCents": "integer | null",
    "slug": "string | null",
    "appliesAtSignup": "boolean",
    "appliesAtReload": "boolean",
    "expiresAt": "string | null"
  }
}
Fields13Expand
valid
booleanrequired
true when the offer exists and is currently redeemable.
reason
string | nullrequired
Why the offer is NOT redeemable: 'not_found' | 'inactive' | 'not_started' | 'expired' | 'fully_redeemed'. null when valid is true.
offer
object
object — fields below
code
stringrequired
The canonical promo code.
offerKind
stringrequired
'credit' (adds balance instantly at redemption) | 'reload_discount' (a bonus applied to a future qualifying top-up).
creditAmount
number | nullrequired
USD dollars credited on redemption for a 'credit' offer; null for other kinds.
discountPercent
number | nullrequired
Bonus percent applied to a qualifying top-up for a 'reload_discount' offer; null for 'credit' offers.
discountCapCents
integer | nullrequired
Upper bound of the reload bonus in integer cents; null for 'credit' offers.
minTopupCents
integer | nullrequired
Minimum top-up in integer cents before the offer applies; null when there is no minimum.
slug
string | nullrequired
Landing-page slug — the offer's shareable page is /promo/<slug>. null when the offer has no landing page.
appliesAtSignup
booleanrequired
Whether the offer applies when a new user signs up with the code.
appliesAtReload
booleanrequired
Whether the offer applies at reload (top-up) time.
expiresAt
string | nullrequired
ISO timestamp after which the offer expires; null when it never expires.

Code Examples

Resolve a promo code (case-insensitive).

curl "$BASE/api/promotions/resolve?code=WELCOME10"

# Response — 200 OK
# {
#   "valid": true,
#   "reason": null,
#   "offer": {
#     "code": "WELCOME10",
#     "offerKind": "credit",
#     "creditAmount": 10,
#     "discountPercent": null,
#     "discountCapCents": null,
#     "minTopupCents": null,
#     "slug": "welcome",
#     "appliesAtSignup": true,
#     "appliesAtReload": true,
#     "expiresAt": null
#   }
# }
POST/api/billing/redeem-promo#

Redeem a promo code

Redeems a promo code for the signed-in user. A 'credit' offer credits your USD balance instantly; a 'reload_discount' offer records a claim that is applied automatically to your next qualifying top-up. Each user can redeem a given code at most once, and redemption caps are enforced atomically. Same-origin requests only (CSRF protection), rate-limited to 10 attempts/minute per user.

Authentication

Session cookie required (browser session) — not available with API keys.

Request Schema

JSON type shapeExpand
{
  "code": "string"
}
Fields1Expand
code
stringrequired
The promo code to redeem. Minimum 4 characters; letters, digits and hyphens only; case-insensitive.

Response Schema

JSON type shapeExpand
{
  "success": "boolean",
  "offerKind": "string",
  "creditAmount": "string",
  "newBalance": "string",
  "discountPercent": "string | null",
  "message": "string"
}
Fields6Expand
success
booleanrequired
true when the redemption (or discount claim) was recorded.
offerKind
stringrequired
'credit' | 'reload_discount'.
creditAmount
stringoptional
USD dollars credited, as a decimal string. Present only for 'credit' offers.
newBalance
stringoptional
Your updated USD balance, as a decimal string. Present only for 'credit' offers.
discountPercent
string | nulloptional
The claimed bonus percent, as a decimal string. Present only for 'reload_discount' offers.
message
stringoptional
Human-readable confirmation. Present only for 'reload_discount' offers.

Code Examples

Redeem a credit or reload-discount code with your browser session.

# Runs with your signed-in browser session cookie (see Authentication above).
curl -X POST $BASE/api/billing/redeem-promo \
  -H "Content-Type: application/json" \
  -b "authjs.session-token=<your browser session cookie>" \
  -d '{ "code": "WELCOME10" }'

# Response — 200 OK (credit offer: balance credited instantly)
# { "success": true, "offerKind": "credit", "creditAmount": "10", "newBalance": "12.50" }

# Response — 200 OK (reload_discount offer: claim saved, applied on your next top-up)
# {
#   "success": true,
#   "offerKind": "reload_discount",
#   "discountPercent": "20",
#   "message": "Discount saved — it will apply to your next reload."
# }
GET/api/referrals/me#

Your referral stats & share link

Returns the signed-in user's referral summary for the Share & Earn view: whether you are enrolled as a promoter, your shareable link, how many users you have referred, and your commission totals in integer cents. Enrollment is automatic when the Share & Earn program is enabled (your first call provisions your link); otherwise it is admin-managed. Non-enrolled users receive asPromoter:false with zeroed totals — never an error. The response shape is identical in every case.

Authentication

Session cookie required (browser session) — not available with API keys.

Response Schema

JSON type shapeExpand
{
  "data": {
    "asPromoter": "boolean",
    "shareUrl": "string | null",
    "referredCount": "number",
    "commission": {
      "accruedCashCents": "integer",
      "paidCashCents": "integer",
      "autoCreditedCents": "integer",
      "currency": "string"
    }
  },
  "generatedAt": "string"
}
Fields10Expand
data
object
object — fields below
asPromoter
booleanrequired
true when you are enrolled as a promoter. Enrollment is admin-managed — when false every other field is its zero/null state.
shareUrl
string | nullrequired
Your shareable referral link: a /promo/<slug> landing page when a landing-page offer exists, otherwise a tracked /r/<id> redirect that forwards visitors to signup. null when not enrolled.
referredCount
numberrequired
Users attributed to you. Attribution is first-wins: a user counts for at most one referrer, forever.
commission
object
object — fields below
accruedCashCents
integerrequired
Commission earned and owed to you, awaiting cash payout. Integer cents.
paidCashCents
integerrequired
Commission already paid out to you in cash. Integer cents.
autoCreditedCents
integerrequired
Commission automatically credited to your platform balance. Integer cents.
currency
stringrequired
Always 'USD'.
generatedAt
stringrequired
ISO timestamp of when the response was generated.

Code Examples

Read your own referral stats (enrolled and not-enrolled shapes shown).

# Runs with your signed-in browser session cookie (see Authentication above).
curl "$BASE/api/referrals/me" \
  -b "authjs.session-token=<your browser session cookie>"

# Response — 200 OK (enrolled promoter)
# {
#   "data": {
#     "asPromoter": true,
#     "shareUrl": "https://ainvented.com/r/9c1f4c1e-0000-4000-8000-000000000000",
#     "referredCount": 3,
#     "commission": {
#       "accruedCashCents": 1500,
#       "paidCashCents": 200,
#       "autoCreditedCents": 0,
#       "currency": "USD"
#     }
#   },
#   "generatedAt": "2026-07-01T12:00:00.000Z"
# }

# Response — 200 OK (not enrolled)
# {
#   "data": { "asPromoter": false, "shareUrl": null, "referredCount": 0,
#             "commission": { "accruedCashCents": 0, "paidCashCents": 0,
#                             "autoCreditedCents": 0, "currency": "USD" } },
#   "generatedAt": "2026-07-01T12:00:00.000Z"
# }
Reference · 4 endpoints

Vector Stores

Use Ainvented as a vector store. A store is a named collection of documents in your project's vector space. Ingest documents (text, URL, or file) — they are chunked and embedded — then run semantic search, optionally with a RAG-generated answer.

Node.js client (example)

Node 18+ can call the REST endpoints directly with its built-in fetch. This complete example creates a store, adds reusable text, checks every HTTP response, and runs a semantic query—no extra client library required:

quickstart.mjs — Node 18+, create → ingest → search
const headers = {
  Authorization: `Bearer ${process.env.API_KEY}`,
  'Content-Type': 'application/json',
}

async function request(path, options = {}) {
  const response = await fetch(`https://ainvented.com${path}`, { headers, ...options })
  if (!response.ok) throw new Error(`Ainvented API ${response.status}: ${await response.text()}`)
  return response.json()
}

const store = await request('/api/v1/vector-stores', {
  method: 'POST', body: JSON.stringify({ name: 'product-docs' }),
})
await request(`/api/v1/vector-stores/${store.id}/documents`, {
  method: 'POST', body: JSON.stringify({ text: 'Ainvented can store reusable knowledge.' }),
})
const result = await request(`/api/v1/vector-stores/${store.id}/query`, {
  method: 'POST', body: JSON.stringify({ query: 'How does it work?', top_k: 5, answer: true }),
})
console.log(result.answer, result.results)
POST/api/v1/vector-stores#

Create / list / get stores

Create a store (local, zero-cost — reserves a tag). GET /v1/vector-stores lists; GET /v1/vector-stores/{id} retrieves; POST /v1/vector-stores/{id} updates name/description; all are local.

Authentication

Bearer <api_key>

Request Schema

JSON type shapeExpand
{
  "name": "string",
  "description": "string",
  "project_id": "uuid"
}
Fields3Expand
name
stringrequired
1–200
description
stringoptional
≤500
project_id
uuidoptional
; ignored when the API key is project-scoped

Response Schema

JSON type shapeExpand
{
  "id": "string (cuid)",
  "name": "string",
  "description": "string|null",
  "tag": "string (vs:<id>)",
  "status": "\"active\" | \"archived\"",
  "project_id": "uuid",
  "document_count": "number",
  "created_at": "ISO",
  "updated_at": "ISO"
}
Fields9Expand
id
string (cuid)
name
string
description
string|null
tag
string (vs:<id>)
status
"active" | "archived"
project_id
uuid
document_count
number
created_at
ISO
updated_at
ISO

Code Examples

Create a store.

curl -X POST "$BASE/api/v1/vector-stores" \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"product-docs","description":"Docs for RAG"}'
POST/api/v1/vector-stores/{id}/documents#

Add / list documents

Add a document via JSON (text or url) or multipart (file ≤100MB). Raw text is stored as a .txt document. Embedding tokens are metered. Very large text-heavy files may take up to 10 minutes to embed. GET lists documents (?status=ready|pending|failed|archived|all).

Authentication

Bearer <api_key>

Request Schema

JSON type shapeExpand
{
  "text": "string",
  "url": "string (url)",
  "file (multipart)": "binary",
  "title": "string",
  "tags": "string[]",
  "chunk_size": "int",
  "chunk_overlap": "int",
  "metadata": "object",
  "generate_metadata": "boolean"
}
Fields9Expand
text
string
raw text to embed (one of text/url/file)
url
string (url)
fetch + embed remote content
file (multipart)
binary
documents, code/config, images, audio, or ANY extension (best-effort text), ≤100MB
title
stringoptional
≤300
tags
string[]optional
extra tags ≤20
chunk_size
intoptional
chunk_overlap
intoptional
metadata
objectoptional
key→value pairs applied to EVERY chunk of this document; filter on them later with query metadata_filter. JSON body field for text/url; multipart `metadata` form field (a JSON-object string) for file. Optional & backward-compatible — omit it and the request is unchanged.
generate_metadata
booleanoptional
default false — when true, an LLM extracts semantic metadata for the document (topics, summary, keywords, entities, language) and merges it into every chunk's filterable metadata. This makes an extra, fully-audited model call billed cost-plus alongside embeddings; omit/false adds no cost. Multipart: `generate_metadata=true` form field.

Response Schema

JSON type shapeExpand
{
  "id": "string",
  "store_id": "string",
  "kind": "\"text\"|\"url\"|\"file\"",
  "title": "string|null",
  "doc_uid": "string|null",
  "source": "string|null",
  "char_count": "number",
  "chunk_count": "number",
  "embed_tokens": "number",
  "status": "\"ready\"|\"failed\"",
  "error_code": "string|null",
  "created_at": "ISO"
}
Fields12Expand
id
string
store_id
string
kind
"text"|"url"|"file"
title
string|null
doc_uid
string|null
this document’s own identity, used as the delete selector. null on documents added before identities existed (those still delete by title/source).
source
string|null
char_count
number
chunk_count
number
embed_tokens
number
status
"ready"|"failed"
error_code
string|null
created_at
ISO

Code Examples

Embed a text blob.

curl -X POST "$BASE/api/v1/vector-stores/$SID/documents" \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"text":"Ainvented can be used as a vector store.","chunk_size":800,"chunk_overlap":100}'

Details

Per-chunk metadata (optional)

Attach arbitrary metadata (a JSON object of key→value pairs) when adding a document and it is stored on every chunk of that document. You can then narrow a search to matching chunks with the query metadata_filter (see below). Pass it as a JSON body field for text/url adds, or as a multipart metadata form field (a JSON-object string) for file uploads. A malformed or non-object multipart metadata value is silently ignored — never an error.

Supported file types

Uploads are chunked + embedded (text is extracted; audio is transcribed; images are embedded for visual search). Unknown extensions are ingested as best-effort plain text. Max 100 MB. Very large text-heavy files may take up to 10 minutes to embed.

  • 📄 Text & Office: .pdf .docx .pptx .xlsx .xls .csv .json .jsonl .txt .md
  • 💻 Code & config: .py .js .ts .tsx .jsx .java .cpp .c .h .cs .php .rb .go .rs .swift .kt .scala .r .sh .bash .sql .xml .yaml .yml .toml .ini .cfg .conf .css .scss .sass .less
  • 🖼️ Images: .png .jpg .jpeg .gif .webp .bmp .tiff .svg
  • 🔊 Audio: .mp3 .wav .m4a .ogg .flac .aac
  • 🎬 Video: .mp4 .mov .webm .mkv .avi .3gp .mpeg .flv .wmv — up to 50 MB (413 video_too_large above it). Read as a text record (summary, timestamped visual timeline with on-screen text, transcript) that is chunked and searchable like any document.
  • 🌐 Web: pass url (JSON body) for an HTTP/HTTPS page — text plus extracted images/audio. A link to a video file, or a public YouTube link, is read as a video.
POST/api/v1/vector-stores/{id}/query#

Query — semantic search (+ optional RAG answer)

Embeds the query and returns ranked chunks. Set answer:true to also generate a grounded answer from the retrieved chunks (a second, billable generation step). optimize_query:true adds a billable query rewrite.

Authentication

Bearer <api_key>

Request Schema

JSON type shapeExpand
{
  "query": "string",
  "top_k": "int",
  "threshold": "number",
  "optimize_query": "boolean",
  "tags": "string[]",
  "answer": "boolean (optional",
  "max_tokens": "int",
  "metadata_filter": "Array<{ key: string, op?: \"eq\"|\"neq\"|\"in\"|\"contains\"|\"gt\"|\"gte\"|\"lt\"|\"lte\"|\"exists\"|\"not_exists\", value?: any }> . AND-ed pre-filters applied BEFORE the semantic search: the matching chunks are selected first and ranked exactly by similarity, so a chunk that matches is never dropped by the approximate index",
  "text_contains": "string"
}
Fields9Expand
query
stringrequired
≤10000
top_k
intoptional
1–100
threshold
numberoptional
0–1 similarity cutoff; default 0: every chunk that survives the filters, ranked best first; raise it to drop weak matches
optimize_query
booleanoptional
; extra cost
tags
string[]optional
extra tag filter
answer
boolean (optional
generate a RAG answer)
max_tokens
intoptional
answer length cap
metadata_filter
Array<{ key: string, op?: "eq"|"neq"|"in"|"contains"|"gt"|"gte"|"lt"|"lte"|"exists"|"not_exists", value?: any }> . AND-ed pre-filters applied BEFORE the semantic search: the matching chunks are selected first and ranked exactly by similarity, so a chunk that matches is never dropped by the approximate indexoptional
≤20 — a document is filterable the moment it is "ready". op defaults to "eq"; "exists"/"not_exists" take no value; "in" takes an array; a boolean or number value compares as stored (true, 42). threshold defaults to 0, so a filtered query lists every matching chunk ranked by similarity; set it above 0 to cut weak matches. Optional & backward-compatible.
text_contains
stringoptional
≤500 — keep only chunks whose text contains this substring (applied before the semantic search). Optional & backward-compatible.

Response Schema

JSON type shapeExpand
{
  "results": "[{ text, title, source, score, metadata, ... }]",
  "count": "number",
  "answer": "string (only when answer:true)",
  "usage": {
    "total_tokens": "number"
  }
}
Fields5Expand
results
[{ text, title, source, score, metadata, ... }]
each chunk now carries the metadata it was ingested with (object, may be empty/absent)
count
number
answer
string (only when answer:true)
usage
object
object — fields below
total_tokens
number
the audited, billable count for the search (embedding, plus generation when answer:true); identical to what your consumption reports show for this call

Code Examples

Top-3 semantic search.

curl -X POST "$BASE/api/v1/vector-stores/$SID/query" \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"query":"How do I use Ainvented as a vector store?","top_k":3}'

Details

Map-reduce pre-filters (optional)

metadata_filter is an array of AND-ed predicates (≤20), each { key, op?, value? }. Supported op values: eq (default) neq in contains gt gte lt lte exists not_exists. exists and not_exists test for the presence/absence of the key and take no value; in takes an array of values. text_contains (≤500 chars) keeps only chunks whose text contains that substring. Both are applied before the semantic search (the “map” step), then the usual top_k/threshold ranking runs over what survives.

DELETE/api/v1/vector-stores/{id}#

Delete a store or a document

DELETE /v1/vector-stores/{id} deletes the store and removes its documents from search. DELETE /v1/vector-stores/{id}/documents/{docId} removes a single document — precisely that one, even when other documents share its filename.

Authentication

Bearer <api_key>

Response Schema

JSON type shapeExpand
{
  "id": "string",
  "status": "\"archived\"",
  "deleted": "true",
  "collateral_archived": "number (document delete only)"
}
Fields4Expand
id
string
status
"archived"
deleted
true
collateral_archived
number (document delete only)
how many OTHER documents were archived alongside this one. Always 0 for documents carrying a doc_uid. See below.

Code Examples

Archive a store.

curl -X DELETE "$BASE/api/v1/vector-stores/$SID" -H "Authorization: Bearer $API_KEY"

Details

Deleting one document among duplicates

Uploading the same file twice creates two documents, with the same title and source but different ids. Each carries its own doc_uid, and a delete matches on that — so removing one copy never touches the other, and collateral_archived comes back 0.

Cost & metering

Adding a document, searching, and generating an answer each consume tokens that are metered against your project's budget and token quota — one usage event per billable call, so your Consumption totals always match what you actually used. Local operations (create, rename, list, delete) are free.

Reference · 5 endpoints

Webhooks

Register HTTPS endpoints to receive notifications when a project crosses a budget threshold (50%, 80%, 95%, 100% by default), exhausts its budget, or — opt-in — on every recorded usage event. Deliveries are signed with HMAC-SHA256 over the raw body using the secret returned at registration. Each delivery is idempotent on a payload hash so retries do not duplicate. URLs must be https:// and cannot resolve to RFC1918 / loopback / link-local / multicast addresses.

POST/api/v1/projects/{projectId}/webhooks#

Register a webhook

Creates a webhook registration. The signing secret is returned ONCE in the response and is never readable again — store it securely.

Authentication

Bearer <api_key> — must own the project. Project must not be disabled.

Request Schema

JSON type shapeExpand
{
  "url": "string",
  "events": "(\"budget.threshold\" | \"budget.exhausted\" | \"tokens.threshold\" | \"tokens.exhausted\" | \"usage.recorded\")[]"
}
Fields2Expand
url
stringrequired
https://, no credentials, ≤2048 chars, public IP only
events
("budget.threshold" | "budget.exhausted" | "tokens.threshold" | "tokens.exhausted" | "usage.recorded")[]optional
default ["budget.threshold","budget.exhausted"]

Response Schema

JSON type shapeExpand
{
  "id": "uuid",
  "url": "string",
  "events": "string[]",
  "is_active": "boolean",
  "secret": "string",
  "created_at": "ISO"
}
Fields6Expand
id
uuid
url
string
events
string[]
is_active
boolean
secret
string
64 hex chars; returned only once
created_at
ISO

Code Examples

curl -X POST $BASE/api/v1/projects/$PID/webhooks \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://hooks.your-app.com/budget", "events": ["budget.threshold","budget.exhausted"] }'
GET/api/v1/projects/{projectId}/webhooks#

List webhooks

Lists the project's active webhooks. Secrets are NEVER returned by list.

Authentication

Bearer <api_key> — must own the project.

Response Schema

JSON type shapeExpand
{
  "data": [
    {
      "id": "uuid",
      "url": "string",
      "events": "string[]",
      "is_active": "boolean",
      "created_at": "ISO",
      "last_delivery_at": "ISO | null",
      "failure_count": "number"
    }
  ]
}
Fields8Expand
data
array
array — fields below
id
uuid
url
string
events
string[]
is_active
boolean
created_at
ISO
last_delivery_at
ISO | null
failure_count
number

Code Examples

curl $BASE/api/v1/projects/$PID/webhooks -H "Authorization: Bearer $API_KEY"
PUT/api/v1/projects/{projectId}/webhooks/{webhookId}#

Update a webhook (incl. rotate secret)

Patches url, events, or is_active. Pass rotate_secret: true to issue a new signing secret — the new secret is returned ONCE in the response and the old one stops verifying immediately. Empty patch returns 400.

Authentication

Bearer <api_key> — must own the project.

Request Schema

JSON type shapeExpand
{
  "url": "string",
  "events": "string[]",
  "is_active": "boolean",
  "rotate_secret": "boolean"
}
Fields4Expand
url
stringoptional
https-only same rules as register
events
string[]optional
is_active
booleanoptional
rotate_secret
booleanoptional

Response Schema

JSON type shapeExpand
{
  "id": "uuid",
  "url": "string",
  "events": "string[]",
  "is_active": "boolean",
  "secret": "string (only present when rotate_secret was true)"
}
Fields5Expand
id
uuid
url
string
events
string[]
is_active
boolean
secret
string (only present when rotate_secret was true)

Code Examples

curl -X PUT $BASE/api/v1/projects/$PID/webhooks/$WH_ID \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "rotate_secret": true }'
DELETE/api/v1/projects/{projectId}/webhooks/{webhookId}#

Delete a webhook

Stops all deliveries to the webhook. Idempotent: a second call returns 410.

Authentication

Bearer <api_key> — must own the project.

Code Examples

curl -X DELETE $BASE/api/v1/projects/$PID/webhooks/$WH_ID -H "Authorization: Bearer $API_KEY"
POST/api/v1/projects/{projectId}/webhooks/{webhookId}/test#

Test fire a webhook

Sends a synthetic budget.threshold payload to the registered URL with the same signing scheme as production deliveries. Useful for verifying signature handling on the receiving side.

Authentication

Bearer <api_key> — must own the project. Webhook must be active.

Response Schema

JSON type shapeExpand
{
  "delivered": "boolean",
  "attempts": "number"
}
Fields2Expand
delivered
boolean
attempts
number

Code Examples

curl -X POST $BASE/api/v1/projects/$PID/webhooks/$WH_ID/test \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
Guide

Webhook payload & signature

Every delivery sends the following headers and a JSON body. Verify the signature using HMAC-SHA256 over the raw request body with the webhook's secret.

  • X-Ainvented-Event — event type, e.g. budget.threshold
  • X-Ainvented-Delivery — unique delivery UUID
  • X-Ainvented-Signature — sha256=<hex> over the raw body
  • User-Agent — Ainvented-Webhook/1.0
budget.threshold delivery
{
  "id": "<delivery uuid>",
  "event": "budget.threshold",
  "occurred_at": "2026-04-29T10:30:00Z",
  "project_id": "<uuid>",
  "data": {
    "currency": "USD",
    "budget_cents": 25000,
    "consumed_cents": 20000,
    "remaining_cents": 5000,
    "remaining_pct": 20.0,
    "threshold_pct": 80,
    "by_component": {
      "completion": { "events": 412, "total_tokens": 88000, "cost_cents": 9100 },
      "workflow":   { "events": 33,  "total_tokens": 21000, "cost_cents": 4200 },
      "skill":      { "events": 71,  "total_tokens": 11334, "cost_cents": 2500 },
      "assistant":  { "events": 18,  "total_tokens":  3000, "cost_cents": 1520 }
    }
  }
}

Token-quota events (tokens.threshold / tokens.exhausted) fire when consumed_tokens crosses one of the project's alert_thresholds percentages of token_limit. Each threshold fires at most once per re-arm cycle (re-arm happens automatically when token_limit or alert_thresholds is updated). Payload shape:

tokens.threshold delivery
{
  "id": "<delivery uuid>",
  "event": "tokens.threshold",
  "occurred_at": "2026-05-14T16:00:00Z",
  "project_id": "<uuid>",
  "data": {
    "currency": "USD",
    "token_limit":      1000000,
    "consumed_tokens":   800000,
    "remaining_tokens":  200000,
    "remaining_pct":     20.0,
    "threshold_pct":     80,
    "by_component": {
      "completion": { "events": 412, "total_tokens": 660000, "cost_cents": 9100 },
      "workflow":   { "events": 33,  "total_tokens":  90000, "cost_cents": 4200 }
    }
  }
}

Reliability: 3 retries with exponential backoff (1 s, 4 s, 16 s). Each delivery is recorded in ProjectWebhookDelivery; the payloadHash column makes deliveries idempotent so retries never duplicate. Webhooks are auto-disabled after 20 consecutive failures.

Reference

Error Codes

Every failure, on every endpoint, comes back in the same envelope. type names the family, code the specific reason and param the field that caused it when there is one.

Error envelope
{
  "error": {
    "message": "string - Human-readable error message",
    "type": "string - Error type (see Error Codes section)",
    "param": "string (optional) - Parameter that caused the error",
    "code": "string (optional) - Specific error code"
  }
}
400

Bad Request

invalid_request_error

Description: The request is malformed or contains invalid parameters. This includes validation errors for multimodal content (invalid base64, unsupported formats, size limits exceeded) and for image-generation inputs (see image-gen specific codes below).

context_length_exceeded (param: "messages") — the prompt is larger than the selected model tier's input budget and could not be trimmed without cutting your own messages. The message states how many input tokens the request needs and how many are available. This refusal is NOT billed: it is decided before the model is called, so no tokens are consumed and no usage is recorded. Retry with fewer or shorter messages, fewer attached files, or a tier with more headroom. Requests that can be trimmed succeed instead and report what was dropped on the context_truncation response field.

Image-generation specific codes (all return 400 with type invalid_request_error):

  • image_modality_unsupported — modalities contained a value other than text/image.
  • invalid_modalities — modalities was null or not an array.
  • image_count_out_of_range — image.n was outside 1..4 or not an integer.
  • image_size_unsupported — image.size was not one of the seven allowed values (256x256, 512x512, 1024x1024, 1024x1536, 1536x1024, 1024x1792, 1792x1024).
  • invalid_quality / invalid_style / invalid_response_format — corresponding image.* field had an unsupported value.
  • invalid_remove_background — image.remove_background was not a boolean.
  • invalid_image_block — image was not a plain object.

502 image-generation specific codes (with type service_unavailable):

  • image_generation_failed — image generation returned a non-OK status, an empty image list, or fewer images than requested (partial failure = failure).
  • image_background_removal_failed — background removal returned a non-OK status or an invalid response (when background removal is enabled for your project).
  • empty_response / partial_failure / upstream_<status> / fetch_error — finer-grained sub-codes that appear in the code field of an image_generation_failed envelope.

Video codes (400, never billed): invalid_video_url, unsupported_format, video_too_large, too_many_videos, video_unavailable (an explicitly attached link could not be downloaded, or a YouTube link to a video that does not exist, was removed or is private), video_content_rejected (the video may contain restricted content). See Video Content.

Structured output and reasoning (400, never billed): invalid_response_format (param: "response_format") — the value is not one of the three forms, a strict schema breaks a strict-mode rule (the message names it), or it was combined with modalities: ["image"]; invalid_reasoning_option — include_reasoning is not a boolean, or reasoning is not an object with a boolean exclude. See Structured output.

Voice (400, never billed): invalid_model, invalid_input, input_too_long, invalid_language, invalid_speed, invalid_response_format, invalid_file, invalid_file_type, consent_required, voice_read_only, project_mismatch; 413 file_too_large; 404 voice_not_found / voice_project_not_found / generation_not_found; 409 voice_project_archived / duplicate_name. See Voice.

Handling: Check the error message and param field to identify which parameter is invalid. Validate your request against the schema. For multimodal content, ensure proper base64 encoding, supported formats, and size limits.

401

Unauthorized

authentication_error

Description: Authentication failed. Invalid, missing, or revoked API key. One exception is not about your key: upstream_unavailable (type server_error) on chat completions or workflow execution is a temporary platform-side problem.

Handling: Verify your API key is correct and hasn't been revoked. Ensure the Authorization header is properly formatted: Bearer YOUR_API_KEY. For upstream_unavailable, keep the key and retry with backoff — nothing was billed.

403

Forbidden

permission_error

Description: Insufficient permissions to access the requested resource. Image-gen specific: image_generation_disabled fires when the request explicitly opted in to image output via modalities/tools but image generation is disabled for the project.

Handling: Verify you own the project you're trying to access. Check that your API key has permission for the requested operation. For image_generation_disabled, enable image generation in the AI Chat → Configure pane → "Image Generation" toggle, or remove 'image' from modalities.

404

Not Found

not_found_error

Description: The requested resource doesn't exist or doesn't belong to your API key.

Handling: Verify the project ID or session ID exists and belongs to your API key. For sessions, ensure you're using a session ID from a previous response.

429

Too Many Requests

rate_limit_error

Description: Rate limit exceeded for your API key.

Handling: Implement exponential backoff. Check the Retry-After header for seconds until reset. Monitor rate limit headers to avoid hitting limits.

429

Server At Capacity

server_overloaded

Description: Distinct from the per-key rate limit above. Under sustained high concurrency a node may briefly shed new requests to protect the requests already in flight (load-shedding / admission control). The request was not processed, reached no AI model, and incurred no token cost — it is fully safe to retry.

Handling: Honor the Retry-After header and retry with backoff. Because the request did nothing, retrying does not double-bill.

451

Not Available In Your Region

permission_error

Description: region_not_supported — the request comes from a country the platform does not serve (judged by the client's network address; for messaging, by the phone number's country). The request was not processed, reached no AI model and is not billed. A WhatsApp or SMS send to a number in such a country answers the same code and nothing is sent.

Handling: Do not retry — the answer will not change. Webhooks, cron and health endpoints are never affected.

500

Internal Server Error

server_error

Description: An unexpected error occurred on the server.

Handling: Retry the request with exponential backoff. If the error persists, contact support with the error ID from the response.

502

Response Format Not Met

service_unavailable

response_format_failed — every generation of a turn with response_format failed validation (a bounded number of attempts was made). The generations are billed like any other turn. Retry the request; if it keeps failing, simplify the schema or give the model clearer instructions about the keys you expect.

503

Service Unavailable

service_unavailable

Description: The service is temporarily unavailable due to maintenance or overload.

video_processing_unavailable — a message's video could not be read because video reading was momentarily unavailable. Nothing reached the model and nothing is billed; any video that was already read is cached, so the retry does not pay for it again.

voice_unavailable (503) / voice_timeout (504) — speech, design or transcription could not be completed right now. A failed voice call is not billed; retry.

Handling: Retry after a delay (honor Retry-After). Check status page for maintenance announcements.

Reference

Best Practices

The habits that keep an integration fast, cheap and safe.

  • Error handling

    Always implement proper error handling for all HTTP status codes. Parse error responses to provide meaningful feedback to users.

  • Rate limiting

    Monitor rate limit headers and implement exponential backoff when approaching limits.

  • Session management

    Reuse session IDs for multi-turn conversations to maintain context. Store session IDs securely on your backend.

  • Streaming

    Use streaming mode for better user experience with long responses. Handle SSE parsing correctly and display tokens as they arrive.

  • Security

    Never expose API keys in client-side code. Always make API calls from your backend. Rotate keys regularly and revoke compromised keys immediately.

  • Timeouts

    Set appropriate timeouts for your requests (recommended: 60 seconds for completions).

  • Multimodal content

    Compress images and audio before encoding to reduce token usage. Use appropriate detail levels for images ('low' for simple recognition, 'high' for detailed analysis). Monitor size limits and handle validation errors gracefully.

Reference

Additional Resources

Where to go next.

  • API Key ManagementCreate, rotate and revoke your API keys.
  • Chat projectsCreate and configure the projects these endpoints manage.
  • ObservabilityPer-request traces, latency and cost, matched to X-Request-Id.
  • BillingYour balance, invoices and project budgets.
  • contact@ainvented.comAccess to the reseller surface (payments, WhatsApp) and anything this reference leaves unanswered.