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.
https://api.personaizer.com/v1/chat0 credits on operator-held turns · 0 credits on idempotent retry (same Idempotency-Key).
Body parameters
messagestringrequiredThe user's message for this turn.
session_idstringoptionalGUID 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.
channelstringoptionalFree-form origin tag, e.g. "web" (≤128 chars). Persists once sent. Recommended from day one.
user_attributes{ name?, email?, phone? }optionalReserved 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.
metadataobjectoptionalArbitrary 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-IdheaderEcho 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-TokenheaderInstead 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-KeystringA 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:
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"https://api.personaizer.com/v1/chat{
"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" }
}Optional image — JPEG/PNG/GIF/WebP · ≤5 MB. Text is optional when an image is attached.
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".
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}https://api.personaizer.com/v1/chat/{session_id}/eventsEvents 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
livebooleanDefault true — replays past events then stays open for new ones. Set to false for one-shot rehydration: replays history and closes.
sincestringCursor 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.
:keepaliveSent every ~20 s on idle connections to prevent proxy timeouts. No data — ignore it.
https://api.personaizer.com/v1/chat/{session_id}/eventsevent: 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"}
:keepalivehttps://api.personaizer.com/v1/chat/{session_id}/typingVisitor 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.
https://api.personaizer.com/v1/chat/{session_id}/typing(no body)Sessions & continuity
ContinuingSend the session_id from any previous response to continue that conversation — across separate HTTP calls, processes, or hosts.
One turn at a timeTurns on one session are serialized. A concurrent call on the same session_id returns 409 session.turn_in_flight.
ExpiryConversations idle > 24h expire. Reusing the old session_id starts a fresh conversation under the same id (no error).
IdempotencyPass 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.
https://api.personaizer.com/v1/chat/conversationsA 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-IdheaderThe 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-TokenheaderInstead 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_idstringOpen it with GET …/{session_id}/events?live=false to rehydrate, then continue via POST /v1/chat.
updated_atnumberEpoch milliseconds of the last message.
titlestringThe first user message, truncated.
previewstringThe last message, truncated.
message_countnumberMessages in the conversation.
https://api.personaizer.com/v1/chat/conversations{
"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-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.
validation.failedMissing/oversized message, over-128-char end_user_id/channel, or a session_id that isn't a GUID issued by this API.auth.api_key_invalidThe key is invalid, disabled, or was regenerated.auth.forbiddenThe session_id belongs to a different persona's conversation.session.turn_in_flightA turn is already running on this session — wait for it to finish, then retry.limits.quota_exceededCredit quota exceeded, or the per-key rate limit (60/min) was hit.See the full error code catalog.