Gateway Inbound Hooks

Trigger agents from external services via authenticated POST /hooks/{path}

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.

python
from praisonaiagents import Agent
from praisonai.gateway import Gateway

agent = Agent(name="triage", instructions="Triage inbound alerts and summarise the action needed.")
gw = Gateway(agents=[agent])
gw.register_hook({"path": "alerts", "agent": "triage", "auth": "my-shared-secret"})
gw.start()

The user POSTs JSON to /hooks/<path>; the gateway authenticates the payload, runs the mapped agent, and delivers the reply to the configured channel.

Diagram

External Service

POST /hooks/gmail

Auth check

Dedup / Idempotency

Template render

Agent.start

Delivery channel

User

Quick Start

  1. Define a hook and start the gateway
    python
    from praisonai.gateway import Gateway
    from praisonaiagents import Agent
    
    agent = Agent(
        name="triage",
        instructions="Triage the inbound alert and summarise the action needed."
    )
    
    gw = Gateway(agents=[agent])
    gw.register_hook({
        "path": "alerts",
        "agent": "triage",
        "auth": "my-shared-secret",
        "deliver_to": "telegram:123456789",
        "message": "Alert from {{ payload.source }}: {{ payload.title }}",
    })
    gw.start()
  2. Point your external service at the hook URL
    bash
    curl -X POST https://your-gateway/hooks/alerts \
      -H "Authorization: Bearer my-shared-secret" \
      -H "Content-Type: application/json" \
      -d '{"source": "Sentry", "title": "NullPointerException in prod"}'
  3. The agent replies to Telegram automatically

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


How It Works

Diagram
Delivery ChannelAgentHookCoalescerIdempotency StoreEvent FilterSignature VerifyGateway /hooks/alertsExternal ServiceDelivery ChannelAgentHookCoalescerIdempotency StoreEvent FilterSignature VerifyGateway /hooks/alertsExternal Servicealt[invalid token]alt[invalid / missing signature]alt[stale / missing timestamp]alt[event not in allow-list]default: sqlite (durable) · memory for testsalt[duplicate]opt[coalesce configured and group_key non-empty]alt[deliver_only][agent action]POST (raw body + Authorization + signature header)Auth checkHTTP 401verify_signature(raw_body, headers)HTTP 401 {“error”:”invalid signature”}freshness check (timestamped / structured schemes)HTTP 401 {“error”:”invalid signature”}event_allowed(payload, headers)HTTP 200 {“ok”:true,”skipped”:”event”}reserve idempotency keyalready in-flightHTTP 200 (no-op)submit(group, payload)HTTP 202 {“coalesced”:true,”pending”:N}one run after quiet window (latest payload)rendered message (no LLM turn)HTTP 200 {“ok”:true,”action”:”deliver”,”delivered”:true}start(rendered message)replycommit key (success)deliver replyHTTP 200

Three Ways to Register a Hook

Python

python
from praisonai.gateway import Gateway
from praisonaiagents import Agent

agent = Agent(
    name="email-triager",
    instructions="Triage the email and decide on an action."
)

gw = Gateway(agents=[agent])

gw.register_hook({
    "path": "gmail",
    "agent": "email-triager",
    "action": "agent",
    "auth": "shared-secret",
    "session_key": "hook:gmail:{from}",
    "idempotency_key": "{message_id}",
    "deliver_to": "telegram:123456789",
    "message": "New mail from {{ payload.from }}: {{ payload.subject }}",
})

gw.start()

YAML (gateway.yaml)

yaml
hooks:
  - path: gmail
    agent: email-triager
    action: agent
    auth: shared-secret
    session_key: "hook:gmail:{from}"
    idempotency_key: "{message_id}"
    deliver_to: telegram:123456789
    message: "New mail from {{ payload.from }}: {{ payload.subject }}"

Start the gateway pointing at the config:

bash
praisonai gateway start --config gateway.yaml

CLI

bash
# Path is a positional argument, not --path
praisonai gateway hooks add gmail \
  --agent email-triager \
  --deliver-to telegram:123456789 \
  --message "New mail from {from}: {subject}"

