REST API v1
Manage agents, read conversations and contacts, and chat from your own backend, with plain HTTP.
curl https://api.onetype.ai/v1/agents -H "Authorization: Bearer sk_live_…"Authentication and keys
Create keys in Developer › API keys (owner or admin). A key is shown once.
| Key | Prefix | Use | Can |
|---|---|---|---|
| Secret | sk_live_ | Your servers only | Everything an admin of the workspace can do, except identity and ownership actions |
| Restricted | rk_live_ | Servers, CI, automation tools | Only the permissions you tick when creating it (never more than your own) |
API keys come with Premium. Send the key as Authorization: Bearer <key>. Keys belong to one workspace, and a key can never read another workspace's data. Keep secret and restricted keys out of browsers, mobile apps and repositories. If one leaks, revoke it in the dashboard, and the very next request with it fails.
Resources
| Endpoint | Scope (permission) | Status |
|---|---|---|
GET/POST /v1/agents, GET/PATCH/DELETE /v1/agents/:id | agents.read, agents.update, … | Available |
GET /v1/conversations?agent&status&created[gte], GET /v1/conversations/:id, GET /v1/conversations/:id/messages | conversations.read | Available |
POST /v1/conversations { agent } | conversations.write (keys only) | Available |
POST /v1/conversations/:id/messages { content, stream? } | conversations.write | Available |
GET /v1/contacts?agent, GET /v1/contacts/:id | contacts.read | Available |
POST/DELETE /v1/mcp, /v1/mcp/agents/:agent | see MCP | Available |
Knowledge and usage are managed in the dashboard.
A contact belongs to one agent (agent, an agt_… id): the same person on two of your agents' sites is two contacts. ?agent=agt_… lists one agent's.
Public objects use snake_case fields and prefixed ids (the visitor routes of a custom chat use camelCase, for example isAiGenerated; snake_case, is_ai_generated, exists only in the /v1 shapes). They never contain internal fields. Every message object has is_ai_generated (true for the agent's replies, false for your team's), as the EU AI Act requires.
OpenAPI: https://api.onetype.ai/v1/openapi.json, generated from the same schemas the API validates with.
Conversation statuses
Conversation objects (in /v1 and in conversation.* webhooks) carry status and, when resolved, resolved_by:
status | Meaning |
|---|---|
open | In progress |
escalated | Handed to your team; only a person closes it |
resolved | Closed with an answer. resolved_by: ai (auto-closed after a good answer), member (a person on your team), visitor (the visitor confirmed), or idle (conversations closed before 2026-09-24) |
unresolved | Auto-closed after the agent's last answer said it didn't know, or was rated down. It returns to open if the visitor writes again |
failed | Couldn't be answered because of an error, or closed by your team as not resolved (also after a handoff) |
A conversation the agent handles alone closes automatically 30 minutes after its last message. Conversations with a handoff, held by your team, or waiting on an unanswered visitor message are never closed automatically. New values may be added; treat unknown ones as open.
Pagination
List endpoints use cursors:
curl "https://api.onetype.ai/v1/conversations?limit=50" -H "Authorization: Bearer sk_live_…"{ "object": "list", "data": [ … ], "has_more": true, "next_cursor": "b3BhcXVl…" }Pass cursor=<next_cursor> for the next page. limit defaults to 20, with a maximum of 100. Cursors are opaque and stay stable when new items arrive while you page.
Idempotency
Send Idempotency-Key: <any unique string> on any POST to make retries safe. The first result is kept for 24 hours, and a retry with the same key returns it instead of doing the work again.
curl -X POST https://api.onetype.ai/v1/conversations \
-H "Authorization: Bearer sk_live_…" \
-H "Idempotency-Key: 6f1c9f2e-3b1d-4a0e-9d0f-2f6f0b3c9a11" \
-H "Content-Type: application/json" \
-d '{"agent":"YOUR_AGENT_ID"}'- A replayed response carries
Idempotent-Replayed: true. - Keys are 1–255 printable ASCII characters. Anything else is
400 BAD_IDEMPOTENCY_KEY. - The same key with a different body is
409 IDEMPOTENCY_KEY_REUSED. Use a new key for a new request. - While the first request with a key is still running, a retry gets
409 IDEMPOTENCY_KEY_IN_USE. Wait and retry. - Errors from validation are not stored (fix the request and retry with the same key). A refusal the work itself produced (e.g. a plan limit) is stored and replayed.
- Keys are private to your workspace and key.
Errors
Every error has the same shape, plus an x-request-id header. Include the request id when you contact support.
{ "error": { "status": 422, "code": "VALIDATION_FAILED", "message": "content: required", "fields": { "content": "Required" } } }| Status | When | Typical codes |
|---|---|---|
| 400 | The request is invalid | VALIDATION_FAILED (see fields), BAD_IDEMPOTENCY_KEY |
| 401 | The key is missing, wrong or revoked | UNAUTHENTICATED |
| 403 | The key lacks the permission | PERMISSION_DENIED |
| 404 | Not found in your workspace (other workspaces' objects look the same as missing) | NOT_FOUND |
| 409 | Conflicts | IDEMPOTENCY_KEY_REUSED, IDEMPOTENCY_KEY_IN_USE, INVALID_TRANSITION |
| 402 / 422 | Plan limits and business rules | QUOTA_EXCEEDED, REFUSED (the message says why) |
| 429 | Too many requests | RATE_LIMITED |
| 5xx | Our fault | always INTERNAL, with the request id. If it keeps happening, email admin@onetype.ai with the request id |
code values are stable. Build your handling on them, not on message.
Rate limits
Per key: 100 requests per 10 seconds, and 20 chat messages per 10 seconds. Higher limits come with higher plans. Every response carries:
RateLimit-Limit: 100
RateLimit-Remaining: 87
RateLimit-Reset: 6A 429 also carries Retry-After (seconds). Wait that long, then retry. Don't retry in a tight loop.
Failed authentication is limited per network address. Requests with a wrong or revoked key count against the address they come from. If many clients share one public address (a corporate NAT, a CI provider), one misconfigured client can exhaust the budget, and then even requests with a valid key from that address get 429 RATE_LIMITED until the window resets. Handle it like any 429: honor Retry-After, then fix the client that sends the bad key.
async function call(url: string, init: RequestInit, attempts = 3): Promise<Response>
{
const response = await fetch(url, init);
if (response.status !== 429 || attempts <= 1)
{
return response;
}
const waitSeconds = Number(response.headers.get("retry-after") ?? "1");
await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
return call(url, init, attempts - 1);
}Chatting from your backend
Create a conversation for an agent, then send messages into it. Each reply is written, stored and billed exactly like a widget answer.
curl -X POST https://api.onetype.ai/v1/conversations \
-H "Authorization: Bearer sk_live_…" -H "Content-Type: application/json" \
-d '{"agent":"YOUR_AGENT_ID"}'
# → { "id": "cnv_…", "object": "conversation", "status": "open", … }
curl -X POST https://api.onetype.ai/v1/conversations/cnv_…/messages \
-H "Authorization: Bearer sk_live_…" -H "Idempotency-Key: order-1234-question-1" -H "Content-Type: application/json" \
-d '{"content":"Where is order 1234?"}'
# → { "id": "msg_…", "object": "message", "role": "assistant", "is_ai_generated": true, "parts": [ … ], … }The reply's parts hold text parts and a source-document part for each cited source (title, and url for web pages).
Streaming
Send "stream": true to receive the reply as it is written, as server-sent events in the AI SDK UI Message Stream v1 format. The response carries x-vercel-ai-ui-message-stream: v1, and each event's data is one JSON part: start, text-start, text-delta (with delta), text-end, source-document, finish (with messageMetadata.outcome), error or abort. The stream ends with data: [DONE].
const response = await fetch(`https://api.onetype.ai/v1/conversations/${conversationId}/messages`, {
method: "POST",
headers: { "Authorization": `Bearer ${process.env.ONETYPE_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify({ content: "How do refunds work?", stream: true }),
});
const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
for (;;)
{
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const events = buffer.split("\n\n");
buffer = events.pop() ?? "";
for (const event of events)
{
const data = event.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
if (data === undefined || data === "[DONE]") continue;
const part = JSON.parse(data);
if (part.type === "text-delta") process.stdout.write(part.delta);
if (part.type === "source-document") console.log(`\n[source] ${part.title}`);
}
}With the Vercel AI SDK on your own server you can pass the response straight through to useChat, because it is the same format.
- Disconnecting doesn't cancel the answer. The reply is still written, stored and billed.
- Retrying a finished stream is safe. Retry with the same
Idempotency-Keyand you get the stored reply as JSON (status 200), not a second answer.
Versioning
/v1 only changes additively: new fields, new endpoints, new event types. Your code should ignore fields it doesn't know. If a breaking change is ever needed, it will ship as a new version selected per key, with advance notice.