Typebot on WhatsApp through the official API, with ZapClaw

In short: ZapClaw delivers each WhatsApp message to a small relay, the relay runs your published Typebot through Typebot's Chat API, and the bot's replies go back through ZapClaw. Messages travel on Meta's official Cloud API, and the number keeps working in the WhatsApp Business app on your phone. You keep the bot you built, and you need no Meta app of your own.

Connect your number in minutes and put the bot you already built on it, hosted by Typebot or self-hosted.

Business plan: US$ 29 per month, with unlimited numbers

Start for free See the relay

What you need to connect

You do not need a company tax ID or a verified business to connect. The full checklist.

Why ZapClaw for a Typebot on WhatsApp

Why a relay sits in between

Typebot cannot take ZapClaw's webhook as it is. Its Webhook block only resumes a conversation that is already paused on it, and its Chat API expects one call per message, with a session kept for each person.

So a small relay does that bookkeeping, the way Typebot's guide for messaging apps describes it: it starts a session on a customer's first message, continues it on the next ones and sends the bot's text bubbles back through ZapClaw. It is under 100 lines of Node.js, with nothing to install.

Set it up in four steps

  1. In Typebot, publish your bot and copy its public ID (where to find it). Keep the flow text first: text bubbles, text inputs and buttons.
  2. In ZapClaw, connect your number, create an API key and copy the Signing secret from the Webhook page.
  3. Run the relay below with those three values on a server with a public HTTPS address, and paste that address under Webhook in ZapClaw. ZapClaw delivers only to a public HTTPS address.
  4. Press Send test event in ZapClaw: the relay answers 200 and starts nothing. Then send a WhatsApp message to your number from another phone: the bot answers.

The relay, ready to run

Save it as relay.mjs and start it with node relay.mjs on Node.js 18 or newer. It reads its settings from the environment: ZAPCLAW_API_KEY, ZAPCLAW_SECRET (the signing secret), TYPEBOT_PUBLIC_ID, TYPEBOT_API for a self-hosted Typebot, and PORT, the port the relay listens on (8787 by default), which your HTTPS proxy forwards to.

// relay.mjs: ZapClaw webhook in, Typebot Chat API, replies out through ZapClaw.
// Node.js 18 or newer, nothing to install. Start it with: node relay.mjs
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';

const ZAPCLAW_API_KEY = process.env.ZAPCLAW_API_KEY; // zc_live_...
const ZAPCLAW_SECRET = process.env.ZAPCLAW_SECRET; // the webhook signing secret
const TYPEBOT_PUBLIC_ID = process.env.TYPEBOT_PUBLIC_ID; // your published bot
const TYPEBOT_API = process.env.TYPEBOT_API || 'https://typebot.co/api';

const sessions = new Map(); // customer: { id, choices }. Use your database in production.
const queues = new Map(); // one message at a time per customer, in order

const isSigned = (raw, header = '') => {
  const hmac = createHmac('sha256', ZAPCLAW_SECRET).update(raw).digest('hex');
  const expected = Buffer.from(`sha256=${hmac}`);
  const given = Buffer.from(String(header));
  return given.length === expected.length && timingSafeEqual(given, expected);
};