praisonai gateway hooks list

praisonai gateway hooks remove gmail
FlagTypeDefaultDescription
<path> (positional)strrequiredHook path — gmail exposes POST /hooks/gmail
--agentstrfirst registered agentAgent id to run
--actionstragentagent runs a turn; wake nudges a session
--authstrnoneBearer token / shared secret for this hook
--session-keystrnoneSession key template (see HookConfig Options)
--idempotency-keystrnoneIdempotency key template
--deliver-tostrnoneplatform:target delivery spec, e.g. telegram:12345
--messagestrnoneMessage template built from the payload
--configstrgateway.yamlPath to the gateway configuration file

HookConfig Options

OptionTypeDefaultDescription
pathstrrequiredURL segment after /hooks/. "gmail" exposes POST /hooks/gmail. Must be non-empty.
agentstrNoneAgent name/id to run. Falls back to the gateway’s first registered agent.
actionstr"agent""agent" runs a new turn; "wake" nudges an existing session without a new message.
authstrNoneBearer token required on inbound requests. Falls back to the gateway’s global auth_token.
session_keystrNoneTemplate for the session id, e.g. "hook:gmail:{message_id}". Defaults to "hook:{path}".
idempotency_keystrNoneTemplate for the dedup key. When unset, hashes the entire payload.
deliver_tostrNoneplatform:target delivery spec, e.g. "telegram:123456789". Omit to skip outbound delivery.
messagestrNoneTemplate for the message built from the payload and sent to the agent.
enabledboolTrueWhether the hook is active. false returns 404.
metadatadict{}Free-form key/value pairs passed to the agent context.
secretstrNoneHMAC signing secret. When set, the gateway verifies the provider signature over the raw body before any agent runs and rejects (401) a missing/invalid signature — fail-closed.
signature_headerstrNoneHeader carrying the signature. Auto-defaults to "X-Hub-Signature-256" when secret is set with no explicit header.
signature_algostr"sha256"Digest for the HMAC. Any name hashlib.new accepts (e.g. "sha1", "sha512").
signature_prefixstrNoneOptional signature prefix, e.g. "sha256=".
signature_schemestr"hmac_hex"How the provider signs — "hmac_hex" (default, hex HMAC of the body), "hmac_base64" (base64 HMAC of the body), "hmac_timestamped" (hex HMAC of "{timestamp}.{body}"), or "structured" (Stripe-style t=…,v1=… composite header; any v1 may match). Invalid values raise ValueError.
timestamp_headerstrNoneHeader carrying the provider timestamp (unix seconds). Only valid with hmac_timestamped or structured; required for hmac_timestamped. For structured, the t= component is read from the signature header when omitted.
signature_tolerance_secondsint300Replay window for timestamped and structured schemes — a request outside ± this many seconds is rejected before any agent runs. 0 disables the freshness check (not recommended). Negative values raise ValueError.
eventslist[str]NoneAllow-list of event types. Unmatched deliveries are ack’d (200) with no LLM turn. A string is coerced to a single-element list.
event_headerstrNoneHeader carrying the event type, e.g. "X-GitHub-Event". When omitted the event is read from the payload as a dotted path (defaulting to "event").
coalesceCoalesceConfig \dictNoneOpt-in burst coalescing. Deliveries whose group_key renders to the same value within window_seconds collapse into one agent run carrying the latest payload. None keeps one run per delivery. See Burst Coalescing.
deliver_onlyboolFalseWhen True the rendered message is the delivered content, routed straight through deliver_to with no LLM turn.

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

