Add an AI chatbot to your website with Claude, GPT or Gemini
Embed a chatbot on any website with a small widget and a Node.js server that keeps the API key secret. Tested code, safety limits and costs.
Put a small chat widget on your page and let your own server call the model. The widget sends each question to your server, which adds the API key and the instructions, so the key never reaches the browser. With a 5abeer key, the OpenAI SDK reaches Claude, GPT and Gemini models below official prices.
What do you need to build a website chatbot?#
- A 5abeer API key for the chatbot's provider. The code uses the default model,
claude-sonnet-5.5, so it needs a Claude key. See Create a key and How to get an API key for Claude, GPT and Gemini. - Node.js 22 or later, which the current
openaiSDK requires, with theexpressandopenaipackages. - A server that runs Node.js and stays on.
Why can't the browser call the API directly?#
Anything the browser loads, a visitor can read. A key in your page's JavaScript shows in the page source, and anyone who copies it can spend your balance. OpenAI's API reference says the same about its own keys: "Don't share it with others or expose it in any client-side code such as browsers or apps."
The chatbot has three parts:
- The widget: an HTML section and a short script on your page. It sends each question to
/api/chaton your server. - Your server: a small Express app that checks each request, adds the key and the instructions, calls the model and returns only the answer.
- The 5abeer API: it runs the model and charges your balance for the tokens.
The instructions, called the system prompt, stay on the server too, where visitors can't read them.
How do you connect your server to Claude, GPT or Gemini?#
5abeer speaks the OpenAI Chat Completions format, so the server uses the official OpenAI SDK with a different base URL and key. Only the model id and the key's provider change between Claude, GPT and Gemini. The same setup runs a Telegram AI bot.
https://api.5abeer.ai/v1Authorization: Bearer <KEY>https://api.5abeer.aix-api-key: <KEY>https://api.5abeer.ai/v1betax-goog-api-key: <KEY>First, send one test request with your key. If it answers, the key and the model are ready. The format is in Chat Completions.
curl --request POST "https://api.5abeer.ai/v1/chat/completions" \
--header "Authorization: Bearer $KHABEER_API_KEY" \
--header "Content-Type: application/json" \
--data '{"model":"claude-sonnet-5.5","messages":[{"role":"user","content":"Write a product idea in one sentence."}]}'{
"id": "chatcmpl-9f2",
"object": "chat.completion",
"model": "claude-sonnet-5.5",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello! How can I help you today?"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 21,
"completion_tokens": 12,
"total_tokens": 33
}
}The key is read from KHABEER_API_KEY, so it never appears in your code.
What does the server code look like?#
The server is one file. It serves the widget from the public folder and answers POST /api/chat.
import express from "express";
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.KHABEER_API_KEY, baseURL: "https://api.5abeer.ai/v1" });
const MODEL = process.env.CHAT_MODEL || "claude-sonnet-5.5";
// The assistant's instructions stay on the server: visitors can't read or change them.
const SYSTEM = `You are the website assistant of Example Store.
Answer only questions about the store, in the visitor's language, briefly and in plain text.
Opening hours: 9:00 to 21:00, Saturday to Thursday. Shipping takes 2 to 4 days.
If you don't know the answer, say so and suggest the contact page.`;
const MAX_MESSAGES = 11; // the last 5 questions and answers, plus the new question
const MAX_CHARS = 4000; // per message
const LIMIT = 20; // requests per visitor IP every 10 minutes
const hits = new Map();
setInterval(() => hits.clear(), 10 * 60 * 1000).unref();
function clean(messages) {
if (!Array.isArray(messages) || messages.length === 0 || messages.length > MAX_MESSAGES) return null;
const out = [];
for (const m of messages) {
if (!m || (m.role !== "user" && m.role !== "assistant") || typeof m.content !== "string") return null;
out.push({ role: m.role, content: m.content.slice(0, MAX_CHARS) });
}
return out[0].role === "user" && out.at(-1).role === "user" ? out : null;
}
const app = express();
// app.set("trust proxy", 1); // behind Nginx, Caddy or a load balancer: see the notes below
app.use(express.json());
app.use(express.static("public"));
app.post("/api/chat", async (req, res) => {
const count = (hits.get(req.ip) ?? 0) + 1;
hits.set(req.ip, count);
if (count > LIMIT) return res.status(429).json({ error: "Too many messages. Try again in a few minutes." });
const messages = clean(req.body?.messages);
if (!messages) return res.status(400).json({ error: "Invalid conversation." });
try {
const reply = await client.chat.completions.create({
model: MODEL,
messages: [{ role: "system", content: SYSTEM }, ...messages],
max_tokens: 800,
});
const answer = (reply.choices[0]?.message?.content ?? "").trim();
if (!answer) throw new Error("empty answer");
res.json({ answer });
} catch (error) {
console.error("model error:", error.message);
res.status(502).json({ error: "The assistant can't answer right now. Try again in a minute." });
}
});
app.listen(process.env.PORT || 3000, () => console.log("Chat server running on port", process.env.PORT || 3000));What does the chat widget look like?#
The widget is two files in the public folder: a page with the chat section, and the script that sends the questions.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chat with us</title>
<style>
.chat { max-width: 28rem; font: 16px/1.5 system-ui, sans-serif; border: 1px solid #767676; border-radius: 0.75rem; padding: 0.75rem; }
.chat-log { height: 20rem; overflow-y: auto; display: flex; flex-direction: column; gap: 0.5rem; padding: 0.25rem; }
.chat-log p { margin: 0; padding: 0.5rem 0.75rem; border-radius: 0.75rem; max-width: 85%; white-space: pre-wrap; }
.chat-log .user { align-self: flex-end; background: #1d4ed8; color: #fff; }
.chat-log .assistant { align-self: flex-start; background: #f1f5f9; color: #111; }
.chat form { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem; }
.chat label { flex-basis: 100%; }
.chat input { flex: 1; min-width: 0; font: inherit; padding: 0.5rem; }
.chat button { font: inherit; padding: 0.5rem 1rem; }
.chat :focus-visible { outline: 3px solid #1d4ed8; outline-offset: 2px; }
</style>
</head>
<body>
<section class="chat" aria-labelledby="chat-title">
<h2 id="chat-title">Ask our assistant</h2>
<div id="chat-log" class="chat-log" role="log" aria-label="Conversation" tabindex="0"></div>
<form id="chat-form">
<label for="chat-input">Your question</label>
<input id="chat-input" name="question" autocomplete="off" maxlength="2000" required>
<button type="submit">Send</button>
</form>
</section>
<script type="module" src="chat.js"></script>
</body>
</html>const log = document.getElementById("chat-log");
const form = document.getElementById("chat-form");
const input = document.getElementById("chat-input");
const button = form.querySelector("button");
const turns = [];
function add(role, text) {
const p = document.createElement("p");
p.className = role;
p.textContent = text; // text only: a reply can never inject HTML into the page
log.append(p);
log.scrollTop = log.scrollHeight;
return p;
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const question = input.value.trim();
if (!question) return;
input.value = "";
button.disabled = true;
add("user", question);
const reply = add("assistant", "Thinking…");
let text = "Sorry, something went wrong. Please try again.";
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: [...turns, { role: "user", content: question }] }),
});
const data = await res.json().catch(() => ({}));
if (res.ok && data.answer) {
text = data.answer;
turns.push({ role: "user", content: question }, { role: "assistant", content: data.answer });
turns.splice(0, turns.length - 10); // keep the last 5 questions and answers
} else if (data.error) {
text = data.error;
}
} catch {
// network error: keep the default message
}
reply.textContent = text;
button.disabled = false;
input.focus();
});We tested these three files on 2026-11-01 with Node.js 22, Express 5.3 and the openai SDK 7.47, against a local stand-in for the API and in a real browser, using a fake key.
How do you run it on your computer?#
Put server.mjs in a folder, with index.html and chat.js in a public folder inside it. Run these commands in that folder:
npm install express openai
export KHABEER_API_KEY="YOUR_5ABEER_API_KEY"
node server.mjsnpm install express openai
$env:KHABEER_API_KEY="YOUR_5ABEER_API_KEY"
node server.mjsOpen http://localhost:3000 and ask a question. Don't double-click index.html: the widget needs the server. To change the model or the port, set CHAT_MODEL or PORT the same way as the key.
How does the code work?#
- Instructions:
SYSTEMtells the model what the assistant is for. Replace the example store's details with your own facts. - Checks: the server accepts at most 11 user and assistant messages, starting and ending with a question, and cuts each one to 4,000 characters. Anything else, including a system message from a visitor, returns a 400 error without calling the model.
- Memory: the widget sends the last 5 questions and answers with each new question, so follow-ups work.
- Rate limit: each visitor IP gets 20 requests every 10 minutes. The 21st returns a 429 error and costs nothing. The counter lives in memory and resets when the server restarts.
- Errors: the SDK retries rate limits, timeouts and server errors twice. If a request still fails, the visitor sees a short apology and the server logs the error.
max_tokens: 800caps the answer length for Claude and Gemini models. GPT models ignore it, as listed in Parameters.- Safe output: the widget writes answers with
textContent, so HTML in an answer can't run in your page. - Accessibility: the conversation is a named
role="log"region that screen readers announce, and the input has a visible label.
How do you add it to an existing website?#
- Copy the
<section class="chat">element into your page and the<style>rules into your stylesheet. - Add the
chat.jsscript tag before</body>, with your path to the file. As a module, it can't clash with your site's other scripts. - Make
/api/chaton your site reach the Node.js server.
For step 3, the simplest way is one domain: in your web server, such as Nginx or Caddy, or in your host's proxy settings, forward /api/chat to the Node.js server. The widget then works unchanged, with no CORS. If the server lives on another domain, put its full URL in chat.js and allow CORS for your site's origin only, so other websites can't use your chatbot from their pages.
For an Arabic page, set lang="ar" dir="rtl" on the html element and translate the visible texts. The bubbles switch sides on their own.
What changes behind a proxy or a load balancer?#
The rate limit counts by req.ip, which Express takes from the connection by default. Behind Nginx, Caddy or a load balancer, that is the proxy's address, so all visitors share one counter.
The fix is the commented-out line in server.mjs: set trust proxy to the number of proxies in front of the app, usually 1. Express's guide, checked on 2026-11-01:
trust proxy value | Where the visitor's IP comes from |
|---|---|
| Not set (the default) | The direct connection |
A number, such as 1 | At most that many hops away, read from X-Forwarded-For right to left |
true | The left-most address in X-Forwarded-For |
Use true only if your last proxy overwrites X-Forwarded-For. Otherwise, the guide warns, "it may be possible for the client to provide any value", and a visitor could skip the limit. With several copies of the server, keep the counts in a shared store, such as Redis.
How much does a website chatbot cost?#
The widget and Express add no fees, so you pay for the server and the model's tokens. Each question sends the instructions, the recent conversation and the new message. In this example, the chatbot answers 3,000 questions a month, at about 1,500 tokens in and 300 out each:
4.5M input tokens and 900K output tokens
Official price from the provider's page, checked October 19, 2026.
The price per token for the default model:
Official price from the provider's page, checked October 19, 2026.
To spend less, pick a smaller model from the models list, keep SYSTEM short or lower max_tokens. Compare providers on the API page and the Claude, OpenAI and Gemini API pages, and plans on the pricing page.
Give the chatbot its own key with a spend limit, as in Key controls. At the limit, requests fail with API_KEY_QUOTA_EXHAUSTED and visitors see the apology, so a busy day or abuse can't spend more than you set.
How do you keep the chatbot safe?#
- Keep the key in an environment variable on the server, never in the page, in
chat.jsor in a repository. If it leaks, disable it and create a new one. - If the server has a fixed address, add an IP allow-list to its key, as in Key controls.
- Anyone can call
/api/chatwithout your page. The rate limit and the spend limit cap what that can cost. - Visitors can edit the history the widget sends, or try to talk the assistant out of its instructions. Keep secrets and customer data out of
SYSTEM, and treat answers as information, not promises. - Serve the page over HTTPS, and tell visitors that an AI assistant answers them.
FAQ#
Can the chatbot use GPT or Gemini instead of Claude?#
Yes. Set CHAT_MODEL to a model id from the models list and use a key for that model's provider. Each key covers one provider, so a GPT model with a Claude key returns MODEL_PLATFORM_NOT_ALLOWED.
Can it answer from my own content?#
Short content, such as hours, shipping and common questions, fits in SYSTEM. For a large catalog, search your content for each question and send only the relevant parts with it. The model knows only what you send.
Can the answer appear word by word?#
Yes, with streaming: set stream: true on the server and pass the parts to the widget as they arrive, as in Streaming. This guide waits for the full answer to keep the code short.
Does it work on WordPress or a site builder?#
Yes, if your platform accepts your own HTML and scripts, for example in a custom HTML block. The Node.js server runs on any host that supports Node.js.
What happens when my balance runs out?#
Requests fail with INSUFFICIENT_BALANCE, and visitors see the apology until you top up.
How do I pay for the API?#
You top up your 5abeer balance with any method in Payment methods, and the chatbot's API calls draw from that balance.
Is 5abeer part of OpenAI, Anthropic or Google?#
No. 5abeer is an independent service, not affiliated with any of them.
Sources#
Checked on 2026-11-01:
- OpenAI API reference: Overview: keeping API keys out of browsers and apps
- Express: Behind proxies:
trust proxyvalues - MDN: ARIA log role: chat logs as live regions
- openai and express on npm: the tested versions and their Node.js requirements
Get started#
Create a Claude key, put it in KHABEER_API_KEY and start the server.
Related guides
- How to build a Telegram AI bot with Claude, GPT or GeminiCreate a Telegram bot in BotFather and answer messages with Claude, GPT or Gemini through an OpenAI-compatible API, with tested Python code and costs.
- How to get an API key for Claude, GPT and GeminiWhat an AI API key does, where to create one at Anthropic, OpenAI and Google, what it costs to use, and how to keep it safe.
- How to pay for the OpenAI or Claude API from EgyptWhy Egyptian cards get declined by OpenAI and Anthropic, every way to pay from Egypt, and topping up API credit in EGP by wallet or InstaPay.
Ready to start?
Pick a plan that runs the official apps, or create an API key to use from your own code.