> ## Documentation Index
> Fetch the complete documentation index at: https://praison.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Gateway Inbound Hooks

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

<Note>
  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](/docs/guides/praisonai-bot-migration).
</Note>

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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    S[External Service] --> P[POST /hooks/gmail]
    P --> A[Auth check]
    A --> D[Dedup / Idempotency]
    D --> T[Template render]
    T --> G[Agent.start]
    G --> C[Delivery channel]
    C --> U[User]

    classDef input fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef process fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef agent fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef result fill:#10B981,stroke:#7C90A0,color:#fff

    class S input
    class P,A,D,T process
    class G agent
    class C,U result

```

## Quick Start

<Steps>
  <Step title="Define a hook and start the gateway">
    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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()
    ```
  </Step>

  <Step title="Point your external service at the hook URL">
    ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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"}'
    ```
  </Step>

  <Step title="The agent replies to Telegram automatically">
    No polling, no webhook handler code — the gateway does it all.
  </Step>
</Steps>

***

## How It Works

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant Ext as External Service
    participant GW as Gateway /hooks/alerts
    participant Sig as Signature Verify
    participant Ev as Event Filter
    participant Dedup as Idempotency Store
    participant Agent
    participant Chan as Delivery Channel

    Ext->>GW: POST (raw body + Authorization + signature header)
    GW->>GW: Auth check
    alt invalid token
        GW-->>Ext: HTTP 401
    end
    GW->>Sig: verify_signature(raw_body, headers)
    alt invalid / missing signature
        Sig-->>Ext: HTTP 401 {"error":"invalid signature"}
    end
    GW->>Ev: event_allowed(payload, headers)
    alt event not in allow-list
        Ev-->>Ext: HTTP 200 {"ok":true,"skipped":"event"}
    end
    GW->>Dedup: reserve idempotency key
    Note over Dedup: default: sqlite (durable) · memory for tests
    alt duplicate
        Dedup-->>GW: already in-flight
        GW-->>Ext: HTTP 200 (no-op)
    end
    alt deliver_only
        GW->>Chan: rendered message (no LLM turn)
        Chan-->>Ext: HTTP 200 {"ok":true,"action":"deliver","delivered":true}
    else agent action
        GW->>Agent: start(rendered message)
        Agent-->>GW: reply
        GW->>Dedup: commit key (success)
        GW->>Chan: deliver reply
        Chan-->>Ext: HTTP 200
    end
```

***

## Three Ways to Register a Hook

