Website chat API
Connect your own form to the chat widget or build your own chat UI with the widget SDK, REST, typing, SSE and visitor activity.
Last updated on
You install the official widget in the app: see Putting the chat on your website. Snippet options are on Website chat settings. This page is for developers: the widget SDK for your own form and the REST layer for a custom chat UI. See OpenAPI, tag inbox.
Widget SDK
After the widget script has loaded, window.TillorInbox is ready. With it you pass on contact details from your own form (contact page, sales detail page, quote request). All methods wait until the widget config has loaded.
| Method | Description |
|---|---|
ready() | Promise that resolves as soon as the widget has loaded |
getVisitor() | Reads stored contact details from localStorage and filled-in widget fields; synchronous, no network |
setVisitor({ name?, firstName?, lastName?, email?, phone?, message?, customFields? }, options?) | Fills in chat fields without sending |
open() | Opens the chat window |
close() | Closes the chat window |
toggle() | Opens or closes the chat window |
send({ body, name?, firstName?, lastName?, email?, phone?, message?, customFields?, open?, silent?, markSubmitted?, metadata? }) | Sends a message with contact details |
getVisitor() returns this object:
| Field | Description |
|---|---|
visitorId | ID of this visitor in this browser (localStorage) |
name | Full name (a single field in the widget) |
firstName / lastName | Derived from name (first word / rest) |
email, phone | Contact details |
customFields | Extra fields from the widget settings |
message | Draft in the message field |
detailsSubmitted | Whether the visitor form counts as filled in |
Use getVisitor() to prefill your own form from localStorage (same browser, also after a page refresh).
Options for setVisitor (second argument):
| Option | Default | Explanation |
|---|---|---|
open | false | Open the chat window straight away |
markSubmitted | false | Hide the visitor form if all required fields are filled in |
onlyEmpty | false | Only overwrite empty fields |
persist | false | Keep the details in localStorage (same browser, also after a new tab) |
Options for send:
| Option | Default | Explanation |
|---|---|---|
open | false | Open the chat window before sending |
silent | true if open is false | No sound, no error message in the widget; use the Promise for success or failure |
markSubmitted | true | Contact details do not need to be entered again in the widget afterwards |
metadata | - | Optional hints for Tillie (not visible in the chat window); see metadata |
With open: false (default) the chat button stays closed. The message does go to Tillor; your own form can say thanks or show an error. With open: true the chat window opens and the visitor sees the normal sending behaviour (sound, ticks, any widget errors).
Want to use your own button instead of the floating chat button? Set data-launcher="false" on the snippet and call TillorInbox.open().
metadata (booking form)
Use metadata when you have your own form on the website and Tillie should send only a confirmation, without follow-up questions (for example because the tab closes afterwards).
| Key | Type | Explanation |
|---|---|---|
forceHandover | boolean | Confirmation message and handover to a colleague, without intake questions. Also ignores the AI pause after a team message (see ignoreAiPause) |
ignoreAiPause | boolean | Tillie also replies when she is paused in Automatic mode after a team message (30 minutes). Handy for silent form submission; combine with forceHandover or let Tillie do the intake as normal |
handoverIntent | "reservation" | "caravan_rental" | "sale" | "other" | Optional: flow for the handover (default: derived automatically from the message) |
source | string | Optional integration label for your team (lowercase letters, digits, -, _; max. 64 characters) |
await TillorInbox.send({
name: "Jan Janssens",
email: "jan@voorbeeld.be",
phone: "+32 3 779 81 64",
body: structuredBookingMessage,
silent: true,
metadata: {
forceHandover: true,
handoverIntent: "reservation",
source: "website_booking_form",
},
});Replacing the contact form (silent sending)
Use this when you want to keep the existing contact form, but have messages go through Tillor without opening the chat window.
<form id="site-contact-form">
<input name="name" type="text" required />
<input name="email" type="email" required />
<input name="phone" type="tel" />
<textarea name="message" required></textarea>
<button type="submit">Verstuur</button>
<p id="contact-status" hidden></p>
</form>
<script>
document.getElementById("site-contact-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const status = document.getElementById("contact-status");
const data = new FormData(form);
try {
await TillorInbox.send({
name: String(data.get("name") ?? ""),
email: String(data.get("email") ?? ""),
phone: String(data.get("phone") ?? ""),
body: String(data.get("message") ?? ""),
});
status.textContent = "Bedankt, we nemen snel contact op.";
status.hidden = false;
form.reset();
} catch (error) {
status.textContent = error instanceof Error ? error.message : "Versturen mislukt.";
status.hidden = false;
}
});
</script>You do not need to set open and silent: without open: true the window stays closed and Tillor treats the call as silent sending.
Linking the contact form (opening the chat)
Pass on fields and open the chat window, so that the visitor sees the conversation:
<form id="site-contact-form">
<input name="name" type="text" required />
<input name="email" type="email" required />
<input name="phone" type="tel" />
<textarea name="message" required></textarea>
<button type="submit">Verstuur</button>
</form>
<script>
document.getElementById("site-contact-form").addEventListener("submit", (event) => {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
void TillorInbox.send({
name: String(data.get("name") ?? ""),
email: String(data.get("email") ?? ""),
phone: String(data.get("phone") ?? ""),
body: String(data.get("message") ?? ""),
open: true,
});
});
</script>Or only fill in fields and open the window, without sending straight away:
void TillorInbox.setVisitor(
{
name: "Jan Janssens",
email: "jan@voorbeeld.be",
phone: "+32 3 779 81 64",
message: "Ik heb een vraag over de stacaravan te koop.",
},
{ markSubmitted: true, open: true },
);A sales page with immediate sending and opening the chat:
void TillorInbox.send({
name: "Jan Janssens",
email: "jan@voorbeeld.be",
phone: "+32 3 779 81 64",
body: "Ik heb interesse in deze stacaravan.",
open: true,
});Syncing live while someone is typing:
const form = document.getElementById("site-contact-form");
form.addEventListener("input", () => {
void TillorInbox.setVisitor({
name: form.name.value,
email: form.email.value,
phone: form.phone.value,
message: form.message.value,
});
});Calling early (before the widget script)
If you call a method before the script has loaded, put the call in the queue:
<script>
window.TillorInbox = window.TillorInbox || { q: [] };
window.TillorInbox.q.push([
"setVisitor",
{ name: "Jan", email: "jan@voorbeeld.be" },
{ open: true },
]);
</script>
<script src="https://tillor.eu/inbox-widget.js" data-org-id="org_abc123" data-widget-key="iwk_xxx" async defer></script>Custom chat UI
If you build your own chat UI instead of the widget, you use the REST layer below.
Authentication: header X-Tillor-Inbox-Widget-Key (value from Settings > Integrations > Website chat).
Typing
POST /api/orgs/{orgId}/inbox/website/typing
X-Tillor-Inbox-Widget-Key: iwk_xxx
Content-Type: application/json
{
"visitorId": "vis_…",
"active": true,
"visitorName": "Jan",
"visitorEmail": "jan@voorbeeld.nl",
"visitorPhone": "+32470123456"
}active: truewhen the visitor is typing;falsewhen the field is empty or the visitor stops (the widget sendsfalseafter about 4 seconds without input)visitorName/visitorEmail/visitorPhoneare optional- Use the same
visitorIdas withPOST …/inbox/website/messagesandGET …/inbox/website/messages
Realtime (SSE)
GET /api/orgs/{orgId}/inbox/website/subscribe?visitorId=vis_…
X-Tillor-Inbox-Widget-Key: iwk_xxx
Accept: text/event-stream| Event | When |
|---|---|
inbox:website:message-created | New team or AI message |
inbox:website:message-updated | Team or AI message changed or deleted |
inbox:website:typing-changed | data.staffTyping or data.staffOutboundSenderType changes |
The payload shape is the same as org SSE (event, data, timestamp). Reconnect after a drop; there is no resumption.
The official widget uses SSE as its primary layer and fetches GET …/messages again every 5 seconds as a fallback (every 1 second with the panel open when SSE briefly drops). That way replies are marked as delivered as long as the visitor is on the page.
Visitor activity
POST /api/orgs/{orgId}/inbox/website/events
X-Tillor-Inbox-Widget-Key: iwk_xxx
Content-Type: application/jsonevent: chat_closed, tab_hidden or navigation (with navigation also url). Your team sees these events as grey info text in the conversation, see Conversations via Website chat.
Limits
POST …/inbox/website/messages and POST …/inbox/website/attachments/upload answer 429 when a visitor or a network sends too much. The body carries data.reason = INBOX_WIDGET_RATE_LIMITED and data.retryAfterSeconds. Limits per organization:
| Route | Per visitor (visitorId) | Per network (IP address) |
|---|---|---|
messages | 10 per minute, 200 per day | 30 per minute, 600 per day |
attachments/upload | 10 per hour | 50 per hour |
Wait retryAfterSeconds before you try again, and show the visitor a message instead of retrying on your own.