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 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. A Telegram user sends a message, and Telegram calls your /telegram/webhook.
  2. You push it into the bot with POST /v1/send/{customer_token}/.
  3. The bot replies, and Landbot calls your /landbot/webhook.
  4. You forward the reply to Telegram with sendMessage or sendPhoto.

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, for example 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, then 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: Push the Telegram 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: Forward the bot's reply to Telegram

Landbot posts an ApichatWebhookPayload: { messages: [...], customer, channel, agent }. This recipe handles three message types: text, image and dialog (a prompt with buttons). Landbot also sends location, media_dialog, and files typed video, audio or document; see the APIchat payload. We recover the chat_id from customer.token and translate each message to its Telegram equivalent.

This webhook delivers what the bot and your human agents send. 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. agent.type on the envelope is bot for a bot message, or human (with the agent's id, name and email) for a message an agent sent from the inbox.

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

  • 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). The APIchat payload has no message ID, but Landbot sends the same body on every retry, so dedupe on customer.token plus the message timestamp. Landbot retries any response outside 2xx, including 3xx (it doesn't follow redirects) and 4xx, about 5, 10, 15, 20 and 25 seconds apart, for 6 attempts in total. You can't use a status code to stop the retries, so always answer 200 and handle rejection internally. Connection errors and timeouts (60 seconds) aren't retried. See Webhooks.
  • 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. Each delivery carries one message in messages, and deliveries can arrive out of order. Sort by timestamp if order matters. Mind Telegram's per-chat limit and its global limit of about 30 messages a second.
  • 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 · Authentication