Gateway Inbound Hooks
Trigger agent runs from external HTTP events with POST /hooks/{path}
Trigger an agent run from any external HTTP event — Gmail, GitHub, Stripe, CI, forms, IoT — by POSTing JSON to /hooks/<path>.
from praisonaiagents import Agent
agent = Agent(name="assistant", instructions="Summarise inbound hook payloads for the team.")
agent.start("Process the Gmail hook payload and post a short summary.")The user POSTs JSON to /hooks/<path>; the gateway verifies auth, runs the mapped agent, and delivers the reply on the configured channel.
Quick Start
- YAML — simplest form
Create
gateway.yaml:yaml agents: assistant: instructions: "You are a helpful email assistant." hooks: - path: gmail # POST /hooks/gmail agent: assistant auth: "${GATEWAY_HOOK_TOKEN}" session_key: "hook:gmail:{{ payload.message_id }}" idempotency_key: "{{ payload.message_id }}" deliver_to: "telegram:123456789" message: "New email from {{ payload.from }}: {{ payload.subject }}"Start the gateway:
bash praisonai gateway start --config gateway.yamlFire a test event:
bash curl -X POST http://localhost:8765/hooks/gmail \ -H "Authorization: Bearer $GATEWAY_HOOK_TOKEN" \ -H "Content-Type: application/json" \ -d '{"message_id":"abc","from":"alice@example.com","subject":"Hello"}'Response:
json {"ok": true, "session_key": "hook:gmail:abc"} - Python — register programmatically
python from praisonaiagents import Agent from praisonai.gateway import WebSocketGateway gateway = WebSocketGateway() gateway.register_agent("assistant", Agent( name="Assistant", instructions="You are a helpful email assistant.", )) gateway.register_hook( path="gmail", agent="assistant", session_key="hook:gmail:{message_id}", idempotency_key="{message_id}", deliver_to="telegram:123456789", message_template="New email from {from}: {subject}", ) - CLI — manage hooks at runtime
bash praisonai gateway hooks add gmail \ --agent assistant \ --session-key "hook:gmail:{message_id}" \ --deliver-to telegram:123456789 \ --message "New email from {from}: {subject}" praisonai gateway hooks list praisonai gateway hooks remove gmail
How It Works
| Concept | YAML key | Python kwarg | CLI flag |
|---|---|---|---|
| URL segment | path | path= | positional |
| Agent to run | agent | agent= | --agent |
| Action mode | action | action= | --action |
| Bearer secret | auth | auth= | --auth |
| Session id template | session_key | session_key= | --session-key |
| Dedup key template | idempotency_key | idempotency_key= | --idempotency-key |
| Delivery target | deliver_to | deliver_to= | --deliver-to |
| Agent message template | message | message_template= | --message |
| Active? | enabled | enabled= | — |
| HMAC signing secret | secret | secret= | — |
| Signature header | signature_header | signature_header= | — |
| Signature algorithm | signature_algo | signature_algo= | — |
| Signature prefix | signature_prefix | signature_prefix= | — |
| Signature scheme | signature_scheme | signature_scheme= | — |
| Timestamp header | timestamp_header | timestamp_header= | — |
| Signature tolerance | signature_tolerance_seconds | signature_tolerance_seconds= | — |
| Event allow-list | events | events= | — |
| Event type source | event_header | event_header= | — |
| Deliver without a turn | deliver_only | deliver_only= | — |
Two Actions: agent vs wake
Choose based on whether the external event carries new content for the agent.
action: agent (default) — runs a full agent turn on the templated message. Use when the external event carries new content the agent should process.
action: wake — nudges an existing session’s _last_activity without a new user message. Use when the external event means “this session is still alive / re-deliver any pending work”.
Templating
Both placeholder styles work and render the same payload fields:
| Style | Example | Used in |
|---|---|---|
{{ payload.x }} | {{ payload.message_id }} | YAML examples |
{x} | {message_id} | Python / CLI examples |
Rules:
- A leading
payload.is optional —{{ payload.from }}and{{ from }}both work. - Dotted paths resolve nested keys:
{{ payload.user.email }}→{user: {email: "x"}}. - Missing keys render as empty strings — templates never raise.
- Substitution is single-pass — payload values containing
{...}are not re-expanded (prevents key-corruption via payload injection). - 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.
Worked example:
Payload:
{"message_id": "abc123", "from": "alice@example.com", "subject": "Hello"}Template config:
session_key: "hook:gmail:{{ payload.message_id }}"
idempotency_key: "{{ payload.message_id }}"
message: "New email from {{ payload.from }}: {{ payload.subject }}"Resolved values:
session_key→"hook:gmail:abc123"idempotency_key→"abc123"message→"New email from alice@example.com: Hello"
Idempotency & Retries
idempotency_keyis a template; the rendered value is hashed (sha256) and scoped bypathso the same id on different hooks never collide.- If omitted, the entire payload is hashed deterministically (canonical JSON).
- Store is bounded (
10,000recorded entries) with a24hTTL — pruned lazily on each delivery. - Key is only recorded on success — transient failures stay retryable.
- Concurrent identical deliveries are deduplicated atomically (in-flight reservation prevents TOCTOU between seen-check and record).
- Duplicate response:
200 {"ok": true, "deduplicated": true}.
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:
hooks:
idempotency:
store_backend: sqlite # "sqlite" (default) | "memory" | "redis"
hooks:
- path: gmail
agent: assistant
# ...When hooks: is a top-level list (the common shape), use the sibling key instead:
hooks_idempotency:
store_backend: sqlite
hooks:
- path: gmail
agent: assistant
# ...| Backend | Survives restart? | Shared across replicas? | Dependencies | When to use |
|---|---|---|---|---|
sqlite (default) | ✅ | ✅ (when the DB file is shared) | stdlib sqlite3 only | Production, any provider with retries, multi-replica |
memory | ❌ | ❌ | none | Tests / ephemeral runs (explicit opt-out) |
redis | ✅ | ✅ genuine cross-replica dedup via Redis (falls back to durable SQLite when Redis is unavailable) | redis Python package (pip install redis) | Multi-replica gateway deployments requiring cluster-wide exactly-once inbound admission; auto-shares the gateway’s push RedisConfig. |
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=NORMALfor durable but low-latency writes. reserveis a crash-safeUNIQUEinsert — a redelivery and a concurrent duplicate both fail on the primary-key constraint (this is the inbound analogue of the outbound queue’sUNIQUE idempotency_key).- Bounded: max
10,000recorded entries +24hTTL, pruned lazily onreserve. - Crash recovery: a durable
inflightreservation whose run neither recorded nor released it (only cause: a process crash during the hook) is reclaimable after a15 mininflight_lease_secondslease, so the provider’s post-restart retry re-runs instead of being deduplicated for the full TTL.recordedkeys 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).
Hot-reload: flipping store_backend via reload_config rebuilds the store lazily on the next reserve. See Gateway Hot-Reload.
Authentication
- Per-hook bearer: set
auth:to a literal token or${ENV_VAR}in YAML. - Falls back to the gateway’s
auth_tokenwhen no hook-specific secret is set. - Bearer header only —
?token=query params are rejected by design (prevents secret leakage into access logs). - Compared in constant time (
secrets.compare_digest). 401if no token provided,403if token is wrong.
hooks:
- path: github
auth: "${GITHUB_WEBHOOK_SECRET}" # per-hook secret
agent: triageVerifying Provider Signatures (HMAC)
Set secret to verify the provider’s signature, and pick signature_scheme to match how they sign — a missing, invalid, or stale signature returns 401 {"error": "invalid signature"} 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. The default signature_scheme is hmac_hex, so existing hooks keep working unchanged.
GitHub (hmac_hex)
hooks:
- path: github
agent: triager
secret: "${GITHUB_WEBHOOK_SECRET}"
signature_prefix: "sha256=" # signature_header defaults to X-Hub-Signature-256
# signature_scheme: hmac_hex # default — can be omitted
deliver_to: "slack:#triage"
message: "New {{ payload.action }} on #{{ payload.issue.number }}: {{ payload.issue.title }}"Base64 (hmac_base64)
hooks:
- path: custom
agent: triager
secret: "${CUSTOM_WEBHOOK_SECRET}"
signature_header: "X-Signature"
signature_scheme: "hmac_base64"Timestamped (hmac_timestamped)
hooks:
- path: billing
agent: billing-support
secret: "${PROVIDER_WEBHOOK_SECRET}"
signature_header: "X-Signature"
signature_scheme: "hmac_timestamped"
timestamp_header: "X-Timestamp"
signature_tolerance_seconds: 300 # ±5 minutes — defaultStripe (structured)
hooks:
- path: stripe
agent: billing-support
secret: "${STRIPE_WEBHOOK_SECRET}"
signature_header: "Stripe-Signature"
signature_scheme: "structured" # t=…,v1=… composite
# timestamp_header omitted — t= is read from Stripe-Signature itself
deliver_to: "slack:#billing"
message: "Stripe event {{ payload.type }} for {{ payload.data.object.customer }}"| Scheme | Signs | Signature format |
|---|---|---|
hmac_hex (default) | raw body | hex digest, optionally with an algo= prefix |
hmac_base64 | raw body | base64 digest |
hmac_timestamped | "{timestamp}.{body}" | hex digest; timestamp in timestamp_header |
structured | "{t}.{body}" | t=…,v1=…,v1=… header; any v1 may match |
Replay Window
A signed body captured from network logs can be replayed forever unless the timestamp is checked — signature_tolerance_seconds rejects requests older (or newer) than the window.
- The freshness check runs only for
hmac_timestampedandstructured, because only those sign the timestamp.hmac_hexandhmac_base64hooks behave exactly as before. - Default is
300seconds (±5 minutes).0disables the check — use it for testing only. - A negative or non-integer
signature_tolerance_secondsraisesValueErrorat config time. timestamp_headeronhmac_hex/hmac_base64is refused at config time: an unsigned timestamp would let an attacker replay an old body and signature with a fresh header.hmac_timestampedwithouttimestamp_headeris also refused (nowhere to read the timestamp from).
Test Locally
Sign the exact body you POST.
hmac_hex (GitHub)
SECRET="my-signing-secret"
BODY='{"action":"opened","issue":{"number":42,"title":"Bug"}}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')"
curl -X POST http://localhost:8765/hooks/github \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: $SIG" \
-H "X-GitHub-Event: issues" \
-d "$BODY"hmac_timestamped
SECRET="my-signing-secret"
BODY='{"id":"evt_1"}'
NOW=$(date +%s)
SIG=$(printf '%s' "$NOW.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -X POST http://localhost:8765/hooks/billing \
-H "Content-Type: application/json" \
-H "X-Signature: $SIG" \
-H "X-Timestamp: $NOW" \
-d "$BODY"structured (Stripe)
SECRET="my-signing-secret"
BODY='{"id":"evt_1","type":"invoice.paid"}'
NOW=$(date +%s)
V1=$(printf '%s' "$NOW.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -X POST http://localhost:8765/hooks/stripe \
-H "Content-Type: application/json" \
-H "Stripe-Signature: t=$NOW,v1=$V1" \
-d "$BODY"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 absent, from the payload as a dotted path (defaulting to "event").
hooks:
- path: github
agent: triage
event_header: "X-GitHub-Event"
events: ["issues.opened", "pull_request.opened"]
deliver_to: "slack:#triage"
message: "New {{ payload.action }} on #{{ payload.issue.number }}"A namespaced filter like issues.opened matches only when the payload’s action == "opened" — fail-closed: a delivery that omits action is never admitted, so a bare issues event cannot pass a filter that only allows issues.opened.
Read the event from a payload field by pointing event_header at a dotted path:
hooks:
- path: stripe
event_header: "type" # read payload["type"]
events: ["invoice.payment_failed"]
deliver_to: "slack:#billing"
message: "⚠️ Payment failed for {{ payload.data.object.customer }}"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. Requires deliver_to.
hooks:
- path: sentry
deliver_only: true
deliver_to: "telegram:${OPS_CHAT_ID}"
secret: "${SENTRY_WEBHOOK_SECRET}"
signature_header: "Sentry-Signature"
message: "🚨 {{ payload.project }}: {{ payload.event.title }}\n{{ payload.url }}"| Outcome | Response |
|---|---|
| Delivered | {"ok": true, "action": "deliver", "delivered": true} |
| Empty rendered message | {"ok": true, "action": "deliver", "delivered": false, "skipped": "empty message"} |
Missing deliver_to | {"ok": false, "error": "deliver_only hook requires 'deliver_to'"} |
| Delivery failed | {"ok": false, "error": "hook delivery failed", "delivered": false} |
deliver_only composes with signature verification and event filtering — forward alerts (Sentry → Telegram, CI → Slack) at near-instant latency with zero LLM cost.
Delivery
deliver_to: "channel:target"— e.g."telegram:123456789","discord:987654321","slack:U12345".- Reuses the same channel-bot send path as scheduled delivery (hooks and scheduler route outbound identically).
- If the channel bot is not registered, delivery is logged as failed and the hook returns
{"ok": false}so the sender retries. - Omit
deliver_toto skip outbound delivery entirely — the agent still runs.
Config Locations & Hot-Reload
YAML hooks can live at the top level (hooks:) or nested under gateway: (for grouping with other gateway settings):
# Top-level (recommended)
hooks:
- path: gmail
agent: assistant
# Or nested under gateway:
gateway:
host: "0.0.0.0"
hooks:
- path: gmail
agent: assistantBoth locations are picked up at startup and at reload_config — a config reload clears and re-registers the entire hook table, so removed hooks and rotated secrets take effect without a process restart. See Gateway Hot-Reload.
Real User-Interaction Flow
Gmail received a new email. Zapier POSTs the parsed message to
/hooks/gmail. The gateway deduplicates bymessage_id, runs theassistantagent on a templated summary, and Telegram chat123456789gets a notification with the agent’s reply. If Zapier retries the same delivery, the gateway returns{"ok": true, "deduplicated": true}instantly without re-running the agent.
Burst Coalescing
Add coalesce to a hook to collapse rapid deliveries for the same entity (e.g. five pushes to one PR) into a single agent run. See Burst Coalescing.
Common Patterns
GitHub Issue Triage
hooks:
- path: github/issue
agent: triage
auth: "${GITHUB_WEBHOOK_SECRET}"
session_key: "gh:issue:{{ payload.issue.number }}"
idempotency_key: "{{ payload.delivery }}"
deliver_to: "slack:#triage"
message: "New issue #{{ payload.issue.number }}: {{ payload.issue.title }}"Webhook POSTs arrive, the triage agent classifies the issue, and a Slack notification lands in #triage.
Stripe Payment Event
hooks:
- path: stripe/payment
agent: billing-support
action: wake # event has no new content — just re-activate
auth: "${STRIPE_WEBHOOK_SECRET}"
session_key: "billing:{{ payload.data.object.customer }}"
idempotency_key: "{{ payload.id }}"Payment events nudge the billing-support session for that customer without generating a new agent turn.
CI Failure Ping
hooks:
- path: ci/failure
agent: ops
auth: "${CI_WEBHOOK_TOKEN}"
session_key: "ci:{{ payload.repo }}:{{ payload.branch }}"
idempotency_key: "{{ payload.run_id }}"
deliver_to: "telegram:${OPS_CHAT_ID}"
message: "CI failed on {{ payload.repo }}/{{ payload.branch }} — {{ payload.url }}"The ops agent investigates and the reply goes directly to the on-call Telegram chat.
Best Practices
Pick a stable idempotency_key from the payload
Use the provider’s native delivery id (Gmail message_id, GitHub X-GitHub-Delivery, Stripe event id). Never rely on time of receipt — providers retry with identical ids.
Use one per-hook auth secret per provider
A separate auth: token per integration means a leaked secret isolates to one source, not your entire webhook surface.
Keep session_key templates payload-derived
Derive the session key from a stable entity id (customer, repo, user) so related events thread through the same conversation and the agent has full context.
Prefer action: wake when the event has no new content
If the webhook just signals “still alive” or “payment completed” without carrying content the agent should read, action: wake saves an LLM call.
Related
Gateway architecture and how channels, agents, and routing connect.
Gateway CLIFull CLI reference including the praisonai gateway hooks subcommand.
Declarative recurring agent → channel deliveries — the outbound counterpart to inbound HTTP triggers.
Bot Lifecycle HooksIn-process outbound hooks (GATEWAY_START, SESSION_START, SCHEDULE_TRIGGER) — the counterpart to inbound HTTP triggers.
Gateway Hot-ReloadHow hook changes and rotated secrets take effect without a process restart.
Untrusted Request FencingHow inbound payloads are fenced as data before the agent sees them.
Shared Webhook Ingress/webhooks/<channel> — signature-verified platform-webhook ingress, distinct from /hooks/<path>.