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)
- Telegram user sends a message → Telegram calls your
/telegram/webhook. - You push it into the bot →
POST /v1/send/{customer_token}/. - The bot replies → Landbot calls your
/landbot/webhook. - 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_tokenstore instead, looking up bycustomer.tokenon 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 optionalcustomerobject 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.typeon the envelope tells you whether abotor ahumansent 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
200first and work after. Keep it that way. - De-dupe. Telegram resends on retry (
update_id); for Landbot, dedupe on thesentry-tracerequest header — it's unique per message and preserved across retries (or fall back tocustomer.token+ messagetimestamp). Note Landbot retries on any non-2xx, including 4xx, so you can't use a status code to signal "stop retrying" — always ack200and handle rejection internally. - Persist state.
knownandseenIdsare in-memory — a restart re-creates customers (harmless, thanks to the409) 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_datais capped at 64 bytes. If a Landbotpayloadis longer, store it server-side and pass a short key ascallback_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.typeisbotorhuman— 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