What you are embedding
Flowlith currently provides a server-to-server chat API, not a hosted chat widget. There is no Flowlith script URL or iframe to paste into a page. The example below gives your developer a small text-chat interface and a backend handler to connect to it.
The browser calls only your own website. Your backend authenticates the customer, holds the Flowlith key, remembers the pending message, and reads the reply stream. This example returns the completed reply to the browser as JSON; it is not a token-by-token animation. Your hosting must allow a request lasting up to about five minutes and must not cache this route.
Read the Website API contract first. For a no-code site without a backend, ask its developer to add a secure server-side integration. Putting a key in a page builder, browser automation, or a public build variable is not an alternative.
1. Prepare your backend adapters
Reuse your site's existing authentication and database. The handler below is complete request/SSE logic, not a standalone application: wire these adapters to your real project before using it. Do not replace them with an unverified browser ID or an in-memory Map in production.
| Adapter | Required behavior |
|---|---|
origin |
Your exact public HTTPS origin, such as https://shop.example.com. |
apiKey |
Read FLOWLITH_API_KEY from the server secret manager. Never send it to the browser. |
authenticate(request) |
Verify your site's session cookie and return { id }, or null. Map the account to a stable API-compatible ID. For guests, issue a random identity in a signed, HttpOnly, Secure, SameSite cookie. |
allowRequest(customerId) |
Enforce your application's shared per-customer quota, returning a boolean. Add normal edge/IP abuse protection too. Fail closed if the limiter is unavailable. |
store.current(customerId) |
Read only this customer's latest record, or return null. |
store.prepare(customerId, text) |
In one durable transaction, return the existing pending record unchanged, or create a record with a fresh UUID messageId, exact text, millisecond createdAt, and state: "pending". Permit only one pending record per customer, across tabs and servers. |
store.finish(customerId, messageId, result) |
Atomically save the terminal reply/error only if this customer's pending ID matches. Keep the ID, original text and creation time; make repeated completion calls idempotent. Return the saved record. Never overwrite a different/newer message. |
All storage queries must be scoped to the authenticated customer and your business. Use your own normal transcript retention policy; never log the key or raw request headers. Keep pending records through restarts. The example deliberately does not add a second authentication system or a new database.
Preparation and sending are separate: the backend saves the request before any API call. If the browser reloads, it recovers the saved message instead of inventing another ID. If an unresolved request is more than 24 hours old, investigate it; do not silently discard it or send it as a new message.
2. Add the backend handler
Save this as a server-only module and route /api/iris to the returned handler. It uses the standard Fetch API (Request, Response, streams and fetch). Supply the adapters from step 1 at application startup. Do not bundle this file for the browser.
export function createIrisChat({ origin, apiKey, authenticate, allowRequest, store }) {
const json = (body, status = 200, extra = {}) => new Response(JSON.stringify(body), {
status, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...extra }
});
return async function handle(request) {
try {
const url = new URL(request.url);
if (url.pathname !== '/api/iris') return json({ error: 'not_found' }, 404);
if (!['GET', 'POST'].includes(request.method)) return json({ error: 'method' }, 405, { Allow: 'GET, POST' });
// Use the configured public origin, not a client-controlled forwarding header.
if (request.method === 'POST' && request.headers.get('Origin') !== origin)
return json({ error: 'origin' }, 403);
const customer = await authenticate(request);
if (!customer) return json({ error: 'sign_in_required' }, 401);
if (!/^[A-Za-z0-9._:@+-]{1,128}$/.test(customer.id)) throw Error('identity_mapping');
if (!await allowRequest(customer.id))
return json({ error: 'slow_down' }, 429, { 'Retry-After': '60' });
if (request.method === 'GET') return json({ record: await store.current(customer.id) });
if (request.headers.get('Content-Type')?.split(';')[0].trim() !== 'application/json')
return json({ error: 'json_required' }, 415);
const reader = request.body?.getReader();
if (!reader) return json({ error: 'invalid_input' }, 400);
let body = '', bytes = 0;
const decoder = new TextDecoder();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > 16000) { await reader.cancel(); return json({ error: 'too_large' }, 413); }
body += decoder.decode(value, { stream: true });
}
} finally { reader.releaseLock(); }
let input;
try { input = JSON.parse(body + decoder.decode()); }
catch { return json({ error: 'invalid_json' }, 400); }
if (!input || typeof input !== 'object' || Array.isArray(input)) return json({ error: 'invalid_input' }, 400);
if (input.operation === 'prepare') {
if (Object.keys(input).some(key => !['operation', 'text'].includes(key)) ||
typeof input.text !== 'string' || !input.text.trim() || input.text.length > 2000)
return json({ error: 'invalid_message' }, 400);
// Atomically reuse an existing pending row, or persist a new UUID + exact text.
return json({ record: await store.prepare(customer.id, input.text.trim()) });
}
if (input.operation !== 'send' || Object.keys(input).length !== 1)
return json({ error: 'invalid_operation' }, 400);
const pending = await store.current(customer.id);
if (!pending || pending.state !== 'pending') return json({ record: pending });
if (Date.now() - pending.createdAt >= 24 * 60 * 60 * 1000)
return json({ error: 'old_pending_message_contact_support', retryable: false, record: pending }, 409);
const response = await fetch('https://api.flowlith.com/v1/chat', {
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(310000),
headers: { Authorization: 'Bearer ' + apiKey, 'Content-Type': 'application/json', Accept: 'text/event-stream' },
body: JSON.stringify({ user_id: customer.id, message_id: pending.messageId, text: pending.text })
});
if (!response.ok) {
await response.body?.cancel();
if ([400, 401, 403, 413].includes(response.status))
return json({ error: 'owner_setup_required', retryable: false, record: pending }, 502);
return json({ error: 'iris_unavailable', record: pending }, response.status === 429 ? 429 : 503,
response.status === 429 ? { 'Retry-After': response.headers.get('Retry-After') || '60' } : {});
}
if (!response.headers.get('Content-Type')?.includes('text/event-stream')) {
await response.body?.cancel(); throw Error('unexpected_response');
}
const result = await readIrisReply(response.body, pending.messageId);
if (result.retryable) return json({ error: 'retry_saved_message', record: pending }, 503);
const record = await store.finish(customer.id, pending.messageId, result);
return json({ record });
} catch {
// A lost response may already have completed. Keep the saved ID for recovery.
return json({ error: 'unconfirmed_reload_and_retry_saved_message' }, 503);
}
};
}
export async function readIrisReply(body, messageId) {
if (!body) throw Error('missing_stream');
const reader = body.getReader(), decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { value, done } = await reader.read();
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
// shortcut: cap one SSE frame at 256 KiB; raise only after reviewing UI limits.
if (buffer.length > 262144) throw Error('frame_too_large');
let boundary;
while ((boundary = /\r?\n\r?\n/.exec(buffer))) {
const frame = buffer.slice(0, boundary.index);
buffer = buffer.slice(boundary.index + boundary[0].length);
let event = 'message'; const data = [];
for (const line of frame.split(/\r?\n/)) {
if (line.startsWith('event:')) event = line.slice(6).trim();
if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
}
if (!['reply', 'error'].includes(event)) continue; // accepted and keepalive
const result = JSON.parse(data.join('\n'));
if (result.message_id !== messageId) throw Error('message_mismatch');
if (event === 'reply' && result.ok === true && ['sent', 'deduplicated'].includes(result.status) &&
typeof result.reply === 'string' && result.reply.length)
return { state: 'reply', reply: result.reply };
if (event === 'error' && result.ok === false && typeof result.retryable === 'boolean')
return { state: 'error', error: 'Iris could not complete this message.', retryable: result.retryable };
throw Error('invalid_terminal_event');
}
if (done) throw Error('stream_ended_without_result');
}
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
}
The backend consumes SSE frames incrementally, including frames split between network chunks and CRLF line endings. Only a matching terminal reply is success. A retryable error, timeout, network failure, or missing terminal frame leaves the original message pending. Rejected authentication or invalid requests stop ordinary retries until the owner fixes the setup. There is no API polling endpoint: Retry saved message reconnects the same POST with its saved ID and text.
The strict Origin check is for this same-origin browser example. Keep your existing session/CSRF protections. Do not enable wildcard CORS, accept a browser-supplied user_id, forward arbitrary client headers, or take the API destination from the browser.
3. Add the chat form
Place this HTML where chat should appear. Serve the JavaScript in step 4 as /iris-chat.js from your own site. Use your existing design system and content-security policy; the example needs no third-party browser script.
<form id="iris-chat" aria-label="Chat with Iris">
<label for="iris-message">Your message</label>
<textarea id="iris-message" name="message" maxlength="2000" rows="3" required disabled></textarea>
<button id="iris-send" type="submit" disabled>Send message</button>
<button id="iris-retry" type="button" hidden>Retry saved message</button>
</form>
<p id="iris-status" role="status" aria-live="polite">Loading chat…</p>
<div id="iris-reply" aria-label="Iris reply" style="white-space:pre-wrap"></div>
<script src="/iris-chat.js" defer></script>
4. Add the browser JavaScript
This contains no API key. Responses are inserted with textContent, not HTML. The saved record comes from your authenticated backend, so it survives reloads without trusting a customer identity from local storage.
const form = document.querySelector('#iris-chat');
const input = document.querySelector('#iris-message');
const send = document.querySelector('#iris-send');
const retry = document.querySelector('#iris-retry');
const status = document.querySelector('#iris-status');
const output = document.querySelector('#iris-reply');
let record = null;
let recoverAt = 0;
let setupRequired = false;
async function call(operation, text) {
const response = await fetch('/api/iris', operation ? {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operation, ...(text === undefined ? {} : { text }) })
} : { credentials: 'same-origin', cache: 'no-store' });
const result = await response.json();
if ('record' in result) record = result.record;
if (!response.ok) {
if (result.retryable === false) {
setupRequired = true;
throw Error('Ask the business owner to fix this request or account setup, then reload. Do not send a replacement message.');
}
if (response.status === 429) {
recoverAt = Date.now() + Number(response.headers.get('Retry-After') || 60) * 1000;
throw Error('Please wait before retrying the saved message.');
}
throw Error(response.status === 401 ? 'Sign in to your website account first.' :
'Not confirmed. Retry the saved message; if it keeps failing, contact the business.');
}
}
function render() {
input.disabled = send.disabled = record?.state === 'pending';
retry.hidden = record?.state !== 'pending' || setupRequired;
if (record) input.value = record.state === 'pending' ? record.text : '';
output.textContent = record?.state === 'reply' ? record.reply : '';
status.textContent = record?.state === 'pending' ? 'Message saved. Retry to recover its reply.' :
record?.state === 'error' ? record.error : 'Ready. Please do not send passwords or payment details.';
}
async function work(task) {
input.disabled = send.disabled = retry.disabled = true;
status.textContent = 'Waiting for Iris…';
try { await task(); render(); }
catch (error) {
// Reload state before another attempt; never create a replacement message here.
try { await call(); render(); } catch { input.disabled = send.disabled = true; retry.hidden = setupRequired; }
status.textContent = error.message;
} finally { retry.disabled = false; }
}
form.addEventListener('submit', event => {
event.preventDefault();
const text = input.value.trim();
if (!text || Date.now() < recoverAt) return;
work(async () => { await call('prepare', text); await call('send'); });
});
retry.addEventListener('click', () => {
if (setupRequired) return;
if (Date.now() < recoverAt) { status.textContent = 'Please wait before retrying.'; return; }
work(async () => { await call(); if (record?.state === 'pending') await call('send'); });
});
work(() => call());
5. Test before exposing chat to customers
- Verify two signed-in customers cannot see or resume each other's message. Tampered/expired guest cookies must not choose an identity.
- Start a message, reload the page, and recover the same saved ID. Repeat with two tabs and a dropped network response.
- Split SSE frames across arbitrary bytes, including a multibyte character. Check keepalives, CRLF, a missing terminal event, and a mismatched message ID.
- Test an expired key, rate limiting, exhausted credits, inactive Iris, and a stream timeout. Show a real error, never a fabricated reply.
- Inspect the browser bundle and network responses: the Flowlith key must never appear. Do not log it in server errors either.
- Confirm your host permits the long request. If it does not, use a durable background job and your own authenticated status route; do not substitute a public Flowlith polling endpoint, because none exists.
- Send a real customer-style test message with permission. It can use a reply credit and any enabled Actions may have real effects.
This example is text-only and shows the latest saved message, not a complete transcript. Add transcript rendering using your existing customer-scoped storage if needed. For supported photo/voice URLs and exact limits, use the Website API reference. For external-system calls from Iris, use Actions.