OptionTypeDefaultDescription
store_backendstr"sqlite""sqlite" (default, durable, survives restart) — "memory" (per-process, zero-dep, opt-in for tests / ephemeral runs) — "redis" (cluster-wide exactly-once admission across replicas via RedisIdempotencyStore, Issue #5154; reuses the gateway’s push RedisConfig — or a redis_url override; falls back to durable SQLite and records a durability:idempotency degraded fact if the redis package is missing or Redis is unreachable at build time; a runtime outage fails open (admits) and records the degraded fact, then clears it automatically on recovery — see Cross-Replica Idempotency and Gateway Turn Lock). Unset means "sqlite" (Issue #4339). A genuinely unknown value (e.g. "totally-unknown") falls back to "memory" with a debug log.

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:

yaml
message: "New mail from {{ payload.from }}: {{ payload.subject }}"
session_key: "hook:gmail:{message_id}"

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:

yaml
action: agent
message: "Triage: {{ payload.title }}"

"wake" — nudges an existing session (triggers proactive delivery or a scheduled check-in) without injecting a new user message:

yaml
action: wake
session_key: "daily:{{ payload.user_id }}"

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.

Dedup Store — Durable (SQLite) by default vs Memory

The dedup store is durable (SQLite) by default — a webhook provider that retries after a gateway restart within its retry window keeps its dedup key, so the retry does not start a duplicate agent run (duplicate reply, duplicate tool action).

Explicitly opt out to the in-memory backend for tests / ephemeral runs:

yaml
hooks:
  idempotency:
    store_backend: memory      # explicit opt-out — "sqlite" (default) | "memory"
  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: memory

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 gateways needing cluster-wide exactly-once inbound admission; auto-shares the gateway’s push RedisConfig. See Multi-replica gateways.
Diagram

Dedup Store Backend

Tests / ephemeral

Single — default

Multiple

Redis reachable

Redis unavailable
falls back

fallback records

💾 In-Memory
per-process
lost on restart

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

⚙️ store_backend: redis
cluster-wide exactly-once

🔐 RedisIdempotencyStore
cross-replica dedup

⚠️ durability:idempotency degraded fact
on Redis unavailable

How many replicas?

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 in-memory so inbound delivery keeps working (a warning is logged). On fallback it also records a durability-degraded fact (owner_kind="gateway", owner_id="durability:idempotency") — visible in praisonai gateway status and health()["degraded_owners"] with retry_hint="praisonai gateway doctor --fix". The reason is redacted (the raw store path stays in the log). The fact clears automatically the moment the durable store comes up (fresh start or hot-reload). See Gateway State Durability and Degraded Capabilities.
  • Selecting "redis" builds the cluster-wide RedisIdempotencyStore when Redis is reachable — genuine cross-replica exactly-once admission. If the redis package is missing or Redis is unreachable at build time, the wrapper falls back to the durable SQLite store, logs a warning (path-redacted), and records durability:idempotency with reason "redis idempotency backend not available (running per-replica)". If the SQLite fallback also fails, the more severe "...unavailable (running in-memory)" fact wins — the milder “per-replica” reason never masks the loss of restart durability. See Cross-Replica Idempotency and Multi-replica gateways.

Multi-replica gateways and store_backend: redis

store_backend: redis gives cluster-wide exactly-once admission via RedisIdempotencyStore (Issue #5154), mirroring the RedisTurnLock. A webhook fanned to multiple replicas is admitted on exactly one, keyed on (platform, account, channel_id, message_id), so a duplicate delivery never runs your agent twice.

The store reuses the gateway’s push RedisConfig — no new operator knob — exactly as build_turn_lock does:

  • reserve(key) → a single SET NX PX (atomic claim + owner token + inflight-lease TTL). Rejects both a redelivery and a concurrent duplicate on another replica; a crashed replica’s claim self-heals via TTL expiry.
  • record(key) → an owner-checked upgrade to a long-lived recorded value (TTL ttl_seconds). A late record from a replica whose lease already expired is a no-op when another replica reclaimed the key.
  • release(key) → a compare-and-del on the owner token, so it never drops another replica’s live claim.

If the redis package is missing or Redis is unreachable at build time, the wrapper falls back to the durable SQLite store, logs a warning (path-redacted), and records durability:idempotency with reason "redis idempotency backend not available (running per-replica)". A runtime outage (Redis reachable at build, dropped later) fails open — it admits the delivery rather than 500-ing the hook path — and records reason "redis idempotency backend unreachable at runtime (dedup best-effort until it recovers)", which auto-clears on the next successful Redis op.

Check status with any of:

bash
praisonai gateway status
praisonai gateway doctor

Or read health()["degraded_owners"] in-process.

yaml
# Multi-replica gateway with cluster-wide exactly-once inbound admission.
# Reuses the gateway's push RedisConfig; no extra idempotency knob needed.
hooks:
  idempotency:
    store_backend: redis
  hooks:
    - path: gmail
      agent: assistant

When Redis is unavailable and the store falls back, health() shows:

json
{
  "degraded_owners": [
    {
      "owner_kind": "gateway",
      "owner_id": "durability:idempotency",
      "reason": "redis idempotency backend not available (running per-replica)",
      "retry_hint": "praisonai gateway doctor --fix"
    }
  ]
}
Diagram

store_backend: redis

Yes

No — build-time

⚙️ Operator sets store_backend: redis

🔍 Redis reachable?

🔐 RedisIdempotencyStore
cluster-wide exactly-once
SET NX PX

🗄️ Durable SQLite fallback
cross-restart · cross-replica
only if state file shared

⚠️ record_durability_degraded
durability:idempotency
“running per-replica”

🩺 health.degraded_owners

Enable both the turn lock and store_backend: redis together for a multi-replica gateway — see Cross-Replica Idempotency.

Hot-reload: flipping store_backend via reload_config rebuilds the store lazily on the next reserve. See Gateway Hot-Reload.


Burst Coalescing

Set coalesce to collapse a burst of deliveries for the same entity into one agent run on the latest payload.

Diagram

5 pushes → 1 agent run

quiet window elapsed

📥 push #1

🧺 group buffer
key: repo#PR

📥 push #2

📥 push #3

📥 push #4

📥 push #5

🤖 one agent run
latest payload

Without coalescing, five rapid pushes to one PR run the agent five times. With it, you get one run, fewer LLM calls, and no stale replies.

Python

python
from praisonaiagents import Agent
from praisonaiagents.gateway import CoalesceConfig
from praisonai.gateway import Gateway

reviewer = Agent(name="reviewer", instructions="Summarise what changed on this PR.")

gw = Gateway(agents=[reviewer])
gw.register_hook({
    "path": "github",
    "agent": "reviewer",
    "message": "Latest push: {{ payload.head_commit.message }}",
    "coalesce": CoalesceConfig(
        group_key="{repository.full_name}#{pull_request.number}",
        window_seconds=30,
        max_wait_seconds=300,
    ),
})
gw.start()

YAML

yaml
hooks:
  - path: github
    agent: reviewer
    message: "Latest push: {{ payload.head_commit.message }}"
    coalesce:
      group_key: "{repository.full_name}#{pull_request.number}"
      window_seconds: 30
      max_wait_seconds: 300

How Coalescing Works

Diagram
sequenceDiagram
    participant Ext as External Service
    participant GW as Gateway /hooks/github
    participant Dedup as Idempotency Store
    participant Buf as HookCoalescer
    participant Agent

    Ext->>GW: POST push #1
    GW->>Dedup: reserve + record
    GW->>Buf: submit(group=repo#PR, payload #1)
    GW-->>Ext: 202 {"coalesced": true, "pending": 1}
    Ext->>GW: POST push #2
    GW->>Buf: submit(group=repo#PR, payload #2)
    GW-->>Ext: 202 {"coalesced": true, "pending": 2}
    Note over Buf: quiet window elapses<br/>(or max_wait reached)
    Buf->>Agent: start("[2 events coalesced; only the latest is shown] ...")
    Agent-->>Buf: reply
    Buf->>GW: deliver

Coalesce Options

OptionTypeDefaultDescription
group_keystrrequiredTemplate over the payload, e.g. "{repository.full_name}#{pull_request.number}". A delivery whose rendered key is empty runs on its own (not coalesced). Groups are scoped per hook path.
window_secondsfloat30.0Debounce quiet window. The group fires after this many seconds with no new delivery. Must be > 0.
max_wait_secondsfloat300.0Hard cap from the first delivery in the group, so a steady stream still dispatches. Must be >= window_seconds.

Invalid values raise ValueError (empty group_key, non-finite durations, window_seconds <= 0, or max_wait_seconds < window_seconds).

Agent Message Prefix

When a run absorbs more than one delivery, the agent sees:

text
[N events coalesced; only the latest is shown]

<rendered message from the latest payload>

Response Shapes

OutcomeStatusBody
Delivery buffered into a coalesced group202{"ok": true, "coalesced": true, "pending": N}
Pending-group cap (10,000) exceeded503 + Retry-After: 30{"ok": false, "error": "too many pending coalesced groups"}
coalesce not configured, or group_key renders empty200 after the run (unchanged)existing shape

Choosing Options

Diagram

No

Yes

No

Yes: PR number,
user id, thread

Interactive

Background batch

Bursty traffic
on the same entity?

Leave coalesce unset
one run per delivery

Can the payload
identify the entity?

Set coalesce with that
template as group_key

Freshness SLA?

window 5–10s
max_wait 30–60s

window 30–120s
max_wait 300–600s

Key on the entity, not the event

For GitHub pushes to a PR use "{repository.full_name}#{pull_request.number}", not "{head_commit.id}". The commit sha changes every push, so nothing coalesces.

Keep window_seconds short for interactive bots

30s suits background triage; use 5–10s when a user is waiting.

Set max_wait_seconds to a multiple of the window

The default is 10×. A steady stream (CI logs, scrapers) still dispatches at the cap.

Prefer deliver_only when you don't need the LLM

Plain forwarding with deliver_only is already sub-second. Coalescing pays off by skipping repeated LLM turns on the same entity.


Security

BehaviourDetail
Auth required401 on missing or invalid Authorization: Bearer header
Per-hook authauth field on the hook overrides the gateway’s global auth_token
Malformed JSON400 response, no agent run
Missing agent500 when the hook names an agent that isn’t registered (no silent fallback to another agent)
Disabled hook404 when enabled: false
Untrusted-payload fencingOn for agent-turn hooks; interpolated {{ payload.x }} values wrapped in <external_request_payload>. Off for deliver_only: true where the message is delivered verbatim.

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 that header is absent, from the payload as a dotted path (defaulting to "event").

yaml
hooks:
  - path: github
    agent: triager
    event_header: "X-GitHub-Event"
    events: ["issues.opened", "pull_request.opened"]
    deliver_to: "slack:#triage"
    message: "New {{ payload.action }} on #{{ payload.issue.number }}"

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:

yaml
hooks:
  - path: stripe
    deliver_only: true
    deliver_to: "slack:#billing"
    event_header: "type"                    # read payload["type"]
    events: ["invoice.payment_failed"]
    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.

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 }}"
python
gw.register_hook({
    "path": "sentry",
    "deliver_only": True,                   # zero LLM cost, sub-second forwarding
    "deliver_to": "telegram:${OPS_CHAT_ID}",
    "secret": "${SENTRY_WEBHOOK_SECRET}",
    "signature_header": "Sentry-Signature",
    "signature_algo": "sha256",
    "message": "🚨 {{ payload.project }}: {{ payload.event.title }}\n{{ payload.url }}",
})

deliver_only composes with signature verification and event filtering and requires deliver_to. Response shapes:

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}

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

yaml
hooks:
  - path: gmail
    agent: email-triager
    action: agent
    auth: ${GMAIL_HOOK_SECRET}
    session_key: "gmail:{message_id}"
    idempotency_key: "{message_id}"
    deliver_to: telegram:123456789
    message: |
      From: {{ payload.from }}
      Subject: {{ payload.subject }}
      Snippet: {{ payload.snippet }}

      Triage this email and suggest a one-line action.
  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

Always set an idempotency_key for event-driven sources

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.

Use per-hook auth tokens, not the global token

Set a distinct auth secret per hook so you can rotate individual secrets without restarting the gateway or changing the global token.

Set session_key to scope conversations

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.

Test locally with curl before connecting a real service
bash
curl -X POST http://localhost:8765/hooks/gmail \
  -H "Authorization: Bearer my-shared-secret" \
  -H "Content-Type: application/json" \
  -d '{"from": "alice@example.com", "subject": "Hello", "message_id": "test-001"}'

Related