#5146·PraisonAI

Gateway has no shared HTTP ingress: each webhook channel binds its own port, so running multiple webhook bots needs multiple ports/public URLs (and collides on 8080)

Author: MervinPraisonCreated Sep 18, 2026Updated Sep 18, 2026
Labelsclaude

Summary

The gateway exposes one unified HTTP listener for its own control/observability surface, but platform channels that receive inbound messages via webhook each stand up their own private HTTP server on their own port. There is no way to serve several webhook-based channels behind the gateway's single listener (one port, one public URL, routed by path).

For an operator this means: to run, say, a WhatsApp Cloud bot plus a couple of generic webhook bots, you must open/forward N ports, terminate TLS and publish N public HTTPS endpoints (tunnel/reverse-proxy rules), one per channel — and two webhook channels silently collide on the default port 8080. This is a real ease-of-use and ops-robustness gap for a gateway that is otherwise production-grade. A world-class gateway should let a user point one public URL at the gateway and reach every channel by path.

Current behaviour

Each webhook-bearing adapter creates and binds its own aiohttp application + TCPSite, independent of the gateway's listener:

src/praisonai-bot/praisonai_bot/bots/webhook.py (generic webhook channel):

python
# ~line 302
app = web.Application()
app.router.add_post(self._path, self._handle_webhook)
app.router.add_get(self._path, self._handle_health)
self._runner = web.AppRunner(app)
await self._runner.setup()
self._site = web.TCPSite(self._runner, "0.0.0.0", self._webhook_port)   # own server
await self._site.start()

src/praisonai-bot/praisonai_bot/bots/whatsapp.py (WhatsApp Cloud):

python
# ~line 354
app = web.Application()
...
self._site = web.TCPSite(self._runner, "0.0.0.0", self._webhook_port)   # own server, default 8080

The per-channel port is a first-class config field, defaulting to 8080 for every webhook channel:

src/praisonai-bot/praisonai_bot/bots/_config_schema.py:

python
mode: str = "poll"          # poll | ws | webhook | hybrid   (line 443)
webhook_url: Optional[str] = None                            # line 461
webhook_port: int = 8080                                     # line 462  -> collides across channels

Meanwhile the gateway's own Starlette server already owns a single listener and a clean route table — but it hosts only control/observability routes and the generic inbound-trigger surface, not platform webhooks:

src/praisonai-bot/praisonai_bot/gateway/server.py:

python
# ~line 2173
Route("/", magic_link_handler, methods=["GET"]),
Route("/hooks/{path:path}", hook_handler, methods=["POST"]),   # generic inbound triggers (Issue #2281)
Route("/health", health, methods=["GET"]),
Route("/ready",  ready,  methods=["GET"]),
Route("/metrics", metrics, methods=["GET"]),
Route("/info", info, methods=["GET"]),

/hooks/{path} is the declarative inbound-trigger surface from #2281 (fire an agent/wake from an arbitrary POST); it is not a signature-verified platform-webhook ingress for channels such as WhatsApp Cloud or a Slack Events endpoint. start_channels() (server.py:7390) starts each channel, but a webhook channel then binds its own TCPSite rather than registering a route on the gateway app.

Notably, the contract to do this the right way already exists in core — it is simply not used to consolidate the listener:

src/praisonai-agents/praisonaiagents/bots/protocols.py:

python
class PlatformCapabilities:
    ...
    accepts_webhooks: bool = False        # line 135

class WebhookVerifierProtocol(Protocol):  # line 292
    """A platform that accepts webhooks exposes a verifier ..."""

So the core already declares "this adapter accepts webhooks and here is its verifier"; what is missing is a wrapper-side shared ingress that mounts those verified handlers onto the gateway's one HTTP server.

Desired behaviour

