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
Originheader. A request with noOrigin(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
- Open Settings / Widgets / Contact forms.
- Pick the recipient address and the layout (full form, floating button, inline row, minimal).
- Under How the form authenticates, keep Publishable key and click Create publishable key. Optionally list your website under Allowed websites.
- 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_keyBody:
| 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
- The visitor opens the chat on your site, enters their email (and name, if you ask for it) and writes a first message.
- 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.
- Every later message from the visitor is added to the same thread instantly, and the thread is highlighted as unread.
- 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.
- 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.
- 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.
- 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. - 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()- 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. |