All posts

How to make a WhatsApp chatbot with the ChatGPT API

kenari team9 min read

Someone sends “halo” to your WhatsApp number, and the number answers with text the model wrote. That is the whole scope here: one message in, one reply out.

WhatsApp stays on Fonnte, and the model call is an ordinary OpenAI-compatible Chat Completions request. Your key starts with kn-, the base URL points at kenari, and the first model ends in :free, so nothing needs topping up before you know the loop works.

This is not an n8n post. If your workflow already lives on a canvas, Point n8n at kenari is the one you want, because here the whole host is one inbound Fonnte webhook pointed at POST /v1/chat/completions.

Get one message working before you build a product

Leave the catalog, the queue, and the customer dashboard for later. Today’s target is one loop: you send “halo” to the connected number, and that number answers with text the model wrote. A feature stacked on a loop that does not run yet is just another error to debug.

It helps to know what you are not using. Fonnte documents an AI feature through the flow menu, with its own AI quota and AI data, and Aksita is a separate integration (flow nodes plus an Aksita API key) rather than that native one. Their 10 March 2026 update planned to deprecate AI data & AI quota on 1 June 2026 and pointed people at aksita.ai, though the quota and AI-data pages are still up. None of it is the ChatGPT API. On the three pages we cite below (Node webhook, PHP webhook, send API), the docs list no custom GPT or OpenAI endpoint fields (API key, base URL, model) where your kn- and https://kenari.id/v1 would go. That is not a full audit of their docs, so do not invent that screen.

What their docs do spell out is an inbound webhook: incoming chat gets POSTed to your URL, you call kenari, and the answer goes back out through https://api.fonnte.com/send.

Where the bot lives: Fonnte’s inbound webhook

Every field below comes from the Fonnte docs, not a dashboard label we guessed at:

What we use:

Direction Fields / values they document
Webhook body sender, message (also device, name, inboxid, and others)
Send URL https://api.fonnte.com/send
Send header Authorization: TOKEN (the token itself, no Bearer)
Send body target, message

The PHP guide walks it in order: create a device, copy the token, connect the device first, then paste a public URL into the webhook field under device → edit and switch autoread on below it. Fonnte autoreply stops while a webhook is active, so the webhook becomes the only thing answering. The URL has to be public, and their example is https://fonnte.com/urlwebhook.php, so localhost will not do.

Test kenari first, before the webhook

Prove the model call works first, because if curl to kenari comes back empty, Fonnte is not the problem.

  1. Sign in to the dashboard, open API keys, and click Create key. It starts with kn- and is shown once, so copy it now. One key works for every model, though you can keep several labeled ones.
  2. Skip the top-up, because nothing here costs anything.
  3. Take a live id from the public catalog: GET /v1/models needs no key. The docs say the unfiltered list is chat, with embedding, rerank, and moderation behind ?modality=, and the 21 Aug 2026 snapshot also carried image, audio, and music ids.
curl https://kenari.id/v1/models

Look for an id ending in :free, or pricing.free: true. Do not memorize the catalog, because it moves. When we checked, step-3-7-flash:free was still there with pricing.free: true and chat among its endpoints.

Now swap kn-... for your key and send the first one.

curl https://kenari.id/v1/chat/completions \
  -H "Authorization: Bearer kn-..." \
  -H "Content-Type: application/json" \
  -d '{
    "model": "step-3-7-flash:free",
    "messages": [{"role": "user", "content": "Halo dari WhatsApp"}]
  }'

When choices[0].message.content comes back with an answer, that half is done. kenari accepts x-api-key too, but Bearer is plenty here.

The OpenAI-compat base URL has to be exactly https://kenari.id/v1, not the bare https://kenari.id and not https://kenari.id/v1/v1, since both wrong shapes usually return 405. Once that curl returns content, attach the webhook.

A webhook that POSTs to kenari

It fits in one Node file. The Fonnte token and the kenari key live in env, not in the repo, and the names below are just what this example calls them (FONNTE_TOKEN, KENARI_API_KEY, KENARI_MODEL).

