Bridge a channel to Landbot

Put your Landbot bot on any channel it doesn't natively support, using Telegram as the worked example — a small relay server bridges messages both ways.

Run your Landbot bot on Telegram. By the end you'll have a small server that relays messages both ways: a Telegram user talks to your bot, and the bot's replies — text, images, and button prompts — come back in Telegram.

This is the pattern for putting Landbot on any channel it doesn't natively support (SMS, IVR, a custom app). Telegram is just the worked example; the Landbot half is identical for all of them.

How the bridge works

A bridge is a four-party relay with two inbound directions, each arriving at a different webhook on your server. Keep this picture in mind — the wiring confuses people more than the code does.

   Telegram user
       │ ▲
   (1) │ │ (4)  bot reply
 user msg │
       ▼ │
 ┌─────────────────────────────────────────────┐
 │              Your bridge server               │
 │   POST /telegram/webhook   POST /landbot/webhook
 └─────────────────────────────────────────────┘
       │ ▲
   (2) │ │ (3)  APIchat webhook (bot reply)
 send  │ │
       ▼ │
   Landbot bot  (APIchat channel @ chat.landbot.io)
  1. Telegram user sends a message → Telegram calls your /telegram/webhook.
  2. You push it into the bot → POST /v1/send/{customer_token}/.
  3. The bot replies → Landbot calls your /landbot/webhook.
  4. You forward the reply to Telegram → sendMessage / sendPhoto.

The one concept that makes or breaks it: identity mapping

Telegram identifies users by chat_id. Landbot identifies them by customer_token. The bridge needs a stable link between the two.

Because the APIchat Create customer endpoint lets you supply your own token, you can derive it deterministically from the chat_id — e.g. tg-<chat_id>. That keeps the bridge stateless: on a bot reply, the webhook hands you customer.token, and you recover the chat_id by stripping the prefix. No database required.

If you'd rather use opaque Landbot-generated tokens, omit the token on create and keep a chat_id ↔ customer_token store instead, looking up by customer.token on the inbound webhook. The rest of the recipe is unchanged.

Prerequisites

  • A Landbot bot with an APIchat channel, and that channel's token (Settings → your APIchat channel). This is your Authorization: Token <channel_token>.
  • A Telegram bot + token from @BotFather.
  • Node.js 18+ (for built-in fetch).
  • A public HTTPS URL. For local dev, ngrok http 3000 — both webhooks require HTTPS.

Step 1 — Project setup

mkdir landbot-telegram-bridge && cd $_
npm init -y
npm install express

Create .env (load it however you prefer — node --env-file=.env server.js on Node 20.6+, or the dotenv package on older versions):

LANDBOT_CHANNEL_TOKEN=your-apichat-channel-token
TELEGRAM_BOT_TOKEN=123456:ABC-your-bot-token
TELEGRAM_WEBHOOK_SECRET=a-long-random-string
LANDBOT_WEBHOOK_SECRET=another-long-random-string
PUBLIC_URL=https://your-subdomain.ngrok-free.app
PORT=3000

Step 2 — Shared helpers and the token strategy

const express = require("express");
const app = express();
app.use(express.json());

const LANDBOT_BASE = "https://chat.landbot.io/v1";

