# Webhook'lar

Bir WHost agent'ından abone endpoint'lerine push tabanlı olay teslimatı.
Partner faturalama sistemlerinin hesap / domain / SSL / yedekleme yaşam
döngüsü olaylarını, agent üzerinde gerçekleştiği anda öğrenmesi için
polling yerine webhook kullanın.

---

## Nasıl çalışır

1. Sistem yöneticisi, yönetici panelinde (veya API üzerinden — bkz.
   [Kayıt](#kay-t)) bir abone URL'si kaydeder; imzalama gizli anahtarını,
   yönetici kendisi vermezse agent üretir.
2. Katalogdaki bir olaya karşılık gelen bir operasyon denetim loguna her
   yazıldığında, agent'ın denetim logu köprüsü olayı disk üzerindeki bir
   kuyruğa ekler.
3. Bir arka plan dispatcher'ı kuyruğu okur, payload'u `events` filtresi
   eşleşen, etkin ve sessize alınmamış her aboneye POST eder ve her
   teslimatı kaydeder.
4. Başarısız teslimatlar üstel geri çekilme (exponential backoff) ile
   tekrar denenir; 5. başarısız denemeden sonra teslimat
   dead-letter'lanır (manuel tekrar deneme için teslimat geçmişinde
   görünür).

---

## Olay kataloğu

Kararlı, açık (public) dot-notation tanımlayıcılar. Yeni olaylar
eklenir; mevcut isimler asla değişmez. Tam makine-okunabilir katalog:
`GET /api/v1/system/webhooks/events`.

| Olay | Tetikleyici |
|-------|---------|
| `account.created`             | Hesap sağlandı. |
| `account.suspended`           | Hesap askıya alındı (panel veya API). |
| `account.unsuspended`         | Hesabın askısı kaldırıldı. |
| `account.terminated`          | Kalıcı silme tamamlandı. |
| `account.package_changed`     | Plan / paket değişimi (`change-package`). |
| `account.password_changed`    | Bir hesabın panel parolası değiştirildi veya sıfırlandı (yönetici, bayi, hesap sahibi ya da e-postayla gelen sıfırlama bağlantısıyla). Yönetici kendi admin parolasını değiştirdiğinde de gönderilir — bu durumda `username` `null` olur. |
| `domain.added`                | Addon domain eklendi (subdomain ve park edilmiş domainler olay göndermez). |
| `domain.removed`              | Addon domain kaldırıldı. |
| `ssl.issued`                  | Sertifikası olmayan bir domaine sertifika kuruldu: panel ya da API üzerinden bir Let's Encrypt isteği veya özel sertifika yüklemesi, ya da yeni bir addon domainin otomatik sertifikası. Yeni hesabın otomatik sertifikası ve toplu (bulk) sertifika üretimi için gönderilmez. |
| `ssl.renewed`                 | Zaten sertifikası olan bir domain için panel ya da API üzerinden Let's Encrypt sertifikası yeniden istendi ve yeni sertifika üretildi. certbot'un kendi zamanlamasıyla (systemd timer ya da cron) yaptığı yenilemeler olay göndermez. |
| `ssl.failed`                  | Panel ya da API üzerinden yapılan bir Let's Encrypt isteği veya özel sertifika yüklemesi sunucu tarafında başarısız oldu; toplu üretimde başarısız olan her domain için. Otomatik sertifikalar (yeni hesap, yeni addon domain) ve certbot'un zamanlanmış yenilemeleri olay göndermez. |
| `backup.completed`            | Bir yedek arşivi oluşturuldu (manuel veya zamanlanmış); başarısız bir uzak hedef yüklemesi `extra.remote_upload_error` alanında bildirilir. |
| `backup.failed`               | Bir yedekleme girişiminden arşiv çıkmadı. |
| `database.created`            | MariaDB veritabanı sağlandı. |
| `database.deleted`            | Veritabanı silindi. |
| `email.created`               | Posta kutusu sağlandı. |
| `email.deleted`               | Posta kutusu kaldırıldı. |
| `ftp.created`                 | FTP hesabı sağlandı. |
| `ftp.deleted`                 | FTP hesabı kaldırıldı. |
| `dns.record_added`            | DNS kaydı eklendi. |
| `dns.record_updated`          | DNS kaydı değiştirildi. |
| `dns.record_deleted`          | DNS kaydı kaldırıldı. |
| `plan.created`                | Hosting paketi eklendi. |
| `plan.updated`                | Hosting paketi değiştirildi. |
| `plan.deleted`                | Hosting paketi kaldırıldı. |

---

## Payload yapısı

Her giden teslimat, kararlı bir zarf (Stripe tarzı) ile JSON formatındadır.
Burada girintili gösterilir; gövde sıkıştırılmış (compact) JSON olarak
gönderilir.

```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` — olay başına benzersizdir. Abone tarafında idempotent
  yinelemeleri ayıklamak için bunu kullanın; agent zaman aşımı, taşıma
  (transport) hatası ya da 2xx olmayan her yanıttan sonra tekrar dener ve
  manuel tekrar deneme aynı `id`'yi yeniden gönderir, bu nedenle yinelenen
  POST'lar beklenir.
- `event` — yukarıdaki katalog tanımlayıcılarından biri.
- `created` — unix saniyesi; agent'ın kuyruğa alma anındaki saati.
- `data` — olaya özgü payload. Bir operasyonun doğurduğu olayda denetim
  logu satırıdır: `audit_id`, `GET /api/v1/audit-logs` içindeki satırın
  `id`'siyle eşleşir, `actor` işlemi yapanı gösterir (`admin`, `hmac`,
  `reseller`, `client`, …), `extra` olaya göre değişir. Test olayı bunun
  yerine `{"test": true, "endpoint_id": …, "note": …}` taşır (bkz.
  [Test fire](#test-fire)).

---

## Giden HTTP header'ları

| Header | Değer |
|--------|-------|
| `Content-Type`                  | `application/json` |
| `User-Agent`                    | `WHost-Webhook/1.0` |
| `X-WHost-Webhook-Event`         | Olay adı (`account.created`, ...) — gövdedeki `event` ile aynı; alıcının önündeki bir yönlendirici gövdeyi ayrıştırmadan yönlendirme yapabilir. |
| `X-WHost-Webhook-Event-Id`      | `evt_<32 hex>` — gövdedeki `id` ile aynı. |
| `X-WHost-Webhook-Delivery-Id`   | `dlv_<32 hex>` — teslimat başına bir tane: bir teslimatın otomatik tekrar denemeleri aynısını kullanır, manuel tekrar deneme yenisini alır; agent'ın teslimat geçmişinde görünür. |
| `X-WHost-Webhook-Timestamp`     | İmza üretildiğindeki unix saniyesi (her denemede yeniden üretilir). |
| `X-WHost-Webhook-Signature`     | `sha256=<hex>` — `"{timestamp}.{raw_body}"` ifadesinin uç nokta gizli anahtarıyla anahtarlanmış HMAC-SHA256'sı, küçük harfli hex. |

---

## İmzalama modeli

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

- Her zaman **ham (raw)** gövde baytlarına karşı doğrulama yapın.
  Abone tarafında JSON'u yeniden kodlamak anahtarları / boşlukları
  yeniden sıralar ve imzayı bozar.
- `X-WHost-Webhook-Timestamp` değeri saatinizden ±300 saniyeden
  fazla saparsa teslimatları reddedin — bu replay'i engeller.
- Hesaplanan HMAC'i karşılaştırırken sabit zamanlı karşılaştırma
  kullanın (PHP'de `hash_equals`, Node.js'te `node:crypto` modülünden
  `crypto.timingSafeEqual` — iki buffer'ın uzunluğu aynı olmalıdır).

### PHP'de doğrulama (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).
```

### SDK olmadan doğrulama

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

---

## Tekrar deneme + dead-letter

Başarısız teslimatlar (zaman aşımı ya da başka bir transport hatası veya
2xx olmayan her yanıt — yönlendirmeler izlenmez) şu programa göre tekrar
denenir:

| Deneme | Tekrar denemeden önceki gecikme |
|---------|--------------------|
| 1       | anında             |
| 2       | 1s                 |
| 3       | 10s                |
| 4       | 60s                |
| 5       | 5 dk               |
| 5+      | dead-letter        |

Her deneme kaydedilir: dispatcher hâlâ yeniden denerken teslimat
`attempts` ve `next_attempt_at` ile `failed` okunur; aynı `delivery_id`
için yazılan son kayıt (başarı ya da `dead_letter`) onun yerini alır.
Agent, bir teslimat sonraki denemesini beklerken durursa teslimat son
kayıt olarak `failed` kalır ve otomatik olarak yeniden denenmez — manuel
olarak yeniden deneyin.

`dead_letter` durumundaki bir teslimat
`GET /api/v1/system/webhooks/deliveries?status=dead_letter` adresinde
görünür ve `POST /api/v1/system/webhooks/deliveries/{id}/retry` üzerinden
manuel olarak yeniden kuyruğa alınabilir.

Aboneler ŞUNLARI YAPMALI:

- Kabul ettikleri herhangi bir payload için 2xx döndürmeli (bilinmeyen
  olay tipleri dahi) — agent 2xx olmayanı bir teslimat başarısızlığı
  olarak kabul eder.
- Hızlıca onaylamalı (< 5s); agent bireysel denemeleri 10 saniyede
  zaman aşımına uğratır.
- Ağır işlemeyi istek yolundan çıkarmalı (kuyruk, worker) — böylece
  ACK zaman aşımından önce dönmüş olur.
- Gövdedeki `id` üzerinden tekrarları ayıklamalı — bir teslimat aynı
  `id` ile en fazla 5 deneme yapar ve manuel tekrar deneme onu yeniden
  gönderir; tek bir olay için sayaçları asla iki kez artırmayın.

---

## SSRF savunmaları

Agent yalnız `http://` ve `https://` URL'lerini kabul eder; özel, loopback,
link-local, CGNAT veya belirsiz (unspecified) adreslere çözümlenen
URL'lere kayıt yapmayı veya teslimat yapmayı reddeder:

- `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 adresleri (`::ffff:…`) taşıdıkları IPv4 adresi olarak
  kontrol edilir
- hiçbir zaman genel bir hedefi adlandırmayan aralıklar da: `192.0.0.0/24`,
  `198.18.0.0/15`, `224.0.0.0/4` (multicast), `240.0.0.0/4` (ayrılmış,
  `255.255.255.255` dahil), `::/96`, `fec0::/10`, `ff00::/8`,
  `64:ff9b:1::/48`; NAT64 (`64:ff9b::/96`) ya da 6to4 (`2002::/16`) adresi
  taşıdığı IPv4 adresi olarak kontrol edilir

Çözümlenemeyen bir hostname de reddedilir.

DNS çözümlemesi teslimat anında yeniden kontrol edilir — sonradan
engelli bir adrese çevrilen ya da artık çözümlenemeyen kayıtlı bir
hostname, istek gönderilmeden dead-letter'lanır (0 deneme; kaydın
`error` alanı `ssrf_block` ile başlar).

Bir teslimatın her denemesi bu kontrolün onayladığı adreslere bağlanır:
hostname kontrol ile bağlantı arasında yeniden çözümlenmez, yeniden denemeler
de aynı adreslere gider. İstek hostname'i `Host` başlığında ve HTTPS'te TLS
sunucu adı olarak taşır; sertifika doğrulaması değişmez. Birden çok adresi
olan bir hostname'de adresler çözümleyici sırasıyla denenir; bir sonraki
adrese yalnız öncekine bağlantı açılamadığında geçilir.

---

## Kayıt

### Yönetici paneli

Ayarlar (üst çubuktaki dişli simgesi) → **Tüm Ayarlar** → **Webhook'lar**
→ **Yeni uç nokta**. **Gizli anahtar** alanını boş bırakırsanız anahtar
üretilir; isterseniz kendi anahtarınızı girin (16–128 yazdırılabilir ASCII
karakter, boşluksuz). Uç nokta kaydedildikten sonra panel gizli anahtarı
**Webhook gizli anahtarı oluşturuldu** diyaloğunda bir kez gösterir —
o anda aboneye kopyalayın.

### API (HMAC)

İmza tam yolu (`/api/v1/system/webhooks`) ve gönderilen gövde string'inin
tam hâlini kapsar:

```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` en az bir olay adlandırmalıdır; boş liste oluşturmada da
güncellemede de `400 WEBHOOK_VALIDATION` ile reddedilir. `secret`
isteğe bağlıdır: gönderilmezse 43 karakterlik rastgele bir gizli anahtar
üretilir; gönderilirse 16–128 yazdırılabilir ASCII karakter olmalı ve
boşluk içermemelidir.

Yanıt (düz metin gizli anahtarın tek seferlik görünümü):

```json
{
  "status": "success",
  "data": {
    "id": "whk_a1b2c3...",
    "url": "https://billing.example.com/whost/webhook",
    "secret": "<üretilen gizli anahtar, 43 karakter>",
    "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."
}
```

### Gizli anahtar rotasyonu

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

Yeni düz metin gizli anahtarı JSON yanıt gövdesinde **bir kez**
döndürür:

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

Hemen kopyalayın — `GET /api/v1/system/webhooks/{id}` (liste ve
güncelleme yanıtı gibi) gizli anahtarı maskeli döndürür: son dört karakter
dışındaki her karakter `*` ile değiştirilir. Gizli anahtarı kaybederseniz,
yeniden rotasyon yapın.

**Neden rotasyon?**
- Abone tarafında sızıntı şüphesi (CI logları, hata izleyici, yedek
  dump'ı).
- Operatör değişimi — gizli anahtarı ilk kaydeden kişi ayrılıyor.
- Zamanlanmış rotasyon politikası (her 90 günde bir yaygın bir SOC
  gereksinimidir).

**Yuvarlanan (rolling) pencere yok — geçişi koordine edin.**

Rotasyon çağrısı 200 döndükten sonra gönderilen her teslimat yeni gizli
anahtarla imzalanır. O anda tekrar deneme programında olan teslimatlar ise
son denemelerine kadar (ilk denemelerinden sonra en fazla yaklaşık yedi
dakika) başladıkları gizli anahtarla imzalamaya devam eder. Aboneler yeni
değeri sonraki olay ateşlenmeden **önce** kabul etmek zorundadır, aksi
takdirde imza doğrulaması başarısız olur ve teslimat tekrar deneme
kuyruğuna düşer (sonunda dead-letter).

Önerilen abone tarafı rotasyon akışı:

1. Yeni gizli anahtarı sakin bir döngüde üretin (yoğun olmayan
   saatlerde).
2. **Önce**, abone güncellemesini hem eski HEM de yeni gizli
   anahtarı kabul edecek şekilde yayına alın (önce eskiyi deneyin,
   yeniye geri düşün). Bu çağrı başına bir try/except'tir, config
   bayrağı değil — doğrulamayı ucuz tutar ve yarış pencerelerini
   önler.
3. Rotasyon API çağrısını tetikleyin. Agent o andan itibaren her yeni
   teslimatı yeni gizli anahtarla imzalar.
4. Test-fire endpoint'i üzerinden (bkz. [Test fire](#test-fire)), sizin
   tarafınızda gerçek bir imzalı payload'un artık yeni gizli anahtarla
   eşleştiğini onaylayın.
5. **İkinci deploy**: eski gizli anahtar fallback yolunu kaldırın.

Bu üç adımlı koordinasyon, Stripe ve Slack'in webhook rotasyonları
için önerdikleri aynı pattern'dir. İki gizli anahtarlı aboneler ayrıca
"operatör rotasyona basıyor" ile "abone yeniden yayına alındı" arasındaki
aralığı ve rotasyondan önce başlamış teslimatların tekrar denemelerini de
karşılar.

İki-deploy pattern'ini çalıştıramazsanız (abone donmuş bir SaaS,
vb.), önce `muted_until` üzerinden uç noktayı sessize alın, rotasyon
yapın, abonenin gizli anahtarını güncelleyin, sonra sesi açın. Sessize
alma / sesi açma tek bir `PUT /api/v1/system/webhooks/{id}` çağrısıdır:
`muted_until` alanına ISO-8601 bir zaman verin (saat dilimi olmayan değer
UTC okunur; sessizlik o zamanda kendiliğinden biter) ya da erken açmak için
`"muted_until": ""` gönderin — `null` alanı değiştirmez. Sessize alma
olayları bekletmez: uç nokta sessizdeyken (veya devre dışıyken) oluşan
olaylar o uç için kuyruğa alınmaz ve sonradan da teslim edilmez — bunları
`GET /api/v1/audit-logs` üzerinden mutabık kılın.

---

## Teslimat geçmişi + manuel tekrar deneme

| Endpoint | Açıklama |
|----------|-------------|
| `GET /api/v1/system/webhooks/deliveries`        | Sayfalanmış liste, en yeni önce (`limit` 1–500, varsayılan 50; `offset`; filtre: `endpoint_id`, `status` — `success`, `failed`, `dead_letter` — ve `event_type`). |
| `GET /api/v1/system/webhooks/deliveries/{id}`   | Orijinal payload ve son yanıt alıntısı (ilk 512 karakter) dahil tam kayıt. Bilinmeyen id için `404 DELIVERY_NOT_FOUND`. |
| `POST /api/v1/system/webhooks/deliveries/{id}/retry` | Orijinal olayı (aynı olay `id`'siyle) teslimatın kendi ucuna yeniden kuyruğa alır; yeni teslimat yeni bir `delivery_id` alır. Uç silinmişse `404 WEBHOOK_NOT_FOUND`, devre dışı ya da sessize alınmışsa `409 WEBHOOK_DISABLED`, saklanan payload yoksa `400 DELIVERY_PAYLOAD_MISSING`. |

---

## Test fire

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

Yalnız bu uca adreslenmiş sentetik bir olayı kuyruğa alır (endpoint'in
filtre listesindeki ilk olay); aynı olaya abone diğer uçlar bunu almaz.
`data` alanı
`{"test": true, "endpoint_id": "whk_…", "note": "Synthetic webhook fired from admin panel"}`
şeklindedir — yan etki üretmeden işleyin. Devre dışı ya da sessize
alınmış uç `409 WEBHOOK_DISABLED` döner.
Endpoint'i üretim trafiğine güvenmeden önce ağ bağlantısını + imza
doğrulamasını teyit etmek için yararlıdır.
