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.

KeyPrefixUseCan
Secretsk_live_Your servers onlyEverything an admin of the workspace can do, except identity and ownership actions
Restrictedrk_live_Servers, CI, automation toolsOnly 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

EndpointScope (permission)Status
GET/POST /v1/agents, GET/PATCH/DELETE /v1/agents/:idagents.read, agents.update, …Available
GET /v1/conversations?agent&status&created[gte], GET /v1/conversations/:id, GET /v1/conversations/:id/messagesconversations.readAvailable
POST /v1/conversations { agent }conversations.write (keys only)Available
POST /v1/conversations/:id/messages { content, stream? }conversations.writeAvailable
GET /v1/contacts?agent, GET /v1/contacts/:idcontacts.readAvailable
POST/DELETE /v1/mcp, /v1/mcp/agents/:agentsee MCPAvailable

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:

statusMeaning
openIn progress
escalatedHanded to your team; only a person closes it
resolvedClosed 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)
unresolvedAuto-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
failedCouldn'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" } } }
StatusWhenTypical codes
400The request is invalidVALIDATION_FAILED (see fields), BAD_IDEMPOTENCY_KEY
401The key is missing, wrong or revokedUNAUTHENTICATED
403The key lacks the permissionPERMISSION_DENIED
404Not found in your workspace (other workspaces' objects look the same as missing)NOT_FOUND
409ConflictsIDEMPOTENCY_KEY_REUSED, IDEMPOTENCY_KEY_IN_USE, INVALID_TRANSITION
402 / 422Plan limits and business rulesQUOTA_EXCEEDED, REFUSED (the message says why)
429Too many requestsRATE_LIMITED
5xxOur faultalways 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: 6

A 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-Key and 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.