// Talk to Landbot APIchat (always authenticated with the channel token).
async function landbot(path, body) {
  return fetch(`${LANDBOT_BASE}${path}`, {
    method: "POST",
    headers: {
      Authorization: `Token ${process.env.LANDBOT_CHANNEL_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });
}

// Talk to Telegram.
async function telegram(method, body) {
  const res = await fetch(
    `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/${method}`,
    { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
  );
  return res.json();
}

// Deterministic, reversible mapping — this is why we need no database.
const tokenForChat = (chatId) => `tg-${chatId}`;
const chatIdFromToken = (token) =>
  token && token.startsWith("tg-") ? token.slice(3) : null;

Step 3 — Telegram → Landbot (push the user's message into the bot)

On first contact we register the customer with our own token. Create is idempotent for us: a 409 ("a customer with the same identifying field already exists") means it's already there, which we treat as success.

You can skip the explicit create if you prefer: POST /send/{token}/ accepts an optional customer object and will create the customer from it when the token is new. We register explicitly here so the name is set on first contact and the flow below reads in obvious order.

const known = new Set(); // in-memory cache; swap for Redis/DB in prod

async function ensureCustomer(chatId, name) {
  const token = tokenForChat(chatId);
  if (known.has(token)) return token;
  const res = await landbot("/customers/", { token, name }); // CustomerCreateRequest
  if (res.status === 201 || res.status === 409) known.add(token);
  else throw new Error(`createCustomer failed: ${res.status}`);
  return token;
}

app.post("/telegram/webhook", (req, res) => {
  // 1. Verify the request really came from Telegram.
  if (req.get("X-Telegram-Bot-Api-Secret-Token") !== process.env.TELEGRAM_WEBHOOK_SECRET) {
    return res.sendStatus(403);
  }
  // 2. Acknowledge immediately, then work — Telegram retries on slow/non-200 responses.
  res.sendStatus(200);
  handleTelegramUpdate(req.body).catch((e) => console.error("telegram→landbot", e));
});

async function handleTelegramUpdate(update) {
  if (update.update_id && seen(update.update_id)) return; // de-dupe retries

  if (update.message && update.message.text) {
    const { id: chatId, first_name } = update.message.chat;
    const token = await ensureCustomer(chatId, first_name);
    // Send body shape: { message: { type: "text", message } }
    await landbot(`/send/${token}/`, {
      message: { type: "text", message: update.message.text },
    });
  } else if (update.callback_query) {
    // The user tapped an inline button we sent from a Landbot "dialog".
    const cq = update.callback_query;
    const chatId = cq.message.chat.id;
    const token = await ensureCustomer(chatId, cq.from.first_name);
    await telegram("answerCallbackQuery", { callback_query_id: cq.id });
    // `payload` is what Landbot matches on to route the flow (see the dialog mapping in Step 4).
    // `message` is the text recorded in the transcript — here it's the payload value (e.g. "$0");
    // if you want the button's label there instead, encode it into `callback_data` too (64-byte cap).
    await landbot(`/send/${token}/`, {
      message: { type: "text", message: cq.data, payload: cq.data },
    });
  }
}

Step 4 — Landbot → Telegram (forward the bot's reply)

Landbot posts an ApichatWebhookPayload: { messages: [...], customer, channel, agent }. Each item in messages is one of three typed shapes — text, image, or dialog (a prompt with buttons). We recover the chat_id from customer.token and translate each message to its Telegram equivalent.

This webhook fires for bot-emitted messages only. The customer's own input (the messages you push in Step 3) never comes back here — so there's no echo loop to guard against, and agent.type on the envelope tells you whether a bot or a human sent the reply.

app.post("/landbot/webhook", (req, res) => {
  // APIchat outbound webhooks carry NO Authorization header and no signature — there is
  // no per-hook secret field in the dashboard. So you authenticate with what you control:
  // a secret in the webhook URL (used here), an IP allowlist, or a reverse proxy. The
  // request body is never proof of origin — don't treat its contents as authorization.
  if (req.query.secret !== process.env.LANDBOT_WEBHOOK_SECRET) return res.sendStatus(403);
  res.sendStatus(200); // ack fast; Landbot also retries
  handleLandbotWebhook(req.body).catch((e) => console.error("landbot→telegram", e));
});

async function handleLandbotWebhook(payload) {
  const chatId = chatIdFromToken(payload.customer && payload.customer.token);
  if (!chatId) return; // not one of our Telegram customers — ignore

  for (const msg of payload.messages || []) {
    await deliverToTelegram(chatId, msg); // preserve order
  }
}

async function deliverToTelegram(chatId, msg) {
  switch (msg.type) {
    case "text":
      await telegram("sendMessage", { chat_id: chatId, text: msg.message });
      break;

    case "image":
      await telegram("sendPhoto", { chat_id: chatId, photo: msg.url, caption: msg.message || undefined });
      break;

    case "dialog": {
      // buttons[i] = label shown; payloads[i] = value sent back when tapped.
      const inline_keyboard = msg.buttons.map((label, i) => [
        { text: label, callback_data: msg.payloads[i] },
      ]);
      await telegram("sendMessage", {
        chat_id: chatId,
        text: msg.title,
        reply_markup: { inline_keyboard },
      });
      break;
    }

    default:
      console.warn("Unhandled Landbot message type:", msg.type); // log, never drop silently
  }
}

// Naive de-dupe — replace with an LRU/TTL store in production.
const seenIds = new Set();
function seen(id) {
  if (seenIds.has(id)) return true;
  seenIds.add(id);
  if (seenIds.size > 5000) seenIds.clear();
  return false;
}

app.listen(process.env.PORT || 3000, () => console.log("Bridge listening"));

Step 5 — Wire up both webhooks

Start the server (node server.js) and expose it (ngrok http 3000), then register both directions:

# Tell Telegram to deliver updates to your server (with the secret we verify in Step 3).
curl "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/setWebhook" \
  -H "Content-Type: application/json" \
  -d "{\"url\":\"${PUBLIC_URL}/telegram/webhook\",\"secret_token\":\"${TELEGRAM_WEBHOOK_SECRET}\"}"

Then point your APIchat channel's webhook at:

${PUBLIC_URL}/landbot/webhook?secret=${LANDBOT_WEBHOOK_SECRET}

Step 6 — Test it end to end

Message your Telegram bot. You should see: the message reach your Landbot conversation (check the channel), the bot's reply arrive back in Telegram, and tappable buttons appear for any choice step.

If nothing comes back, walk the four hops in order: was /telegram/webhook called (log it)? Did /send/ return 201? Did /landbot/webhook fire? Did chatIdFromToken resolve? A break is almost always one missing webhook registration or a non-200 response triggering retries.

Production checklist — the parts that bite at 2am

  • Acknowledge before processing. Both platforms retry on slow or non-200 responses; we return 200 first and work after. Keep it that way.
  • De-dupe. Telegram resends on retry (update_id); for Landbot, dedupe on the sentry-trace request header — it's unique per message and preserved across retries (or fall back to customer.token + message timestamp). Note Landbot retries on any non-2xx, including 4xx, so you can't use a status code to signal "stop retrying" — always ack 200 and handle rejection internally.
  • Persist state. known and seenIds are in-memory — a restart re-creates customers (harmless, thanks to the 409) but can re-deliver messages. Use Redis/DB with TTL in production.
  • Secure both endpoints. Verify Telegram's secret-token header; protect the Landbot webhook with the shared secret. Never treat payload contents as authorization.
  • Telegram callback_data is capped at 64 bytes. If a Landbot payload is longer, store it server-side and pass a short key as callback_data.
  • Order and rate. A webhook may carry more than one item in messages — deliver them in array order. Mind Telegram's per-chat and ~30/sec global send limits.
  • Human handoff. agent.type is bot or human — branch on it if you want different behavior once a human agent takes over. The dashboard side (assigning, the team inbox) is covered in the Help Center's Human takeover & inbox.

Next steps

Swap the in-memory Sets for Redis, deploy behind a real HTTPS host, and extend deliverToTelegram with any message types you use. To bridge a different channel, keep the Landbot half exactly as-is and replace only the Telegram adapter — the /send/ call and the webhook handler don't change.

Reference: Create customer · Send message · APIchat webhook payload · Authentication