sone centraldocs
Try the interactive chat

Sample store

Sone assistant

Do you have linen shirts in size M?

Yes! We have two colors left in M.

Linen shirt, stone

M and L in stock

1.290,00 TL

View product

Stone, relaxed fit. Explore sizes M and L in this example.

Linen shirt, navy

Only M left

1.290,00 TL

View product

Navy, relaxed fit. Explore size M in this example.

Uses sample data; does not start a real conversation.

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

FormatLocation
Chat CompletionsNon-streamed: choices[0].message.sone.messages. Streamed: top-level sone.message on each message's final chunk.
Responsessone on each message output item.
Vercel AI SDKdata-sone-catalog, data-sone-actions, data-sone-file, data-sone-staff. Assistant plain text uses text parts.
Conversation messagesdata 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

sone-message.ts
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"]
  }
}
formatContents
buttonstext and 1–3 options in choices.
quick_repliestext and 1–13 options in choices.
link_buttontext and one link button in buttons.
cardtitle, 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 url to 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: attachment and preserve the filename, using filename* 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 return 503 file_unavailable.
  • Each file ID permits 10 downloads/hour, then 429 file_download_limit. Workspace daily egress defaults to 2 GiB per UTC day, then 429 file_egress_limit. Both include Retry-After and 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.

render-sone-message.js
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.