<!-- Source: https://docs.infraio.xyz/tr/webhooks/signature-verification -->
<!-- Last updated: 2026-10-04 -->

# İmza doğrulama

Her webhook teslimi birlikte kullanılan iki başlık içerir:

```http
X-Signature: sha256=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b
X-Timestamp: 1729536000
```

`sha256=`'den sonraki hex string,
`HMAC-SHA256(secret, timestamp + "." + raw_body)`'dir. Nokta gerçek bir
bayttır; timestamp ASCII olarak unix-saniyedir.

## Neden doğrulamalısınız

Webhook URL'leri proxy loglarında, ekran görüntülerinde, tarayıcı
geçmişinde ve destek taleplerinde sızabilir. Bir imza kontrolü olmadan,
URL'nizi öğrenen herkes sahte bir `payment.settled` event'i POST edebilir
ve sizi ödenmemiş siparişleri karşılamaya kandırabilir. Doğrulama, isteğin
InfraIO Pay'dan geldiğini kanıtlar.

İmzalanmış payload'ın içine timestamp'i dahil etmek size ayrıca **replay
koruması** sağlar: bir teslim yakalayan saldırgan, imza algılanabilir
şekilde eskimeden onu daha sonra yeniden gönderemez.

## Algoritma

```
signed_payload  = timestamp + "." + raw_body
expected_header = "sha256=" + lowercase_hex( HMAC_SHA256(secret, signed_payload) )
constant_time_compare(expected_header, x_signature_header)
```

Ardından timestamp'in yakın olduğunu kontrol edin (tipik tolerans:
±5 dakika).

> **Warning:**
>
> Her zaman **ham** istek gövdesi baytlarını iletin. Framework'ler
> genellikle handler'ınız çalışmadan önce JSON'ı ayrıştırır; yeniden
> stringleştirilmiş sürüm gönderdiğimizden farklı olabilir (anahtar
> sırası, boşluk, sayı biçimlendirmesi) ve HMAC eşleşmez. Next.js App
> Router'da `JSON.parse`'tan *önce* `await req.text()` kullanın.
> Express'te yalnızca webhook rotasında
> `express.raw({ type: 'application/json' })` mount edin.

## Uygulamalar

**Node / TS**

```ts filename="lib/verify-infraio.ts"
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export function verifyInfraIo({
  body,
  signature,
  timestamp,
  secret,
}: {
  body: string;       // ham metin — ayrıştırılmış JSON DEĞİL
  signature: string;  // X-Signature başlığının değeri
  timestamp: string;  // X-Timestamp başlığının değeri (unix saniye)
  secret: string;     // whsec_…
}): boolean {
  const ts = Number.parseInt(timestamp, 10);
  if (!Number.isFinite(ts)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > TOLERANCE_SECONDS) {
    return false; // çok eski veya gelecekte çok ileri
  }

  const expected = "sha256=" + createHmac("sha256", secret)
    .update(`${timestamp}.${body}`)
    .digest("hex");

  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

**Go**

```go
package infraio

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "strconv"
    "time"
)

const toleranceSeconds = 5 * 60

// VerifyWebhook, sig HMAC-SHA256(secret, timestamp + "." + body) ile
// eşleşirse VE timestamp tolerans penceresinin içindeyse true döndürür.
func VerifyWebhook(body []byte, sig, timestamp, secret string) bool {
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return false
    }
    skew := time.Now().Unix() - ts
    if skew < 0 {
        skew = -skew
    }
    if skew > toleranceSeconds {
        return false
    }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(timestamp))
    mac.Write([]byte("."))
    mac.Write(body)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return subtle.ConstantTimeCompare([]byte(sig), []byte(expected)) == 1
}
```

**Python**

```python
import hmac, hashlib, time

TOLERANCE_SECONDS = 5 * 60

def verify_infraio(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    """body: ham bayt. signature: 'sha256=<hex>'. timestamp: unix saniye."""
    try:
        ts = int(timestamp)
    except ValueError:
        return False
    if abs(int(time.time()) - ts) > TOLERANCE_SECONDS:
        return False

    signed = timestamp.encode() + b"." + body
    expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature, expected)
```

**Ruby**

```ruby
require 'openssl'

TOLERANCE_SECONDS = 5 * 60

def verify_infraio(body, signature, timestamp, secret)
  ts = Integer(timestamp) rescue (return false)
  return false if (Time.now.to_i - ts).abs > TOLERANCE_SECONDS

  signed   = "#{timestamp}.#{body}"
  expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, signed)
  Rack::Utils.secure_compare(signature.to_s, expected)
