Skip to main content
The gateway now ships in the praisonai-bot package. praisonai serve gateway still works exactly as documented here; for a standalone install see praisonai-bot Migration.
Inbound hooks expose a POST /hooks/<path> endpoint on the gateway — point any external service (GitHub Actions, Linear, Gmail push, Sentry alerts) at it and the gateway runs an agent and delivers the reply to a configured channel.
The user POSTs JSON to /hooks/<path>; the gateway authenticates the payload, runs the mapped agent, and delivers the reply to the configured channel.

Quick Start

1

Define a hook and start the gateway

2

Point your external service at the hook URL

3

The agent replies to Telegram automatically

No polling, no webhook handler code — the gateway does it all.

How It Works


Three Ways to Register a Hook


HookConfig Options

hooks.idempotency (gateway-level, not per-hook)

Also accepted as siblings when hooks: is a list:
  • hooks_idempotency: { store_backend: sqlite }
  • idempotency: { store_backend: sqlite }
  • Under gateway:gateway.hooks_idempotency: { store_backend: sqlite }

Templating

Both {{ payload.x }} (Jinja-style) and {x} (format-string style) are accepted:
Rules:
  • The leading payload. is optional — {{ payload.from }} and {from} resolve identically.
  • Missing keys render as empty strings — the template never raises on partial payloads.
  • Single-pass substitution — a payload value containing {...} is never re-expanded (injection-safe).
  • Interpolated payload values are automatically fenced as untrusted request data on agent-turn hooks. Operator template text stays outside the fence. See Untrusted Request Fencing.

Actions

"agent" (default) — runs a full agent turn with the rendered message as the user input:
"wake" — nudges an existing session (triggers proactive delivery or a scheduled check-in) without injecting a new user message:

Idempotency & Retries

The gateway deduplicates concurrent and retried deliveries:
  1. When idempotency_key is set, the key is rendered from the payload and hashed as SHA-256(path + "\x00" + rendered_key).
  2. When unset, the entire payload is JSON-canonicalized and hashed.
  3. The key is reserved in-flight atomically — concurrent identical POSTs dedup across the await boundary.
  4. The key is committed only after a successful agent run — transient failures remain retryable.
External services that retry on timeout (e.g. GitHub webhooks, Linear) are safe to point directly at inbound hooks without additional dedup logic on your side.

Dedup Store — Memory vs Durable (SQLite)

By default the dedup store is in-memory (per process). A webhook provider that retries after a gateway restart within its retry window — or a deployment with replicas > 1 — needs the dedup key to survive that, otherwise the retry starts a duplicate agent run (duplicate reply, duplicate tool action). Opt in to the durable SQLite backend:
When hooks: is a top-level list (the common shape), use the sibling key instead:
Storage details (SQLite):
  • DB file: ~/.praisonai/state/hook_idempotency.sqlite (auto-created; same state dir as the ingress journal and outbound queue).
  • WAL mode with synchronous=NORMAL for durable but low-latency writes.
  • reserve is a crash-safe UNIQUE insert — a redelivery and a concurrent duplicate both fail on the primary-key constraint (this is the inbound analogue of the outbound queue’s UNIQUE idempotency_key).
  • Bounded: max 10,000 recorded entries + 24h TTL, pruned lazily on reserve.
  • Crash recovery: a durable inflight reservation whose run neither recorded nor released it (only cause: a process crash during the hook) is reclaimable after a 15 min inflight_lease_seconds lease, so the provider’s post-restart retry re-runs instead of being deduplicated for the full TTL. recorded keys are never touched by the lease.
  • On any failure to build the SQLite store, the gateway falls back to the in-memory default so inbound delivery keeps working (a warning is logged).
For a custom store, implement IdempotencyStoreProtocol and inject it — the memory and SQLite backends are the two built-ins.
Hot-reload: flipping store_backend via reload_config rebuilds the store lazily on the next reserve. See Gateway Hot-Reload.

Security

Authorization: Bearer <token> is the only accepted form. The ?token= query-parameter path was removed because it leaks the shared secret into access logs.

Verifying Provider Signatures (HMAC)

Set secret to have the gateway verify the provider’s HMAC signature over the raw request body — a missing or invalid signature is rejected with 401 before any agent runs. Verification is fail-closed and opt-in: with no secret set, nothing changes. When secret is set without an explicit signature_header, the header defaults to X-Hub-Signature-256 (the GitHub/webhook convention) so a bare secret is a working config, not a 401 trap.
Test locally by signing the exact body you POST:
The signature is computed over the raw bytes the provider signed. Do not re-serialize the payload before signing — sign the exact body you send.

Event Filtering

Set events to an allow-list so only matching deliveries run a turn — everything else is a cheap 200 {"ok": true, "skipped": "event"} with no LLM cost. The event type is read from event_header (a request header) or, when that header is absent, from the payload as a dotted path (defaulting to "event").
GitHub sends the base event (issues) in the header and the sub-type in the payload’s action. A namespaced filter like issues.opened matches only when action == "opened"fail-closed: a delivery that omits action is never admitted, so a bare issues event cannot slip through a filter that only allows issues.opened. Read the event from a payload field instead of a header by pointing event_header at a dotted path:

Deliver-Only Mode (no LLM turn)

Set deliver_only: true to route the rendered message straight to deliver_to — no agent, no LLM cost, sub-second forwarding.
Because deliver_only bypasses the agent, no untrusted-request fence is added — the recipient never sees literal <external_request_payload> markup.
deliver_only composes with signature verification and event filtering and requires deliver_to. Response shapes:
Use deliver_only for notification forwarding — Sentry → Telegram, CI → Slack — where the payload already contains the exact text to send. No agent turn means zero LLM cost and near-instant delivery.

End-to-End Example: Gmail → Triage Agent → Telegram

  1. Gmail push subscription fires POST /hooks/gmail with the email payload.
  2. Gateway verifies the bearer token from $GMAIL_HOOK_SECRET.
  3. Session key gmail:<message_id> scopes the conversation to this email thread.
  4. The rendered message is sent to the email-triager agent.
  5. The agent’s reply is delivered to Telegram chat 123456789.

Best Practices

External webhooks retry on timeout. Without an idempotency_key, a slow agent run followed by a timeout retry will run the agent twice. Use a message or event id from the payload.
Set a distinct auth secret per hook so you can rotate individual secrets without restarting the gateway or changing the global token.
Without session_key, all deliveries to a hook share the same session (hook:<path>). Use a payload field like {user_id} or {message_id} to isolate conversations by sender or thread.

Webhook Verification

HMAC signature verification for outbound bot webhooks (different surface)

Proactive Delivery

Delivery routing — the channel:target format used in deliver_to

Gateway Overview

Gateway configuration, channels, and multi-bot mode

Gateway CLI

All gateway CLI commands including hooks add / list / remove

Untrusted Request Fencing

How inbound payloads are fenced as data before the agent sees them