Idempotent Webhook İşleme: Tekrar Eden İstekleri Yönetme
Üretim ortamında bir webhook altyapısı kurduğunuzda, en sık karşılaşılan ve en sinir bozucu problemlerden biri şudur: aynı event birden fazla kez işleniyor. Ödeme tamamlandı bildirimi iki kez geliyor, kullanıcı iki kez oluşturuluyor ya da stok iki kez düşürülüyor. Bu durum hem veri tutarsızlığına hem de ciddi iş kayıplarına neden olabilir. İşte bu noktada idempotent webhook işleme kavramı devreye giriyor.
Idempotency Nedir ve Neden Webhook’larda Kritiktir
Matematikten gelen bu kavram, basitçe şu anlama gelir: aynı işlemi kaç kez yaparsanız yapın, sonuç değişmez. HTTP metodları açısından düşündüğünüzde GET ve DELETE doğaları gereği idempotent’tir. POST ise değildir. Webhook’lar da genellikle POST üzerinden çalıştığı için, tekrar eden istekleri yönetmek tamamen sizin sorumluluğunuzdadır.
Peki neden webhook’lar tekrar eder? Birkaç temel neden var:
- Ağ zaman aşımları: Gönderen sistem, webhook endpoint’inizden 200 OK alamazsa isteği yeniden gönderir
- Provider tarafı retry mekanizmaları: Stripe, GitHub, Shopify gibi platformlar başarısız teslimleri otomatik olarak yeniden dener
- At-least-once delivery garantisi: Çoğu webhook sistemi “en az bir kez iletilir” garantisi verir, “tam olarak bir kez” değil
- Ağınızdaki geçici hatalar: Load balancer yeniden yönlendirme, pod restart gibi durumlar
Gerçek hayattan bir senaryo düşünelim: E-ticaret sitenize entegre ettiğiniz Stripe, bir ödeme başarıyla tamamlandığında payment_intent.succeeded event’ini gönderir. Endpoint’iniz isteği aldı, siparişi oluşturdu, ancak veritabanı işlemi yavaş olduğu için Stripe’a 200 dönemedi ve timeout aldı. Stripe aynı event’i 5 dakika sonra tekrar gönderir. Artık aynı sipariş iki kez oluşturulmuş olur.
Temel Kavramlar: Idempotency Key ve Event ID
Her webhook isteğinde bir benzersiz kimlik bulunur. Bu kimliği doğru şekilde kullanmak idempotency’nin temelidir.
Stripe örneğinde her event’in bir id alanı vardır:
# Stripe webhook payload örneği
curl -X POST https://yoursite.com/webhooks/stripe
-H "Content-Type: application/json"
-H "Stripe-Signature: t=1614556996,v1=abc123..."
-d '{
"id": "evt_1J2Y3K4L5M6N7O8P",
"type": "payment_intent.succeeded",
"data": {
"object": {
"id": "pi_1J2Y3K4L5M6N7O8P",
"amount": 9900,
"currency": "try"
}
}
}'
GitHub webhook’larında ise X-GitHub-Delivery header’ı kullanılır:
# GitHub webhook header kontrolü
X-GitHub-Event: push
X-GitHub-Delivery: 72d3162e-cc78-11e3-81ab-4c9367dc0958
X-Hub-Signature-256: sha256=abc123def456...
Bu ID’leri işleme almadan önce bir veritabanında veya önbellekte kontrol etmek, tekrar eden işlemlerin önüne geçmenin en güvenilir yoludur.
Veritabanı Tabanlı Idempotency
En sağlam yöntemlerden biri, işlenen webhook event’lerini bir veritabanı tablosunda saklamaktır.
# PostgreSQL'de webhook_events tablosu oluşturma
psql -U postgres -d myapp << 'EOF'
CREATE TABLE webhook_events (
id SERIAL PRIMARY KEY,
event_id VARCHAR(255) UNIQUE NOT NULL,
provider VARCHAR(50) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSONB,
processed_at TIMESTAMP DEFAULT NOW(),
status VARCHAR(20) DEFAULT 'processed'
);
CREATE INDEX idx_webhook_events_event_id ON webhook_events(event_id);
CREATE INDEX idx_webhook_events_processed_at ON webhook_events(processed_at);
EOF
Python ile basit bir idempotency kontrolü:
# Python Flask webhook handler - psycopg2 ile
cat > /opt/webhooks/stripe_handler.py << 'EOF'
import psycopg2
import json
from flask import Flask, request, jsonify
app = Flask(__name__)
def get_db_connection():
return psycopg2.connect(
host="localhost",
database="myapp",
user="webhook_user",
password="güçlü_şifre_buraya"
)
def is_event_processed(event_id, provider):
"""Event daha önce işlendi mi kontrol et"""
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute(
"SELECT id FROM webhook_events WHERE event_id = %s AND provider = %s",
(event_id, provider)
)
result = cur.fetchone()
return result is not None
finally:
cur.close()
conn.close()
def mark_event_processed(event_id, provider, event_type, payload):
"""Event'i işlendi olarak işaretle"""
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute(
"""INSERT INTO webhook_events (event_id, provider, event_type, payload)
VALUES (%s, %s, %s, %s)
ON CONFLICT (event_id) DO NOTHING""",
(event_id, provider, event_type, json.dumps(payload))
)
conn.commit()
return cur.rowcount > 0
finally:
cur.close()
conn.close()
@app.route('/webhooks/stripe', methods=['POST'])
def stripe_webhook():
payload = request.get_json()
event_id = payload.get('id')
event_type = payload.get('type')
# Duplicate kontrolü
if is_event_processed(event_id, 'stripe'):
app.logger.info(f"Duplicate event tespit edildi: {event_id}, atlanıyor")
return jsonify({"status": "already_processed"}), 200
# Event'i işle
success = process_stripe_event(payload)
if success:
mark_event_processed(event_id, 'stripe', event_type, payload)
return jsonify({"status": "processed"}), 200
else:
return jsonify({"status": "error"}), 500
def process_stripe_event(payload):
event_type = payload.get('type')
if event_type == 'payment_intent.succeeded':
# Sipariş oluşturma mantığı buraya
pass
return True
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
EOF
Buradaki kritik nokta ON CONFLICT (event_id) DO NOTHING kısmıdır. Race condition durumunda, iki istek neredeyse eş zamanlı gelirse, veritabanının UNIQUE constraint’i sayesinde yalnızca biri kayıt oluşturabilir.
Redis ile Yüksek Performanslı Idempotency
Veritabanı yaklaşımı güvenilir olsa da yüksek trafikli sistemlerde gecikmeye neden olabilir. Redis, bu durumda çok daha hızlı bir alternatif sunar.
# Redis'e idempotency key kaydetme - bash örneği
REDIS_CLI="redis-cli -h localhost -p 6379"
# Event'in daha önce işlenip işlenmediğini kontrol et
check_event() {
local event_id=$1
local provider=$2
local key="webhook:processed:${provider}:${event_id}"
result=$($REDIS_CLI GET "$key")
if [ -n "$result" ]; then
echo "DUPLICATE"
return 1
else
echo "NEW"
return 0
fi
}
# Event'i işlendi olarak işaretle (24 saat TTL ile)
mark_event() {
local event_id=$1
local provider=$2
local key="webhook:processed:${provider}:${event_id}"
local ttl=86400 # 24 saat
# NX flag'i: sadece key yoksa set et (atomik işlem)
result=$($REDIS_CLI SET "$key" "1" NX EX "$ttl")
if [ "$result" = "OK" ]; then
echo "MARKED"
return 0
else
echo "ALREADY_EXISTS"
return 1
fi
}
# Kullanım
EVENT_ID="evt_1J2Y3K4L5M6N7O8P"
PROVIDER="stripe"
if check_event "$EVENT_ID" "$PROVIDER" | grep -q "DUPLICATE"; then
echo "Bu event zaten işlendi, atlıyorum"
exit 0
fi
# İşlemi yap
echo "Event işleniyor..."
# ... işlem mantığı ...
# Başarılıysa işaretle
mark_event "$EVENT_ID" "$PROVIDER"
Redis’teki SET key value NX EX ttl komutu atomik çalışır. NX (Not eXists) flag’i sayesinde race condition olmadan idempotency sağlanır. TTL eklemek de önemlidir; sonsuza kadar veri biriktirmemek için 24-48 saatlik bir süre genellikle yeterlidir.
Nginx ile Webhook Signature Doğrulama
Idempotency’den önce gelen bir katman da signature doğrulamadır. Sahte webhook isteklerini işlemeden önce filtrelemek, hem güvenlik hem de gereksiz işlem yükünü azaltma açısından kritiktir.
# Nginx'te webhook için temel yapılandırma
cat > /etc/nginx/sites-available/webhooks << 'EOF'
server {
listen 443 ssl;
server_name webhooks.sirketiniz.com;
ssl_certificate /etc/letsencrypt/live/webhooks.sirketiniz.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/webhooks.sirketiniz.com/privkey.pem;
# Webhook boyut limiti - büyük payload'ları reddet
client_max_body_size 1m;
location /webhooks/stripe {
# Rate limiting - aynı IP'den fazla istek gelirse yavaşlat
limit_req zone=webhook_limit burst=20 nodelay;
proxy_pass http://localhost:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Timeout ayarları - webhook provider'ına göre ayarlayın
proxy_connect_timeout 10s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
}
# Rate limiting zone tanımı - http bloğuna ekleyin
# limit_req_zone $binary_remote_addr zone=webhook_limit:10m rate=30r/m;
EOF
nginx -t && systemctl reload nginx
Stripe signature doğrulama için shell script:
#!/bin/bash
# stripe_signature_check.sh
# Stripe webhook signature'ını doğrula
WEBHOOK_SECRET="whsec_sizin_secret_anahtariniz"
PAYLOAD=$1
STRIPE_SIGNATURE=$2
# Signature header'ı parse et
TIMESTAMP=$(echo "$STRIPE_SIGNATURE" | grep -oP 't=K[^,]+')
V1_SIG=$(echo "$STRIPE_SIGNATURE" | grep -oP 'v1=K[^,]+')
# Beklenen imzayı oluştur
SIGNED_PAYLOAD="${TIMESTAMP}.${PAYLOAD}"
EXPECTED_SIG=$(echo -n "$SIGNED_PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')
# İmzaları karşılaştır
if [ "$V1_SIG" = "$EXPECTED_SIG" ]; then
echo "VALID"
exit 0
else
echo "INVALID"
exit 1
fi
Queue Tabanlı Yaklaşım: Daha Güçlü Bir Mimari
Büyük ölçekli sistemlerde webhook’ları doğrudan işlemek yerine bir queue’ya atmak ve oradan tüketmek çok daha sağlıklıdır. Bu yaklaşım hem idempotency’yi kolaylaştırır hem de sistemin dayanıklılığını artırır.
# RabbitMQ ile webhook queue yapılandırması
# rabbitmq.conf içine veya management UI üzerinden
cat > /tmp/setup_webhook_queue.sh << 'EOF'
#!/bin/bash
RABBITMQ_HOST="localhost"
RABBITMQ_USER="webhook_user"
RABBITMQ_PASS="güçlü_şifre"
VHOST="webhooks"
# Vhost oluştur
rabbitmqctl add_vhost "$VHOST"
rabbitmqctl set_permissions -p "$VHOST" "$RABBITMQ_USER" ".*" ".*" ".*"
# Exchange ve queue oluştur (management API üzerinden)
BASE_URL="http://localhost:15672/api"
AUTH="-u ${RABBITMQ_USER}:${RABBITMQ_PASS}"
# Dead letter exchange oluştur
curl $AUTH -X PUT "$BASE_URL/exchanges/$VHOST/webhook.dlx"
-H "Content-Type: application/json"
-d '{"type":"direct","durable":true}'
# Dead letter queue oluştur
curl $AUTH -X PUT "$BASE_URL/queues/$VHOST/webhook.dead"
-H "Content-Type: application/json"
-d '{"durable":true}'
# Ana webhook queue - dead letter ile
curl $AUTH -X PUT "$BASE_URL/queues/$VHOST/webhook.stripe"
-H "Content-Type: application/json"
-d '{
"durable": true,
"arguments": {
"x-dead-letter-exchange": "webhook.dlx",
"x-message-ttl": 86400000,
"x-max-length": 10000
}
}'
echo "Queue yapılandırması tamamlandı"
EOF
chmod +x /tmp/setup_webhook_queue.sh
/tmp/setup_webhook_queue.sh
Monitoring ve Alert: Duplicate Tespiti
İdempotency sisteminin çalışıp çalışmadığını izlemek için basit bir monitoring scripti:
#!/bin/bash
# webhook_duplicate_monitor.sh
# Duplicate webhook oranını izle ve alert gönder
DB_HOST="localhost"
DB_NAME="myapp"
DB_USER="monitor_user"
ALERT_EMAIL="[email protected]"
THRESHOLD=5 # Yüzde olarak kabul edilebilir duplicate oranı
# Son 1 saatteki webhook istatistikleri
STATS=$(psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -t -c "
SELECT
provider,
COUNT(*) as total,
COUNT(CASE WHEN status = 'duplicate' THEN 1 END) as duplicates,
ROUND(
COUNT(CASE WHEN status = 'duplicate' THEN 1 END) * 100.0 / COUNT(*),
2
) as duplicate_rate
FROM webhook_events
WHERE processed_at > NOW() - INTERVAL '1 hour'
GROUP BY provider;
")
echo "Son 1 saatlik webhook istatistikleri:"
echo "$STATS"
# Threshold kontrolü
while IFS='|' read -r provider total duplicates rate; do
provider=$(echo "$provider" | xargs)
rate=$(echo "$rate" | xargs)
if (( $(echo "$rate > $THRESHOLD" | bc -l) )); then
echo "UYARI: ${provider} için duplicate oranı %${rate} - threshold %${THRESHOLD}"
# Mail alert gönder
echo "Webhook duplicate oranı kritik seviyede!
Provider: ${provider}
Toplam: ${total}
Duplicate: ${duplicates}
Oran: %${rate}
Zaman: $(date)" | mail -s "WEBHOOK ALERT: Yüksek Duplicate Oranı - ${provider}" "$ALERT_EMAIL"
fi
done <<< "$STATS"
Bu scripti crontab’a ekleyin:
# Her 15 dakikada bir çalıştır
*/15 * * * * /opt/scripts/webhook_duplicate_monitor.sh >> /var/log/webhook_monitor.log 2>&1
Gerçek Dünya Senaryosu: E-Ticaret Ödeme Akışı
Tüm bu kavramları bir araya getiren gerçek bir senaryoya bakalım. Bir e-ticaret platformundasınız ve Iyzico üzerinden ödeme alıyorsunuz. Ödeme başarıyla tamamlandığında sipariş oluşturulmalı, stok düşürülmeli ve müşteriye mail gönderilmelidir.
Bu işlemlerin tekrar etmemesi için izlenecek adımlar:
- Adım 1: Webhook geldiğinde önce signature doğrula
- Adım 2: Event ID’yi Redis’te kontrol et (hızlı ön kontrol)
- Adım 3: Veritabanında sipariş numarasını kontrol et (ikinci güvenlik katmanı)
- Adım 4: Tüm işlemleri tek bir veritabanı transaction’ı içinde gerçekleştir
- Adım 5: Başarılı tamamlanırsa event ID’yi hem Redis’te hem veritabanında işaretle
# transaction_based_webhook.sh
# Idempotent işlem için transaction örneği (psql ile)
process_payment_webhook() {
local event_id=$1
local order_id=$2
local amount=$3
local user_id=$4
# Redis kontrolü (hızlı)
if redis-cli SET "webhook:payment:${event_id}" "1" NX EX 86400 | grep -q "OK"; then
echo "Event yeni, işleniyor: $event_id"
else
echo "Duplicate event, atlıyorum: $event_id"
return 0
fi
# Veritabanı transaction'ı
psql -h localhost -U app_user -d myapp << EOSQL
BEGIN;
-- Siparişin zaten var olup olmadığını kontrol et
DO $$
DECLARE
existing_order INTEGER;
BEGIN
SELECT id INTO existing_order
FROM orders
WHERE payment_event_id = '${event_id}'
FOR UPDATE SKIP LOCKED;
IF existing_order IS NOT NULL THEN
RAISE EXCEPTION 'ORDER_EXISTS';
END IF;
-- Sipariş oluştur
INSERT INTO orders (user_id, amount, payment_event_id, status)
VALUES (${user_id}, ${amount}, '${event_id}', 'confirmed');
-- Stok güncelle
UPDATE products SET stock = stock - 1
WHERE id IN (SELECT product_id FROM cart_items WHERE user_id = ${user_id});
-- Webhook event logla
INSERT INTO webhook_events (event_id, provider, event_type, status)
VALUES ('${event_id}', 'iyzico', 'payment.success', 'processed')
ON CONFLICT (event_id) DO NOTHING;
END;
$$;
COMMIT;
EOSQL
local exit_code=$?
if [ $exit_code -eq 0 ]; then
echo "Ödeme başarıyla işlendi: $event_id"
# Mail gönderme kuyruğuna ekle
redis-cli LPUSH "mail:queue" "{"type":"order_confirmed","user_id":${user_id}}"
else
# Redis'teki kilidi kaldır - yeniden deneme için
redis-cli DEL "webhook:payment:${event_id}"
echo "İşlem başarısız, retry için kilit kaldırıldı: $event_id"
return 1
fi
}
Temizlik ve Bakım
Webhook event tablosu zamanla büyür. Düzenli temizlik işlemi hem performansı korur hem de depolama maliyetini düşürür.
#!/bin/bash
# webhook_cleanup.sh
# 30 günden eski işlenmiş webhook event'lerini temizle
RETENTION_DAYS=30
DB_HOST="localhost"
DB_NAME="myapp"
DB_USER="app_user"
echo "$(date): Webhook temizliği başlıyor..."
DELETED=$(psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -t -c "
WITH deleted AS (
DELETE FROM webhook_events
WHERE processed_at < NOW() - INTERVAL '${RETENTION_DAYS} days'
AND status = 'processed'
RETURNING id
)
SELECT COUNT(*) FROM deleted;
")
DELETED=$(echo "$DELETED" | xargs)
echo "$(date): ${DELETED} eski webhook event silindi"
# Redis'teki TTL'si dolmuş keyleri zaten otomatik silinir
# Ama manuel kontrol için:
REDIS_WEBHOOK_KEYS=$(redis-cli KEYS "webhook:processed:*" | wc -l)
echo "$(date): Redis'te ${REDIS_WEBHOOK_KEYS} aktif webhook key mevcut"
Bunu crontab’a ekleyin:
# Her gece 03:00'da çalıştır
0 3 * * * /opt/scripts/webhook_cleanup.sh >> /var/log/webhook_cleanup.log 2>&1
Sonuç
Idempotent webhook işleme, “çalışıyor gibi görünen” sistemlerin gerçek anlamda güvenilir hale gelmesi için zorunlu bir yaklaşımdır. Özellikle finans, e-ticaret ve kritik bildirim sistemlerinde tekrar eden istekler ciddi sonuçlar doğurabilir.
Özet olarak dikkat edilmesi gereken noktalar:
- Signature doğrulamayı her zaman ilk adım olarak yapın
- İki katmanlı kontrol kullanın: Redis (hızlı) + veritabanı (güvenilir)
- Veritabanı transaction’larını ve UNIQUE constraint’leri idempotency garantisi için kullanın
- TTL’li Redis key’leri ile hız ve bellek verimliliğini dengeleyin
- Dead letter queue ile başarısız event’leri kaybetmeden yönetin
- Monitoring kurarak duplicate oranınızı takip edin
- Düzenli temizlik ile veritabanı şişmesini önleyin
En önemli kural şudur: webhook endpoint’iniz her zaman tekrar aranabileceğini varsayarak tasarlanmalıdır. “Bu zaten gelmez” diye düşündüğünüz senaryolar, tam üretim baskısı altında mutlaka gerçekleşir.
