# Webhooks

Push-based event delivery from a WHost agent to subscriber endpoints.
Use webhooks instead of polling so partner billing systems learn about
account / domain / SSL / backup lifecycle events the moment they
happen on the agent.

---

## How it works

1. The operator registers a subscriber URL in the admin panel (or via
   the API — see [Registration](#registration)); the signing secret is
   generated by the agent unless the operator supplies one.
2. Each time an operation that maps to a catalog event is written to the
   audit log, the agent's audit log bridge appends the event to an
   on-disk queue.
3. A background dispatcher reads the queue, POSTs the payload to
   every enabled, unmuted subscriber whose `events` filter matches, and
   records each delivery.
4. Failed deliveries are retried with exponential backoff; after the
   5th unsuccessful attempt the delivery is dead-lettered (visible in
   the delivery history for manual retry).

---

## Event catalog

Stable, public dot-notation identifiers. New events are additive;
existing names never change. Full machine-readable catalog:
`GET /api/v1/system/webhooks/events`.

| Event | Trigger |
|-------|---------|
| `account.created`             | Account provisioned. |
| `account.suspended`           | Account suspended (panel or API). |
| `account.unsuspended`         | Account unsuspended. |
| `account.terminated`          | Permanent deletion completed. |
| `account.package_changed`     | Plan / package switch (`change-package`). |
| `account.password_changed`    | An account's panel password was changed or reset (by the operator, a reseller, the account owner or a mailed reset link). Also sent when the operator changes the admin password — `username` is then `null`. |
| `domain.added`                | Addon domain attached (subdomains and parked domains send no event). |
| `domain.removed`              | Addon domain removed. |
| `ssl.issued`                  | A certificate was installed for a domain that had none: a Let's Encrypt request or a custom upload from the panel or the API, or the automatic certificate of a new addon domain. Not sent for the automatic certificate of a new account or for bulk issuance. |
| `ssl.renewed`                 | A Let's Encrypt certificate was requested again from the panel or the API for a domain that already had one, and a new certificate was issued. certbot's scheduled renewals (systemd timer or cron job) send no event. |
| `ssl.failed`                  | A Let's Encrypt request or custom upload from the panel or the API failed on the server side; in bulk issuance, every domain that failed. Automatic certificates (new account, new addon domain) and certbot's scheduled renewals send no event. |
| `backup.completed`            | A backup archive was created (manual or scheduled); a failed remote upload is reported in `extra.remote_upload_error`. |
| `backup.failed`               | A backup attempt produced no archive. |
| `database.created`            | MariaDB database provisioned. |
| `database.deleted`            | Database dropped. |
| `email.created`               | Mailbox provisioned. |
| `email.deleted`               | Mailbox removed. |
| `ftp.created`                 | FTP account provisioned. |
| `ftp.deleted`                 | FTP account removed. |
| `dns.record_added`            | DNS record inserted. |
| `dns.record_updated`          | DNS record modified. |
| `dns.record_deleted`          | DNS record removed. |
| `plan.created`                | Hosting plan added. |
| `plan.updated`                | Hosting plan modified. |
| `plan.deleted`                | Hosting plan removed. |

---

## Payload shape

Every outbound delivery is JSON with a stable envelope (Stripe-style).
It is shown indented here; the body is sent as compact JSON.

```json
{
  "id":      "evt_4f2c8c1e9b3d40e98c2e1cf7e9c9c3a2",
  "event":   "account.created",
  "created": 1748640123,
  "data": {
    "audit_id":   "1c2a4f3e-...",
    "actor":      "admin",
    "username":   "demo01",
    "description":"Account 'demo01' created with plan 'starter'",
    "ip_address": "203.0.113.4",
    "extra": {
      "domain":   "demo01.example.com",
      "plan":     "starter"
    },
    "timestamp":  "2026-05-23T10:42:03.512947+00:00"
  }
}
```

- `id` — unique per event. Use this for idempotent dedupe on the
  subscriber side; the agent retries after a timeout, a transport error
  or any non-2xx answer, and a manual retry sends the same `id` again, so
  duplicate POSTs are expected.
- `event` — one of the catalog identifiers above.
- `created` — unix seconds; the agent's clock at enqueue time.
- `data` — event-specific payload. For an event raised by an operation it
  is the audit log row: `audit_id` matches the row's `id` in
  `GET /api/v1/audit-logs`, `actor` is who acted (`admin`, `hmac`,
  `reseller`, `client`, …), and `extra` differs per event. A test event
  carries `{"test": true, "endpoint_id": …, "note": …}` instead (see
  [Test fire](#test-fire)).

---

## Outbound HTTP headers

| Header | Value |
|--------|-------|
| `Content-Type`                  | `application/json` |
| `User-Agent`                    | `WHost-Webhook/1.0` |
| `X-WHost-Webhook-Event`         | Event name (`account.created`, ...) — same as the body's `event`, so a router in front of the receiver can dispatch without parsing the body. |
| `X-WHost-Webhook-Event-Id`      | `evt_<32 hex>` — same as the body's `id`. |
| `X-WHost-Webhook-Delivery-Id`   | `dlv_<32 hex>` — one per delivery: the automatic retries of a delivery reuse it, a manual retry gets a new one; visible in the agent's delivery history. |
| `X-WHost-Webhook-Timestamp`     | Unix seconds when the signature was minted (fresh on every attempt). |
| `X-WHost-Webhook-Signature`     | `sha256=<hex>` — lowercase hex HMAC-SHA256 of `"{timestamp}.{raw_body}"`, keyed with the endpoint secret. |

---

## Signing model

```
signature = HMAC_SHA256(
    secret,
    timestamp + "." + raw_body
)
```

- Always verify against the **raw** body bytes. Re-encoding the JSON
  on the subscriber side will reorder keys / whitespace and break the
  signature.
- Reject deliveries whose `X-WHost-Webhook-Timestamp` drifts more than
  ±300 seconds from your clock — defeats replay.
- Use a constant-time comparison (`hash_equals` in PHP,
  `crypto.timingSafeEqual` from `node:crypto` in Node.js — both buffers
  must have the same length) when comparing the computed HMAC.

### Verifying in PHP (whost-php-sdk)

```php
use WHost\Http\WebhookVerifier;

$body = file_get_contents('php://input');
$sig  = $_SERVER['HTTP_X_WHOST_WEBHOOK_SIGNATURE']  ?? '';
$ts   = $_SERVER['HTTP_X_WHOST_WEBHOOK_TIMESTAMP'] ?? '';

try {
    $event = WebhookVerifier::parse($body, $sig, $ts, $WEBHOOK_SECRET);
} catch (\WHost\Exceptions\WHostException $e) {
    http_response_code(400);
    exit;
}

// $event is the decoded envelope (id, event, created, data).
```

### Verifying without the SDK

```bash
# Bash — verify the HMAC manually
expected=$(printf '%s.%s' "$ts" "$body" | openssl dgst -sha256 -hmac "$secret" | awk '{print $2}')
test "sha256=$expected" = "$received_signature"
```

---

## Retry + dead-letter

Failed deliveries (a timeout or other transport error, or any non-2xx
response — redirects are not followed) are retried on this schedule:

| Attempt | Delay before retry |
|---------|--------------------|
| 1       | immediate          |
| 2       | 1s                 |
| 3       | 10s                |
| 4       | 60s                |
| 5       | 5 min              |
| 5+      | dead-letter        |

Each attempt is recorded: while the dispatcher is still retrying, the
delivery reads `failed` with `attempts` and `next_attempt_at`; the final
record for the same `delivery_id` (success or `dead_letter`) supersedes it.
If the agent stops while a delivery waits for its next attempt, the
delivery keeps a final `failed` record and is not retried automatically —
retry it manually.

A delivery in the `dead_letter` state is visible in
`GET /api/v1/system/webhooks/deliveries?status=dead_letter` and can be
re-enqueued manually via `POST /api/v1/system/webhooks/deliveries/{id}/retry`.

Subscribers SHOULD:

- Return 2xx for any payload they accept (even unknown event types) —
  the agent treats non-2xx as a delivery failure.
- Acknowledge quickly (< 5s); the agent times out individual attempts
  at 10 seconds.
- Move heavy processing off the request path (queue, worker) so the
  ACK comes back before the timeout.
- Dedupe on the body's `id` — one delivery makes up to 5 attempts with
  the same `id`, and a manual retry sends it again; never increment
  counters twice for one event.

---

## SSRF defenses

The agent accepts only `http://` and `https://` URLs and refuses to
register or deliver to URLs that resolve to private, loopback,
link-local, CGNAT or unspecified addresses:

- `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`
- `100.64.0.0/10` (CGNAT), `0.0.0.0/8`
- `169.254.0.0/16` (link-local)
- `127.0.0.0/8`, `::/128`, `::1/128`, `fc00::/7`, `fe80::/10`
- IPv4-mapped IPv6 addresses (`::ffff:…`) are checked as the IPv4 address
  they carry
- also ranges that never name a public destination: `192.0.0.0/24`,
  `198.18.0.0/15`, `224.0.0.0/4` (multicast), `240.0.0.0/4` (reserved,
  including `255.255.255.255`), `::/96`, `fec0::/10`, `ff00::/8`,
  `64:ff9b:1::/48`; a NAT64 (`64:ff9b::/96`) or 6to4 (`2002::/16`)
  address is checked as the IPv4 address it carries

A hostname that does not resolve is refused as well.

DNS resolution is re-checked at delivery time — a registered hostname
that later flips to a blocked address, or no longer resolves, is
dead-lettered without sending the request (0 attempts; the record's
`error` starts with `ssrf_block`).

Every attempt of a delivery connects to the addresses that check approved:
the hostname is not looked up again between the check and the connection,
and the retries go to the same addresses. The request keeps the hostname in
its `Host` header and, over HTTPS, as the TLS server name, so certificate
validation is unchanged. A hostname with several addresses is tried in
resolver order; the next address is used only when no connection could be
opened to the previous one.

---

## Registration

### Admin panel

Settings (gear icon in the top bar) → **All Settings** → **Webhooks** →
**Add endpoint**. Leave **Secret** empty to have one generated, or enter
your own (16–128 printable ASCII characters, no spaces). After the
endpoint is saved, the panel shows the secret once in the **Webhook secret
created** dialog — copy it to the subscriber then.

### API (HMAC)

The signature covers the full path `/api/v1/system/webhooks` and the exact
body string that is sent:

```bash
BODY='{"url":"https://billing.example.com/whost/webhook","events":["account.created","account.suspended","account.terminated"],"description":"Billing system order lifecycle"}'
TS=$(date +%s)
NONCE=$(openssl rand -hex 16)
SIG=$(printf '%s\n%s\n%s\n%s\n%s' POST /api/v1/system/webhooks "$TS" "$NONCE" "$BODY" \
  | openssl dgst -sha256 -hmac "$WHOST_API_SECRET" | awk '{print $NF}')

curl -X POST https://hosting.example.com/api/v1/system/webhooks \
  -H "X-WHost-Key: $WHOST_API_KEY" \
  -H "X-WHost-Timestamp: $TS" \
  -H "X-WHost-Nonce: $NONCE" \
  -H "X-WHost-Signature: $SIG" \
  -H "Content-Type: application/json" \
  --data-binary "$BODY"
```

`events` must name at least one event; an empty list is refused with
`400 WEBHOOK_VALIDATION`, on create and on update alike. `secret` is
optional: omit it to have a 43-character random secret generated, or send
16–128 printable ASCII characters without whitespace.

Response (one-time view of the plaintext secret):

```json
{
  "status": "success",
  "data": {
    "id": "whk_a1b2c3...",
    "url": "https://billing.example.com/whost/webhook",
    "secret": "<generated secret, 43 characters>",
    "events": ["account.created", "account.suspended", "account.terminated"],
    "description": "Billing system order lifecycle",
    "enabled": true,
    "created_at": "2026-05-23T10:00:00.418204+00:00",
    "updated_at": "2026-05-23T10:00:00.418311+00:00",
    "muted_until": null
  },
  "message": "Webhook endpoint registered. Save the secret — it won't be shown again."
}
```

### Secret rotation

```
POST /api/v1/system/webhooks/{id}/rotate
```

Returns the new plaintext secret **once**, in the JSON response body:

```json
{
  "status": "success",
  "data": { "endpoint_id": "whk_…", "secret": "<new secret, 43 characters>" },
  "message": "Secret rotated. Subscriber must use this value immediately."
}
```

Copy it immediately — `GET /api/v1/system/webhooks/{id}` (like the list
and the update response) returns the secret masked: every character
except the last four is replaced by `*`. If you lose the secret, rotate
again.

**Why rotate?**
- Subscriber-side leak suspicion (CI logs, error tracker, backup dump).
- Operator turnover — the person who first saved the secret is leaving.
- Scheduled rotation policy (every 90 days is a common SOC requirement).

**No rolling window — coordinate the cut-over.**

Every delivery dispatched after the rotation call returns 200 is signed
with the new secret. Deliveries that were already in their retry schedule
keep signing with the secret they started with, until their last attempt
(up to about seven minutes after their first). Subscribers must accept the
new value **before** the next event fires, or signature verification
will fail and the delivery will land in the retry queue (eventually
dead-letter).

Recommended subscriber-side rotation flow:

1. Generate the new secret on a calm cycle (off-peak hours).
2. **First**, deploy the subscriber update that accepts BOTH the old
   AND the new secret (try old first, fall back to new). This is a
   per-call try/except, not a config flag — keeps verification cheap
   and avoids race windows.
3. Trigger the rotation API call. The agent signs every new delivery
   with the new secret from then on.
4. Confirm via the test-fire endpoint (see [Test fire](#test-fire)) that
   a real signed payload now matches the new secret on your side.
5. **Second deploy**: drop the old-secret fallback path.

This three-step coordination is the same pattern Stripe and Slack
recommend for their webhook rotations. Two-secret subscribers also cover
the gap between "operator hits rotate" and "subscriber redeployed", and
the retries of deliveries that started before the rotation.

If you cannot run the two-deploy pattern (subscriber is a frozen
SaaS, etc.), mute the endpoint first via `muted_until`, rotate, update
the subscriber's secret, then unmute. Mute / unmute is a single
`PUT /api/v1/system/webhooks/{id}` call: set `muted_until` to an ISO-8601
time (a value without a zone is read as UTC; the mute ends by itself at
that time), or send `"muted_until": ""` to unmute early — `null` leaves
the field unchanged. A mute does not hold events back: events that occur
while the endpoint is muted (or disabled) are not queued for it and are
not delivered afterwards — reconcile them from `GET /api/v1/audit-logs`.

---

## Delivery history + manual retry

| Endpoint | Description |
|----------|-------------|
| `GET /api/v1/system/webhooks/deliveries`        | Paginated list, newest first (`limit` 1–500, default 50; `offset`; filter by `endpoint_id`, `status` — `success`, `failed`, `dead_letter` — and `event_type`). |
| `GET /api/v1/system/webhooks/deliveries/{id}`   | Full record incl. original payload and last response excerpt (first 512 characters). `404 DELIVERY_NOT_FOUND` for an unknown id. |
| `POST /api/v1/system/webhooks/deliveries/{id}/retry` | Re-enqueue the original event (same event `id`) at the delivery's own endpoint; the new delivery gets a fresh `delivery_id`. `404 WEBHOOK_NOT_FOUND` when that endpoint is gone, `409 WEBHOOK_DISABLED` while it is disabled or muted, `400 DELIVERY_PAYLOAD_MISSING` when the stored payload is gone. |

---

## Test fire

```
POST /api/v1/system/webhooks/{id}/test
```

Enqueues a synthetic event addressed at this endpoint only (the first
event in the endpoint's filter list); other subscribers of the same event
do not receive it. Its `data` is
`{"test": true, "endpoint_id": "whk_…", "note": "Synthetic webhook fired from admin panel"}`
— handle it without side effects. A disabled or muted endpoint answers
`409 WEBHOOK_DISABLED`. Useful for confirming network connectivity +
signature validation before relying on the endpoint for production
traffic.
