Reference

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.

HeaderRequiredWhat it is
X-Rendel-KeyYesYour 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-DeviceRecommendedAn 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-TypeOn POSTapplication/json.
X-Request-IdNoYour 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-MatchNoOn 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.

StatusWhenBody
200SuccessThe endpoint's response body.
204Accepted, nothing to sayEmpty. Only POST /v1/events.
304Your cached copy is currentEmpty. Only GET /v1/apps/config.
400The request did not parse, or a required header was missing{ error: { code, message, details? } }
401Unknown, revoked or malformed key{ error: { code: "invalid_key" } }
403The app is suspended, or identity verification is required and was not presented{ error: { code, message? } }
404The thing named does not exist, or is not yours{ error: { code: "invalid_request", message } }
429Rate limited{ error: { code: "rate_limited", message } }, sometimes with Retry-After
503The 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.

FieldRequiredWhat it is
conversation_idNoOmit it to start one. The server mints it and returns it on turn.start.
messageOne of three{ text }. Exactly one of message, form_submission or tool_results.
form_submissionOne of three{ form_id, values }, where a value is a string, a number or a boolean.
tool_resultsOne of three1 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_idNoA 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.
userNoWho 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.
clientYesThe 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.
contextNo{ 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.

EventFieldsMeaning
turn.startconversation_id, message_id, model, request_idAssistant 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.startindex, id, block_typeA component is announced; draw its skeleton.
block.deltaindex, text_deltaStreaming text for a text block.
block.readyindex, blockThe validated block; hydrate the skeleton.
action.executeinvocation_id, name, params, timeout_msRun this registered read action and send the result back.
turn.endstatus, pending?, billable?completed, awaiting_tool_results, awaiting_confirmation or error.
errorcode, message, retryableTyped 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?"] }
  }
}
FieldWhat it is
channelpublished 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.
versionThe published version number, or null when nothing has been published.
updated_atWhen it was published. Carried for information; it does not decide the ETag.
configThe 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.

StatusWhen
200A payload, or {} when nothing is published.
304Your If-None-Match matches. No body.
401Unknown or revoked key.
403{ error: { code: "app_suspended" } }
429Two 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

FieldRequiredWhat it is
external_user_idYesYour id for this person, 1–128 characters.
traitsNoAnything you want the copilot to know about them. Treated as data, never as instructions.
user_hmacWhen verification is onLowercase 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.

StatusWhen
400Malformed body.
403The app requires verification and no valid user_hmac was presented.
429Rate 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.

StatusWhen
400No X-Rendel-Device, a malformed decision, or an invocation whose risk is read — a read has no confirmation to give.
404No 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.
429Rate 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

FieldRequiredWhat it is
message_idYesThe message_id from that turn's turn.start.
ratingYes1 or -1.
reasonNoUp 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.

QueryDefaultWhat it is
limit50Capped 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.