webhooks & deploy
two webhook handlers — a fetch-style one and a node http one — both feeding the same handleUpdate entry point, with a constant-time secret check, a body-size cap, and the options that make a webhook production-ready: timeouts, retried-vs-acked errors, and the reply envelope.
polling vs webhooks
| long polling | webhook | |
|---|---|---|
| setup | bot.start(), nothing else | a public HTTPS URL + setWebhook |
| where it runs | anywhere with outbound HTTPS | anywhere that can receive it — including serverless |
| latency | up to one poll cycle | telegram pushes immediately |
| scaling | one process owns the poll loop | scales like any HTTP endpoint |
| good for | local dev, single-instance bots | production, serverless, multi-instance |
for development, long polling is simplest: bot.start() loops getUpdates and dispatches each update through handleUpdate, retrying
after a short delay if a poll fails (see bot.onPollingError and the allowedUpdates bot option). it resolves only when bot.stop() is called.
import { Bot } from "@yaebal/core";
// long polling — start() loops getUpdates and calls handleUpdate for each
const bot = new Bot(process.env.BOT_TOKEN!);
bot.on("message:text", (ctx) => ctx.reply(ctx.text));
await bot.start(); // resolves only when stop() is calledstop it cleanly on the signal your process manager actually sends:
import { Bot } from "@yaebal/core";
const bot = new Bot(process.env.BOT_TOKEN!);
bot.on("message:text", (ctx) => ctx.reply(ctx.text));
const stop = () => void bot.stop();
process.on("SIGINT", stop);
process.on("SIGTERM", stop); // e.g. a container orchestrator's shutdown signal
await bot.start();for production you usually want webhooks: Telegram POSTs each update to your URL, and you hand the request straight to a yaebal handler. no polling loop, and it scales to serverless runtimes.
webhookCallback (fetch)
webhookCallback returns a (Request) => Promise<Response> function — the shape every fetch-based runtime expects. it only accepts POST,
parses the JSON update, and dispatches it.
import { Bot, webhookCallback } from "@yaebal/core";
const bot = new Bot(process.env.BOT_TOKEN!);
bot.on("message:text", (ctx) => ctx.reply(ctx.text));
// (Request) => Promise<Response>
const handler = webhookCallback(bot, { secretToken: process.env.WEBHOOK_SECRET });the secret check
when you set secretToken, the handler requires Telegram's X-Telegram-Bot-Api-Secret-Token header to match. the comparison uses a small
constant-time string loop instead of node:crypto, so the fetch handler stays portable
across Node, Bun, Deno and edge runtimes.
| condition | response |
|---|---|
wrong path (if set) | 404 (or fallback) |
method is not POST | 405 |
secretToken set and header mismatched | 401 |
| body larger than the cap | 413 |
| body is not valid JSON, or not an update object | 400 |
handler throws (with onError: "fail") | 500 |
handler exceeds timeoutMs (with onTimeout: "ack") | 200 — finishes in the background |
| update dispatched | 200 ok, or the claimed reply call's body |
maxBodyBytes) to avoid memory abuse. the limit is enforced while
streaming — an absent or spoofed content-length can't slip a large body past
it — and a fast content-length check rejects oversize declared bodies before reading.options
the options that make a webhook behave correctly under real traffic, not just the happy path:
import { Bot, webhookCallback } from "@yaebal/core";
const bot = new Bot(process.env.BOT_TOKEN!);
const handler = webhookCallback(bot, {
secretToken: process.env.WEBHOOK_SECRET,
path: "/telegram", // only serve this pathname
fallback: () => new Response("ok"), // e.g. a health check on any other path
timeoutMs: 8_000, // answer telegram even if the handler is slow
onTimeout: "ack", // "ack" (default): 200 now, finish in the background
// "fail": 500 — telegram redelivers (handler must be idempotent)
onError: "fail", // "fail" (default): 500, telegram redelivers with backoff
// "ack": 200 — drop the update after logging
reply: (method) => method === "sendChatAction", // let ONLY this call answer the webhook's own
// http response instead of a separate request
});timeoutMs/onTimeout— telegram redelivers a request that hangs, so answering first beats being timed out remotely."ack"(default) returns 200 and lets the update finish in the background — pass the platform'swaitUntil(the handler's second argument) on serverless so it survives past the response;"fail"returns 500 so telegram redelivers later, meaning a deterministic hang repeats every time.path/fallback— serve one exact pathname and hand everything else (health checks, other routes) to your own handler instead of a bare 404/405.reply— the webhook reply envelope: let one eligible api call answer the webhook's own HTTP request instead of making a separate call, saving a round trip per update.trueallows any upload-free call; a predicate restricts which method qualifies. the claimed call's promise still resolves (true— telegram doesn't send back a result), and it's delivered after any direct calls the handler made first.onError—"fail"(default) returns 500, so telegram redelivers with backoff; a handler that deterministically throws will repeat until it ages out."ack"returns 200 and drops the update after logging — safer for a best-effort handler that shouldn't be retried indefinitely.
nodeWebhookCallback (node http)
for a plain Node server, import nodeWebhookCallback from @yaebal/core/node. it returns an (req, res) handler you can drop into http.createServer. keeping it in a Node-only subpath prevents the main @yaebal/core entry from importing node:http.
// Node http
import { createServer } from "node:http";
import { Bot } from "@yaebal/core";
import { nodeWebhookCallback } from "@yaebal/core/node";
const bot = new Bot(process.env.BOT_TOKEN!);
bot.on("message:text", (ctx) => ctx.reply(ctx.text));
createServer(
nodeWebhookCallback(bot, { secretToken: process.env.WEBHOOK_SECRET }),
).listen(8080);handleUpdate
both handlers ultimately call bot.handleUpdate(update), which builds a Context and runs the middleware chain, sending any thrown error to your onError handler. you can also call it directly from a custom HTTP layer.
import { Bot, type Update } from "@yaebal/core";
declare const bot: Bot;
declare const update: Update;
// both handlers ultimately call bot.handleUpdate — the single-update entry point.
// the chain is realized (and frozen) on the first call, so register every
// middleware / plugin before the first handleUpdate or start.
await bot.handleUpdate(update);handleUpdate (or start), so attach all middleware and plugins before
then. webhook handlers also lazily call bot.init() on the first update, so ctx.me//cmd@botname addressing work without ever polling — pass botInfo in the Bot constructor to skip that extra getMe round trip on a cold serverless start.registering the webhook
telegram has to be told the URL. run setWebhook once on deploy (from @yaebal/web) — its secretToken must match the one
you pass to webhookCallback/serve:
import { Bot } from "@yaebal/core";
import { setWebhook } from "@yaebal/web";
const bot = new Bot(process.env.BOT_TOKEN!);
// run once on deploy — points telegram at your url. secretToken must match
// whatever you pass to webhookCallback()/serve() itself.
await setWebhook(bot, "https://example.com/telegram", {
secretToken: process.env.WEBHOOK_SECRET,
allowedUpdates: ["message", "callback_query"],
dropPendingUpdates: true, // discard whatever queued up while the webhook was unset
});deploy: Cloudflare Workers
interface Env {
BOT_TOKEN: string;
WEBHOOK_SECRET: string;
}
interface ExecutionContext {
waitUntil(promise: Promise<unknown>): void;
}
import { Bot, webhookCallback } from "@yaebal/core";
// one bot per isolate, built lazily from env — module scope has no "env" yet,
// it only arrives as fetch()'s second argument
let bot: Bot | undefined;
const getBot = (env: Env) => (bot ??= new Bot(env.BOT_TOKEN));
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// Workers calls fetch(request, env, ctx) — ctx (not env) is the waitUntil()
// execution context webhookCallback's timeout/onTimeout machinery needs
return webhookCallback(getBot(env), { secretToken: env.WEBHOOK_SECRET })(request, ctx);
},
};deploy: Bun
// Bun
import { Bot, webhookCallback } from "@yaebal/core";
declare const Bun: {
serve(options: { port: number; fetch: (request: Request) => Promise<Response> }): unknown;
};
const bot = new Bot(process.env.BOT_TOKEN!);
bot.on("message:text", (ctx) => ctx.reply(ctx.text));
Bun.serve({
port: 8080,
fetch: webhookCallback(bot, { secretToken: process.env.WEBHOOK_SECRET }),
});other runtimes
Deno, Node behind a framework, and every serverless flavor (AWS Lambda, Azure Functions, Google
Cloud Functions) work the same way — hand a Request to webhookCallback (or use the framework-specific adapter). the full, up-to-date matrix lives on deploy targets and runtimes; every adapter itself is documented on @yaebal/web.
going further: @yaebal/web
@yaebal/web wraps this engine with everything a real deployment
needs: one-line adapters for express, fastify, koa, hono, elysia, next.js, sveltekit, aws lambda,
azure and google cloud functions; a serve() that runs on node/bun/deno and returns a
stoppable handle; the sequentialize() and dedupe() combinators for
parallel delivery and redelivery; setWebhook / getWebhookInfo / deleteWebhook; and timeout / error / webhook-reply policies on webhook() itself.