Services

Persona Chat

Converse with a persona over plain REST — the same brain that powers the embedded widget, without any UI. Each call is one turn; the response streams back as Server-Sent Events. Conversation state lives server-side under a session_id.

POSThttps://api.personaizer.com/v1/chat
AuthX-Persona-Id / X-Api-Key
IdentityX-End-User-Id / X-End-User-Token
Cost10 credits
Rate60/min
Returnstext/event-stream

0 credits on operator-held turns · 0 credits on idempotent retry (same Idempotency-Key).

Body parameters

messagestringrequired

The user's message for this turn.

session_idstringoptional

GUID from a previous response's session event. Omit to start a new conversation; the server mints an id and returns it as the first event.

channelstringoptional

Free-form origin tag, e.g. "web" (≤128 chars). Persists once sent. Recommended from day one.

user_attributes{ name?, email?, phone? }optional

Reserved visitor fields (scalar values). Persisted on the conversation, shown in the operator console, and given to the persona as context. Unverified — display only; trusted identity comes from the signed token. For anything else, use metadata.

metadataobjectoptional

Arbitrary host context (plan, role, page…). Primitives only; capped at ≤20 keys, key ≤40 chars, value ≤255 chars, ~4 KB total — over the cap is a 422. Untrusted: used as context + in the console, never as identity (that's the signed token).

Identity headers

X-End-User-Idheader

Echo the server-issued visitor id from a prior session event so this turn stays the same visitor (powers per-visitor history, caps, and the operator console). Omit on the very first call — the server issues one and returns it in the session event. Don't invent your own.

X-End-User-Tokenheader

Instead of X-End-User-Id, a host-signed JWT (HS256, sub = your account id) to recognize a logged-in user across devices. See Authentication.

Optional headers

Idempotency-Keystring

A unique key (UUID or similar) for this turn. Retrying with the same key replays the original response without re-running the AI or charging credits — safe for unreliable networks. If omitted, every call is a new turn.

Attach an image. Send the request as multipart/form-data with a payload_json part (the exact same JSON body) plus one image file — JPEG, PNG, GIF, or WebP, up to 5 MB. The persona sees a vision analysis of it ("what's in this photo?"). One image per turn; message becomes optional when an image is attached. Without an image, send the body as application/json as usual. Use the file picker in the console, or:

chat with an image
curl -X POST "https://api.personaizer.com/v1/chat" \
  -H "X-Api-Key: $PERSONAIZER_API_KEY" \
  -F 'payload_json={"message":"What is this?","channel":"web"};type=application/json' \
  -F "image=@photo.jpg"
POSThttps://api.personaizer.com/v1/chat
Try it
{
  "message": "Trail running shoes under $120?",
  "channel": "web",
  "user_attributes": { "name": "Jane Doe", "email": "jane@acme.com", "phone": "+1 555 0100" },
  "metadata": { "plan": "pro", "page": "/pricing" }
}
No image chosen

Optional image — JPEG/PNG/GIF/WebP · ≤5 MB. Text is optional when an image is attached.

Response
event: session
data: {"session_id":"0f8c4a2e-1b2d-4c3e-9f4a-5b6c7d8e9f0a","end_user_id":"eu_3f9c1a"}

event: docs
data: {"turn_index":1,"cards":[{"title":"Red trail runners","price":109}]}

event: text
data: {"delta":"Let me check our trail runners…"}

event: text
data: {"delta":"\n\n"}

event: text
data: {"delta":"Yes — the Red trail runners are $109."}

event: done
data: {"turn_index":1}

Streamed events

Event order on the wire: session → (uploaded_images) → (docs | text | thinking | image_generating | blended_image)* → (held) → done | error.

Event reference
session{ session_id, end_user_id }

Always first. session_id continues the conversation (send it back in the body). end_user_id is your server-issued visitor identity — persist it and echo it back via the X-End-User-Id header on every conversation endpoint. Don't invent your own.

docs{ turn_index, cards[] }

Knowledge cards the persona cites this turn — title, image URLs, link, price when present. Zero or more, before or between text events.

text{ delta }

A chunk of response text. Concatenate in arrival order. A delta of "\n\n" marks a bubble boundary.

thinking{}

The persona is composing — render typing dots. Arrives before the first reply, and again between replies while it runs tools or looks things up. SSE-only and not persisted: you won't see it when replaying through the events stream.

uploaded_images{ image_ids[] }

The image you attached this turn finished storing. Fetch each at GET /v1/chat/{session_id}/images/{image_id} to render it in the user's bubble. Arrives once, before the reply — only on turns that included an image.

image_generating{ turn_index }

The persona is generating an image (e.g. blending your photo with a product). Show a placeholder; the finished image arrives as blended_image.

blended_image{ turn_index, url, title }

A persona-generated image for this turn — render it as an image bubble. Also persisted to the events stream, so reloads re-render it.

held{ turn_index }

An operator has taken over this conversation mid-turn. The turn completes with 0 credits charged; your UI should switch into operator-view mode and stop rendering further deltas as bot output.

done{ turn_index }

Terminal on success — the turn is committed to the conversation.

error{ code, message }

Terminal on failure — nothing committed; resending the same message is safe.

One turn can be several bubbles. The persona often narrates while it searches, so a turn may produce multiple consecutive persona messages. Render each as its own bubble. On the live stream the boundary is a text delta of "\n\n".

example stream
event: session
data: {"session_id":"0f8c4a2e-1b2d-4c3e-9f4a-5b6c7d8e9f0a","end_user_id":"eu_3f9c1a"}

event: docs
data: {"turn_index":1,"cards":[{"title":"Red trail runners","price":109}]}

event: text
data: {"delta":"Let me check our trail runners…"}

event: text
data: {"delta":"\n\n"}

event: text
data: {"delta":"Yes — the Red trail runners are $109."}

event: done
data: {"turn_index":1}
GEThttps://api.personaizer.com/v1/chat/{session_id}/events
AuthX-Persona-Id / X-Api-Key
Identitysession_id (path)
CostFree
Returnstext/event-stream

Events stream

Replay the full conversation history as SSE events, then optionally stay open for new ones. Use this to rehydrate a chat UI after a page refresh, or to live-tail a conversation from another surface. Access is the unguessable session_id in the path — no separate visitor identity needed.

Query parameters

liveboolean

Default true — replays past events then stays open for new ones. Set to false for one-shot rehydration: replays history and closes.

sincestring

Cursor from a previous caught_up event's last_event_id. Replays only events after this position. Browsers using EventSource can also pass the equivalent Last-Event-ID header for automatic resume.

Events

message{ role, content, turn_index, ts, image_ids? }

A past or live message. role is "user" or "persona". When the visitor attached an image, image_ids is present — fetch each at GET /v1/chat/{session_id}/images/{image_id} and render it in the user's bubble. Render all messages sharing a turn_index as one turn's bubbles.

cards{ turn_index, cards[] }

Knowledge cards attached to a turn. Zero or more per turn, in the same order as the original stream.

blended_image{ turn_index, url, title }

A persona-generated image from this turn, replayed below its turn so a reload re-renders it. url is directly renderable.

typing{ operator_name, stopped }

Transient "an agent is typing" signal while an operator composes a reply. stopped=true means they stopped (cleared the box / idle / sent) — hide the indicator immediately; otherwise show it and keep a short timeout as a safety net. Not replayed and never part of the transcript.

caught_up{ last_event_id }

Marks the boundary between replayed history and live events. Save last_event_id as your ?since= cursor for the next connection.

:keepalive

Sent every ~20 s on idle connections to prevent proxy timeouts. No data — ignore it.

GEThttps://api.personaizer.com/v1/chat/{session_id}/events
Try it
Response
event: message
data: {"role":"user","content":"Trail shoes under $120?","turn_index":1,"ts":1718000000000}

event: message
data: {"role":"persona","content":"Let me check our trail runners…","turn_index":1,"ts":1718000001200}

event: cards
data: {"turn_index":1,"cards":[{"title":"Red trail runners","price":109}]}

event: message
data: {"role":"persona","content":"Yes — the Red trail runners are $109.","turn_index":1,"ts":1718000002600}

event: caught_up
data: {"last_event_id":"01J2MN-001"}

:keepalive
POSThttps://api.personaizer.com/v1/chat/{session_id}/typing
AuthX-Persona-Id / X-Api-Key
IdentityX-End-User-Id / X-End-User-Token
CostFree
Returns200 (no body)

Visitor is typing

Signal that the visitor is composing, so a live operator sees a typing indicator. A bodyless presence ping — throttle it client-side (about once a second while the user types). Pass ?stopped=true when they clear the box, go idle, or send, so the operator's indicator hides at once. It's transient: never replayed and never stored in the transcript. Entirely optional; chat works the same without it.

POSThttps://api.personaizer.com/v1/chat/{session_id}/typing
Try it
Response
(no body)

Sessions & continuity

Continuing

Send the session_id from any previous response to continue that conversation — across separate HTTP calls, processes, or hosts.

One turn at a time

Turns on one session are serialized. A concurrent call on the same session_id returns 409 session.turn_in_flight.

Expiry

Conversations idle > 24h expire. Reusing the old session_id starts a fresh conversation under the same id (no error).

Idempotency

Pass a unique Idempotency-Key header to make retries safe on unreliable networks — the same key returns the cached response without billing or re-running the AI.

Build your own UI

A free read (0 credits) for the scaffolding around your own chat UI — list a visitor's past conversations. Authenticates with the public Persona ID (X-Persona-Id header), so it's safe to call straight from the browser.

GEThttps://api.personaizer.com/v1/chat/conversations
AuthX-Persona-Id / X-Api-Key
IdentityX-End-User-Id / X-End-User-Token
CostFree

A visitor's conversations

A visitor's past conversations — backs a history list / “New conversation” UX. Newest-first, capped at 50, within the 24h window.

Identity headers

X-End-User-Idheader

The server-issued visitor id from the session event of POST /v1/chat — identifies whose conversations to list. An unknown or absent id returns an empty list, so you only see conversations for the id you hold. (Server-side API-key callers may pass any id.)

X-End-User-Tokenheader

Instead of X-End-User-Id, a host-signed JWT (sub = your account id) — lists by the signed identity, so a logged-in user's history follows them across devices. See Authentication.

Send one of the two.

Each conversation

session_idstring

Open it with GET …/{session_id}/events?live=false to rehydrate, then continue via POST /v1/chat.

updated_atnumber

Epoch milliseconds of the last message.

titlestring

The first user message, truncated.

previewstring

The last message, truncated.

message_countnumber

Messages in the conversation.

GEThttps://api.personaizer.com/v1/chat/conversations
Try it
Response
{
  "conversations": [
    {
      "session_id": "0f8c4a2e-1b2d-4c3e-9f4a-5b6c7d8e9f0a",
      "updated_at": 1750200000000,
      "title": "first user message…",
      "preview": "last message…",
      "message_count": 6
    }
  ]
}

Usage from your backend

Read the SSE stream from fetch — EventSource can't POST.

This server example uses the API key (X-Api-Key). A browser-based chat UI calling /v1/chat directly instead sends the public Persona ID (X-Persona-Id) — exactly what the built-in chat widget does.

The persona answers from its knowledge — sync documents first via the Knowledge API. For a ready-made conversational experience with zero backend code, embed the Live Persona (3D) instead.

server.js
// Server-side only. POST + SSE means EventSource won't work;
// read the stream from fetch.
const res = await fetch("https://api.personaizer.com/v1/chat", {
  method: "POST",
  headers: {
    "X-Api-Key": process.env.PERSONAIZER_API_KEY,
    "Content-Type": "application/json",
    // Echo the server-issued visitor id from a prior session event (omit on the first call):
    ...(endUserId && { "X-End-User-Id": endUserId }),
  },
  body: JSON.stringify({ session_id, message, channel: "web" }),
});
if (!res.ok) throw new Error(`chat failed: ${res.status}`); // 409 = turn in flight

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });
  const frames = buffer.split("\n\n");      // SSE frames split on blank line
  buffer = frames.pop() ?? "";
  for (const frame of frames) {
    const event = /^event: (.*)$/m.exec(frame)?.[1];
    const data = JSON.parse(/^data: (.*)$/m.exec(frame)?.[1] ?? "{}");
    switch (event) {
      case "session": session_id = data.session_id; break;
      case "docs":    renderCards(data.cards); break;
      case "text":    appendText(data.delta); break;  // "\n\n" = new bubble
      case "held":    showOperatorBanner(); break;
      case "error":   throw new Error(`${data.code}: ${data.message}`);
    }
  }
}

Errors

Failures before the stream are JSON problem responses with these statuses; failures mid-stream arrive as a terminal error event instead.

400validation.failedMissing/oversized message, over-128-char end_user_id/channel, or a session_id that isn't a GUID issued by this API.
401auth.api_key_invalidThe key is invalid, disabled, or was regenerated.
403auth.forbiddenThe session_id belongs to a different persona's conversation.
409session.turn_in_flightA turn is already running on this session — wait for it to finish, then retry.
429limits.quota_exceededCredit quota exceeded, or the per-key rate limit (60/min) was hit.

See the full error code catalog.