How it works
Your site
The widget
Chat button, messages, handoff. No keys.
Your server
One route
Holds the chat key. Signs each visitor’s session.
Truplexy
The assistant
Answers, tickets, handoff to your team.
Visitors keep their conversation when they reload or come back, without accounts or a database: your route gives each one a signed session. When your team replies from Tickets, the reply appears in the widget live.
1. Issue a chat key
- In the dashboard, open Integrations → Guides → Website chat.
- Add a Website channel, so tickets show they came from your site, and issue its chat key.
- Store it on your server as
TRUPLEXY_CHAT_KEY, in.envor your host’s settings. Never put it in front-end code.
2. Add the route to your server
The widget talks only to this route. It’s one line for most frameworks.
npm i @truplexy/serverimport { createHandler } from "@truplexy/server";
// Reads TRUPLEXY_CHAT_KEY from the environment (.env.local, or your host's settings).
export const POST = createHandler();
export const maxDuration = 60; // replies can take up to 45 secondsPages Router: in pages/api/truplexy.ts, export default createNodeHandler() from "@truplexy/server/node".
npm i @truplexy/serverimport { createHandler } from "@truplexy/server";
const truplexy = createHandler({ chatKey: process.env.TRUPLEXY_CHAT_KEY });
export default defineEventHandler((event) => truplexy(toWebRequest(event)));npm i @truplexy/serverimport { createHandler } from "@truplexy/server";
import { TRUPLEXY_CHAT_KEY } from "$env/static/private";
import type { RequestHandler } from "./$types";
const truplexy = createHandler({ chatKey: TRUPLEXY_CHAT_KEY });
export const POST: RequestHandler = ({ request }) => truplexy(request);npm i @truplexy/serverimport { createHandler } from "@truplexy/server";
const truplexy = createHandler();
export const action = ({ request }: { request: Request }) => truplexy(request);npm i @truplexy/serverimport type { APIRoute } from "astro";
import { createHandler } from "@truplexy/server";
export const prerender = false; // needs a server adapter (Vercel, Netlify, Node…)
const truplexy = createHandler({ chatKey: import.meta.env.TRUPLEXY_CHAT_KEY });
export const POST: APIRoute = ({ request }) => truplexy(request);npm i @truplexy/serverimport express from "express";
import { createNodeHandler } from "@truplexy/server/node";
const app = express();
// Reads TRUPLEXY_CHAT_KEY from the environment.
app.post("/api/truplexy", createNodeHandler());
app.listen(3000);Fastify, Koa and Angular SSR work the same way: createNodeHandler() takes Node’s (req, res).
npm i @truplexy/serverimport { Hono } from "hono";
import { env } from "hono/adapter";
import { createHandler } from "@truplexy/server";
const app = new Hono();
app.post("/api/truplexy", (c) => createHandler({ chatKey: env<{ TRUPLEXY_CHAT_KEY: string }>(c).TRUPLEXY_CHAT_KEY })(c.req.raw));
export default app;npm i @truplexy/server && npx wrangler secret put TRUPLEXY_CHAT_KEYimport { createHandler } from "@truplexy/server";
export default {
fetch(request: Request, env: { TRUPLEXY_CHAT_KEY: string }) {
if (new URL(request.url).pathname !== "/api/truplexy") return new Response("Not found", { status: 404 });
return createHandler({
chatKey: env.TRUPLEXY_CHAT_KEY,
// The site the widget is on, when it isn't served by this Worker.
allowedOrigins: ["https://www.example.com"],
})(request);
},
};npm i @truplexy/serverimport { createHandler } from "@truplexy/server";
// For a site with no server of its own (static HTML, Webflow, Wix, Squarespace…):
// deploy this one file to Vercel and point the widget's endpoint at it.
export const POST = createHandler({ allowedOrigins: ["https://www.example.com"] });
export const OPTIONS = POST;
export const maxDuration = 60;Netlify: put the same handler in netlify/functions/truplexy.mjs as export default, with export const config = { path: "/api/truplexy" }.
- In the dashboard, open Integrations → Guides → WordPress and copy the plugin file.
- Save it as
wp-content/plugins/truplexy-chat/truplexy-chat.phpand activate it under Plugins. - Add
define('TRUPLEXY_CHAT_KEY', 'tpx_…');towp-config.php. - Style the chat under Settings → Truplexy Chat.
The plugin is the route and the widget: it adds /wp-json/truplexy/v1/chat and the chat button to every page, so you can skip step 3. It works for WooCommerce stores too.
The route is one POST endpoint, so any server language can host it. The dashboard's Website chat guide has complete, tested versions for Python (FastAPI), PHP and Go: pick Another language under step 2. To write your own, follow the protocol.
3. Add the widget to your site
Point endpoint at your route. Then reload: the chat button appears bottom right.
<!-- Before </body>. data-endpoint is your route from step 2. -->
<script
src="https://cdn.jsdelivr.net/npm/@truplexy/web@0/dist/truplexy.min.js"
data-endpoint="/api/truplexy"
defer
></script>Webflow, Wix, Squarespace and Shopify themes: paste it into the custom code (footer) setting, with the full URL of your route as data-endpoint.
npm i @truplexy/reactimport { TruplexyChat } from "@truplexy/react";
export default function App() {
return (
<>
{/* …your app… */}
<TruplexyChat endpoint="/api/truplexy" />
</>
);
}Gatsby and Vite work the same way. Open it from your own button with truplexy.open(), imported from "@truplexy/react".
npm i @truplexy/reactimport { TruplexyChat } from "@truplexy/react";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<TruplexyChat endpoint="/api/truplexy" />
</body>
</html>
);
}npm i @truplexy/reactimport { TruplexyChat } from "@truplexy/react";
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>{/* Meta, Links… */}</head>
<body>
{children}
<TruplexyChat endpoint="/api/truplexy" />
{/* ScrollRestoration, Scripts… */}
</body>
</html>
);
}npm i @truplexy/vue<script setup lang="ts">
import { TruplexyChat } from "@truplexy/vue";
</script>
<template>
<RouterView />
<TruplexyChat endpoint="/api/truplexy" />
</template>npm i @truplexy/vue<script setup lang="ts">
import { TruplexyChat } from "@truplexy/vue";
</script>
<template>
<NuxtPage />
<ClientOnly>
<TruplexyChat endpoint="/api/truplexy" />
</ClientOnly>
</template>npm i @truplexy/web<script>
import { onMount } from "svelte";
let { children } = $props();
onMount(() => import("@truplexy/web"));
</script>
{@render children()}
<truplexy-chat endpoint="/api/truplexy"></truplexy-chat>npm i @truplexy/webimport { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
import { RouterOutlet } from "@angular/router";
import "@truplexy/web";
@Component({
selector: "app-root",
imports: [RouterOutlet],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<router-outlet />
<truplexy-chat endpoint="/api/truplexy"></truplexy-chat>
`,
})
export class AppComponent {}npm i @truplexy/web<slot />
<truplexy-chat endpoint="/api/truplexy"></truplexy-chat>
<script>
import "@truplexy/web";
</script>npm i @truplexy/webimport "@truplexy/web";
import type { TruplexyChatAttributes } from "@truplexy/web";
declare module "solid-js" {
namespace JSX {
interface IntrinsicElements {
"truplexy-chat": TruplexyChatAttributes & JSX.HTMLAttributes<HTMLElement>;
}
}
}
export default function App() {
return (
<>
{/* …your app… */}
<truplexy-chat endpoint="/api/truplexy" />
</>
);
}Customise
Attributes
Set them on <truplexy-chat>, as data-* on the script tag (data-show-sources), or as props in React and Vue (showSources).
| Attribute | What it does | Default |
|---|---|---|
endpoint | Your route from step 2. | /api/truplexy |
heading | The title in the header. | Your business name, read from Truplexy |
subtitle | The line under the title. | Ask us anything… |
greeting | The first message visitors see. Markdown works. An empty value hides it. | Hi there! How can we help you today? |
placeholder | The message box hint. | Write a message… |
accent | Any CSS colour, for the button, header and visitor bubbles. | #2338e6 |
avatar | An image URL for the header. | Your business logo, read from Truplexy, else a bot icon |
theme | light, dark, or auto to follow the visitor’s device. | light |
position | right or left. | right |
mode | floating (a button and panel) or inline (a chat box where you put the element). | floating |
open | Present: the chat starts open. | Closed |
show-sources | Present: answers link the help articles they came from. | Off |
demo | Present: canned replies with no server, for previews. | Off |
details | ask shows a name and email form before the first message, and opens a ticket for it. off skips the form. | ask |
storage-key | Where the visitor’s conversation is saved, to keep two widgets apart. | truplexy:<endpoint> |
Colours and size
| CSS variable | Controls |
|---|---|
--truplexy-accent | Button, header and visitor bubbles |
--truplexy-accent-ink | Text on the accent colour |
--truplexy-background | Panel background |
--truplexy-surface | Assistant bubbles |
--truplexy-ink | Text |
--truplexy-muted | Secondary text |
--truplexy-line | Borders |
--truplexy-radius | Panel corner radius |
--truplexy-font | Font family (system UI by default) |
--truplexy-width | Panel width (380px) |
--truplexy-height | Panel height (600px) |
--truplexy-offset-x | Distance from the side (20px) |
--truplexy-offset-y | Distance from the bottom (20px) |
--truplexy-z-index | Stacking order |
truplexy-chat {
--truplexy-accent: #0f766e;
--truplexy-radius: 12px;
--truplexy-font: "Inter", sans-serif;
}
/* Deeper changes: the widget exposes parts. */
truplexy-chat::part(launcher) { box-shadow: none; }Other languages
Every string can be replaced through the labels property, or the labels prop in React and Vue. That includes poweredBy, the small “Powered by Truplexy” link under the message box.
const chat = document.querySelector("truplexy-chat");
chat.labels = {
title: "Soporte",
greeting: "¡Hola! ¿En qué podemos ayudarte?",
placeholder: "Escribe un mensaje…",
handoff: "Hablar con una persona",
escalated: "Una persona del equipo se ha unido y responderá aquí.",
};JavaScript API
The element has open(), close(), toggle(), send(text) and reset(), and fires events that bubble to document.
// Any framework: open the chat from your own "Contact us" button.
import { truplexy } from "@truplexy/web"; // also exported by @truplexy/react and @truplexy/vue
truplexy.open();
truplexy.send("I need help with my order");
// Or the element itself:
const chat = document.querySelector("truplexy-chat");
chat.addEventListener("truplexy:status", (e) => {
if (e.detail.status === "handoff_offered") analytics.track("asked_for_a_person");
});
// With the script tag, the same calls live on window.Truplexy:
Truplexy.open();| Event | When | detail |
|---|---|---|
truplexy:open | The chat opened. | null |
truplexy:close | The chat closed. | null |
truplexy:message | New replies arrived, from the assistant or your team. | { messages } |
truplexy:status | How the assistant handled the visitor’s message: answered, handoff_offered, escalated… | { status } |
truplexy:error | A message failed to send. | { error: { code, message, requestId } } |
In React, pass onMessage, onStatus, onError, onOpen and onClose. In Vue, listen with @message, @status and so on.
Build your own UI
Prefer your own design? The same client runs without the widget: sessions, handoff and live team replies included.
import { useTruplexyChat } from "@truplexy/react";
export function SupportChat() {
const chat = useTruplexyChat({ endpoint: "/api/truplexy" });
return (
<div>
{chat.messages.map((m) => (
<p key={m.id} className={m.role}>
{m.agent && <b>{m.agent}: </b>}
{m.content}
</p>
))}
{chat.sending && <p>Typing…</p>}
{chat.offerHandoff && <button onClick={chat.handoff}>Talk to a person</button>}
<form onSubmit={(e) => { e.preventDefault(); chat.send(e.currentTarget.text.value); e.currentTarget.reset(); }}>
<input name="text" />
</form>
</div>
);
}import { TruplexyClient } from "@truplexy/web";
const chat = new TruplexyClient({ endpoint: "/api/truplexy" });
chat.subscribe((state) => render(state.messages, state.sending));
await chat.start(); // restores the visitor's conversation
await chat.send("Hello"); // state updates as the reply arrives
chat.setOpen(true); // your UI is visible: clears unread, polls fasterVue has the same thing as useTruplexyChat() from @truplexy/vue.
Server options
createHandler(options) returns a standard (request: Request) => Response handler. createNodeHandler(options) from @truplexy/server/node wraps it for Express-style servers.
| Option | What it does | Default |
|---|---|---|
chatKey | The chat key. | TRUPLEXY_CHAT_KEY |
sessionSecret | Signs visitor sessions. Changing it starts everyone afresh. | TRUPLEXY_SESSION_SECRET, then the chat key |
sessionTtl | Seconds a visitor keeps their conversation after their last message. | 30 days |
channelId | Start conversations on this channel instead of the key’s. | The key’s channel |
allowedOrigins | Sites allowed to call the route from another origin, e.g. ["https://www.example.com"]. | Same origin only |
authorize(request) | Runs first. Return false (403) or a Response to refuse: rate limits, sign-in. | Allow all |
context(request) | Background for the assistant, sent once privately when a conversation starts. | None |
onError(error) | Server-side failures, for your logs. Never includes the key. | console.error |
apiBase | The Truplexy API. | TRUPLEXY_API_BASE, then production |
import { createHandler } from "@truplexy/server";
import { getUser, rateLimit } from "./your-app";
export const POST = createHandler({
// 20 messages a minute per visitor; refuse the rest.
authorize: async (request) => rateLimit(request.headers.get("x-forwarded-for"), 20),
// Who the visitor is, so the assistant can say "your order" and mean it.
context: async (request) => {
const user = await getUser(request);
return user ? `Signed in as ${user.name} (${user.email}), ${user.plan} plan.` : null;
},
});Security
- The key stays on your server. The browser only ever sees your route and its own signed session.
- Visitors can’t read each other’s chats. A session names one conversation and is signed; a changed or expired one starts a new conversation.
- Background stays private.
contextmessages are never shown to the visitor, and visitors can’t write their own. - Rate-limit the route as you would a contact form. Every message uses AI replies from your plan.
- Different origin? If the widget and the route are on different domains, list the site in
allowedOrigins.
Protocol
For a route in a language without an SDK: one POST endpoint taking JSON {action, session, text}. Your server calls the Truplexy chat API with the chat key.
| action | Calls | Returns |
|---|---|---|
message | PUT /customer and POST /conversations when there’s no valid session, then POST /conversations/:id/ticket for that new conversation, then POST /conversations/:id/messages | {session, message, reply, status, sources, ticket?} |
history | GET /conversations/:id | {session, conversation}, or {session: null} |
handoff | POST /conversations/:id/handoff | {session, conversation} |
profile | GET /profile | {business: {name, logo_url}, bot: {id, name, avatar_url}} |
live | POST /realtime/token | {url, publishable_key, conversation_topic}, or {} |
payload = "<conversation_id>.<issued_at>" // Unix seconds
token = base64url(payload) + "." + base64url(HMAC_SHA256(secret, payload))
secret = TRUPLEXY_SESSION_SECRET, or the chat key- Trim
text; refuse it when empty, over 4000 characters, or starting with[Context only. - With
customer: {name, email}, refuse a missing name, one over 80 characters, or an invalid email. For a new conversation, record the person withPUT /customerand{name, contacts: [{type: "email", value}]}, and start the conversation with the returnedcustomer_id, then send[Context only] Customer: name <email>first and open the ticket with{subject: "Chat with name"}. Pass on only the ticket’sid, subject, status, and keep chatting if either call fails. - In
profile, pass on only the names and pictures. The widget shows them in its header and falls back to its defaults if the call fails. - Never pass on
bot_topic: it carries every customer’s messages.conversation_topicis safe for the browser. - In
historyandhandoff, drop user messages starting with[Context only, and pass on onlyid, role, content, author, agent, created_at. - Errors are
{error: {code, message, request_id}}with a message you write. Never forward the API’s own message.
Troubleshooting
The widget shows a short message; the error’s code is in the truplexy:error event and in your server’s log.
| Code | Fix |
|---|---|
NOT_CONFIGURED | The route has no chat key, or Truplexy refused it (revoked or mistyped). Check TRUPLEXY_CHAT_KEY and redeploy. |
CHANNEL_DISABLED | The key’s channel is switched off. Turn it on under Integrations → Your channels. |
PLAN_LIMIT_REACHED | This month’s AI replies or tokens are used up. Upgrade or add tokens under Settings → Plan. |
LLM_TIMEOUT | The reply took too long. Give your function at least 60 seconds (maxDuration on Vercel). |
FORBIDDEN_ORIGIN | The widget is on a site not in allowedOrigins. |
NETWORK | The widget couldn’t reach endpoint: check the URL, and that the route accepts POST. |
UPSTREAM_ERROR | Truplexy didn’t answer. Retry; the server log has a request_id for support. |
Still stuck? Email hello@truplexy.com with the request_id.