Webhook alıcısı yazmak: imza doğrulama, tekrar gönderim, sıra bozulması
Üretimde webhook alıcılarını bozan üç şey var: ham gövdeyi kaybettiren imza doğrulama, aynı olayı iki kez işlemek ve olayların sırayla geleceğini varsaymak. Üçünün de kod düzeyinde karşılığı var.
Webhook almak, ilk bakışta bir POST endpoint'i açmaktan ibaret görünüyor. Gönderen taraf veriyi yolluyor, siz 200 dönüyorsunuz. Üretimde bu kurgunun bozulduğu üç yer var: imzayı doğrulamamak, aynı olayı iki kez işlemek ve olayların gönderildiği sırayla gelmediğini varsaymak.
Bu yazı bu üçünü ayrı ayrı ele alıyor. Örnekler Node/Express ile, ama mantık her dilde aynı.
İmza doğrulama: gövdeyi ham hâliyle saklayın
Çoğu sağlayıcı isteğin gövdesini paylaşılan bir sırla HMAC'leyip header'a koyuyor. Doğrulama basit görünüyor ama en sık yapılan hata, gövdenin JSON olarak parse edildikten sonra tekrar stringify edilmesi. Anahtar sırası ya da boşluk değiştiği anda imza tutmuyor.
Çözüm, ham gövdeyi ayrı tutmak:
app.post(
"/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const imza = req.get("X-Signature");
if (!imza) return res.sendStatus(400);
const beklenen = crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(req.body) // Buffer, string değil
.digest("hex");
const a = Buffer.from(imza);
const b = Buffer.from(beklenen);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
const olay = JSON.parse(req.body.toString("utf8"));
// ...
}
);
İki ayrıntı önemli. timingSafeEqual kullanılıyor çünkü === karşılaştırması karakter karakter erken çıkıyor ve teoride zamanlama sızdırıyor; uzunluklar farklıysa fonksiyon zaten hata fırlattığı için önce uzunluk kontrol ediliyor.
İkincisi: imza header'ında çoğu zaman zaman damgası da bulunuyor. Damga yoksa eski bir isteği yakalayan biri onu istediği zaman tekrar gönderebiliyor. Damga varsa pencereyi dar tutun:
const yas = Date.now() / 1000 - Number(damga);
if (Math.abs(yas) > 300) return res.sendStatus(401);
Tekrar gönderim: idempotency tablosu
Sağlayıcılar 2xx almadıklarında yeniden deniyor. Ağ koptuğu için 200'ünüz karşıya ulaşmadıysa aynı olay ikinci kez geliyor. İşleyici para tahsil ediyor ya da mail atıyorsa bu iki kez oluyor.
Uygulama içinde bir Set tutmak yetmiyor; süreç yeniden başladığında kayboluyor ve birden fazla instance çalışıyorsa paylaşılmıyor. Doğru yer veritabanı ve doğru araç unique index:
create table webhook_olay (
id text primary key, -- sağlayıcının olay kimliği
alindi timestamptz not null default now(),
islendi timestamptz
);
İşleyicinin ilk satırı ekleme denemesi oluyor:
const { rowCount } = await db.query(
"insert into webhook_olay (id) values ($1) on conflict do nothing",
[olay.id]
);
if (rowCount === 0) return res.sendStatus(200); // zaten alınmış
Buradaki incelik, işi yapmak ile kaydı işaretlemenin aynı transaction içinde olması. Ayrı olurlarsa arada çöken süreç, işlenmiş görünen ama aslında yapılmamış bir olay bırakıyor. İş dış bir servise gidiyorsa ve transaction'a alınamıyorsa, sıralama şöyle kuruluyor: önce kaydı islendi = null ile aç, işi yap, sonra islendi alanını doldur. Yarım kalanları ayrı bir görev tarıyor.
Sağlayıcı olay kimliği vermiyorsa imzanın kendisi kimlik olarak kullanılabiliyor; aynı gövde aynı imzayı üretiyor.
Sıra bozulması: gövdeye değil, sürüme bakın
En sinsi sorun bu. guncellendi olayı olusturuldu olayından önce gelebiliyor, ya da bir kaydın iki güncellemesi ters sırada düşebiliyor. Paralel gönderim yapan her sistemde bu er geç oluyor.
Olayın içindeki zaman damgası ya da sürüm numarası burada işe yarıyor. Yazma işlemini koşullu hâle getirin:
update musteri
set ad = $2,
surum = $3
where id = $1
and surum < $3;
Eski bir olay geldiğinde where tutmuyor ve satır güncellenmiyor. Kayıt yoksa insert ... on conflict (id) do update set ... where musteri.surum < excluded.surum aynı işi yapıyor.
Sağlayıcı sürüm vermiyorsa, olayın kendi zaman damgası kullanılabiliyor. Bunun zayıf tarafı, aynı milisaniyede üretilen iki olayın ayrışmaması. Sıranın gerçekten kritik olduğu yerlerde tek doğru çözüm webhook'un içeriğine güvenmemek: olay yalnızca "bu kayıt değişti" sinyali sayılıp güncel hâl sağlayıcının API'sinden çekiliyor. Yavaş ama tutarlı.
Endpoint hızlı olsun, iş arkada yapılsın
Çoğu sağlayıcının zaman aşımı süresi birkaç saniye. İşleyici mail atıyor, PDF üretiyor ya da başka bir API'ye gidiyorsa bu süre aşılıyor ve sağlayıcı başarısız sayıp yeniden gönderiyor. Aynı işi ikinci kez tetikliyorsunuz.
Doğru bölünme şu: endpoint imzayı doğrular, olayı kuyruğa yazar, 200 döner. Ağır iş kuyruktan yürür. Kuyruk için ayrı bir altyapı şart değil; yukarıdaki webhook_olay tablosu ham gövdeyi de saklarsa kuyruk görevini görüyor.
Hata dönerken ne söylediğinize dikkat edin
Durum kodu sağlayıcıya ne yapacağını söylüyor. İmza geçersizse 401 dönün, tekrar denemesin. Gövde bozuksa 400 dönün, tekrar denemesin. Veritabanı düştüyse 500 dönün, tekrar denesin. Bilinmeyen bir olay tipi geldiğinde 200 dönün ve loglayın; 400 dönerseniz sağlayıcı günlerce aynı olayı denemeye devam ediyor.
Yazılacak test dört tane: geçersiz imza reddediliyor mu, aynı olay iki kez gönderildiğinde iş bir kez yapılıyor mu, eski sürümlü olay yeni veriyi ezmiyor mu, işleyici çöktüğünde olay yeniden denenebilir durumda kalıyor mu. Bu dördü geçiyorsa alıcı üretime hazır.
- webhook
- idempotency
- HMAC
- Node.js