Everything the panel and the widget do is reachable without them. Build your own inbox for your
team on /v1, your own chat for your users on the contact API, and bots that answer through
webhooks and drafts. Working code for each is in examples/, MIT licensed.
In short
- Make an API key with only the scopes it needs, limited to some inboxes if you like. Every key is a bot with a name, which is shown on what it writes.
- Your own inbox UI: read and write over
/v1, and follow changes through the event feed or realtime. - Your own chat UI:
createYuvaClient()from@useyuva/jsspeaks the contact API without any DOM. - A bot that answers with drafts: a webhook tells it a message came in; it posts a draft, and a member sends it, unless the workspace lets bots send.
- Retried
POSTs are safe with anIdempotency-Key. YUVA_PANEL=offandYUVA_WIDGET=offserve only the API.
API keys
Owners and admins make keys in Settings → API keys, or on the server with
yuva api-key create --workspace <id|name> --name <name> --scope <scope>… --inbox <id>… (without
--scope, the key gets every scope). A key holds:
- Scopes. Every
/v1operation a key may call needs one; the operation’s description in the API reference names it. A call without it gets403 insufficient_scope, with the missing scope inscope. - Inboxes (optional). The key then sees what an agent with access to exactly those inboxes
sees. Such a key cannot hold
inboxes:manage,webhooks:manageorworkspace:manage. - Expiry (optional). After it, calls get
401 api_key_expired. - Bot name and avatar. Messages, notes and events written with the key have the author type
botand show the bot’s name (the key’s name unless you give one) in the panel, the widget, the SDKs, webhook payloads and as the display name on outgoing e-mail.
Scopes, inboxes and expiry are fixed when the key is made; the name, bot name and avatar can be
changed (PATCH /v1/api-keys/{id}). Keys made before scopes existed hold every scope.
| Scope | Allows |
|---|---|
conversations:read |
Conversations, messages, attachments, labels, canned replies, the event feed and realtime |
conversations:write |
Status, assignee, snooze, priority, labels; move and bulk-update conversations |
messages:write |
Messages and drafts: create, edit, discard |
drafts:send |
Send a draft, together with messages:write |
notes:write |
Notes |
contacts:read |
Contacts, lookups, presence |
contacts:write |
Create, update, delete and merge contacts |
inboxes:read |
Workspace, members, inboxes, channels |
inboxes:manage |
Create, change and delete inboxes and channels, inbox access, secrets |
labels:write |
Labels |
canned_replies:write |
Canned replies |
webhooks:manage |
Webhooks, their deliveries and attempts |
workspace:manage |
Workspace settings and usage |
feedback:write |
POST /v1/feedback |
Keep keys on your servers. A browser or app never holds one; contacts use the contact API with their own session instead.
Your own inbox UI
The panel is one client of /v1; yours can be another. A key with conversations:read,
messages:write and conversations:write covers a basic inbox:
curl https://support.example.com/v1/conversations?status=open \
-H "Authorization: Bearer $YUVA_API_KEY"
curl https://support.example.com/v1/conversations/$CONVERSATION/messages \
-H "Authorization: Bearer $YUVA_API_KEY"
curl -X POST https://support.example.com/v1/conversations/$CONVERSATION/messages \
-H "Authorization: Bearer $YUVA_API_KEY" -H "Content-Type: application/json" \
-d '{"kind": "message", "body": "We have shipped a fix.", "draft": true}'
A reply written with a key is a draft unless the workspace lets bots send (see below). For a UI used by your team, members can sign in to Yuva and use their own session, or you can register your app as an OAuth client so it acts as each member.
Typed clients are generated from the OpenAPI contract:
import { createYuvaApi } from "@useyuva/js/api";
const api = createYuvaApi({ server: "https://support.example.com", apiKey: process.env.YUVA_API_KEY! });
const { data, error } = await api.GET("/v1/conversations", { params: { query: { status: "open" } } });
In Go, github.com/productdevbook/yuva/sdk/go/client is the same contract
(sdk/go/README.md). @useyuva/js is not on npm yet; until it is, build it
from sdk/js and depend on it by path, as the examples do.
Event feed
GET /v1/events?after=<id> returns the events realtime and webhooks carry, in order, filtered by
what the caller may see. It suits scripts, cron jobs and servers that were offline for a while:
- On the first start, load what you show over the lists, then take the current position from
GET /v1/events/latest. - Call
GET /v1/events?after=<position>&limit=100. Handleevents, storenextas your position. Whilehas_moreis true, call again at once; otherwise poll a little later. - Events are kept 7 days. A position older than that answers
410 cursor_expired: reload the lists and continue fromGET /v1/events/latest.
after=0 starts at the oldest kept event. A page can hold fewer events than limit while
has_more is true, because events you may not see are skipped. The key needs
conversations:read; contact events also need contacts:read.
examples/inbox-feed does exactly this.
Realtime
For a live UI, open the WebSocket GET /v1/realtime with Authorization: Bearer <key>. Each frame
is one JSON event with an increasing id. Remember the largest id you handled (and the
last_event_id of the ready frame) and reconnect with ?last_event_id=<it>: the server replays
what you missed before the live events, or sends resync_required when the gap is older than 7
days. typing frames have no id and are not replayed.
Your own chat UI
Contacts use the contact API (/client/v1) with a session of their own, never an API key.
createYuvaClient() from @useyuva/js wraps it without any DOM, so it runs in browsers, Node 22+,
Bun, Deno and workers. The <yuva-chat> element is built on it.
import { createYuvaClient } from "@useyuva/js";
const yuva = createYuvaClient({
server: "https://support.example.com",
channel: "yuva_pk_…",
identityToken: () => fetch("/my/yuva-token").then((r) => r.text()),
});
yuva.on("event", (event) => {
if (event.type === "message.created") render(event.data);
});
await yuva.connect();
const { conversation } = await yuva.startConversation({ body: "Hello" });
await yuva.sendMessage(conversation.id, { body: "One more thing" });
channelis the public key of achatorappchannel.chatchannels accept only their allowed origins, so outside a browser use anappchannel.identityTokenreturns a token your backend signs for the signed-in user (Identity tokens). Without it the contact is an anonymous visitor, if the channel allows them.storagekeeps the session and visitor id:localStoragein browsers, memory elsewhere unless you pass your own{ getItem, setItem }.connect()keeps realtime open and resumes after the last event on reconnect.@useyuva/js/reactaddsYuvaProvider,useConversations()anduseMessages(id).
Every option and method is in the SDK README.
examples/headless-chat is a terminal chat built only on the client.
On iOS and Android the mobile SDKs have the same kind of client under their UI.
A bot that answers with drafts
A bot is an API key plus your code. The usual loop:
- Add a webhook for
message.createdthat points at your bot, and keep itswhsec_…secret. - On each request, verify the signature over the raw body,
then look at
data.message: act ondirection: "in"andkind: "message", ignore the rest (your own replies come back asmessage.createdtoo). - Post the answer as a draft, with an
Idempotency-Keyderived from the incoming message, so a webhook Yuva retries gives back the same draft instead of a second one:
curl -X POST https://support.example.com/v1/conversations/$CONVERSATION/messages \
-H "Authorization: Bearer $YUVA_API_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: draft-bot:$INCOMING_MESSAGE_ID" \
-d '{"kind": "message", "direction": "out", "draft": true, "body": "Thanks, we are on it."}'
A draft is never delivered on its own. Members see it in the conversation with the bot’s name and
send, edit or discard it. Over the API: PATCH /v1/messages/{id} edits it, DELETE /v1/messages/{id}
discards it and POST /v1/messages/{id}/send delivers it; anything that is not a draft answers
409 not_a_draft. A draft never reaches the contact, the widget, notifications or unread counts.
Drafts emit draft.created, draft.updated and draft.deleted (on realtime, the event feed, and
as webhook types you can subscribe to); sending one emits the usual message.created, with the bot
kept as author and sent_by naming who sent it.
examples/draft-bot is this bot in Go, with sdk/go/webhook and
sdk/go/client.
Bots may send
The workspace setting Bots may send (Settings → API keys, bots_may_send on
PATCH /v1/workspace) decides whether keys deliver anything:
- Off (the default for new workspaces): a key’s outgoing message must be a draft, else
403 bot_sending_disabled, and a key cannot send drafts. - On: a key with
messages:writeposts replies that are delivered at once, and a key that also holdsdrafts:sendsends drafts.
Only owners and admins change it, with their own session; no key can, so a bot never lets itself send. A bot’s e-mail goes out from the inbox’s address with the bot’s name as display name.
Idempotency
Every authenticated POST on /v1 and /client/v1 accepts an Idempotency-Key header (1 to 255
printable ASCII characters). Yuva remembers it for 24 hours per caller, with the method, path and
body:
| Retry | Answer |
|---|---|
| Same key, same request | The first response again, with Idempotent-Replayed: true |
| Same key, different request | 409 idempotency_key_reused |
| Same key while the first is still running | 409 idempotency_key_in_use |
5xx answers are not stored, so the request can be retried. Sign-in, the contact session request
and the /v1/me/… endpoints ignore the header. In the contact API, a message’s client_id also
makes a retry safe; the JS client always sends one, and outside browsers also sends it as
Idempotency-Key (idempotencyKeys option).
API-only servers
A server that only backs your own UIs and bots does not need the panel or the widget scripts:
| Variable | Effect |
|---|---|
YUVA_PANEL=off |
The panel’s pages answer 404. The OAuth consent page goes too, so OAuth clients cannot connect; API keys keep working. |
YUVA_WIDGET=off |
/yuva.js and /yuva-chat.js answer 404; the contact API stays for the JS client and the mobile SDKs. |
The API, realtime, e-mail ingress and the MCP endpoint stay. See Configuration.