const typebot = async (path, body) => {
  const res = await fetch(`${TYPEBOT_API}/v1/${path}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ...body, textBubbleContentFormat: 'markdown' }),
  });
  return res.ok ? res.json() : null;
};

const sendText = (from, to, text) =>
  fetch('https://api.zapclaw.app/api/v1/send/text', {
    method: 'POST',
    headers: { 'X-API-Key': ZAPCLAW_API_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({ from, to, text }),
  });

const answer = async ({ data, account }) => {
  const customer = data.user_id || data.from;
  const from = account.display_phone_number || account.phone_number_id;
  const to = data.from || data.user_id;
  const session = sessions.get(customer);
  let reply = null;
  if (session) {
    // Typebot takes a button by its exact label, so accept its number or any letter case.
    const typed = data.text.body.trim();
    const choice = session.choices.find(
      (label, i) => typed === String(i + 1) || typed.toLowerCase() === label.toLowerCase()
    );
    reply = await typebot(`sessions/${session.id}/continueChat`, {
      message: { type: 'text', text: choice || typed },
    });
  }
  if (!reply) {
    // First message, or a session that expired (continueChat answers 404): start the bot.
    reply = await typebot(`typebots/${TYPEBOT_PUBLIC_ID}/startChat`, {
      prefilledVariables: { name: data.contact_name, phone: to },
    });
    if (!reply) return;
  }
  const choices =
    reply.input?.type === 'choice input'
      ? reply.input.items.map((item) => item.content).filter(Boolean)
      : [];
  if (reply.input) sessions.set(customer, { id: reply.sessionId || session.id, choices });
  else sessions.delete(customer); // the flow ended: the next message starts it again
  const texts = (reply.messages || [])
    .filter((m) => m.type === 'text')
    // Typebot writes standard Markdown; WhatsApp reads *bold*, ~strike~ and bare links.
    .map((m) => m.content.markdown
      .replace(/\*\*(.+?)\*\*/g, (_, t) => `*${t}*`)
      .replace(/~~(.+?)~~/g, (_, t) => `~${t}~`)
      .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => `${label} (${url})`));
  if (choices.length) texts.push(choices.map((label, i) => `${i + 1}. ${label}`).join('\n'));
  for (const text of texts) if (text) await sendText(from, to, text);
};

createServer((req, res) => {
  const chunks = [];
  req.on('data', (chunk) => chunks.push(chunk));
  req.on('end', () => {
    const raw = Buffer.concat(chunks);
    if (!isSigned(raw, req.headers['x-zapclaw-signature'])) return res.writeHead(401).end();
    res.writeHead(200).end(); // answer ZapClaw now: it waits 10 seconds, a bot can take longer
    let event;
    try {
      event = JSON.parse(raw.toString('utf8'));
    } catch {
      return;
    }
    if (event.event !== 'message.received' || event.data?.type !== 'text') return;
    const key = event.data.user_id || event.data.from;
    const next = (queues.get(key) || Promise.resolve())
      .then(() => answer(event))
      .catch((err) => console.error(err));
    queues.set(key, next);
  });
}).listen(process.env.PORT || 8787);

On a self-hosted Typebot, set TYPEBOT_API to the address of your viewer, the app where people answer your bots, followed by /api.

What it does: it checks the X-ZapClaw-Signature header, answers ZapClaw at once (ZapClaw waits 10 seconds, and a bot that calls an AI can take longer) and handles each customer's messages one at a time, in order. Options from a Buttons block go out as a numbered list, and the customer answers with the number or the label, in any letter case. When the flow ends, the next message starts it again.

Create variables named name and phone in your bot and the relay fills them on the first message with the contact's WhatsApp name and number. Sessions live in the relay's memory, so a restart starts every conversation over: in production, store them in your database, as Typebot's guide recommends.

The relay sends text bubbles. An image bubble carries its address in content.url, which you can send with POST /api/v1/send/media.

Calling ZapClaw from inside the bot

Typebot's HTTP Request block can call ZapClaw's API too, with an X-API-Key header and a custom body built from your variables: for example, to alert a salesperson on WhatsApp when a lead reaches the end of the bot. The same call as a curl command:

curl -X POST https://api.zapclaw.app/api/v1/send/text \
  -H "X-API-Key: zc_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "from": "15551617409", "to": "15555550100", "text": "New lead from the bot" }'

from is your connected number, which the webhook brings in account.display_phone_number, and to is the person you write to. A sent message answers 201. Free text works within 24 hours of that person's last message to your number; outside that window Meta accepts only an approved template, through POST /api/v1/send/template.

What reaches the relay

Each message comes as a message.received event: the text in data.text.body, the sender in data.from (or data.user_id for a contact who shows only a username), the contact's name in data.contact_name and your number in account.display_phone_number.

{
  "event": "message.received",
  "sequence": 1042,
  "timestamp": "2026-10-15T14:00:00.000Z",
  "data": {
    "from": "15555550100",
    "user_id": "US.1A2B3C4D5E6F7G8H9I0J",
    "contact_name": "Maria Silva",
    "message_id": "wamid.HBg...",
    "type": "text",
    "timestamp": "1792072800",
    "text": { "body": "Hi" }
  },
  "account": {
    "phone_number_id": "964283400112599",
    "display_name": "My Business",
    "display_phone_number": "+1 555 161-7409"
  }
}

The relay passes on only incoming text messages: the dashboard's test event and every other event get a 200 and nothing more. Every event and field is in the docs.

What it costs

Meta bills its WhatsApp message fees to you directly, with no ZapClaw markup. Every plan and price.

Who it is for

Teams that built lead capture, qualification, FAQ or booking flows in Typebot and want them on the WhatsApp number their customers already use. Agencies connect their clients' numbers on the Agency plan, each client isolated from the others. It does not fit bulk broadcasting.

Go further

Sources

Typebot's documentation checked on 15 October 2026. Features change, so check each source before you build.

Put your Typebot on WhatsApp today

Start on the free plan: one number, no card. Move to the Business plan when you need more numbers or more messages.

Start for free Read the webhook docs