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 pollingwebhook
setupbot.start(), nothing elsea public HTTPS URL + setWebhook
where it runsanywhere with outbound HTTPSanywhere that can receive it — including serverless
latencyup to one poll cycletelegram pushes immediately
scalingone process owns the poll loopscales like any HTTP endpoint
good forlocal dev, single-instance botsproduction, 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.

polling.ts
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 called

stop it cleanly on the signal your process manager actually sends:

shutdown.ts
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.

webhook.ts
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.

conditionresponse
wrong path (if set)404 (or fallback)
method is not POST405
secretToken set and header mismatched401
body larger than the cap413
body is not valid JSON, or not an update object400
handler throws (with onError: "fail")500
handler exceeds timeoutMs (with onTimeout: "ack")200 — finishes in the background
update dispatched200 ok, or the claimed reply call's body
body size cap. Telegram updates are tiny, so the handler rejects anything over 1 MiB (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:

options.ts
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's waitUntil (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. true allows 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-server.ts
// 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.

handle.ts
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);
register before the first update. the chain is realized and frozen on the first 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:

deploy.ts
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

worker.ts
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-server.ts
// 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.