# Webhooks

The backend delivers signed webhook events to one endpoint per merchant, for checkout sessions and subscriptions alike. Anonymous P2P sessions emit no webhooks — but a p2p request created through a `pk_`-tagged widget is merchant-owned ([Flow B — merchant visibility](#flow-b-request)) and **does** fire your webhook on settlement, riding the same queue and envelope. Delivery runs in a dedicated worker process (the `webhooks` subcommand), separate from the API, backed by a persistent queue with retries -- all event families ride the same queue, worker, and HMAC envelope.

## Configure an Endpoint

| Method | Path | Auth | Description |
| --- | --- | --- | --- |
| **GET** | `/v1/webhook_endpoints` | `sk_` | Returns the configured endpoint `{ id, url, ... }` or `null`. |
| `PUT` | `/v1/webhook_endpoints` | `sk_` | Set/replace the endpoint `{ url }`. The server generates a `whsec_` signing secret and returns it **once**. Re-PUT rotates it. One endpoint per merchant. |
| `DELETE` | `/v1/webhook_endpoints` | `sk_` | Remove the endpoint. |

## Event Taxonomy

| Event | Fires when |
| --- | --- |
| `payment.detected` | status → `detected` (indexer saw the deposit). |
| `payment.processing` | status → `sweeping` (executor started). |
| `payment.succeeded` | status → `swept` AND outcome `succeeded`. |
| `payment.overpaid` | status → `swept` AND outcome `overpaid`. |
| `payment.underpaid` | status → `swept` AND outcome `underpaid`. |
| `payment.failed` | status → `failed` (retries exhausted, or bridge failed/expired). |
| `payment.bounced` | status → `bounced` (terminal failure auto-refunded to the session's `refund_address`; Solana sessions only). |
| `payment.refunded` | `POST /sessions/:id/refund` succeeded. |
| `subscription.created` | `POST /v1/subscriptions` registered a payer-signed mandate. |
| `subscription.charged` | a subscription charge confirmed on-chain (payload adds `amount`, `fee_bps` (platform fee), `app_fee_bps`, `app_fee_recipient`, `tx_hash`, `period_index`). |
| `subscription.charge_failed` | a charge attempt failed -- one event per attempt, up to 3 attempts per period (payload adds `error_message`, `period_index`). |
| `subscription.canceled` | the subscription was canceled -- merchant API, the payer's gasless relay, or an on-chain cancel mirrored by the worker. The three paths share one idempotency key, so exactly one event goes out. |

This page is the delivery contract (signature, retries, idempotency); the `subscription.*` lifecycle and payload details live in [Subscriptions](#subscriptions).

## Signature

Each delivery carries a Stripe-style HMAC header. Verify it before trusting the body. The signed string is `"{t}.{raw_json_body}"` -- sign/verify the **exact bytes** received, never a re-encoded JSON.

```http
Peltier-Signature: t=1714000000,v1=3f8a9c...e21b
Idempotency-Key: <session_id>:<event_type>
Content-Type: application/json
```

where `v1 = hex(HMAC-SHA256(signing_secret, "{t}.{body}"))`. Reject the request if `now - t > 300s` (replay window) or the HMAC does not match (timing-safe compare). The example shows a session delivery -- subscription deliveries carry subscription-scoped keys (exact shapes under Retries, Backoff & Idempotency below).

```typescript
import { verifyWebhookSignature } from '@peltier/node'

// In your webhook handler -- pass the RAW request body (string/Buffer)
const ok = verifyWebhookSignature(
  req.header('Peltier-Signature'),
  rawBody,
  process.env.PELTIER_WEBHOOK_SECRET // the whsec_ value
)
if (!ok) return res.status(400).end()

const event = JSON.parse(rawBody)
// { session_id, event_type, amount, settled_amount, overpaid_amount, ... }
```

## Retries, Backoff & Idempotency

- **Delivery success** = any `2xx` response. Anything else is a failure.
- **Backoff:** on failure the worker retries with `next_retry_at = now + 2^attempts` seconds, for a maximum of **5 attempts** (≈ 1+2+4+8+16s), then marks the delivery `failed`.
- **Idempotency:** the `Idempotency-Key` is deterministic and enforced UNIQUE in the queue -- a double-emit cannot enqueue twice. Session events use `<session_id>:<event_type>`. Subscription events are subscription-scoped: `sub:{uuid}:created`, `sub:{uuid}:canceled`, `sub:{uuid}:charged:{period}`, `sub:{uuid}:charge_failed:{period}:{attempt}`. Make your handler idempotent on this key.
- **Lost a webhook?** Reconcile by polling `GET /v1/payment_sessions/:id` (sessions) or `GET /v1/subscriptions/:id` (subscriptions) with `sk_`.

## SSRF & HTTPS Requirement

> Webhook URLs must be **https** (a dev-only `http://localhost` escape hatch exists behind `WEBHOOK_ALLOW_INSECURE` + dev `MERCHANT_MODE`). The URL is SSRF-guarded both at `PUT` time and again at delivery (DNS-rebind guard): the resolved host must not be loopback, private (10/8, 172.16/12, 192.168/16), link-local (169.254/16), or IPv6 `::1` / `fc00::/7`. HTTP redirects are disabled and connect/total timeouts are bounded. A URL pointing at an internal service is rejected.
