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

# Webhooks

> Get signed, real-time notifications for every payment event, with automatic retries and HMAC verification.

# Webhooks

Webhooks push events to your server the moment they happen: charges created and paid, approvals requested, budgets exhausted. Every delivery is signed with HMAC-SHA256 so you can prove it came from Payzor.

## 1. Register an endpoint

From a console session:

```bash theme={null}
curl -X POST https://your-payzor-host/settings/webhooks \
  -H "Authorization: Bearer <console_jwt>" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://api.yourapp.com/payzor/webhook"}'
```

Response: **the signing secret is shown once**:

```json theme={null}
{
  "id": "wh_3318183a8183dd29",
  "url": "https://api.yourapp.com/payzor/webhook",
  "events": [],
  "secret": "whsec_5b30f56a99d954bf2c386ee8a2427656251cfc60ab4e3045"
}
```

An empty `events` array means **all events**. Pass a subset to filter, e.g. `"events": ["payment.succeeded", "budget.exhausted"]`.

## 2. Receive deliveries

Each POST to your endpoint includes:

| Header               | Meaning                             |
| -------------------- | ----------------------------------- |
| `X-Payzor-Event`     | Event type, e.g. `charge.succeeded` |
| `X-Payzor-Signature` | `t=<unix_ts>,v1=<hex_hmac>`         |

The body is a JSON envelope; `data` holds the event payload:

```json theme={null}
{
  "id": "evt_wh_9eab971e9e1a9bee",
  "type": "payment.succeeded",
  "createdAt": "2026-08-23T05:00:57.334Z",
  "data": {
    "chargeId": "chg_834e692669a3f8ad",
    "merchantId": "mch_c8fdac10d39c0c4d",
    "agentId": "agent_6b75c0e895ba9c3d",
    "amountMinor": 200,
    "currency": "USD",
    "method": "ledger",
    "reference": "PAY_63FC9EDD0FAD129D"
  }
}
```

## 3. Verify the signature

The signature is computed over the **raw body bytes** (not re-serialized JSON) as:

```text theme={null}
hmac_sha256(secret, "<t>.<raw_body>")
```

where `<t>` is the timestamp from the signature header. Compare with a timing-safe equality.

<Tip>
  Verifying against the raw body matters: if you parse and re-stringify JSON first, key order or float formatting can change and verification will fail even for legitimate calls.
</Tip>

<CodeGroup>
  ```javascript Node / Express theme={null}
  const crypto = require('crypto');

  app.post('/payzor/webhook',
    express.raw({ type: '*/*' }),            // raw body is essential
    (req, res) => {
      const sig = req.get('X-Payzor-Signature') || '';
      const [tPart, v1Part] = sig.split(',');
      const t = tPart.slice(2);                // "t=1690..."
      const v1 = v1Part.slice(3);              // "v1=abc..."

      const expected = crypto
        .createHmac('sha256', process.env.PAYZOR_WEBHOOK_SECRET)
        .update(`${t}.${req.body.toString()}`)
        .digest('hex');

      const ok = crypto.timingSafeEqual(
        Buffer.from(v1), Buffer.from(expected)
      );
      if (!ok) return res.status(401).end();

      const event = JSON.parse(req.body.toString());
      // ... handle event.type
      res.json({ ok: true });
    });
  ```

  ```python Python / FastAPI theme={null}
  import hmac, hashlib

  @app.post("/payzor/webhook")
  async def payzor_webhook(request: Request):
      raw = await request.body()
      sig = request.headers["x-payzor-signature"]
      t_part, v1_part = sig.split(",")
      t = t_part[2:]          # after "t="
      v1 = v1_part[3:]        # after "v1="

      expected = hmac.new(
          PAYZOR_WEBHOOK_SECRET.encode(),
          f"{t}.".encode() + raw,
          hashlib.sha256,
      ).hexdigest()

      if not hmac.compare_digest(v1, expected):
          raise HTTPException(status_code=401)

      event = json.loads(raw)
      # ... handle event["type"]
  ```
</CodeGroup>

Optionally also check that `t` is within \~5 minutes of now (replay protection).

## 4. Respond fast, process async

Return any 2xx quickly. If your handler throws, Payzor **retries with exponential backoff**. Deliveries are kept in memory per endpoint; inspect recent ones at:

```bash theme={null}
curl https://your-payzor-host/settings/webhooks/wh_xxx/deliveries \
  -H "Authorization: Bearer <console_jwt>"
```

## Event catalog

| Event               | When                            | Key fields in `data`                         |
| ------------------- | ------------------------------- | -------------------------------------------- |
| `charge.created`    | Merchant creates a charge       | `chargeId`, `amountMinor`, `merchantName`    |
| `payment.succeeded` | Agent settles from wallet       | `chargeId`, `agentId`, `reference`, `method` |
| `charge.succeeded`  | Charge marked paid (any method) | same as above                                |
| `approval.required` | Policy asks a human             | `approvalId`, `reason`, `expiresAt`          |
| `paylink.accepted`  | Agent accepts a budget grant    | `grantId`, `paylinkId`, `agentId`            |
| `budget.exhausted`  | Budget fully drawn              | `paylinkId`, `spentMinor`                    |
| `deposit.confirmed` | On-chain USDC credited          | `agentId`, `amount`, `network`, `address`    |

Full payloads: [Webhook Events Reference](/api-reference/webhook-events).

<Warning>
  Treat webhook handlers as idempotent. Retries mean you may receive the same event more than once, dedupe on `event.id`.
</Warning>
