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)
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):
# ~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):
# ~line 354
app = web.Application()
...
self._site = web.TCPSite(self._runner, "0.0.0.0", self._webhook_port) # own server, default 8080The 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:
mode: str = "poll" # poll | ws | webhook | hybrid (line 443)
webhook_url: Optional[str] = None # line 461
webhook_port: int = 8080 # line 462 -> collides across channelsMeanwhile 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:
# ~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:
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_portrequired; - backward compatibility — a channel that explicitly sets
webhook_portkeeps 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 existingaccepts_webhooks+ verifier seam. - 3-way surface (CLI + YAML + Python): yes — one
gateway.port/public URL + per-channelwebhook_path; no per-channelwebhook_portneeded. Same shape fromgateway.yaml,praisonai gateway start, and the PythonBotOS/gateway API.
Proposed approach
Extension point: the gateway server mounts each channel whose adapter reports
PlatformCapabilities.accepts_webhooksonto its single Starlette app at/webhooks/<channel>, delegating verification to that adapter'sWebhookVerifierProtocol. Amode: webhookchannel with nowebhook_portruns in shared-listener mode; settingwebhook_portkeeps the current standaloneTCPSite(backward-compatible).Minimal API sketch:
# 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
# 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: 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 liveSeverity
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-315andbots/whatsapp.py:354-374(web.Application()+web.TCPSite("0.0.0.0", webhook_port)). - Confirmed the per-channel port config defaults to
8080for 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) andstart_channels()atserver.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).
Source: MervinPraison/PraisonAI