Website widgets: contact forms and live chat

Smailor gives you two kinds of widgets for your website. Both deliver into your Smailor inbox, so every message is a thread your team can answer from the mailbox, the mobile app or Discord.

Contact form Live chat
What the visitor does Sends one message and leaves Has a conversation without leaving the page
What you get A new email thread per submission One thread per conversation, updated live
How you answer By email In the visitor's chat window, and by email if they left
Set up in Settings / Widgets / Contact forms Settings / Widgets / Live chat

Both are configured in Settings / Widgets and are included on every paid plan (Solo, Starter, Pro and Business). On the Free plan, widgets can be designed but do not deliver. Product overview: smailor.com/live-chat. This guide covers setup, every option, and the full JavaScript and HTTP APIs so you can build your own interface.


Publishable keys

A widget is bound to one of your addresses and identified by a publishable key that starts with wk_.

  • The key is meant to be in your page source. It can do exactly one thing: deliver a message into the address the widget is bound to. It cannot read your inbox, send email on your behalf, or reach any other address.
  • Allowed websites restrict where the key works. List your origins (https://example.com, one per line). A leading wildcard is allowed: https://*.example.com. Leave empty to allow any website.
  • The allowlist is enforced on browser requests, which always carry an Origin header. A request with no Origin (your own server, curl) is accepted, since a non-browser client could fake the header anyway. Use rate limits and validation on your side if you call the API from a server.
  • If a key is abused, Issue a new key in the widget settings. The old key stops working immediately; update the embed on your site.
  • Deleting a widget removes it from your site. Past conversations stay in your inbox.

Your account API key (Settings / API) grants full account access. Never put it in a web page. The widgets never need it.


Contact forms

Setup

  1. Open Settings / Widgets / Contact forms.
  2. Pick the recipient address and the layout (full form, floating button, inline row, minimal).
  3. Under How the form authenticates, keep Publishable key and click Create publishable key. Optionally list your website under Allowed websites.
  4. Copy the snippet into your page. It has no dependencies.

Submissions arrive as inbound email: auto-reply rules, routing rules, AI triage and Discord notifications apply exactly as for a real email.

Endpoint

POST https://smailor.com/api/widget/submit

Headers:

Content-Type: application/json
X-Widget-Key: wk_your_publishable_key

Body:

Field Type Required Notes
from string (email) yes The visitor. Becomes the sender of the thread.
name string no Visitor display name, max 255.
subject string yes 1 to 998 characters.
message string yes Plain text, max 100,000 characters.
attachments array no { filename, mimeType?, contentBase64 }. Default limits: 3 files, 5 MB each, 10 MB total. File types are checked from their content, not their name.
widgetKey string no Alternative to the X-Widget-Key header.

With a publishable key the recipient is the widget's address; a to field is ignored.

Responses:

Status error Meaning
200 { "ok": true, "messageId": "<[email protected]>", "attachments": 0 }
400 invalid_body, attachment errors Show message to the visitor when present.
401 unauthorized Unknown, disabled or rotated key, or the address was removed.
403 origin_not_allowed The page is not in the widget's allowed websites.
429 rate_limited 10 submissions per 10 minutes per visitor IP per form.
503 service_unavailable Retry later.

Server-side and legacy mode

POST /api/widget/submit also accepts your account API key (Authorization: Bearer ... or X-API-Key) from your own backend. In that mode to is required and must be one of your active addresses. Snippets generated before publishable keys existed keep working unchanged.


Live chat

How it works

  1. The visitor opens the chat on your site, enters their email (and name, if you ask for it) and writes a first message.
  2. That message becomes a new thread in your inbox, through the same pipeline as an email: routing rules, groups, AI triage and contacts all apply. Email auto-replies are not sent for chats; the widget shows its own greeting and away message.
  3. Every later message from the visitor is added to the same thread instantly, and the thread is highlighted as unread.
  4. You answer the thread like any email, from:
    • the Smailor inbox (web or mobile app),
    • the Discord thread of the ticket (type directly in it, see below),
    • the Reply button on the Discord ticket card.
  5. Your answer appears in the visitor's chat window within a second. If the visitor has left the page and Email replies the visitor is not there to read is on, the answer is also sent to their email address. When they answer that email, it lands in the same thread and shows up in the chat too.
  6. Resolving or closing the thread closes the conversation in the widget. If the visitor writes again, the thread reopens.

While a reply is being written in the inbox, the visitor sees "is typing". While the visitor types, the thread shows "is typing" too, along with whether they are still on the page.

Install

Create a live chat in Settings / Widgets / Live chat, then paste the embed line before </body> on every page where the chat should appear:

Script attributes:

Attribute Effect
data-widget Required. The publishable key.
data-hide-launcher="true" Hides the round button. Open the chat from your own UI with SmailorChat.open().
data-open="true" Opens the panel when the page loads.
data-api API origin override, for self-hosted Smailor. Defaults to the script's origin.
data-title, data-accent-color, data-labels... Override the look and texts on this page, see Customize per page.

The widget renders inside a Shadow DOM, so your site's CSS cannot break it and its CSS cannot leak into your site. It works on mobile (full screen under 480 px wide), supports keyboard navigation (Escape closes), and stores the visitor's conversation in localStorage so it survives page loads. If storage is blocked, the chat still works for the current page.

Configuration

Every option is edited in the widget settings, with a live preview. The settings are the defaults: each page can override the look and the texts (see Customize per page). Behaviour (availability, email fallback, identity verification, ticket subject, agent names, busy-time limits) is only set in Smailor.

Option Default Effect
Title Chat with us Panel header.
Status line when someone is available We usually reply in a few minutes. Under the title, with a green dot.
Status line when nobody is available We are away right now... Under the title, with a grey dot.
Greeting Hi there. How can we help? First bubble shown to every visitor. Empty for none.
Team name Support Signs agent messages when agent names are off.
Launcher label empty Text on the launcher. Empty shows a round icon button.
Ticket subject prefix Live chat Thread subject: Live chat: Jane Doe.
Accent color #2563eb Avatar, launcher, visitor bubbles and buttons. Text color adapts for contrast.
Position Bottom right Bottom right or bottom left.
Theme Light Light, dark, or follow the visitor's system setting.
Shown as available Automatic Automatic: available while you or a teammate (staff, or a team member on that address) has Smailor open. Always. Never: an asynchronous chat, answers arrive in the widget and by email.
Email replies the visitor is not there to read On A reply to a visitor who left the page is also emailed to them.
Ask for the visitor name On Adds a name field to the first screen. The email is always asked unless your site identifies the visitor.
Show agent first names On Agent messages carry the agent's first name. Off: the team name.
Conversations land in The address the threads are created in.
Allowed websites empty See Publishable keys.
Require identity verification Off Only visitors signed by your server can start a chat.
Limit No limit How many live chats your team holds at once, see Busy times.
Chats at once / per agent 3 The limit itself, in total or for every agent who has Smailor open.
Visitors allowed to wait 20 Beyond this a visitor leaves a message to be answered later.
Longest wait 10 minutes After this a waiting visitor is taken as a message to answer later.
Free a slot after silence 15 minutes A chat nobody wrote in for this long stops counting.
Text shown while waiting, text when answered later What the visitor reads in line, and when their message is kept for later.

Busy times: a waiting line

By default every visitor who writes becomes a thread in your inbox, however many are writing at once. When your team cannot answer them all live, set a limit in the widget settings and the chat protects your inbox with a waiting line:

  • Fixed number: at most N live chats at once, whoever is online.
  • Per agent online: N chats for every person who can answer this chat (you, your staff, team members on the address) and has Smailor open. Two people online with 3 per agent means 6 at once; when one closes Smailor the limit follows.

Past the limit a visitor is not turned away and does not reach your inbox yet. They write their first message as usual, see their place in line ("Number 3 in line"), can keep adding details, and are connected the moment a slot is free. Only then does the message become a thread, so your inbox only holds the chats you have room for.

What counts as a slot, and how nobody gets lost:

  • A live chat holds a slot while it is active: open and with a message from either side within the release time (15 minutes by default). Resolving the thread frees the slot at once, and the next visitor is let in. A tab left open on a silent chat gives its slot back by itself.
  • The line is first come, first served among visitors who are still on the page. A visitor who has gone is not given a slot nobody would use.
  • A visitor who has waited longer than your maximum wait, who clicks Leave a message instead, or who arrives when the line is already at its limit is not dropped: their message becomes an ordinary thread, answered in the widget and by email like when nobody is online. It does not take a slot, and the thread shows that they left a message instead of waiting live.
  • When nobody has Smailor open (and availability is automatic) nobody waits: the chat behaves as an away message, answered later.
  • A chat that already has a thread is never blocked by the limit: a visitor who writes again in their open conversation, or reopens a resolved one, is not queued.

The settings page shows the line live (slots in use, visitors waiting, agents online), and the preview has a Waiting line view for the texts.

Customize per page

One widget can look and speak differently on each page or language of your site, without creating another widget in Smailor. Precedence: widget settings, then data-* attributes on the script tag, then SmailorChat.configure().

With attributes:

With JavaScript, before or after the script loads (changes apply immediately):

window.SmailorChat = window.SmailorChat || [];
SmailorChat.push(['configure', {
  title: 'Need help with your order?',
  accentColor: '#0f766e',
  theme: 'auto',
  labels: { startChat: 'Ask us', composerPlaceholder: 'Type here...' },
}]);

Overridable options:

Option Attribute Values
title data-title text, max 80
subtitle data-subtitle text, max 160
greeting data-greeting text, max 500 (empty for none)
offlineMessage data-offline-message text, max 300
queueMessage data-queue-message text, max 300 (shown while waiting in line)
busyMessage data-busy-message text, max 300 (shown when the message is answered later because the team is full)
teamName data-team-name text, max 60
launcherLabel data-launcher-label text, max 40 (empty = icon only)
accentColor data-accent-color #rrggbb
position data-position right, left
theme data-theme light, dark, auto
askName data-ask-name true, false

Pass null to configure() to return an option to the widget setting. Invalid values are ignored with a warning in the browser console, and so are attempts to change a behaviour option from the page.

Built-in texts (labels, or data-labels as JSON):

Label Default
online Online
closeChat Close chat
nameLabel, namePlaceholder Your name, Jane Doe
emailLabel, emailPlaceholder Your email (we reply here and by email), [email protected]
messageLabel, messagePlaceholder Message, How can we help?
startChat Start chat
sending Sending...
notSent, retry Not sent, Retry
composerPlaceholder Write a message...
send Send
isTyping {name} is typing (read by screen readers; the widget shows animated dots)
connecting Connecting you with the team...
closedNotice This conversation was closed. Writing again reopens it.
newChat New chat
newMessages New messages
today, yesterday Today, Yesterday
inLine In line
queuePosition Number {position} in line
queueNext You are next
queueLeave Leave a message instead
queueComposer Add details while you wait...
busyHint All our agents are busy. Write your message and you will join the line.
busyHintWaiting All our agents are busy, {count} waiting. Write your message and you will join the line.
signInRequired Please sign in on this website to chat with us.
invalidEmail Please enter a valid email address.
emptyMessage Please write a message.
errorRateLimited Too many messages. Please wait a moment.
errorUnavailable Chat is not available right now.
errorGeneric Something went wrong. Please try again.

Messages written by your team and by the visitor are never translated: only the widget's own texts are.

Answering from Discord

If the address or group of the chat is linked to a Discord channel, each chat opens a ticket card with a thread, like an email. In that thread:

  • every visitor message is posted as it arrives;
  • type a message in the thread to answer the visitor live. A check mark reaction confirms delivery to the chat; an envelope reaction means it was also sent by email because the visitor had left;
  • start a message with // to keep it internal: it is saved as a note on the thread and never shown to the visitor;
  • the Reply, Claim, Move, Note and Resolve buttons of the ticket card work as usual. Resolve closes the conversation in the widget.

Only Discord users linked to a Smailor account (Settings / Profile) who have access to the ticket's group can answer. Anyone else gets a cross reaction and a short explanation. Visitor text is posted with mentions disabled, so a visitor cannot ping your server.

Files cannot be sent from Discord into a chat. Reply from Smailor to attach files: a reply with attachments, Cc or Bcc is sent by email, and its text is also shown in the chat.

Identity verification

If your site has signed-in users, you can pass their identity to the chat so they are not asked for their email, and so nobody can open a chat in their name.

  1. In the widget settings, click Create identity secret and store it on your server (for example SMAILOR_CHAT_SECRET). Never send it to the browser.
  2. On your server, sign the user's email:
// Node.js
import crypto from 'node:crypto';
const emailHash = crypto
  .createHmac('sha256', process.env.SMAILOR_CHAT_SECRET)
  .update(user.email.trim().toLowerCase())
  .digest('hex');
// PHP
$emailHash = hash_hmac('sha256', strtolower(trim($user->email)), getenv('SMAILOR_CHAT_SECRET'));
# Python
import hmac, hashlib, os
email_hash = hmac.new(os.environ["SMAILOR_CHAT_SECRET"].encode(),
                      user.email.strip().lower().encode(), hashlib.sha256).hexdigest()
  1. In the page, identify the visitor (this can run before the widget script loads):

A conversation started with a valid signature shows a Verified badge in the inbox. A wrong signature is refused. With Require identity verification on, a visitor without a signature sees "Please sign in on this website to chat with us." On logout, call SmailorChat.shutdown() so the next person on that device does not see the conversation.

JavaScript API

The script exposes window.SmailorChat. Calls made before it loads can be queued with SmailorChat.push([method, ...args]) as shown above.

Method Effect
open(), close(), toggle() Show or hide the panel.
isOpen() true while the panel is open.
configure({ ...options, labels }) Look and texts for this page, see Customize per page.
identify({ email, name, emailHash, metadata }) Known visitor: skips the email and name fields. emailHash enables identity verification.
setMetadata({ key: value }) Context shown to agents next to the conversation (up to 20 keys, 40-character keys, 500-character values). Sent with the first message. null removes a key.
newConversation() Forgets the current conversation on this device and shows the first screen.
shutdown() newConversation() plus forgets the identity and metadata. Call it on logout.
on(event, fn), off(event, fn) Subscribe to events.

Events:

Event Payload When
ready the API object The widget is loaded and drawn.
open, close The panel opens or closes.
conversation { id } The visitor started a conversation.
message message object A new message arrived (visitor or agent).
unread number Agent messages received while the panel was closed or the tab hidden.

Example, your own button with an unread counter:



HTTP API (custom chat interfaces)

The widget is built entirely on a public HTTP API. Use it to build your own chat UI (React, Vue, a mobile app, a game client) on top of the same inbox.

Base URL: https://smailor.com/api/chat/{key} where {key} is the publishable key. Every endpoint answers CORS preflights and returns JSON. Errors have the shape { "error": "code", "message": "text you can show" }.

Authentication: starting a conversation returns a visitor token. Send it on every call about that conversation as X-Chat-Token: <token> (or Authorization: Bearer <token>; the stream takes it as ?token=). Store it like a session: anyone holding it can read and write that conversation. Only its hash is stored by Smailor, so it cannot be recovered.

GET /config

{
  "widget": { "name": "Website chat" },
  "config": { "title": "Chat with us", "subtitle": "...", "greeting": "...", "offlineMessage": "...",
              "teamName": "Support", "launcherLabel": "", "accentColor": "#2563eb", "position": "right",
              "theme": "light", "askName": true, "showAgentNames": true, "availability": "auto",
              "emailFallback": true, "requireIdentityVerification": false, "subjectPrefix": "Live chat" },
  "online": true,
  "queue": { "busy": true, "waiting": 2 },
  "identityVerification": false,
  "limits": { "messageMaxLength": 5000 }
}

config also carries the busy-time settings (capacityMode, capacityLimit, idleReleaseMinutes, queueMax, queueWaitMinutes, queueMessage, busyMessage). queue is null unless the widget limits live chats and someone is available; busy is true when every slot is taken or visitors are already waiting, so a custom client can warn before the first message.

POST /conversations

Starts a conversation. Body:

Field Required Notes
email yes The visitor's email. Replies may be sent there.
message yes First message, 1 to 5000 characters.
name no Max 120 characters.
pageUrl, referrer no http(s) URLs, shown to agents.
metadata no Object of up to 20 keys; values are strings, numbers or booleans.
emailHash no Identity signature, see Identity verification.

Response 201:

{
  "conversationId": "6f1c2a4e-...",
  "token": "q9Xc...",
  "status": "pending",
  "live": true,
  "queue": null,
  "verified": false,
  "messages": [
    { "id": "open-6f1c2a4e-...", "from": "visitor", "body": "Hello", "createdAt": "2026-10-01T10:42:00.000Z", "authorName": null }
  ]
}

status is pending until the thread exists in the inbox (usually under a second), then open; closed when the thread is resolved or closed. When the widget limits live chats and every slot is taken, status is queued, queue is { "position": 3, "size": 4 } and the message is held until a slot frees up (see Busy times). live: false means the message will be answered later: nobody is available, or the line was already full.

GET /conversations/{id}

The conversation as the visitor sees it: { conversationId, status, live, queue, visitor: { email, name }, verified, online, messages: [...] }. While status is queued this call also advances the line, so a client that cannot hold a stream can poll it every few seconds. Internal notes are never included. Reload it whenever your live connection (re)opens, so nothing published while it was down is missed.

POST /conversations/{id}/messages

Body { "body": "text" }, 1 to 5000 characters. Response 201 { "message": {...} }. Writing on a closed conversation reopens it.

POST /conversations/{id}/leave-queue

No body. The visitor stops waiting for a free agent: their message is kept and answered later like an away message. Response { "left": true, "status": "pending", "live": false }. Safe to repeat; left is false when the conversation was not waiting.

POST /conversations/{id}/typing

No body. Shows "is typing" to agents watching the thread. Send at most every 2 to 3 seconds while the visitor types. Answers 204.

GET /conversations/{id}/stream?token=...

Server-Sent Events. Each event is a JSON object in data::

type Fields Meaning
message message A new message (agent reply, or the visitor's own from another tab).
typing from: "agent", name An agent is writing. Hide it after about 4 seconds without a new one.
state status, live The conversation is queued, pending, open or closed. Sent on connect, then on every change. live: false means it continues as a message answered later.
queue position, size Where the visitor stands in line, while queued. Sent whenever it changes.
ping Keep-alive, every 20 seconds.

The stream also tells Smailor the visitor is on the page: a reply sent while no stream is open counts as "visitor left" for the email fallback.

Message object

Field Notes
id Stable id. Use it to de-duplicate between the stream, the POST response and reloads.
from visitor or agent.
body Plain text. Render it as text, never as HTML.
createdAt ISO 8601.
authorName Agent first name or team name; null for visitor messages.
pending true while the message waits for the thread to exist.

Errors and limits

Status error Meaning
400 invalid_body details lists the invalid fields.
403 origin_not_allowed The page is not in the widget's allowed websites.
403 identity_required, identity_invalid Identity verification is required, or the signature does not match.
404 widget_not_found Unknown or disabled key, or the address was removed.
404 conversation_not_found Wrong id or token. Start a new conversation.
429 rate_limited See limits below.
503 service_unavailable Retry with backoff.

A waiting visitor's own stream is what moves the line along, so keep it open (or poll GET /conversations/{id}) while status is queued.

Limits: 5 new conversations per 10 minutes per visitor IP per widget, 300 per hour per widget; 20 messages per minute per conversation and 60 per 10 minutes per IP; messages up to 5000 characters.

Minimal custom client

const BASE = 'https://smailor.com/api/chat/wk_your_publishable_key';
let convo = JSON.parse(localStorage.getItem('chat') || 'null');

async function call(path, body) {
  const res = await fetch(BASE + path, {
    method: body ? 'POST' : 'GET',
    headers: { 'Content-Type': 'application/json', ...(convo ? { 'X-Chat-Token': convo.token } : {}) },
    body: body ? JSON.stringify(body) : undefined,
  });
  const data = res.status === 204 ? {} : await res.json();
  if (!res.ok) throw Object.assign(new Error(data.message), { code: data.error });
  return data;
}

async function start(email, message) {
  const data = await call('/conversations', { email, message, pageUrl: location.href });
  convo = { id: data.conversationId, token: data.token };
  localStorage.setItem('chat', JSON.stringify(convo));
  listen();
  return data.messages;
}

function listen() {
  const es = new EventSource(`${BASE}/conversations/${convo.id}/stream?token=${encodeURIComponent(convo.token)}`);
  es.onopen = async () => render((await call(`/conversations/${convo.id}`)).messages);
  es.onmessage = (e) => {
    const event = JSON.parse(e.data);
    if (event.type === 'message') addMessage(event.message); // de-duplicate on message.id
    if (event.type === 'typing') showTyping(event.name);
    if (event.type === 'state') setStatus(event.status);
  };
}

const send = (text) => call(`/conversations/${convo.id}/messages`, { body: text });

What is stored

For each conversation: the visitor's email and name, whether the identity was verified, the page URL and referrer, the browser user agent, and the metadata your site passed. Messages are stored as the thread in your inbox and follow your archive and retention settings. Visitor IP addresses are used for rate limiting only and are not stored with the conversation. Mention the chat in your privacy policy as a support channel processed by Smailor.

Troubleshooting

Symptom Cause
Nothing appears on the page Check the browser console for [Smailor chat]. A 404 means the key is wrong, rotated or the widget is disabled; a 403 means the page origin is not in the allowed websites.
Always shown as away Availability is Automatic and nobody has Smailor open, or it is set to Never.
Replies do not appear in Discord The address or group has no linked Discord channel. Link one with /smailor link in your server.
Typing in the Discord thread does nothing Your Discord account is not linked to Smailor, or you have no access to the ticket's group. The bot reacts with a cross and explains.
The visitor did not get an email copy They were still on the page (the reply reached their chat), or email fallback is off.
A conversation stays "Connecting" The first message is still being processed. It is safe: it is queued and will appear in the inbox.
Back to docs

Found an issue on this page?