end
```

## Replay koruması

İmzalanmış payload'ın içindeki timestamp **ilk** savunma hattıdır — bir
teslim yakalayan saldırgan, tolerans pencereniz dolduktan sonra onu
yeniden gönderemez.

Ekstra güvenlik için (`payment.settled` gibi yüksek değerli event'ler
için önerilir):

1. Benzersiz bir kısıtlama ile bir tabloda **`X-Delivery` üzerinde dedup
   yapın**. Tolerans penceresi içindeki replay'ler no-op'a dönüşür —
   handler'ınız işi iki kez yapmadan 200 döndürür. Bu, meşru yeniden
   denemeler için istediğiniz aynı idempotency'dir. (`X-Delivery` bir
   teslimin her yeniden denemesinde stabildir; payload bir `event_id`
   alanı taşımaz.)
2. **Saat sapmanızın izin verdiği en küçük toleransı kullanın.** ±5
   dakika önerilen varsayılandır ve çoğu NTP-senkronize filonun
   sürdürebileceği şeyle eşleşir. Daha sıkı olması iyidir; ±30 saniyenin
   altında, yavaş yukarı yönlü NTP'li ağlarda meşru teslimleri reddetmeye
   başlarsınız.

## Bir secret'ı rotate etme

1. **Panel → Geliştiriciler → Webhook'lar → [uç nokta] → ⋮ → Rotate Secret.**
2. Yeni bir secret oluşturulur ve **tam olarak bir kez** gösterilir.
   Diyaloğu kapatmadan önce kopyalayın.
3. Env var'ınızı güncelleyin ve doğrulayıcınızı 24 saat içinde yeniden
   dağıtın.

### Kayma penceresi (çift imzalama)

Bir rotation'dan sonraki **24 saat** boyunca, her teslim iki imza taşır:

```http
X-Signature:       sha256=<hmac(new_secret,  ts + "." + body)>
X-Signature-Prev:  sha256=<hmac(prev_secret, ts + "." + body)>
X-Timestamp:       1729536000
```

**Önceki** secret'ı çalıştıran bir doğrulayıcı `X-Signature-Prev` ile
eşleşir; **yeni** secret'ı çalıştıran bir doğrulayıcı `X-Signature` ile
eşleşir. Her iki başlığın geçmesi yeterlidir — handler'ınız dağıtımı
tutmadan geçiş sırasında teslimi kabul edebilir.

Kayma penceresi kapandıktan sonra yalnızca `X-Signature` gönderilir.
Önceki secret kabul edilmeyi durdurur ve hâlâ onunla yapılandırılmış
herhangi bir doğrulayıcı teslimleri reddetmeye başlar — bu yüzden
roll-out'unuzu 24 saatlik bütçe içinde bitirin.

### Önerilen alıcı deseni

```ts
// Bir rotation kayma penceresinde her iki imzayı da kabul edin.
const sig     = req.headers["x-signature"]      ?? "";
const sigPrev = req.headers["x-signature-prev"] ?? "";
const ok = verify(body, sig, ts, CURRENT_SECRET)
       || (PREV_SECRET && verify(body, sigPrev, ts, PREV_SECRET));
```

Uç noktanızdaki kayma penceresi süresi dolduğunda ve `PREV_SECRET`'ı
env'inizden kaldırdığınızda, `X-Signature-Prev` dalını düşürebilirsiniz.

### Acil durum iptali

Bir secret public olarak sızdıysa ve önceki secret'ı hemen geçersiz
kılmanız gerekiyorsa — yani 24 saatlik çakışmanın bilinen-hatalı bir
anahtarı canlı tutmasını istemiyorsanız — iki kez rotate edin. İlk
rotation, sızdırılmış secret'ı prev yuvasına taşır; ikinci rotation,
onu prev yuvasından çıkarır (hâlâ yeni anahtarla değiştirir), böylece
sızdırılmış değer artık kabul edilmez.

## Kablolamanızı test etme

Panelde, **Geliştiriciler → Webhook'lar**'ı açın ve doğrulamak
istediğiniz uç noktada **Send Test**'e tıklayın. URL'ye senkron olarak
sentetik bir zarf imzalar ve POST ederiz, ardından HTTP durumunu,
gecikmeyi ve yanıtınızın 512 baytlık bir parçacığını gösteririz.
Payload şekli:

```json
{
  "event_id":   "<uuid>",
  "event_type": "webhook.test.ping",
  "created_at": "2026-05-17T12:00:00Z",
  "test":       true,
  "data": {
    "merchant_id": "<your-merchant-id>",
    "webhook_id":  "<endpoint-id>",
    "message":     "Test ping from the merchant dashboard..."
  }
}
```

Test ping, production teslimleri ile aynı imzalama şemasını kullanır,
bu yüzden bu butondan yeşil bir onay, doğrulayıcınızın gerçek event'leri
de kabul ettiğini doğrular. Test ping'leri yeniden denenmez. Yeniden
denemeleri çalıştırmak için ilgili API akışı üzerinden gerçek bir event
tetikleyin.

## Yaygın arızalar

| Belirti | Olası neden |
| --- | --- |
| Dev'de her zaman false döndürür | Gövde HMAC öncesinde JSON-ayrıştırıldı. Önce ham baytları okuyun. |
| Dün çalışıyordu, bugün başarısız | Secret'ı rotate ettiniz ama bu sunucudaki env var hâlâ eskisini içeriyor. Yeni secret ile yeniden dağıtın. |
| Eski event'lerde başarısız, yenilerde çalışıyor | Rotation öncesinde bir teslim kuyruğa alınmıştı; imza eski secret'ı kullanıyor ve doğrulayıcınız artık kabul etmiyor. Yeniden denemenin onu düşürmesini bekleyin veya panel üzerinden replay yapın. |
| Timestamp karşılaştırmasında bir kayma | Unix-saniyeyi unix-saniyeye karşılaştırdığınızdan emin olun. JS'deki `Date.now()` **milisaniyedir** — 1000'e bölün. |
| Yerelde çalışıyor, prod'da başarısız | Bir proxy (Cloudflare, nginx) açıyor, yeniden kodluyor veya sonundaki bir newline'ı çıkarıyor. Handler'ınızın gördüğü baytları inceleyin. |
| Test ping 401 / imza uyumsuzluğu diyor | Doğrulayıcınız yalnızca `body`'yi imzalıyor. Bunun yerine `timestamp + "." + body` imzalayın. |
| Başlık tamamen eksik | Uç nokta farklı bir ortam için kayıtlı. Test modu uç noktaları yalnızca `environment=test` event'leri alır. |
