How to Embed a Claude API Chatbot Widget on Your Website
Embedding a Claude-powered chatbot on your website means building three pieces: a backend that calls the Claude API and holds your secret key, a small frontend widget (a chat bubble, an input box, a message list), and a way to stream responses back so the widget feels responsive instead of laggy. You cannot call Claude directly from browser JavaScript because that would expose your API key to every visitor — so the backend proxy step is not optional.
This guide walks through the actual architecture, a working widget you can drop into any page, and how to stream tokens so the chat feels instant. It applies whether you're calling the Claude API directly or through a proxy like SubToAPI that adds application-level keys on top.
Why you can't call Claude directly from the browser
Any request made from client-side JavaScript is visible in the network tab, including headers. If your Anthropic API key or a raw sub_live_... key ships in your frontend bundle, anyone can extract it and run up your bill. The fix is always the same: your website's backend (a serverless function, an Express route, a Next.js API route) holds the secret key and forwards requests to Claude. The browser only ever talks to your own domain.
This also gives you a place to rate-limit by IP or session, strip or validate user input, add system prompts server-side, and log usage without trusting the client.
The architecture
Browser widget --> Your backend endpoint --> Claude API (or SubToAPI)
^ |
|________________ streamed tokens _______________|
Your backend endpoint does three things:
- Accepts the visitor's message (and conversation history) via POST
- Calls the Claude API with your secret key, streaming enabled
- Pipes the stream back to the browser as Server-Sent Events or chunked text
Step 1: build the backend proxy
Using SubToAPI as the upstream keeps this simple since you get one sub_live_... key per app with usage metadata already attached, instead of managing raw provider credentials:
// /api/chat.js — serverless function
export default async function handler(req, res) {
const { messages } = req.body;
const upstream = await fetch("https://api.subtoapi.app/v1/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SUBTOAPI_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: "claude-sonnet-4",
max_tokens: 1024,
system: "You are a concise support assistant for Acme Inc.",
messages,
stream: true
})
});
res.setHeader("Content-Type", "text/event-stream");
const reader = upstream.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(decoder.decode(value));
}
res.end();
}
See the messages docs for the full request shape and streaming docs for event formats if you want to parse individual token chunks instead of just piping raw text.
Step 2: build the widget markup and styles
A chatbot widget needs three visual states: a closed bubble, an open panel, and a message list that autoscrolls. Keep the markup minimal so it doesn't fight your site's CSS:
<div id="sta-widget">
<button id="sta-toggle">💬</button>
<div id="sta-panel" style="display:none;">
<div id="sta-messages"></div>
<form id="sta-form">
<input id="sta-input" placeholder="Ask a question…" autocomplete="off" />
</form>
</div>
</div>
#sta-widget { position: fixed; bottom: 20px; right: 20px; font-family: system-ui; }
#sta-panel { width: 320px; height: 420px; background: #fff; border-radius: 12px;
box-shadow: 0 8px 30px rgba(0,0,0,.15); display: flex; flex-direction: column; }
#sta-messages { flex: 1; overflow-y: auto; padding: 12px; }
#sta-messages .msg { margin-bottom: 10px; padding: 8px 12px; border-radius: 10px; max-width: 80%; }
#sta-messages .user { background: #e8e8ff; margin-left: auto; }
#sta-messages .bot { background: #f2f2f2; }
Step 3: wire up streaming in the widget
const form = document.getElementById("sta-form");
const input = document.getElementById("sta-input");
const messages = document.getElementById("sta-messages");
let history = [];
form.addEventListener("submit", async (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
history.push({ role: "user", content: text });
appendMessage("user", text);
input.value = "";
const botDiv = appendMessage("bot", "");
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: history })
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let reply = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
reply += chunk;
botDiv.textContent = reply;
messages.scrollTop = messages.scrollHeight;
}
history.push({ role: "assistant", content: reply });
});
function appendMessage(role, text) {
const div = document.createElement("div");
div.className = `msg ${role}`;
div.textContent = text;
messages.appendChild(div);
return div;
}
This handles the 90% case. If you're forwarding raw SSE events rather than plain text, you'll need to parse event:/data: lines on the frontend — the streaming guide covers the event types in detail.
Step 4: make it a drop-in script
Once the widget works, wrap it as a single script tag so it can be embedded on any page (yours or a client's) without copy-pasting HTML:
<script src="https://yourcdn.com/widget.js" data-endpoint="/api/chat" defer></script>
Inside widget.js, read document.currentScript.dataset.endpoint, inject the markup and styles via document.createElement, and attach the same event listeners shown above. This is the pattern used by most embeddable chat widgets — the script self-mounts and doesn't require the host page to add any markup.
Step 5: tool use and context
If the widget needs to look up order status, search docs, or trigger actions, pass tool definitions in the same /v1/messages call and handle tool_use blocks in your backend before returning a final answer. This keeps all tool execution server-side, which is also where it should live for security reasons. See tool use docs for the request/response shape.
Security checklist before you ship
- Secret key lives only in backend environment variables, never in the widget bundle
- Rate-limit the
/api/chatendpoint per IP or session to avoid abuse - Cap
max_tokensand conversation history length so a single visitor can't run an expensive loop - Set a clear
systemprompt server-side so it can't be overridden by user input - Log token usage per request — SubToAPI includes this in every response so you can track cost per widget session without extra instrumentation
Getting a working key takes a couple of minutes at /signup; the quickstart walks through the first request, and pricing covers Solo, Team, and Scale plans if you're rolling this out across multiple client sites.
questions
Can I call the Claude API directly from browser JavaScript for a chat widget? No. Any key in client-side code is exposed to visitors. Always proxy requests through your own backend and keep the secret key server-side.
How do I make the chatbot widget feel fast instead of waiting for a full reply? Use streaming. Enable stream: true in the request and pipe tokens to the browser as they arrive, updating the message div incrementally instead of waiting for the full response.
Can the same widget work across multiple websites or client projects? Yes — package it as a single self-mounting script that reads its config (endpoint URL, styling) from data-* attributes on the script tag, so embedding is a one-line copy-paste per site.