Skip to main content
  • 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:
Socket providers (Baileys, Discord, Telegram in polling mode, Email over IMAP) need no ingress.

With node:http

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

Express

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

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

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): 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():

Responses

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

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: 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).

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