Embedding support chat
Build a chat screen against the Public API end to end — start from your backend, hand a token to the client, poll, and reply.
This is the full lifecycle of a Public API chat integration: a support screen in your mobile app or product, backed by your own UI, feeding the same agent inbox the widget feeds.
The shape, in one sentence: your backend starts the conversation and keeps the key; the client does the talking with a token scoped to that one conversation.
1. Start the conversation, from your backend
Your server holds the pk_ key and calls
POST /api/v1/public/conversations
with the user's first message.
// On your server. The publishable key must never reach the client.
const res = await fetch(`${BASE_URL}/api/v1/public/conversations`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ASSISTIVE_PUBLISHABLE_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
external_id: user.id, // ← your stable ID for this user. See below.
name: user.name,
email: user.email,
message: firstMessage,
}),
});
const { data } = await res.json();Always send external_id
external_id is optional, and skipping it is the most common way to make a mess that's
hard to clean up later.
On every start, the server resolves which customer record the conversation belongs to, in this exact order:
external_idmatches an existing customer → reuse it.- Otherwise,
emailmatches an existing customer → reuse it. - Otherwise → create a new customer.
Because your stable ID is checked first, a user who later changes their name or email in
your app still maps to the same Assistive customer, and the agent sees one continuous
history. Pass only name/email — or nothing — and one human can end up fragmented
across many customer records, each holding a slice of their story. The agent picking up
their third conversation has no idea about the first two.
Routing runs on creation
Conversations started this way are recorded on the api channel, and your
organization's auto-routing rules run as the conversation is created. A rule may assign
it to a team, and that's written to the conversation timeline and the audit trail.
You neither control nor see this from the API. It's simply why a conversation may already belong to a team by the time an agent opens it. (Only team routing applies to a conversation — priority and tag rules are ticket concerns and do nothing here.)
2. Store the token against your user
The response carries the conversation_id and, once and only once, the visitor_token.
// Same request handler — persist before you do anything else with the response.
await db.assistiveThreads.insert({
userId: user.id,
conversationId: data.conversation_id,
visitorToken: data.visitor_token, // never shown again
});If you drop the token, the conversation is unreachable
visitor_token is stored server-side only as a hash. It cannot be listed, looked up, or
re-sent. Lose it and the user's thread still exists — your agents can see it and reply
to it — but your client can never read it again. The only way forward is to start a new,
empty conversation.
Write it to your database in the same transaction that handles the response, before any code that might throw.
3. Hand only the token to the client
// Your endpoint's response to your own app.
return {
conversationId: data.conversation_id,
visitorToken: data.visitor_token,
};The client now has everything it needs and nothing it shouldn't have. The pk_ key stays
on your server. If the token leaks, the damage is bounded to this one conversation — see
Authentication.
4. Poll for messages
From here the client talks to Assistive directly.
GET /conversations/{id}/messages
returns the customer-visible thread.
There is no cursor on this endpoint
The Public API returns the whole thread every time. There is no after parameter, and
anything of the sort you send is ignored rather than rejected — so an assumed cursor fails
silently, and you re-render the whole thread on every tick.
The client must diff on message id itself: keep the set of IDs you've rendered and
append only the ones you haven't seen.
// In your app.
const seen = new Set<string>();
let interval = 3000;
async function poll() {
const res = await fetch(
`${BASE_URL}/api/v1/public/conversations/${conversationId}/messages`,
{ headers: { Authorization: `Bearer ${visitorToken}` } },
);
if (res.status === 429) {
interval = Math.min(interval * 2, 60_000); // back off, don't hammer
return schedule();
}
interval = isChatOpen ? 3000 : 30_000; // recovered
const { data } = await res.json();
const fresh = data.filter((m) => !seen.has(m.id));
for (const m of fresh) seen.add(m.id);
render(fresh);
schedule();
}
function schedule() {
setTimeout(poll, interval);
}A polling strategy that behaves:
- Every 3–5 seconds while the chat screen is open and focused. That's fast enough to feel live, and it stays clear of the limit.
- Back right off when it isn't. Every 30 seconds in the background, or stop entirely and fetch once on resume. A chat screen nobody is looking at does not need to be live.
- Double the interval on
429and keep doubling, up to a ceiling. Retrying a rate limit at the same rate that caused it just extends the outage. - Diff on
id, never on array length or timestamps.
Don't poll every second — that's the limit exactly
Visitor-token endpoints are limited per token, at 60 requests/minute by default. A
one-second poll sits precisely on that line, so the first retry, duplicate timer, or
moment of jitter tips you into 429.
Every conversation has its own budget, so you can't starve another user by getting this wrong — you'll only throttle the very customer you're trying to serve.
Messages come back as {id, author, body, created_at} — author is customer or
agent. That's the entire shape, deliberately: no staff names, no internal notes, no
metadata. It's safe to render directly, with nothing to strip first.
There is no webhook or streaming surface today
Polling is the mechanism. Assistive has no webhooks, no server-sent events, and no WebSocket for this surface. Don't design around one arriving.
5. Post the user's replies
POST /conversations/{id}/messages with
the same token.
await fetch(
`${BASE_URL}/api/v1/public/conversations/${conversationId}/messages`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${visitorToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ body: text }),
},
);The response is the created message. Render it optimistically and let the next poll
reconcile — and because you're diffing on id, the message you just posted won't render
twice when it comes back around.
Attachments aren't available here. There is no attachment_ids field, no multipart
variant, and no upload endpoint on the Public API. File attachments exist only in
the widget — so if screenshots matter to your
support flow, weigh that before committing to this path.
Returning users
A user who comes back should land in the conversation they already have. Your database is
what makes that work — you stored conversationId and visitorToken against their user
ID in step 2, so look those up and skip straight to step 4.
Start a new conversation only when the user asks for one, not on every app launch. Otherwise you'll mint a new token and a new thread each time they open the screen, and your agents will watch one person's problem arrive as five disconnected conversations.
When a token stops working
A stored visitor token won't work forever, and there are two ways it can go dead:
- It expired. Tokens expire after 30 days of disuse — but the window slides, so every read and every post pushes it out again. An active conversation never ages out; one nobody has touched in a month does.
- An agent revoked it from the dashboard, which cuts the client off immediately.
Both come back the same way, and so does a token that was never valid:
401 UNAUTHORIZED — the visitor token is invalidYou can't tell the three apart, and you don't need to — the API keeps a token's state unprobeable on purpose. There is no refresh flow. So handle all of it with one branch:
if (res.status === 401) {
// Expired, revoked, or bad — same remedy either way.
const { conversationId, visitorToken } = await startNewConversation(user, text);
await db.assistiveThreads.upsert({ userId: user.id, conversationId, visitorToken });
return;
}The user gets a fresh thread rather than a resurrected one. That's the intended behaviour,
not a workaround — but it does mean a 401 is a normal thing your client will see after
a long silence, not an error to log and forget.