Skip to content

Developers

Add Truplexy chat to any website

A ready-made chat widget, one small route on your server, and you’re live. It works with React, Next.js, Vue, Nuxt, Svelte, Angular, Astro, WordPress and plain HTML. Your chat key never reaches the browser.

Get a chat key

How it works

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

  1. In the dashboard, open Integrations → Guides → Website chat.
  2. Add a Website channel, so tickets show they came from your site, and issue its chat key.
  3. Store it on your server as TRUPLEXY_CHAT_KEY, in .env or 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.

Terminal
npm i @truplexy/server
app/api/truplexy/route.ts
import { 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 seconds

Pages Router: in pages/api/truplexy.ts, export default createNodeHandler() from "@truplexy/server/node".

Terminal
npm i @truplexy/server
server/api/truplexy.post.ts
import { createHandler } from "@truplexy/server";

const truplexy = createHandler({ chatKey: process.env.TRUPLEXY_CHAT_KEY });

export default defineEventHandler((event) => truplexy(toWebRequest(event)));
Terminal
npm i @truplexy/server
src/routes/api/truplexy/+server.ts
import { 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);
Terminal
npm i @truplexy/server
app/routes/api.truplexy.ts
import { createHandler } from "@truplexy/server";

const truplexy = createHandler();

export const action = ({ request }: { request: Request }) => truplexy(request);
Terminal
npm i @truplexy/server
src/pages/api/truplexy.ts
import 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);
Terminal
npm i @truplexy/server
server.js
import 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).

Terminal
npm i @truplexy/server
src/index.ts
import { 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;
Terminal
npm i @truplexy/server && npx wrangler secret put TRUPLEXY_CHAT_KEY
src/index.ts
import { 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);
  },
};
Terminal
npm i @truplexy/server
api/truplexy.js
import { 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" }.

  1. In the dashboard, open Integrations → Guides → WordPress and copy the plugin file.
  2. Save it as wp-content/plugins/truplexy-chat/truplexy-chat.php and activate it under Plugins.
  3. Add define('TRUPLEXY_CHAT_KEY', 'tpx_…'); to wp-config.php.
  4. 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.

index.html
<!-- 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.

Terminal
npm i @truplexy/react
App.tsx
import { 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".

Terminal
npm i @truplexy/react
app/layout.tsx
import { TruplexyChat } from "@truplexy/react";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <TruplexyChat endpoint="/api/truplexy" />
      </body>
    </html>
  );
}
Terminal
npm i @truplexy/react
app/root.tsx
import { 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>
  );
}
Terminal
npm i @truplexy/vue
App.vue
<script setup lang="ts">
import { TruplexyChat } from "@truplexy/vue";
</script>

<template>
  <RouterView />
  <TruplexyChat endpoint="/api/truplexy" />
</template>
Terminal
npm i @truplexy/vue
app.vue
<script setup lang="ts">
import { TruplexyChat } from "@truplexy/vue";
</script>

<template>
  <NuxtPage />
  <ClientOnly>
    <TruplexyChat endpoint="/api/truplexy" />
  </ClientOnly>
</template>
Terminal
npm i @truplexy/web
src/routes/+layout.svelte
<script>
  import { onMount } from "svelte";
  let { children } = $props();
  onMount(() => import("@truplexy/web"));
</script>

