# Webhooks (outbound, signed)

Instead of polling, an **owner** registers an HTTPS endpoint and Quattrino POSTs signed JSON events to it.
Webhooks are owner-level (organization) objects: the agent credential cannot create them.

## Register (owner session, permission `api_keys.manage`)

```bash
curl -X POST https://quattrino.io/api/v1/webhooks -H "Authorization: Bearer <owner access_token>" -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/quattrino-hook", "events": ["notification.created", "approval.pending", "transaction.completed"], "description": "prod hook"}'
```

Real response - **the secret is shown once**:

```json
{ "endpoint": { "endpoint_id": "whe_ydxacd0sm6ncl9g6jq39", "url": "https://example.com/quattrino-hook",
                "events": ["approval.pending", "notification.created", "transaction.completed"],
                "secret": "whsec_9c2855ef662e49a896cebeffa227f87c", "status": "active", "created_at": "..." },
  "notice": "Store the signing secret now - it is shown only once." }
```

| | endpoint |
|---|---|
| list (secrets hidden) | `GET /api/v1/webhooks` -> `endpoints[]`, `event_types[]`, `signing_note` |
| rotate secret | `POST /api/v1/webhooks/{endpoint_id}/rotate-secret` |
| pause / edit | `PATCH /api/v1/webhooks/{endpoint_id}` |
| delete | `DELETE /api/v1/webhooks/{endpoint_id}` |
| send a signed test event now | `POST /api/v1/webhooks/{endpoint_id}/test` -> `{"ok": true}` (event `notification.created`, `notification_type: webhook_test`) |
| delivery log | `GET /api/v1/webhooks/{endpoint_id}/deliveries?limit=50` -> `deliveries[]` with `status`, `attempts`, `response_code`, `last_error`, `next_attempt_at` |

The URL must be a **public** HTTPS host (private / link-local addresses are rejected at registration and re-checked at
every delivery). `events: ["*"]` (default) subscribes to everything. Web app: *Settings -> Webhooks*.

## Delivery format

```
POST <your url>
Content-Type: application/json
X-Quattrino-Event: notification.created
X-Quattrino-Delivery: whd_...
X-Quattrino-Signature: t=1790399236,v1=<hex HMAC-SHA256>

{"id":"evt_...","type":"notification.created","created_at":"2026-09-26T05:07:16+00:00","data":{...}}
```

**Verify before trusting** - `v1 = HMAC_SHA256(secret, "<t>.<raw request body>")` over the raw bytes:

```python
import hmac, hashlib
def verify(secret: str, header: str, raw_body: bytes, tolerance_s: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    expected = hmac.new(secret.encode(), (parts["t"] + ".").encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"]) and abs(time.time() - int(parts["t"])) < tolerance_s
```

Respond with any `2xx` within 8 seconds. Anything else (or a timeout) is retried.

## Retries

Up to **5 attempts**: immediately, then after 30 s, 2 min, 10 min and 60 min. After the fifth failure the delivery is
`dead` (visible in the delivery log; re-test with `/test`). Deliveries are durable and processed by a background loop
every 30 s. Use `X-Quattrino-Delivery` (or `id` in the body) to de-duplicate: the same delivery may reach you more than
once if your endpoint answered slowly.

## Event types

| event | fires when | `data` |
|---|---|---|
| `notification.created` | any owner notification is created - **this is how authorize-only approvals and alerts arrive** | `notification_type`, `title`, `severity`, `metadata` |
| `approval.pending` | a **Quattrino-executed transaction** enters `APPROVAL_REQUIRED` | `transaction_id`, `state`, `amount_minor`, `currency`, `merchant`, `type` |
| `approval.decided` | reserved for transaction approvals | |
| `transaction.completed` / `transaction.failed` / `transaction.refunded` | a Quattrino-executed transaction reaches a terminal state | `transaction_id`, `state`, `amount_minor`, `currency`, `merchant`, `type` |
| `job.queued` / `job.completed` / `job.failed` / `job.expired` | seller-side fulfillment jobs (marketplace) | job fields |
| `payout.paid` / `payout.rejected` | seller payouts (sandbox) | |
| `authority.pending` / `authority.blocked_charge` | recurring authorities | |

### Authorize-only path: which events to subscribe to

Authorizations created by `POST /authorize` are **not** transactions, so they do not emit `approval.pending` or
`transaction.*`. Subscribe to `notification.created` and branch on `data.notification_type`:

| `data.notification_type` | meaning | `data.metadata` |
|---|---|---|
| `approval_request` | a binding authorization is `APPROVAL_PENDING` ("Authorization needs approval") | `authorization_id`, `approval_id` |
| `firewall_alert` | "Authorization outcome mismatch" (NONCOMPLIANT outcome), "No outcome reported" (expired without outcome), "Execution without valid authority" | `authorization_id`, findings |
| `agent_connected` | an agent used its key for the first time | `agent_name` |
| `webhook_test` | `/test` | |

Then read the authorization: `GET /api/v1/org/authorizations/{authorization_id}` (owner) or, from the agent,
`GET /api/v1/authorizations/{authorization_id}`. The **decision** (approved / rejected) is not a separate event today: the
agent learns it by reading the authorization or polling `GET /api/v1/agent/approvals/{approval_id}`.

> Dedicated `authorization.*` webhook events (e.g. `authorization.approved`, `authorization.consumed`) are logged as a
> deferred architectural item (Pass 6 register P6-D-ARCH-001) - not built in Pass 6, which adds no capabilities.

## Security

- One secret per endpoint (`whsec_...`), shown once, rotatable.
- Signature over `t.<raw body>`; reject stale timestamps (replay) and bodies you re-serialized (always sign the raw bytes).
- Payloads contain ids and summaries, never credentials, card data or policy internals.
