Skip to content

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.

Updated November 1, 2026Markdown version

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?#

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:

  1. The widget: an HTML section and a short script on your page. It sends each question to /api/chat on your server.
  2. Your server: a small Express app that checks each request, adds the key and the instructions, calls the model and returns only the answer.
  3. 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.

OpenAI-compatiblehttps://api.5abeer.ai/v1Authorization: Bearer <KEY>
Anthropic-compatiblehttps://api.5abeer.aix-api-key: <KEY>
Geminihttps://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."}]}'
Response
{
  "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.

server.mjs
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.

public/index.html
<!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>
public/chat.js
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:

macOS / Linux
npm install express openai
export KHABEER_API_KEY="YOUR_5ABEER_API_KEY"
node server.mjs
Windows
npm install express openai
$env:KHABEER_API_KEY="YOUR_5ABEER_API_KEY"
node server.mjs

Open 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: SYSTEM tells 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: 800 caps 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?#

  1. Copy the <section class="chat"> element into your page and the <style> rules into your stylesheet.
  2. Add the chat.js script tag before </body>, with your path to the file. As a module, it can't clash with your site's other scripts.
  3. Make /api/chat on 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 valueWhere the visitor's IP comes from
Not set (the default)The direct connection
A number, such as 1At most that many hops away, read from X-Forwarded-For right to left
trueThe 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:

Example: what Claude Sonnet 5.5 costs

4.5M input tokens and 900K output tokens

Our price$5.4070% below the official price
Official price$18.00

Official price from the provider's page, checked October 19, 2026.

The price per token for the default model:

Claude Sonnet 5.5 price per 1M tokens
Input$0.60Official price: $2
Output$3Official price: $10

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.js or 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/chat without 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:

Get started#

Create a Claude key, put it in KHABEER_API_KEY and start the server.