> ## Documentation Index
> Fetch the complete documentation index at: https://botmesh.afaya.com.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook server (`@botmesh/server`)

> Webhook providers (WhatsApp Cloud API, Messenger, Instagram, Telegram in webhook mode, Evolution, Twilio, TikTok...) do not open a connection: the channel POSTs events to your HTTP server, and you hand each request to provider.ingest(). @botmesh/server is that HTTP layer, done right:

* **Raw body bytes**: `ingest()` gets the exact `Buffer` that arrived.
  Signatures such as Meta's `X-Hub-Signature-256` are computed over those
  bytes, never over re-serialized JSON.
* **Size limit** (`413`), 1 MiB by default, checked against
  `Content-Length` and while the stream arrives.
* **Timeouts** for reading the body (`408`) and for `ingest()` (`503`).
* **Concurrency limit** (`503` + `Retry-After: 1` when saturated).
* **Routing**: `GET` → `verifyChallenge()` (Meta's handshake), `POST` →
  `ingest(raw, headers, url)`. The value `ingest()` returns is the HTTP status.
* **Health endpoints**: `/healthz` (liveness) and `/readyz` (readiness).
* **No sensitive logs**: only method, path, status, sizes and the error
  class name; never bodies, headers, query strings or error messages.
* **No framework dependencies**: adapters for `node:http`, Express, Fastify,
  Hono and fetch-style runtimes, typed structurally.

It is the recommended way to host webhook channels. It has no dependencies:

```bash theme={null}
pnpm add @botmesh/server
```

Socket providers (Baileys, Discord, Telegram in polling mode, Email over
IMAP) need no ingress.

## With `node:http`

```ts theme={null}
import { createServer } from 'node:http'
import { Bot } from '@botmesh/core'
import { CloudAPIProvider } from '@botmesh/meta'
import { TelegramProvider } from '@botmesh/telegram'
import { createIngress, createNodeHandler } from '@botmesh/server'

const whatsapp = new CloudAPIProvider({
  accessToken: process.env.META_TOKEN!,
  phoneNumberId: process.env.META_PHONE_ID!,
  verifyToken: process.env.META_VERIFY_TOKEN!,
  appSecret: process.env.META_APP_SECRET!,
})
const telegram = new TelegramProvider({
  token: process.env.TELEGRAM_TOKEN!,
  polling: false,
  webhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET!,
})
const bot = new Bot({ provider: whatsapp })
const tgBot = new Bot({ provider: telegram })

const ingress = createIngress({
  routes: [
    { path: '/webhook/whatsapp', provider: whatsapp },
    { path: '/webhook/telegram', provider: telegram },
  ],
  bot,                     // /readyz → 200 only when bot.getConnectionState() === 'ready'
  maxBodyBytes: 512 * 1024,
  maxConcurrent: 32,
})

const server = createServer(createNodeHandler(ingress))
await bot.start()
await tgBot.start()
server.listen(3000)

// Graceful shutdown: stop accepting webhooks, drain the ones in flight
process.on('SIGTERM', async () => {
  await ingress.close({ timeoutMs: 10_000 })
  await bot.stop({ drain: true })
  await tgBot.stop({ drain: true })
  server.close()
})
```

`createNodeHandler(ingress, options)` answers `404` for paths it does not
serve; pass `onNotFound: (req, res) => ...` to chain another handler
instead.

## Express

```ts theme={null}
import express from 'express'
import { expressIngress } from '@botmesh/server/express'

const app = express()
app.use(expressIngress(ingress)) // BEFORE express.json()
app.use(express.json())          // the rest of your routes
```

Paths not served by the ingress fall through to `next()`. Paths are matched
against `req.url`, so they are relative to where the middleware is mounted.
If a body parser already consumed the body (`express.json()` mounted
earlier), the ingress answers `500` instead of re-serializing. Behind
`express.raw()` it reuses that `Buffer` (and still applies `maxBodyBytes`).

## Fastify

```ts theme={null}
import Fastify from 'fastify'
import { fastifyIngress } from '@botmesh/server/fastify'

const app = Fastify()
app.register(fastifyIngress(ingress), { prefix: '/hooks' }) // /hooks/webhook/whatsapp, /hooks/healthz...
```

It is an encapsulated plugin: its catch-all content-type parser leaves the
stream untouched only for its own routes; your other routes keep Fastify's
JSON parser.

## Hono and fetch runtimes

```ts theme={null}
import { Hono } from 'hono'
import { honoIngress } from '@botmesh/server/hono'
import { createFetchHandler } from '@botmesh/server'

const app = new Hono()
app.use('*', honoIngress(ingress)) // unserved paths → next()

const handler = createFetchHandler(ingress) // (Request) => Promise<Response>, 404 when no route matches
```

`serveFetch(ingress, request)` is the lower-level variant: it resolves
`undefined` when no route matches, so you can fall through. The Hono adapter
needs `Buffer` (Node, Bun, Deno, Workers with `nodejs_compat`).

## Options

`createIngress(options)`:

| Option | Default | Description |
| - | - | - |
| `routes` | — | `{ path, provider, body?: 'buffer' \| 'text', maxBodyBytes? }[]` |
| `maxBodyBytes` | `1048576` (1 MiB) | Max request body (`413`) |
| `bodyTimeoutMs` | `10000` | Time to receive the whole body (`408`) |
| `ingestTimeoutMs` | `10000` | Time for `ingest()` to settle (`503`); the concurrency slot is held until it really settles |
| `maxConcurrent` | `64` | Webhook requests being read or ingested at once (`503` beyond it) |
| `healthPath` | `'/healthz'` | Liveness path; `false` disables it |
| `readyPath` | `'/readyz'` | Readiness path; `false` disables it |
| `bot` | — | Any object with `getConnectionState()` (a `Bot`): readiness requires `'ready'` |
| `readiness` | — | Extra check, `() => boolean \| Promise<boolean>`; throwing or `false` → not ready |
| `logger` | silent | pino-compatible logger (metadata only) |

Route rules: `path` must start with `/`, a trailing slash is ignored, and
duplicate paths (a webhook path equal to a health path, or `healthPath`
equal to `readyPath`) throw a `TypeError`. A route whose provider has no
`ingest()` method, or whose `body` is not `'buffer'` or `'text'`, also
throws. So do a non-positive limit, a timeout above 2147483647 ms (the
largest delay `setTimeout` honours; a bigger one would fire after 1 ms), a
`bot` without `getConnectionState()` and a `readiness` that is not a
function. Every message starts with `@botmesh/server:` and names the option.

The defaults are exported as `DEFAULT_MAX_BODY_BYTES`,
`DEFAULT_BODY_TIMEOUT_MS`, `DEFAULT_INGEST_TIMEOUT_MS` and
`DEFAULT_MAX_CONCURRENT`.

### `body: 'buffer'` or `'text'`

`ingest()` receives the raw body as a `Buffer` (default) or as a UTF-8
string with `body: 'text'`. `TelegramProvider` takes either (its first
alphas needed `body: 'text'`).

### Per-route size limit

Raise `maxBodyBytes` on the routes that need it, for example raw MIME for
`EmailProvider.ingest()`:

```ts theme={null}
createIngress({
  routes: [
    { path: '/webhook/whatsapp', provider: whatsapp },
    { path: '/inbound-email', provider: email, maxBodyBytes: 40 * 1024 * 1024 },
  ],
})
```

## Responses

| Situation | Status |
| - | - |
| `ingest()` returns (or resolves to) an integer 200-599 | that status |
| `ingest()` returns `{ status, body?, headers? }` | that status; a string `body` is sent as text, an object as JSON; `headers` replace the ingress's own case-insensitively (names are sent lowercase), and framing/hop-by-hop headers (`content-length`, `transfer-encoding`, `connection`, `keep-alive`, `upgrade`, `proxy-connection`, `te`, `trailer`) are ignored |
| `ingest()` returns anything else (including a `1xx` code) / nothing | `200` |
| `ingest()` throws a `SyntaxError` / another error | `400` / `500` |
| `GET`/`HEAD` with `verifyChallenge()` → string / `null` | `200` with the challenge / `403` |
| `GET` without `verifyChallenge()`, or another method | `405` (with `Allow`) |
| Body too large / too slow | `413` / `408` (with `Connection: close`) |
| Body already consumed by a parser | `500` |
| Client aborted the body | `400` |
| Saturated, `ingest()` timed out, or shutting down | `503` + `Retry-After: 1` |

`EmailProvider.ingest()` resolves `{ status, accepted, reason? }`, so its
`status` (`200`, `400` or `413`) is used as is.

With `node:http`, Express and Fastify, before answering with
`Connection: close` (413, 503...) the rest of the unread body is discarded so
the client gets the response instead of an `ECONNRESET`. Draining is bounded
(8 MiB and 5 s by default;
`createNodeHandler(ingress, { drain: { maxBytes, timeoutMs } })`); past the
bounds the response is still written and the socket is reset. A `408` does
not wait for the body.

## Health endpoints

Both accept `GET` and `HEAD` only (`405` otherwise):

* **`/healthz`**: always `200` `{"status":"ok"}` while the process serves.
* **`/readyz`**: `200` when ready, `503` otherwise. The JSON body is
  `{ status: 'ready' | 'not_ready', connection?, draining? }`: `connection`
  is the bot's connection state (when `bot` is set) and `draining` appears
  after `close()`.

Ready means: `close()` has not been called, the `bot` (if any) is
`'ready'`, and `readiness()` (if any) resolved `true`.

## The `Ingress` object

```ts theme={null}
const ingress = createIngress({ routes, bot })

ingress.paths        // every served path (webhooks + enabled health paths)
ingress.matches(p)   // whether a path is served
ingress.inflight     // webhook requests being read or ingested
ingress.closing      // true after close()
await ingress.isReady()
await ingress.close({ timeoutMs: 10_000 }) // 503 for new webhooks, wait for in-flight ones
await ingress.handle(request)              // framework-neutral entry point used by the adapters
```

`close()` without `timeoutMs` waits until every in-flight request settles;
a `timeoutMs` outside 0-2147483647 rejects with a `TypeError`. `close()` is
one-way: build a new ingress to serve again.

## Provider notes

The ingress calls `provider.ingest(rawBody, headers, requestUrl)`, where
`requestUrl` is the request path with its query string (`/hook?token=…`),
exactly as received (not re-encoded, so a URL signature such as Twilio's
still matches). Providers that authenticate a token in the URL read it from there:

| Provider | Route setup |
| - | - |
| `CloudAPIProvider`, `MessengerProvider`, `InstagramProvider` | Default (`buffer`). The `GET` handshake goes to `verifyChallenge()` |
| `TelegramProvider` (`polling: false`) | Default (`buffer`) or `body: 'text'` |
| `EvolutionProvider`, `GoHighLevelProvider`, `TikTokProvider` | Default (`buffer`) |
| `TwilioProvider` | Default. Configure `webhookUrl` (the exact public URL Twilio signs), or `webhookPath` behind a proxy you control (the ingress passes the request's query string, which Twilio signs too) |
| `WPPConnectProvider` | Default. The webhook token arrives in the query string (`?token=…`) or a header (`x-webhook-token` or `Authorization: Bearer`) |
| `GupshupProvider` | Default. The token arrives in the query string of the callback URL (`?token=…`, `webhookTokenParam`) or the `x-webhook-token` header |
| `EmailProvider` | Default, with a larger `maxBodyBytes` |

Without the ingress, mount a route in your framework and pass the URL
yourself: `provider.ingest(body, headers, req.originalUrl)` (WPPConnect,
Gupshup, and Twilio with `webhookPath`) or
`provider.ingestRequest({ url, body, headers })` (Gupshup, and Twilio with the
full public URL) (see [Channels](https://botmesh.afaya.com.co/guide/channels.md)).

## Lower-level exports

* `serveNode(ingress, req, res, options)`: serve one Node request (used by
  the adapters).
* `serveFetch(ingress, request)`: serve a WHATWG `Request`.
* `readNodeBody(req, limit, timeoutMs)` / `readWebBody(request, limit, timeoutMs)`:
  read a raw body with a byte limit and a timeout.
* `BodyTooLargeError`, `BodyTimeoutError`, `BodyUnavailableError`,
  `BodyAbortedError`: the errors those readers reject with.
* `normalizePath(path)`, `splitUrl(url)`: path helpers, in
  `@botmesh/server/internal` (no semver promise).

## See also

* [Channels](https://botmesh.afaya.com.co/guide/channels.md): which providers are webhook-based.
* [Deployment](https://botmesh.afaya.com.co/guide/deploy.md)
* [`@botmesh/server` README](https://github.com/andresayac/botmesh/tree/main/packages/server)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.