Geliştirici

Ödeme Webhook'u Güvenliği: HMAC İmza Doğrulama ve Idempotency Rehberi

KriptoGo Ekibi··5 dk okuma

Webhook, ödeme sağlayıcısının sunucunuza 'para geldi' demesidir. Bu mesajı doğrulamadan siparişi onaylarsanız, webhook adresinizi bilen herkes bedava alışveriş yapabilir. Bu rehber doğru işlemenin her adımını, üç dilde örnekle anlatır.

Webhook neden doğrulanmalı?

Webhook ucunuz herkese açık bir HTTPS adresidir; öyle olmak zorundadır, yoksa sağlayıcı size ulaşamaz. Bu adresi bilen biri kendi bilgisayarından {"status":"PAID","invoice_id":"..."} gövdeli bir POST gönderirse sunucunuz bunu gerçek ödeme sanır. Adres tahmin edilebilir (/webhook, /kriptogo-webhook), ödeme sayfası fatura kimliğini herkese gösterir. Yani saldırının tüm malzemesi ortadadır. Tek koruma imzadır.

HMAC imza nasıl çalışır?

KriptoGo her webhook gövdesini, yalnızca sizin ve KriptoGo'nun bildiği secretKey ile HMAC-SHA256 algoritmasından geçirir ve sonucu X-Kriptogo-Signature başlığında gönderir. Siz aynı işlemi kendi tarafınızda yapar, iki değeri karşılaştırırsınız. Secret'ı bilmeyen biri geçerli imza üretemez; gövdenin tek bir karakteri değişse imza tutmaz. Secret hiçbir istekte gönderilmez, yalnızca imza hesaplamada kullanılır.

Ham gövde sorunu

En sık yapılan hata şudur: framework gövdeyi otomatik olarak JSON nesnesine çevirir, geliştirici JSON.stringify(req.body) ile geri metne dönüştürüp imzalar. Ama bu yeni metin, gelen ham metinle byte byte aynı olmayabilir: boşluklar, alan sırası, sayı biçimi (49.9049.9) değişir. İmza tutmaz.

Çözüm: imzayı sunucuya gelen ham byte'lar üzerinden hesaplamak. Her framework'te bunun bir yolu vardır: Express'te express.json({ verify }), Next.js App Router'da await req.text(), PHP'de php://input, Flask'ta request.get_data(), Django'da request.body.

Sabit zamanlı karşılaştırma

İki imzayı === ile karşılaştırmak, karşılaştırmanın ilk farklı karakterde durması nedeniyle teorik olarak zamanlama saldırısına açıktır. Pratikte bu uzaktan sömürülmesi zor bir zayıflıktır, ancak düzeltmesi tek satırdır: crypto.timingSafeEqual (Node.js), hash_equals (PHP), hmac.compare_digest (Python). Uzunlukları önce kontrol edin; farklı uzunlukta buffer'lar bazı fonksiyonlarda hata fırlatır.

Node.js, PHP, Python örnekleri

