Backend-Proxied Relay Architecture
This content is not available in your language yet.
Wetel’s default recommended integration (see Core API Flow) has your backend call sdkStart once, then hands the resulting sessionId/avatarToken down to the browser — from there, the browser opens the sessionEvents subscription and calls sdkSendMessage directly, authenticated with that short-lived avatarToken. This is the architecture every existing Wetel integration uses, and it’s the one to default to unless you have a specific reason not to.
This page documents a legitimate alternative: your backend holds the WebSocket connection to Wetel itself, and your frontend never talks to Wetel directly at all — not even with the short-lived avatarToken.
Should you use this?
Section titled “Should you use this?”Neither architecture is more secure than the other, once sdkStart is server-side in both (see Authentication — the API key must never reach the browser regardless of which pattern you choose below). This is a data-ownership and complexity trade-off, not a security decision.
Choose the backend-proxied relay specifically if you want:
- The option to persist messages natively as they pass through your backend — see the callout below the worked example. This pattern gives you the place to write that persistence (you already see every user message and every AI reply in one spot); it does not write the rows for you.
- Moderation or audit before delivery — your backend can inspect, filter, transform, or log a reply before the end user ever sees it.
- A single reconnection/backoff strategy you control, in one place, rather than relying on every browser tab to manage its own WebSocket lifecycle independently.
Be aware of the trade-offs before committing to this pattern:
- Your backend becomes a mandatory hop for every message — added latency, and it’s now a dependency for live conversations. If it restarts mid-session, you need your own reconnect/replay logic; Wetel’s subscription does not buffer missed events for a dropped connection to catch up on later.
- You take on fan-out: one Wetel subscription per active conversation needs to reach the right frontend client. If you run multiple backend instances, that’s your own connection-to-user routing problem to solve (a pub/sub layer, sticky sessions, or similar) — Wetel has no visibility into or opinion on this part.
- You can stop needing External Conversation Sync once you actually write the persistence yourself (see the callout below) — but the relay alone doesn’t replace it. Running both against the same data on purpose is fine; running both by accident, because you assumed the relay was already persisting, produces duplicate records from two independent write paths.
Architecture
Section titled “Architecture”Browser --message--> Your Backend --sdkSendMessage(avatarToken)--> WetelBrowser <--your own channel-- Your Backend <--sessionEvents subscription-- WetelYour backend:
- Calls
sdkStartserver-side (API key never leaves your backend), same as the default architecture. - Also opens the
sessionEventssubscription itself — a server-to-servergraphql-wsconnection, authenticated with the sameavatarTokena browser would otherwise use directly. - Relays events from that subscription to your frontend over a channel you control — Server-Sent Events, your own WebSocket, or long-polling. Wetel has no opinion on this half; it’s entirely your integration’s own transport.
- Never sends the
avatarTokenor Wetel’s GraphQL endpoint to the browser. Your frontend only ever holds an opaque session identifier meaningful to your backend, not to Wetel.
Worked example
Section titled “Worked example”This example uses Server-Sent Events for the backend-to-frontend leg — a WebSocket or any other push transport works identically in principle. It builds on the graphql-ws connection pattern from Events & Subscriptions; read that page first for the subscription handshake details this reuses server-side instead of client-side.
Your backend: start a session and open the relay subscription
const sessions = new Map(); // swap for Redis/a DB in a real integration
async function startRelaySession() { const startRes = await fetch(WETEL_GRAPHQL_URL, { method: "POST", headers: { "Content-Type": "application/json", "x-huat-platform": "customer", "x-api-key": WETEL_API_KEY, // never sent to the browser }, body: JSON.stringify({ query: `mutation Start($input: SdkStartInput!) { sdkStart(input: $input) { sessionId avatarToken } }`, variables: { input: { agentId: YOUR_AGENT_ID } }, }), }); const { data } = await startRes.json();
const relaySessionId = crypto.randomUUID(); const session = { wetelSessionId: data.sdkStart.sessionId, avatarToken: data.sdkStart.avatarToken, sseClients: new Set(), }; sessions.set(relaySessionId, session); openWetelSubscription(relaySessionId, session);
// The frontend learns ONLY this opaque id — never the avatarToken. return { relaySessionId };}Your backend: hold the subscription, relay events over SSE
function openWetelSubscription(relaySessionId, session) { const ws = new WebSocket(WETEL_WS_URL, "graphql-transport-ws");
ws.on("open", () => { ws.send( JSON.stringify({ type: "connection_init", payload: { Authorization: `Bearer ${session.avatarToken}`, "x-huat-platform": "customer", }, }) ); });
ws.on("message", raw => { const msg = JSON.parse(raw.toString()); if (msg.type === "connection_ack") { ws.send( JSON.stringify({ id: "1", type: "subscribe", payload: { // Same union-fragment rule as the browser-direct pattern — // an unselected event type is silently dropped. See Events & // Subscriptions' "Critical gotcha" section. query: `subscription SessionEvents($sessionId: ID!) { sessionEvents(sessionId: $sessionId) { __typename ... on AiResponseEvent { text isFinal turnComplete } ... on SessionEndedEvent { durationSeconds } } }`, variables: { sessionId: String(session.wetelSessionId) }, }, }) ); } else if (msg.type === "next") { const event = msg.payload?.data?.sessionEvents; if (event) broadcastToClients(session, event); } });
ws.on("error", err => { // A real integration MUST handle this — at minimum, notify connected // clients that the relay dropped so the UI doesn't wait indefinitely // for a reply that will never arrive. No reconnect logic is shown // here; see "What this pattern does not give you" below. console.error(`Wetel subscription error for ${relaySessionId}:`, err); });
session.ws = ws;}
function broadcastToClients(session, event) { const payload = `data: ${JSON.stringify(event)}\n\n`; for (const res of session.sseClients) res.write(payload);}Your backend: send a message and end the session on the user’s behalf
async function relaySend(relaySessionId, text) { const session = sessions.get(relaySessionId); await fetch(WETEL_GRAPHQL_URL, { method: "POST", headers: { "Content-Type": "application/json", "x-huat-platform": "customer", Authorization: `Bearer ${session.avatarToken}`, // avatarToken here, NOT x-api-key }, body: JSON.stringify({ query: `mutation Send($input: SdkSendMessageInput!) { sdkSendMessage(input: $input) }`, variables: { input: { sessionId: session.wetelSessionId, text } }, }), });}sdkStart authenticates with x-api-key; sdkSendMessage and sdkEndSession authenticate with Authorization: Bearer <avatarToken> instead — a different guard entirely (see Authentication). Sending the wrong credential to the wrong mutation fails with a generic "Missing or malformed Authorization header" rather than a specific “wrong credential type” error — double-check which header each call needs.
Your frontend: connect over your own channel, never Wetel’s
async function startChat() { const res = await fetch("/api/wetel-relay/start", { method: "POST" }); const { relaySessionId } = await res.json();
const events = new EventSource( `/api/wetel-relay/events?relaySessionId=${relaySessionId}` ); events.onmessage = e => { const event = JSON.parse(e.data); if (event.__typename === "AiResponseEvent" && event.isFinal) { renderReply(event.text); } };
return relaySessionId;}
async function sendMessage(relaySessionId, text) { await fetch("/api/wetel-relay/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ relaySessionId, text }), });}Notice this frontend code never imports a Wetel URL, never sees an avatarToken, and never sends x-api-key — every one of those lives only in the backend snippets above.
What this pattern does not give you for free
Section titled “What this pattern does not give you for free”This worked example is intentionally minimal, to keep the architecture shape clear. Before taking this to production, you still need to add:
- The actual message persistence, if that’s why you chose this pattern. This is worth stating plainly: adopting the backend-proxied relay does not, by itself, write anything to your database. It’s easy to build the relay (hold the subscription, forward events over SSE), see your own backend sitting in the middle of every message, and assume persistence is “basically already happening” — it isn’t, until you add an explicit write. Add it in two places: where you handle the incoming user message (before or after calling
sdkSendMessage), and where you receive a finalAiResponseEventfrom the subscription (a streaming reply can emit several chunks withisFinal: falsebefore the real final one — persist only the final line, or you’ll duplicate the same reply). If you skip this, your relay works perfectly as a transport — messages flow, replies arrive — while silently persisting nothing, which is easy to miss because nothing about the chat itself looks broken. - Reconnect/backoff logic for the backend-to-Wetel subscription. A dropped connection here is silent to the end user unless you explicitly surface it — Wetel does not replay missed events for a subscription that reconnects later. If you added message persistence per the point above, a dropped subscription silently stops that too, not just the live relay to your frontend.
- A real session store. The in-memory
Mapabove does not survive a backend restart and does not work across multiple backend instances. Use Redis or a database row, and make sure whichever backend instance receives asendcall can find the right session’s Wetel credentials. - Fan-out across backend instances, if you run more than one. A message sent to instance A needs to reach whichever instance actually holds that session’s SSE/WebSocket connection to the frontend, unless every instance also holds every session’s Wetel subscription (wasteful) or you route consistently by session (sticky sessions, or a pub/sub layer like Redis pub/sub or NATS).
See also
Section titled “See also”- Core API Flow — the default browser-direct architecture this page is an alternative to.
- Authentication — the three-credential model (
x-api-key,avatarToken, JWT) both architectures share. - Events & Subscriptions — the full
sessionEventsevent catalogue and thegraphql-wshandshake this pattern moves server-side. - External Conversation Sync — a lighter-weight alternative if all you want is your own copy of conversation records, without taking on a full relay.