A mode: webhook channel should, by default, be reachable through the gateway's existing listener at a per-channel path (e.g. https://<gateway-host>/webhooks/<channel>), with:

  • one public URL / one port for all webhook channels;
  • per-channel signature verification via the existing WebhookVerifierProtocol;
  • per-channel body-size limits and 404 isolation (an unknown path returns 404, never another channel's handler);
  • no port collisions — no per-channel webhook_port required;
  • backward compatibility — a channel that explicitly sets webhook_port keeps its standalone server (opt-out preserved).

Layer placement

  • Primary layer: wrapper (praisonai-bot)
  • Why not core: the required contract is already in core (PlatformCapabilities.accepts_webhooks, WebhookVerifierProtocol); the missing part is concrete HTTP serving/route-mounting on the gateway's Starlette app, which is heavy transport wiring and belongs in the wrapper, not the protocol-only core.
  • Why not wrapper: — it is wrapper. (The whole change lives in praisonai-bot: the gateway server and the webhook adapters.)
  • Why not tools: this is inbound transport/ingress infrastructure, not an agent-callable capability invoked during a task.
  • Why not plugins: it is not a lifecycle guardrail/policy/skill wrapping a turn; it is the gateway's own HTTP request handling.
  • Secondary touch (optional): a tiny core addition only if adapters need to advertise a preferred webhook_path/route descriptor beyond the existing accepts_webhooks + verifier seam.
  • 3-way surface (CLI + YAML + Python): yes — one gateway.port/public URL + per-channel webhook_path; no per-channel webhook_port needed. Same shape from gateway.yaml, praisonai gateway start, and the Python BotOS/gateway API.

Proposed approach

  • Extension point: the gateway server mounts each channel whose adapter reports PlatformCapabilities.accepts_webhooks onto its single Starlette app at /webhooks/<channel>, delegating verification to that adapter's WebhookVerifierProtocol. A mode: webhook channel with no webhook_port runs in shared-listener mode; setting webhook_port keeps the current standalone TCPSite (backward-compatible).

  • Minimal API sketch:

python
# gateway server, during start_channels()
if adapter.capabilities.accepts_webhooks and channel_cfg.webhook_port is None:
    # shared-listener mode: no private TCPSite
    self._mount_channel_webhook(
        path=f"/webhooks/{channel_name}",
        verifier=adapter.webhook_verifier(),      # WebhookVerifierProtocol (core seam)
        on_event=adapter.handle_inbound,
        max_body_bytes=channel_cfg.max_inbound_bytes,
    )
# else: adapter.start() binds its own port as today (opt-out)

Resolution sketch

yaml
# Before (today): every webhook bot needs its own port + its own public URL,
# and these two silently collide on 8080.
channels:
  whatsapp:
    platform: whatsapp
    mode: webhook
    webhook_port: 8080        # public URL #1  -> https://a.example.com  (tunnel/proxy #1)
  billing_hook:
    platform: webhook
    mode: webhook
    webhook_port: 8080        # CONFLICT: second server cannot bind 8080

# After (proposed): one gateway listener, one public URL, routed by path.
gateway:
  port: 8765                  # the only public endpoint -> https://bots.example.com
channels:
  whatsapp:
    platform: whatsapp
    mode: webhook             # served at https://bots.example.com/webhooks/whatsapp
  billing_hook:
    platform: webhook
    mode: webhook             # served at https://bots.example.com/webhooks/billing_hook
python
# Python: no per-channel port bookkeeping; the gateway owns the listener.
from praisonaiagents.bots import BotOS
bot = BotOS(agent=agent, channels={
    "whatsapp":     {"platform": "whatsapp", "mode": "webhook"},
    "billing_hook": {"platform": "webhook",  "mode": "webhook"},
})
bot.start()   # one HTTP server; /webhooks/whatsapp and /webhooks/billing_hook both live

Severity

High — it affects anyone running more than one webhook-based channel, forces per-bot ports + public HTTPS endpoints, and the shared 8080 default is a silent bind-collision footgun. It is squarely an ease-of-use / operational-robustness gap for the gateway, and the enabling contract (accepts_webhooks + WebhookVerifierProtocol) already exists in core, so the fix is well-scoped to the wrapper.

Validation

  • Confirmed each webhook adapter binds its own server: bots/webhook.py:302-315 and bots/whatsapp.py:354-374 (web.Application() + web.TCPSite("0.0.0.0", webhook_port)).
  • Confirmed the per-channel port config defaults to 8080 for all webhook channels: bots/_config_schema.py:443,461-462.
  • Confirmed the gateway's single listener hosts only control/observability + the generic /hooks/{path} trigger, not platform webhooks: gateway/server.py:2173-2179 (routes) and start_channels() at server.py:7390.
  • Confirmed the enabling core seam already exists but is unused for ingress consolidation: praisonaiagents/bots/protocols.py:135 (accepts_webhooks) and :292 (WebhookVerifierProtocol).
  • Confirmed no existing open/closed issue tracks shared webhook ingress / single-listener multiplexing (searched the repository's issues).