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

python
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.

Diagram

Gateway

🌐 External Service

POST /hooks/path

🔐 Auth

🔁 Idempotency

📋 Session

🤖 Agent / Wake

📱 Channel Bot

👤 User

Quick Start

  1. 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.yaml

    Fire 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"}
  2. 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}",
    )
  3. 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

Diagram
UserChannel BotAgentGatewayExternal ServiceUserChannel BotAgentGatewayExternal Servicealt[deliver_only][action: agent]POST /hooks/gmail (raw body + signature header)1. Authenticate bearer token2. Verify signature (401 if invalid, before any agent)2b. Freshness check for timestamped schemes (401 if stale)3. Event filter (200 skipped if not allowed)4. Reserve idempotency key (atomic — memory | sqlite backend)5. Resolve session_key templateDeliver rendered message (no LLM turn)Run agent on templated messageAgent replyDeliver reply to telegram:123456789Message delivered6. Record idempotency key (success)200 {“ok”: true}
ConceptYAML keyPython kwargCLI flag
URL segmentpathpath=positional
Agent to runagentagent=--agent
Action modeactionaction=--action
Bearer secretauthauth=--auth
Session id templatesession_keysession_key=--session-key
Dedup key templateidempotency_keyidempotency_key=--idempotency-key
Delivery targetdeliver_todeliver_to=--deliver-to
Agent message templatemessagemessage_template=--message
Active?enabledenabled=—
HMAC signing secretsecretsecret=—
Signature headersignature_headersignature_header=—
Signature algorithmsignature_algosignature_algo=—
Signature prefixsignature_prefixsignature_prefix=—
Signature schemesignature_schemesignature_scheme=—
Timestamp headertimestamp_headertimestamp_header=—
Signature tolerancesignature_tolerance_secondssignature_tolerance_seconds=—
Event allow-listeventsevents=—
Event type sourceevent_headerevent_header=—
Deliver without a turndeliver_onlydeliver_only=—

Two Actions: agent vs wake

Choose based on whether the external event carries new content for the agent.

Diagram

Yes

No

📨 External Event

Has new content?

action: agent

action: wake

🤖 Full agent turn on templated message

💤 Nudge session last_activity — no new message

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:

StyleExampleUsed 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:

json
{"message_id": "abc123", "from": "alice@example.com", "subject": "Hello"}

Template config:

yaml
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_key is a template; the rendered value is hashed (sha256) and scoped by path so the same id on different hooks never collide.
  • If omitted, the entire payload is hashed deterministically (canonical JSON).
  • Store is bounded (10,000 recorded entries) with a 24h TTL — 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:

yaml
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:

yaml
hooks_idempotency:
  store_backend: sqlite

hooks:
  - path: gmail
    agent: assistant
    # ...
BackendSurvives restart?Shared across replicas?DependenciesWhen to use
sqlite (default)✅✅ (when the DB file is shared)stdlib sqlite3 onlyProduction, any provider with retries, multi-replica
memory❌❌noneTests / 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.
Diagram

Dedup Store Backend

No

Yes

💾 In-Memory
per-process
lost on restart

🗄️ SQLite
~/.praisonai/state/hook_idempotency.sqlite
survives restart · shareable across replicas

Restart or multi-replica?

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

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_token when 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).
  • 401 if no token provided, 403 if token is wrong.
yaml
hooks:
  - path: github
    auth: "${GITHUB_WEBHOOK_SECRET}"    # per-hook secret
    agent: triage

Verifying 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.

Diagram

hex HMAC of body

base64 HMAC of body

hex HMAC of ‘timestamp.body’
timestamp in own header

Composite ‘t=…,v1=…’ header

Keep default

Testing only

🔐 Which signature format
does the provider send?

hmac_hex · default
GitHub

hmac_base64

hmac_timestamped

structured
Stripe

🕒 Replay protection?

signature_tolerance_seconds: 300

signature_tolerance_seconds: 0

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)

yaml
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)

yaml
hooks:
  - path: custom
    agent: triager
    secret: "${CUSTOM_WEBHOOK_SECRET}"
    signature_header: "X-Signature"
    signature_scheme: "hmac_base64"

Timestamped (hmac_timestamped)

yaml
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 — default

Stripe (structured)

yaml
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 }}"
SchemeSignsSignature format
hmac_hex (default)raw bodyhex digest, optionally with an algo= prefix
hmac_base64raw bodybase64 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.

Diagram
AgentGatewayExternal ServiceAgentGatewayExternal Servicealt[invalid / missing signature]alt[stale / missing timestamp]POST (body + signature + timestamp)Verify signature over “timestamp.body”HTTP 401Freshness check (|now − timestamp| ≤ tolerance)HTTP 401Run
  • The freshness check runs only for hmac_timestamped and structured, because only those sign the timestamp. hmac_hex and hmac_base64 hooks behave exactly as before.
  • Default is 300 seconds (±5 minutes). 0 disables the check — use it for testing only.
  • A negative or non-integer signature_tolerance_seconds raises ValueError at config time.
  • timestamp_header on hmac_hex / hmac_base64 is refused at config time: an unsigned timestamp would let an attacker replay an old body and signature with a fresh header.
  • hmac_timestamped without timestamp_header is also refused (nowhere to read the timestamp from).

Test Locally

Sign the exact body you POST.

hmac_hex (GitHub)

bash
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

bash
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)

bash
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").

yaml
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:

yaml
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.

yaml
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 }}"
OutcomeResponse
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_to to 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):

yaml
# Top-level (recommended)
hooks:
  - path: gmail
    agent: assistant

# Or nested under gateway:
gateway:
  host: "0.0.0.0"
hooks:
  - path: gmail
    agent: assistant

Both 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 by message_id, runs the assistant agent on a templated summary, and Telegram chat 123456789 gets 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

yaml
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

yaml
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

yaml
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