export FONNTE_TOKEN="fonnte-device-token"
export KENARI_API_KEY="kn-..."
export KENARI_MODEL="step-3-7-flash:free"
const express = require("express");
const app = express();
app.use(express.json());

const FONNTE_TOKEN = process.env.FONNTE_TOKEN;
const KENARI_KEY = process.env.KENARI_API_KEY;
const MODEL = process.env.KENARI_MODEL || "step-3-7-flash:free";

async function chat(text) {
  const res = await fetch("https://kenari.id/v1/chat/completions", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${KENARI_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: MODEL,
      messages: [
        { role: "system", content: "You are a concise WhatsApp assistant." },
        { role: "user", content: text },
      ],
    }),
  });
  const data = await res.json();
  if (!res.ok) {
    throw new Error(res.status + " " + JSON.stringify(data));
  }
  return data.choices[0].message.content;
}

async function sendFonnte(target, message) {
  const res = await fetch("https://api.fonnte.com/send", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: FONNTE_TOKEN,
    },
    body: JSON.stringify({ target, message }),
  });
  console.log(await res.json());
}

app.post("/webhook", async function (req, res) {
  const sender = req.body.sender;
  const message = req.body.message;
  res.end();
  if (!sender || !message) return;
  try {
    const reply = await chat(String(message));
    await sendFonnte(sender, reply);
  } catch (err) {
    console.error(err);
  }
});

app.listen(3000, function () {
  console.log("webhook on :3000/webhook");
});

sendFonnte follows Fonnte’s Node example exactly: POST https://api.fonnte.com/send, the raw token in Authorization with no Bearer in front, and a body of target plus message, where target is whatever arrived as req.body.sender. Their sample never sends inboxid, which the send docs describe as the way to answer an inbox message once you enable inbox under device → edit, so this bot leaves it out and POSTs target = sender.

The ordering matters. Fonnte’s Node example calls send and then res.end(), and their 12 January 2026 update says the webhook has to return HTTP 200 to count as a success. Nothing in the docs asks for that ACK before the model call, but the sample above calls res.end() first anyway, so the request is not held open while the model runs. The WhatsApp reply lands once the model has answered.

Then paste a public URL into the device webhook field, turn autoread on, and send “halo” to the connected number. The path is yours: the Node sample uses /webhook, the PHP sample a file URL, and neither is required. Nothing on the pages we cite says the sender has to be a number other than the device’s own. If a reply shows up, the loop works.

After one reply works

There is still no reason to top up, because :free is billed at Rp 0. Switch to a paid id later, without the :free suffix, and a Rp 0 balance hands back HTTP 402 insufficient_balance, which is the meter doing its job rather than a broken key.

Your kn- does not change when the model does, only the model field does. Keep several labeled keys if that helps, one per bot, and revoke any that leaks.

If you get stuck

  • Webhook silent: the URL is not public, the path does not match what you pasted (Node sample: /webhook), the device is not connected, or autoread is still off. Log the body (sender, message) the way Fonnte’s example does.
  • kenari answered but WhatsApp did not: the Fonnte token is wrong, or you prefixed Bearer. Fonnte docs pass the token directly.
  • 401 from kenari: the kn- is wrong, truncated, or still an sk-. The 401 body is plain text.
  • model_not_found: the id is mistyped, or it left the catalog. Fetch GET /v1/models again. (Kenari’s error table says HTTP 400 here, so do not rely on 404.)
  • 405: a doubled /v1, or a missing /v1. The base URL must be https://kenari.id/v1.
  • 402 insufficient_balance: a paid model on Rp 0. Switch back to :free, or top up first.
  • 429: the :free limit. Read Retry-After, and do not memorize the number.

If it turns out the plain request is what is stuck rather than the webhook, it is faster to step back to your first request from zero until one curl works. Picking a free id, including which flag you actually need to read, is in try a free model first.