<Tabs>
  <Tab title="Python">
    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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()
    ```
  </Tab>

  <Tab title="YAML (gateway.yaml)">
    ```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    praisonai gateway start --config gateway.yaml
    ```
  </Tab>

  <Tab title="CLI">
    ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    # 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
    ```

    | Flag                  | Type  | Default                | Description                                                          |
    | --------------------- | ----- | ---------------------- | -------------------------------------------------------------------- |
    | `<path>` (positional) | `str` | required               | Hook path — `gmail` exposes `POST /hooks/gmail`                      |
    | `--agent`             | `str` | first registered agent | Agent id to run                                                      |
    | `--action`            | `str` | `agent`                | `agent` runs a turn; `wake` nudges a session                         |
    | `--auth`              | `str` | none                   | Bearer token / shared secret for this hook                           |
    | `--session-key`       | `str` | none                   | Session key template (see [HookConfig Options](#hookconfig-options)) |
    | `--idempotency-key`   | `str` | none                   | Idempotency key template                                             |
    | `--deliver-to`        | `str` | none                   | `platform:target` delivery spec, e.g. `telegram:12345`               |
    | `--message`           | `str` | none                   | Message template built from the payload                              |
    | `--config`            | `str` | `gateway.yaml`         | Path to the gateway configuration file                               |

    <Note>
      Only these nine options are exposed through the CLI. The full `HookConfig` (HMAC signing, event allow-lists, metadata, etc.) is available via YAML / SDK only — see the [HookConfig Options table below](#hookconfig-options).
    </Note>
  </Tab>
</Tabs>

***

## HookConfig Options

| Option             | Type        | Default    | Description                                                                                                                                                                         |
| ------------------ | ----------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `path`             | `str`       | required   | URL segment after `/hooks/`. `"gmail"` exposes `POST /hooks/gmail`. Must be non-empty.                                                                                              |
| `agent`            | `str`       | `None`     | Agent name/id to run. Falls back to the gateway's first registered agent.                                                                                                           |
| `action`           | `str`       | `"agent"`  | `"agent"` runs a new turn; `"wake"` nudges an existing session without a new message.                                                                                               |
| `auth`             | `str`       | `None`     | Bearer token required on inbound requests. Falls back to the gateway's global `auth_token`.                                                                                         |
| `session_key`      | `str`       | `None`     | Template for the session id, e.g. `"hook:gmail:{message_id}"`. Defaults to `"hook:{path}"`.                                                                                         |
| `idempotency_key`  | `str`       | `None`     | Template for the dedup key. When unset, hashes the entire payload.                                                                                                                  |
| `deliver_to`       | `str`       | `None`     | `platform:target` delivery spec, e.g. `"telegram:123456789"`. Omit to skip outbound delivery.                                                                                       |
| `message`          | `str`       | `None`     | Template for the message built from the payload and sent to the agent.                                                                                                              |
| `enabled`          | `bool`      | `True`     | Whether the hook is active. `false` returns 404.                                                                                                                                    |
| `metadata`         | `dict`      | `{}`       | Free-form key/value pairs passed to the agent context.                                                                                                                              |
| `secret`           | `str`       | `None`     | HMAC 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_header` | `str`       | `None`     | Header carrying the signature. Auto-defaults to `"X-Hub-Signature-256"` when `secret` is set with no explicit header.                                                               |
| `signature_algo`   | `str`       | `"sha256"` | Digest for the HMAC. Any name `hashlib.new` accepts (e.g. `"sha1"`, `"sha512"`).                                                                                                    |
| `signature_prefix` | `str`       | `None`     | Optional signature prefix, e.g. `"sha256="`.                                                                                                                                        |
| `events`           | `list[str]` | `None`     | Allow-list of event types. Unmatched deliveries are ack'd (200) with no LLM turn. A string is coerced to a single-element list.                                                     |
| `event_header`     | `str`       | `None`     | Header 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"`).                                |
| `deliver_only`     | `bool`      | `False`    | When `True` the rendered `message` **is** the delivered content, routed straight through `deliver_to` with **no LLM turn**.                                                         |

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

| Option          | Type  | Default    | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| --------------- | ----- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `store_backend` | `str` | `"sqlite"` | `"sqlite"` (default, durable, survives restart) — `"memory"` (per-process, zero-dep, opt-in for tests / ephemeral runs) — `"redis"` (accepts the multi-replica intent; **currently falls back to durable SQLite** and records a degraded fact — no cross-replica Redis backend is implemented yet in the wrapper). Unset means `"sqlite"` ([Issue #4339](https://github.com/MervinPraison/PraisonAI/issues/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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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](/docs/features/untrusted-request-fencing).

***

## Actions

**`"agent"` (default)** — runs a full agent turn with the rendered message as the user input:

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

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

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

<Warning>
  **Changed default ([Issue #4339](https://github.com/MervinPraison/PraisonAI/issues/4339)):** the dedup store is now durable (SQLite) by default. Previously it was in-memory, so a restart inside a provider's retry window could re-process a redelivered webhook (duplicate reply, duplicate tool action). To keep the old behaviour explicitly (for tests / ephemeral runs), set `hooks.idempotency.store_backend: memory`.
</Warning>

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

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
hooks_idempotency:
  store_backend: memory

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`            | ✅ (falls back to durable SQLite) | ⚠️ Only when the SQLite state file is shared — **not** a genuine cross-replica Redis dedup; a degraded fact is recorded | none (fallback path)  | Signals multi-replica intent; today it downgrades to SQLite with a `durability:idempotency` degraded fact until the Redis backend ships. See [Multi-replica gateways](#multi-replica-gateways-and-store_backend-redis). |

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    subgraph "Dedup Store Backend"
        M[💾 In-Memory<br/>per-process<br/>lost on restart]
        S[🗄️ SQLite default<br/>~/.praisonai/state/hook_idempotency.sqlite<br/>survives restart · shareable across replicas]
        R[⚙️ store_backend: redis<br/>multi-replica intent]
        Deg[⚠️ durability:idempotency degraded fact<br/>&quot;running per-replica&quot;]
    end
    Choose{Tests / ephemeral only?}
    Choose -->|Yes, opt out| M
    Choose -->|No — default| S
    R -->|no Redis backend yet<br/>falls back| S
    R --> Deg

    classDef choice fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef mem fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef sql fill:#10B981,stroke:#7C90A0,color:#fff
    classDef redis fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef warn fill:#F59E0B,stroke:#7C90A0,color:#fff

    class Choose choice
    class M mem
    class S sql
    class R redis
    class Deg warn
```

**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](/docs/features/gateway-durability) and [Degraded Capabilities](/docs/features/gateway-degraded-capabilities).
* Selecting `"redis"` is honoured as *intent*, not as a working cross-replica backend. 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. Cross-restart dedup still holds; cross-replica dedup holds **only** when replicas share the same SQLite state file. See [Multi-replica gateways](#multi-replica-gateways-and-store_backend-redis).

<Note>
  For a custom store, implement `IdempotencyStoreProtocol` and inject it — the memory and SQLite backends are the two built-ins. See the [`IdempotencyStoreProtocol` reference](/docs/docs/sdk/reference/praisonaiagents/classes/IdempotencyStoreProtocol).
</Note>

### Multi-replica gateways and `store_backend: redis`

Operators running multiple gateway replicas often set `store_backend: redis` to mirror the [`RedisTurnLock`](/docs/features/gateway-turn-lock) and get cross-replica dedup — but no Redis idempotency backend is implemented in the wrapper yet.

What the wrapper does today when you set `"redis"`:

* Falls back to the **durable SQLite** store, so dedup still survives restart and is cross-replica when the state file is shared.
* Records a `durability:idempotency` degraded fact with reason `"redis idempotency backend not available (running per-replica)"`.
* Emits an operator-facing `warning` log (path-redacted).
* Keeps the more severe `"...unavailable (running in-memory)"` fact if the SQLite fallback *also* fails.

<Warning>
  If a message is fanned out to two replicas that do **not** share the SQLite state file, the agent turn can run twice — duplicate replies, duplicate tool side effects, duplicate billing. The green surface you see without checking `health()` hides this.
</Warning>

Check the downgrade with any of:

```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
praisonai gateway status
praisonai gateway doctor
```

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

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
# Multi-replica gateway that wants cross-replica dedup.
# Today (until the Redis idempotency backend ships) this falls back to durable
# SQLite and records a `durability:idempotency` degraded fact — visible in
# `praisonai gateway status` and `health()["degraded_owners"]`.
hooks:
  idempotency:
    store_backend: redis
  hooks:
    - path: gmail
      agent: assistant
```

The resulting `health()` snippet:

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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    subgraph "store_backend: redis (today)"
        Op[⚙️ Operator sets store_backend: redis] --> Try{🔍 Redis backend<br/>implemented?}
        Try -->|No — not yet| SQ[🗄️ Durable SQLite fallback<br/>cross-restart · cross-replica<br/>only if state file shared]
        SQ --> Deg[⚠️ record_durability_degraded<br/>durability:idempotency<br/>&quot;running per-replica&quot;]
        Deg --> Health[🩺 health.degraded_owners]
    end

    classDef op fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef check fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef store fill:#10B981,stroke:#7C90A0,color:#fff
    classDef warn fill:#F59E0B,stroke:#7C90A0,color:#fff

    class Op op
    class Try check
    class SQ store
    class Deg,Health warn
```

**Workaround until a real Redis backend ships:** share the SQLite state directory across replicas (so the durable dedup key is common to all replicas), or run a single replica for ingress.

**Hot-reload:** flipping `store_backend` via `reload_config` rebuilds the store lazily on the next `reserve`. See [Gateway Hot-Reload](/docs/features/gateway-hot-reload).

***

## Security

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

| Behaviour                 | Detail                                                                                                                                                                        |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Auth required             | 401 on missing or invalid `Authorization: Bearer` header                                                                                                                      |
| Per-hook auth             | `auth` field on the hook overrides the gateway's global `auth_token`                                                                                                          |
| Malformed JSON            | 400 response, no agent run                                                                                                                                                    |
| Missing agent             | 500 when the hook names an agent that isn't registered (no silent fallback to another agent)                                                                                  |
| Disabled hook             | 404 when `enabled: false`                                                                                                                                                     |
| Untrusted-payload fencing | On 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 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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    R[📥 Raw body + signature] --> V{🔐 HMAC valid?}
    V -->|No| X[⛔ HTTP 401 invalid signature]
    V -->|Yes| A[🤖 Run agent / deliver]

    classDef input fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef check fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef reject fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef ok fill:#10B981,stroke:#7C90A0,color:#fff

    class R input
    class V check
    class X reject
    class A ok
```

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.

<Tabs>
  <Tab title="GitHub (Python)">
    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents import Agent
    from praisonai.gateway import Gateway

    triager = Agent(
        name="triager",
        instructions="Triage this GitHub issue and post a one-line action."
    )

    gw = Gateway(agents=[triager])
    gw.register_hook({
        "path": "github",
        "agent": "triager",
        "secret": "${GITHUB_WEBHOOK_SECRET}",
        "signature_prefix": "sha256=",          # X-Hub-Signature-256 is auto-defaulted
        "deliver_to": "slack:#triage",
        "message": "New {{ payload.action }} on #{{ payload.issue.number }}: {{ payload.issue.title }}",
    })
    gw.start()
    ```
  </Tab>

  <Tab title="GitHub (YAML)">
    ```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    hooks:
      - path: github
        agent: triager
        secret: "${GITHUB_WEBHOOK_SECRET}"
        signature_prefix: "sha256="            # signature_header defaults to X-Hub-Signature-256
        deliver_to: "slack:#triage"
        message: "New {{ payload.action }} on #{{ payload.issue.number }}: {{ payload.issue.title }}"
    ```
  </Tab>

  <Tab title="Stripe (YAML)">
    ```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    hooks:
      - path: stripe
        agent: billing-support
        secret: "${STRIPE_WEBHOOK_SECRET}"
        signature_header: "Stripe-Signature"
        signature_algo: "sha256"
        deliver_to: "slack:#billing"
        message: "Stripe event {{ payload.type }} for {{ payload.data.object.customer }}"
    ```
  </Tab>
</Tabs>

Test locally by signing the exact body you POST:

```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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"
```

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

***

## 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

<Note>
  Because `deliver_only` bypasses the agent, no untrusted-request fence is added — the recipient never sees literal `<external_request_payload>` markup.
</Note>

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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:

| 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}`                |

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

***

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

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="Test locally with curl before connecting a real service">
    ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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"}'
    ```
  </Accordion>
</AccordionGroup>

***

## Related

<CardGroup cols={2}>
  <Card title="Webhook Verification" icon="shield-check" href="/docs/features/webhook-verification">
    HMAC signature verification for outbound bot webhooks (different surface)
  </Card>

  <Card title="Proactive Delivery" icon="send" href="/docs/features/proactive-delivery">
    Delivery routing — the channel:target format used in deliver\_to
  </Card>

  <Card title="Gateway Overview" icon="server" href="/docs/features/gateway-overview">
    Gateway configuration, channels, and multi-bot mode
  </Card>

  <Card title="Gateway CLI" icon="terminal" href="/docs/features/gateway-cli">
    All gateway CLI commands including hooks add / list / remove
  </Card>

  <Card title="Untrusted Request Fencing" icon="shield-check" href="/docs/features/untrusted-request-fencing">
    How inbound payloads are fenced as data before the agent sees them
  </Card>
</CardGroup>
