Try the interactive chat
Rich messages
Product and service cards, buttons, quick replies and files arrive as SoneMessage objects, each with a plain text fallback.
The assistant can share the same cards, buttons and documents as on WhatsApp. The API returns structured data you can render in your own UI. This structure is SoneMessage. Staff shares from Messages use the same format with role: "staff": Staff shares.
Where messages appear
| Format | Location |
|---|---|
| Chat Completions | Non-streamed: choices[0].message.sone.messages. Streamed: top-level sone.message on each message's final chunk. |
| Responses | sone on each message output item. |
| Vercel AI SDK | data-sone-catalog, data-sone-actions, data-sone-file, data-sone-staff. Assistant plain text uses text parts. |
| Conversation messages | data from GET /conversations/{id}/messages. |
AI SDK part data contains rendering fields rather than the complete SoneMessage: text, role, and catalog, actions or file. data-sone-staff is only for staff plain text. Staff cards, buttons and files use their respective part types with data.role: "staff". See Vercel AI SDK.
Plain text is always available
Every SoneMessage has populated text. Catalog text lists names, prices and links, so text-only clients can display it directly. Long catalog fallback text may be shortened while catalog.items remains complete: Catalog. OpenAI responses also include this text in content.
SoneMessage
export type SoneMessage = {
id: string; // "msg_…"
role: 'assistant' | 'staff' | 'user'; // staff: a team member wrote this
type: 'text' | 'catalog' | 'actions' | 'file';
text: string; // plain text fallback
created_at: number; // Unix saniyesi
catalog?: SoneCatalog;
actions?: SoneActions;
file?: SoneFile;
};
export type SoneCatalog = {
// Business-selected presentation: "card", "card_button", "image_text", "link_button", "text", "carousel" or "list"
format: string;
items: SoneCatalogItem[];
};
export type SoneCatalogItem = {
id: string; // Sonecentral record ID
kind: 'inventory' | 'offering'; // product or service
title: string;
description?: string;
price?: string; // formatted price: "1.290,00 TL"
detail?: string; // short detail such as stock or duration
attributes?: string; // attributes such as color or size, as plain text
image_url?: string;
url?: string; // product or service page
button_title?: string; // URL button label; present when url exists
};
export type SoneActions = {
format: 'buttons' | 'quick_replies' | 'card' | 'link_button';
title?: string;
text?: string;
image_url?: string;
buttons?: { title: string; url: string }[];
choices?: string[];
};
export type SoneFile = {
id: string; // "file_…"
name: string;
mime_type: string;
size: number; // bayt
url: string; // signed download URL
};role: "user" appears only in conversation message lists and always has type: "text". Turn responses contain business-side messages with role assistant or staff.
Empty optional fields such as description, price and image_url are omitted.
Catalog
Each share contains 1–10 records in the assistant's selected order.
{
"id": "msg_c4t2l9g7x1p3h8q",
"role": "assistant",
"type": "catalog",
"text": "Linen shirt, stone\n1.290,00 TL\nM and L in stock\nhttps://www.ornek.com/urun/keten-gomlek-tas\n\nLinen shirt, navy\n1.290,00 TL\nOnly M left\nhttps://www.ornek.com/urun/keten-gomlek-lacivert",
"created_at": 1760866872,
"catalog": {
"format": "carousel",
"items": [
{
"id": "r8k2m9p4q7w1x3z",
"kind": "inventory",
"title": "Linen shirt, stone",
"price": "1.290,00 TL",
"detail": "M and L in stock",
"image_url": "https://cdn.ornek.com/urun/keten-tas.jpg",
"url": "https://www.ornek.com/urun/keten-gomlek-tas",
"button_title": "View product"
},
{
"id": "r4q7w1x8k2m9a5z",
"kind": "inventory",
"title": "Linen shirt, navy",
"price": "1.290,00 TL",
"detail": "Only M left",
"url": "https://www.ornek.com/urun/keten-gomlek-lacivert",
"button_title": "View product"
}
]
}
}format reflects the business's chosen presentation. A single record uses its own format: card, card_button, image_text, link_button or text. Several records use carousel, or list if all were configured as lists. This is a hint; you can render a list as cards. Services use the same fields with kind: "offering"; detail usually carries duration.
text lists each record's name, description, price, short detail, attributes and URL on separate lines, with a blank line between records.
Fallback text is limited to 4,000 characters. Space is shared among records. Records that fit are included in full. Otherwise name, price and detail take priority, the URL is appended if it fits, and attributes and complete description sentences fill the remaining space. A field is included whole or omitted, never cut midway. Always render complete cards from catalog.items; use text as the fallback.
A listed size, color or model is not proof of stock. An untracked inventory card reports that current stock was not verified. Descriptions omit the known legacy assistant-only stock instruction; configured private attributes stay out of customer output. Generated rich-message text and file captions are checked before delivery when they claim an operation or follow a catalog read. A completesReply value does not bypass that check.
Buttons and quick replies
{
"id": "msg_a7c1t9n2k4q8z3w",
"role": "assistant",
"type": "actions",
"text": "Which day works for your appointment?\nThursday\nFriday\nSaturday",
"created_at": 1760866923,
"actions": {
"format": "quick_replies",
"text": "Which day works for your appointment?",
"choices": ["Thursday", "Friday", "Saturday"]
}
}format | Contents |
|---|---|
buttons | text and 1–3 options in choices. |
quick_replies | text and 1–13 options in choices. |
link_button | text and one link button in buttons. |
card | title, short text, optional image_url, and up to 3 link buttons in buttons. |
Choice and button titles are limited to 20 characters. Fallback text includes the title, body, link buttons as Title: URL, and choices on separate lines.
- Each link button carries a
urlto open in a new tab. - Clicking a choice sends its title as a new visitor message. The assistant treats it as typed text.
- Choices remain relevant until the visitor sends their next message. An assistant may send text after its choices; keep the choices until the visitor writes, then hide them.
Files
{
"id": "msg_f1l3d0c5x9w2m6r",
"role": "assistant",
"type": "file",
"text": "Fiyat listesi",
"created_at": 1760867010,
"file": {
"id": "file_p9x2k4m7q1w8e3t",
"name": "fiyat-listesi-2026.pdf",
"mime_type": "application/pdf",
"size": 188416,
"url": "https://pb.sonecentral.com/api/v1/files/file_p9x2k4m7q1w8e3t?exp=1761471810&sig=…"
}
}text is the file description, or its filename if no description exists. Render a link using <a download>. Files can be up to 32 MB.
Sharing the same file again in the same conversation preserves file.id. Another conversation gets its own ID and URL. Storage contains one copy regardless of how many conversations share it.
Downloading files
file.url is a signed GET /files/{id} URL:
GET https://pb.sonecentral.com/api/v1/files/{id}?exp={expiry}&sig={signature}- No API key is required; the URL signature is sufficient. Use the URL unchanged.
- URLs are valid for seven days. Reading conversation messages generates fresh URLs valid for another seven days.
- Downloads use
Content-Disposition: attachmentand preserve the filename, usingfilename*for Turkish characters. The browser does not render the file inside your page. - Invalid or expired URLs return
404 file_not_found. Temporarily unreadable files return503 file_unavailable. - Each file ID permits 10 downloads/hour, then
429 file_download_limit. Workspace daily egress defaults to 2 GiB per UTC day, then429 file_egress_limit. Both includeRetry-Afterand apply regardless of request address. - More than 120 download requests/minute from one address also returns
429; this body does not use OpenAI's error format.
Rendering
This function converts a SoneMessage to DOM. It uses textContent throughout, never innerHTML, to prevent fields from injecting code into your page.
function el(tag, props = {}, ...children) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(props)) {
if (value == null) continue;
if (key === 'class') node.className = value;
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
else node.setAttribute(key, value);
}
for (const child of children.flat()) {
if (child == null || child === false) continue;
node.append(typeof child === 'string' ? document.createTextNode(child) : child);
}
return node;
}
export function renderSoneMessage(message, { onChoose }) {
if (message.type === 'catalog' && message.catalog) {
return el(
'div',
{ class: 'sone-catalog' },
message.catalog.items.map((item) =>
el(
'article',
{ class: 'sone-item' },
item.image_url ? el('img', { src: item.image_url, alt: '', loading: 'lazy' }) : null,
el('strong', {}, item.title),
item.detail ? el('span', {}, item.detail) : null,
item.price ? el('span', { class: 'sone-price' }, item.price) : null,
item.url ? el('a', { href: item.url, target: '_blank', rel: 'noopener' }, item.button_title || 'View') : null,
),
),
);
}
if (message.type === 'actions' && message.actions) {
const { title, text, buttons = [], choices = [] } = message.actions;
return el(
'div',
{ class: 'sone-actions' },
title ? el('strong', {}, title) : null,
text ? el('p', {}, text) : null,
buttons.map((button) => el('a', { href: button.url, target: '_blank', rel: 'noopener' }, button.title)),
choices.map((choice) => el('button', { type: 'button', onclick: () => onChoose(choice) }, choice)),
);
}
if (message.type === 'file' && message.file?.url) {
return el('a', { class: 'sone-file', href: message.file.url, download: message.file.name }, message.file.name);
}
return el('p', { class: message.role === 'staff' ? 'sone-text sone-staff' : 'sone-text' }, message.text);
}Complete widget: Embedded chat widget.