Node.js / Express
app.use('/kriptogo-webhook', express.json({
  verify: (req, res, buf) => { req.rawBody = buf.toString('utf8'); }
}));
app.post('/kriptogo-webhook', (req, res) => {
  const expected = crypto.createHmac('sha256', process.env.KRIPTOGO_SECRET_KEY).update(req.rawBody).digest('hex');
  const got = req.get('X-Kriptogo-Signature') || '';
  const ok = got.length === expected.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
  if (!ok) return res.status(401).send('invalid signature');
  res.sendStatus(200);
  handlePayment(req.body).catch(console.error);   // arka planda
});
PHP
$raw      = file_get_contents('php://input');
$received = $_SERVER['HTTP_X_KRIPTOGO_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $raw, getenv('KRIPTOGO_SECRET_KEY'));
if (!hash_equals($expected, $received)) { http_response_code(401); exit; }
http_response_code(200);
$data = json_decode($raw, true);
// $data['invoice_id'], $data['status'], $data['paid_amount']
Python / Flask
@app.post("/kriptogo-webhook")
def webhook():
    raw = request.get_data()
    expected = hmac.new(os.environ["KRIPTOGO_SECRET_KEY"].encode(), raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Kriptogo-Signature", "")):
        return "invalid signature", 401
    data = request.get_json()
    return "ok", 200

Yeniden deneme ve idempotency

Sunucunuz 10 saniye içinde 200 dönmezse KriptoGo bildirimi 1 ve 2 saniye arayla toplamda üç kez gönderir. Bu iyi bir şeydir: geçici bir kesintide ödemeyi kaçırmazsınız. Ama sonucu da kabul etmelisiniz: aynı bildirim birden fazla kez gelebilir. İlk deneme aslında ulaşmış ama yanıt zaman aşımına düşmüş olabilir.

Çözüm idempotency'dir: siparişi invoice_id ile bulun; zaten "ödendi" ise hiçbir şey yapmadan 200 dönün. Veritabanında invoice_id sütununa benzersizlik kısıtı koymak ve durum güncellemesini "yalnızca hâlâ bekliyorsa" koşuluyla yapmak (UPDATE ... WHERE status = 'pending') yarış durumlarını da kapatır.

Hızlı yanıt, arka planda iş

E-posta göndermek, stok düşmek, PDF üretmek saniyeler alabilir. Bunları yanıttan önce yaparsanız 10 saniyelik süreyi aşar, gereksiz yeniden denemeler tetiklersiniz. Doğru sıra: imzayı doğrula → 200 dön → işi kuyruğa at veya arka planda çalıştır. Express örneğinde res.sendStatus(200)'ın ardından handlePayment çağrısının beklenmemesi bu yüzdendir.

Kontrol listesi

  • İmza ham gövde üzerinden hesaplanıyor.
  • Karşılaştırma sabit zamanlı fonksiyonla yapılıyor.
  • Geçersiz imzada 401 dönüyor ve hiçbir şey yapılmıyor.
  • Aynı invoice_id ikinci kez gelince sipariş tekrar işlenmiyor.
  • paid_amount, sipariş tutarıyla karşılaştırılıyor.
  • Uzun işler yanıttan sonra çalışıyor.
  • Webhook adresi yalnızca HTTPS ve herkese açık; KriptoGo özel ağ adreslerini zaten reddeder.
  • Secret ortam değişkeninde; loglara yazılmıyor.

Uçtan uca akış için adım adım rehbere, tüm alanlar için dökümana bakın.

Next.js App Router örneği

Next.js'te gövdeyi otomatik ayrıştıran bir katman yoktur; req.text() ham metni verir. Route'un Node çalışma zamanında olması gerekir çünkü node:crypto kullanılır.

app/api/kriptogo-webhook/route.ts
import crypto from 'node:crypto';
export const runtime = 'nodejs';

export async function POST(req: Request) {
  const raw = await req.text();                       // ham gövde
  const expected = crypto.createHmac('sha256', process.env.KRIPTOGO_SECRET_KEY!).update(raw).digest('hex');
  const got = req.headers.get('x-kriptogo-signature') ?? '';
  if (got.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
    return new Response('invalid signature', { status: 401 });
  }
  const data = JSON.parse(raw);
  if (data.status === 'PAID') await markOrderPaid(data.invoice_id, data.paid_amount); // idempotent
  return new Response('ok');
}

Test: sahte imza ve tekrar gönderim

Doğrulamanın çalıştığını iki basit testle kanıtlayın. Birincisi yanlış imza: 401 dönmeli ve veritabanında hiçbir şey değişmemeli. İkincisi tekrar gönderim: aynı geçerli isteği iki kez gönderin; sipariş bir kez işlenmeli.

Terminal
# 1) Sahte imza → 401 beklenir
curl -s -o /dev/null -w "%{http_code}
" -X POST https://siten.com/kriptogo-webhook   -H "Content-Type: application/json" -H "X-Kriptogo-Signature: deadbeef"   -d '{"event":"payment.confirmed","invoice_id":"test","status":"PAID"}'

# 2) Geçerli imza üret (secret'ı kendi ortamınızdan alın) ve iki kez gönder
BODY='{"event":"payment.confirmed","invoice_id":"test-1","status":"PAID","amount":"1","paid_amount":"1"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$KRIPTOGO_SECRET_KEY" | awk '{print $2}')
for i in 1 2; do curl -s -X POST https://siten.com/kriptogo-webhook -H "Content-Type: application/json" -H "X-Kriptogo-Signature: $SIG" -d "$BODY"; done

Not: printf '%s' kullanın; echo sona yeni satır ekler ve imza tutmaz. Bu, "ham gövde" kuralının komut satırındaki karşılığıdır.

Loglama ve izleme

  • Her webhook için invoice_id, doğrulama sonucu (geçti/geçmedi) ve işlem süresini loglayın. Gövdenin tamamını ve secret'ı loglamayın.
  • Geçersiz imza sayısında ani artış, adresinizin keşfedildiğini gösterir; alarm kurun.
  • KriptoGo panelinde faturanın "ödendi" olduğu ama sizde siparişin açık kaldığı durumlar için günlük bir mutabakat sorgusu çalıştırın: GET /api/status/:id ile açık siparişleri kontrol edin.
  • Webhook ucunuzun yanıt süresini izleyin; 10 saniyeye yaklaşıyorsa işi arka plana taşıyın.

Sık hatalar ve belirtileri

BelirtiMuhtemel nedenÇözüm
Tüm webhook'lar 401İmza ham gövde yerine yeniden serileştirilmiş JSON üzerinden hesaplanıyor; ya da yanlış secretHam gövdeyi kullan; panelden secret'ı yeniden kopyala
Ürün iki kez teslim edildiYeniden deneme + idempotency yokSipariş durumunu kontrol et; WHERE status='pending' ile güncelle
Webhook hiç gelmiyorAdres panelde kayıtlı değil; HTTP (TLS'siz); güvenlik duvarıHTTPS adresi panele kaydet; sunucu loglarını kontrol et
Bazen 200 bazen zaman aşımıYanıttan önce e-posta/PDF işleriÖnce 200, sonra arka plan
Fatura ödendi ama sipariş tutarı tutmuyorEksik ödeme toleransıpaid_amount karşılaştır; eksikse beklemeye al

Sık sorulan sorular

Webhook alamıyorsam ne yaparım?

Yerel geliştirme gibi dışarıya açık adresi olmayan ortamlarda GET /api/status/:id ucunu birkaç saniyede bir sorgulayabilirsiniz. Üretimde ise webhook adresinin panelde doğru kayıtlı olduğunu, HTTPS olduğunu ve sunucunun 200 döndüğünü kontrol edin.

Aynı webhook iki kez geldi, bu hata mı?

Hayır, yeniden deneme mekanizmasının doğal sonucudur. Sunucunuz idempotent olmalı: aynı invoice_id için siparişi ikinci kez işlememeli.

İmza başlığında sha256= öneki var mı?

İki başlık gönderilir: X-Kriptogo-Signature (yalnızca hex) ve X-Kriptogo-Signature-256 (sha256= önekli). Hangisini kullandığınıza göre öneki dikkate alın.

KriptoGo ile 30 dakikada kripto ödeme al

Kurulum ücreti yok, aylık ücret yok. Yalnızca başarılı tahsilattan %1.

Ücretsiz hesap oluştur