REST API
Every endpoint, every header, every status code the SDK can receive.
The SDK speaks RDUI/1 over HTTPS. You rarely call these endpoints yourself — but when a turn goes wrong, this is the layer the answer is in.
Base URL: https://api.rendel.ai
Headers
Sent on every request. Before the rename they were X-NC-Key, X-NC-Device and
X-NC-User-Token, and keys started nc_pk_; both spellings are accepted, and
an nc_pk_ key keeps working.
| Header | Required | What it is |
|---|---|---|
X-Rendel-Key | Yes | Your publishable key. rd_pk_live_… serves live traffic and counts against your plan; rd_pk_test_… opens sandbox conversations, which are never billed. Publishable keys are public by design — they ship inside your binary — so nothing they can reach is a secret. |
X-Rendel-Device | Recommended | An opaque per-install id, 32 lowercase hex characters. It is what rate limits are bucketed by, what identify binds a user to, and what makes two launches of one install a returning device rather than two new ones. Required on POST /v1/feedback. Omit it and every request from every device that omits it shares one bucket. |
Content-Type | On POST | application/json. |
X-Request-Id | No | Your own trace id for this turn. If you send one we use it; otherwise we mint one. Either way it comes back on the response as X-Request-Id and inside turn.start. |
If-None-Match | No | On GET /v1/apps/config only. Send the last ETag you saw to get a 304 with no body. |
Where the device id comes from
The Flutter SDK mints one and, if you give it RendelConfig.deviceIdStore, keeps it across launches. Without a store it is new on every cold start, which is correct but forgetful: rate limits reset per launch and your console device counts read high.
Status codes
The same codes mean the same things on every endpoint.
| Status | When | Body |
|---|---|---|
200 | Success | The endpoint's response body. |
204 | Accepted, nothing to say | Empty. Only POST /v1/events. |
304 | Your cached copy is current | Empty. Only GET /v1/apps/config. |
400 | The request did not parse, or a required header was missing | { error: { code, message, details? } } |
401 | Unknown, revoked or malformed key | { error: { code: "invalid_key" } } |
403 | The app is suspended, or identity verification is required and was not presented | { error: { code, message? } } |
404 | The thing named does not exist, or is not yours | { error: { code: "invalid_request", message } } |
429 | Rate limited | { error: { code: "rate_limited", message } }, sometimes with Retry-After |
503 | The service is not able to serve this deployment | { error: { code: "internal", message } } |
A 404 is deliberately the same answer for "no such id" and "not your id". Telling a caller holding a public key which ids exist is a lookup oracle over another tenant's id space.
Once the SSE stream has opened, POST /v1/chat reports failures inside the stream as an error event with the same codes, not as an HTTP status — the response was already 200 by then. See Error codes.
POST /v1/chat
The one workhorse. Send exactly one of message, form_submission or tool_results, plus the client handshake. Returns text/event-stream.
| Field | Required | What it is |
|---|---|---|
conversation_id | No | Omit it to start one. The server mints it and returns it on turn.start. |
message | One of three | { text }. Exactly one of message, form_submission or tool_results. |
form_submission | One of three | { form_id, values }, where a value is a string, a number or a boolean. |
tool_results | One of three | 1 to 10 results. Each carries invocation_id, status, and confirmed for a write or destructive action: a success without confirmed: true is recorded as declined. |
turn_id | No | A uuid you mint once per turn and resend unchanged when you retry. The server writes the user's message at most once per (app_id, turn_id), so a retry after a dropped stream neither duplicates the message nor bills for it twice. App-scoped rather than conversation-scoped on purpose: conversation_id is minted by the server and only reaches you on turn.start, so a stream that dies before that leaves you with no conversation to name — which is exactly the case worth deduping. Omit turn_id and every retry counts as a new turn. |
user | No | Who is signed in to your app, on this turn: { id, hmac? }, or null when nobody is. The turn goes to that person whatever device sent it, and a thread that belongs to somebody else is not continued — a new one opens. null puts the turn on the device's anonymous record, never on whoever used the device before. hmac is the same signature /v1/identify takes; a person whose record was signed for only takes signed turns, and an unsigned one is served as anonymous. Omit the field entirely and the server falls back to the device's /v1/identify binding, which is how SDKs before 0.13 work. |
client | Yes | The capability handshake: { protocol, sdk, catalog_version, components, manifest_hash?, locale? }. The server builds the model's toolset from the intersection of its catalog and your components, so an old build is never offered a block it cannot draw. |
context | No | { app_state, current_screen }. Treated as untrusted data, never as instructions. app_state must serialize to 8 KB or fewer. |
curl -N https://api.rendel.ai/v1/chat \
-H "X-Rendel-Key: rd_pk_test_..." \
-H "X-Rendel-Device: 4f3c1a0b9d2e8f7a6b5c4d3e2f1a0b9c" \
-H "Content-Type: application/json" \
-d '{
"message": {"text": "Where is my order 1042?"},
"turn_id": "3f1c2b90-8e4d-4a71-9c15-2b7e0a4d6f83",
"client": {
"protocol": 1,
"sdk": "flutter/0.1.0",
"catalog_version": "2026-09.a",
"components": ["text", "quick_replies", "order_tracker", "confirm_card"]
}
}'Stream events
Every event carries v: 1 and a monotonic seq.
| Event | Fields | Meaning |
|---|---|---|
turn.start | conversation_id, message_id, model, request_id | Assistant turn begins. message_id is the id of the row this turn is stored as — it is what you send back to rate the answer. |
block.start | index, id, block_type | A component is announced; draw its skeleton. |
block.delta | index, text_delta | Streaming text for a text block. |
block.ready | index, block | The validated block; hydrate the skeleton. |
action.execute | invocation_id, name, params, timeout_ms | Run this registered read action and send the result back. |
turn.end | status, pending?, billable? | completed, awaiting_tool_results, awaiting_confirmation or error. |
error | code, message, retryable | Typed failure. |
ping | — | Keep-alive every 15 seconds. Do not filter it: your idle timeout should be reset by it, or a long-thinking turn dies for looking idle. |
When turn.end reports an awaiting status, answer with a continuation POST /v1/chat carrying tool_results for the pending invocations.
GET /v1/apps/config
The copilot's published appearance and opening surface, for a device that has just called init(). Cached by ETag.
Everything here is public by construction — a publishable key can read it, and a publishable key ships in your binary.
Response 200
{
"channel": "published",
"version": 7,
"updated_at": "2026-09-01T10:12:04.000Z",
"config": {
"theme": { "accent": "#2E40EA", "radius": { "card": 16 } },
"copilot": { "assistantName": "Ada", "launcherLabel": "Ask", "suggestions": ["Where is my order?"] }
}
}| Field | What it is |
|---|---|
channel | published for a live key, draft for a test key. Your own app on a test key is the preview device; there is no second thing to keep in step. |
version | The published version number, or null when nothing has been published. |
updated_at | When it was published. Carried for information; it does not decide the ETag. |
config | The payload, or {}. An empty config means "keep whatever the code passed to init()". |
Responses carry Vary: X-Rendel-Key, X-NC-Key — on the 401 and 403 as well as the 200, because the body depends entirely on a request header and a cache keyed on the URL alone would hand one tenant's branding to the next device that asks.
A row that fails validation on the way out is served as {} rather than as something the SDK might try to read.
| Status | When |
|---|---|
200 | A payload, or {} when nothing is published. |
304 | Your If-None-Match matches. No body. |
401 | Unknown or revoked key. |
403 | { error: { code: "app_suspended" } } |
429 | Two buckets: 12/minute per device and 600/minute per app. The app-wide one protects the connection pool /v1/chat shares, and it is the one that still holds when a caller rotates X-Rendel-Device on every request. |
POST /v1/apps/manifest
Idempotent upload of your action manifest. Snapshots are immutable, keyed by (app, hash); posting the same manifest twice touches one row.
Request
{ "manifest": { "version": 1, "actions": [ … ] }, "sdk": "flutter/0.1.0" }Response 200
{ "manifest_hash": "9b2f…" }Send that hash as client.manifest_hash on every chat request. A hash the server has not seen ends the turn with manifest_unknown rather than being served with no actions — an empty action set would let the model say "I can't do that" about something your app definitely can.
Response 400 names the action and the field:
{
"error": {
"code": "invalid_request",
"message": "Invalid action manifest: actions.1.name — snake_case action names (+2 more)",
"details": [
{ "path": "actions.1.name", "message": "snake_case action names" },
{ "path": "actions.3.nc.timeoutMs", "message": "Too big: expected number to be <=120000" }
]
}
}details carries up to twelve issues. path is the location inside the document you posted, so it greps against your own source.
POST /v1/identify
Binds the device to a known user, records their traits, and moves the device's anonymous history onto them. From SDK 0.13 the turns themselves say who is signed in (user on POST /v1/chat); this call still carries the traits.
Request
| Field | Required | What it is |
|---|---|---|
external_user_id | Yes | Your id for this person, 1–128 characters. |
traits | No | Anything you want the copilot to know about them. Treated as data, never as instructions. |
user_hmac | When verification is on | Lowercase hex HMAC_SHA256(external_user_id, app identity secret), computed on your backend. The secret never goes in your app. |
Response 200: { "end_user_id": "…", "verified": true }
verified is the whole point of the call. Traits move only when the caller proved who they are, or when the record was never verified in the first place — otherwise anyone holding your publishable key could rewrite a verified user's profile while the verified badge stayed on it.
| Status | When |
|---|---|
400 | Malformed body. |
403 | The app requires verification and no valid user_hmac was presented. |
429 | Rate limited per device, at your app's per-minute message limit. |
POST /v1/invocations/:id/confirm
The user's answer to a confirmation card, recorded by the server. Call it the moment the button is pressed and before your handler runs, so the record exists independently of what the handler does. X-Rendel-Device is required.
Request: { "decision": "confirm" } or { "decision": "decline" }
Response 200: { "status": "confirmed" }, { "status": "declined" }, or { "status": "expired" }
A second call returns { "status": "…", "already_answered": true } with the answer that was recorded. The first answer stands: a double tap over a flaky link changes one row once, and a decline cannot overwrite a confirmation.
| Status | When |
|---|---|
400 | No X-Rendel-Device, a malformed decision, or an invocation whose risk is read — a read has no confirmation to give. |
404 | No such invocation, not this app's, or the calling device does not own the conversation. One answer for all three: the publishable key ships in your binary, so holding it is not evidence of anything. |
429 | Rate limited per device. |
Whether this call is required is per app. By default the older contract still holds — a result carrying confirmed: true is accepted — because an SDK that predates this endpoint never calls it. Turn on Settings → Security → Record confirmations on the server and the server's own record becomes the only thing that counts. See the security summary.
POST /v1/feedback
One thumb on one assistant turn. X-Rendel-Device is required.
Request
| Field | Required | What it is |
|---|---|---|
message_id | Yes | The message_id from that turn's turn.start. |
rating | Yes | 1 or -1. |
reason | No | Up to 500 characters of free text. It goes through the same redaction pass as a message before it is stored. |
Response 200: { "ok": true }
Keyed by (message, device): a second call changes the rating rather than adding a vote. 404 covers both "no such turn" and "not your turn".
GET /v1/conversations/:id/messages
History, and convergence after a dropped stream.
| Query | Default | What it is |
|---|---|---|
limit | 50 | Capped at 200. |
Response 200
{
"conversation_id": "…",
"messages": [
{ "id": "…", "role": "user", "blocks": [ … ], "created_at": "…" }
]
}Blocks are stored in placeholder form and restored on the way out, so the device asking for its own history gets the values its user typed. A live key cannot read a sandbox conversation and vice versa; a key from another app gets an empty list rather than an error, because the two are the same answer.
POST /v1/events
Batched SDK telemetry. Accepted and discarded today; billing and usage are server-derived and never trust this endpoint.
Response 204, empty.