{@render children()}
<truplexy-chat endpoint="/api/truplexy"></truplexy-chat>
Terminal
npm i @truplexy/web
src/app/app.component.ts
import { 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 {}
Terminal
npm i @truplexy/web
src/layouts/Layout.astro
<slot />
<truplexy-chat endpoint="/api/truplexy"></truplexy-chat>

<script>
  import "@truplexy/web";
</script>
Terminal
npm i @truplexy/web
src/App.tsx
import "@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).

AttributeWhat it doesDefault
endpointYour route from step 2./api/truplexy
headingThe title in the header.Your business name, read from Truplexy
subtitleThe line under the title.Ask us anything…
greetingThe first message visitors see. Markdown works. An empty value hides it.Hi there! How can we help you today?
placeholderThe message box hint.Write a message…
accentAny CSS colour, for the button, header and visitor bubbles.#2338e6
avatarAn image URL for the header.Your business logo, read from Truplexy, else a bot icon
themelight, dark, or auto to follow the visitor’s device.light
positionright or left.right
modefloating (a button and panel) or inline (a chat box where you put the element).floating
openPresent: the chat starts open.Closed
show-sourcesPresent: answers link the help articles they came from.Off
demoPresent: canned replies with no server, for previews.Off
detailsask shows a name and email form before the first message, and opens a ticket for it. off skips the form.ask
storage-keyWhere the visitor’s conversation is saved, to keep two widgets apart.truplexy:<endpoint>

Colours and size

CSS variableControls
--truplexy-accentButton, header and visitor bubbles
--truplexy-accent-inkText on the accent colour
--truplexy-backgroundPanel background
--truplexy-surfaceAssistant bubbles
--truplexy-inkText
--truplexy-mutedSecondary text
--truplexy-lineBorders
--truplexy-radiusPanel corner radius
--truplexy-fontFont family (system UI by default)
--truplexy-widthPanel width (380px)
--truplexy-heightPanel height (600px)
--truplexy-offset-xDistance from the side (20px)
--truplexy-offset-yDistance from the bottom (20px)
--truplexy-z-indexStacking order
styles.css
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.

labels.js
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.

app.js
// 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();
EventWhendetail
truplexy:openThe chat opened.null
truplexy:closeThe chat closed.null
truplexy:messageNew replies arrived, from the assistant or your team.{ messages }
truplexy:statusHow the assistant handled the visitor’s message: answered, handoff_offered, escalated…{ status }
truplexy:errorA 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.

SupportChat.tsx
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>
  );
}
chat.js
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 faster

Vue 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.

OptionWhat it doesDefault
chatKeyThe chat key.TRUPLEXY_CHAT_KEY
sessionSecretSigns visitor sessions. Changing it starts everyone afresh.TRUPLEXY_SESSION_SECRET, then the chat key
sessionTtlSeconds a visitor keeps their conversation after their last message.30 days
channelIdStart conversations on this channel instead of the key’s.The key’s channel
allowedOriginsSites 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
apiBaseThe Truplexy API.TRUPLEXY_API_BASE, then production
app/api/truplexy/route.ts
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. context messages 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.

actionCallsReturns
messagePUT /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?}
historyGET /conversations/:id{session, conversation}, or {session: null}
handoffPOST /conversations/:id/handoff{session, conversation}
profileGET /profile{business: {name, logo_url}, bot: {id, name, avatar_url}}
livePOST /realtime/token{url, publishable_key, conversation_topic}, or {}
session token
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 with PUT /customer and {name, contacts: [{type: "email", value}]}, and start the conversation with the returned customer_id, then send [Context only] Customer: name <email> first and open the ticket with {subject: "Chat with name"}. Pass on only the ticket’s id, 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_topic is safe for the browser.
  • In history and handoff, drop user messages starting with [Context only, and pass on only id, 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.

CodeFix
NOT_CONFIGUREDThe route has no chat key, or Truplexy refused it (revoked or mistyped). Check TRUPLEXY_CHAT_KEY and redeploy.
CHANNEL_DISABLEDThe key’s channel is switched off. Turn it on under Integrations → Your channels.
PLAN_LIMIT_REACHEDThis month’s AI replies or tokens are used up. Upgrade or add tokens under Settings → Plan.
LLM_TIMEOUTThe reply took too long. Give your function at least 60 seconds (maxDuration on Vercel).
FORBIDDEN_ORIGINThe widget is on a site not in allowedOrigins.
NETWORKThe widget couldn’t reach endpoint: check the URL, and that the route accepts POST.
UPSTREAM_ERRORTruplexy didn’t answer. Retry; the server log has a request_id for support.

Still stuck? Email hello@truplexy.com with the request_id.