Webhook Kurulumu: Alıcı Endpoint Yazımı

Dışarıdan gelen bir HTTP isteğini karşılamak, doğrulamak ve işlemek kulağa basit geliyor. Ama gerçek dünyada webhook alıcı endpoint’leri, sistemlerin en kırılgan noktalarından biri haline gelebiliyor. Yanlış yazılmış bir endpoint; duplicate işlemler, güvenlik açıkları ve sessiz veri kayıplarına davetiye çıkarıyor. Bu yazıda sıfırdan sağlam bir webhook alıcı endpoint’i nasıl yazılır, bunu adım adım ele alacağız.

Webhook Alıcı Nedir ve Ne Yapması Gerekir

Webhook alıcı, dışarıdan gelen HTTP POST isteklerini karşılayan bir endpoint’tir. GitHub’dan push eventi, Stripe’tan ödeme bildirimi ya da Slack’ten bir kullanıcı aksiyonu; bunların hepsi webhook üzerinden gelir. Alıcının görevi bu isteği almak, doğrulamak, işlemek ve hızlıca yanıt döndürmektir.

Bir webhook alıcısının yerine getirmesi gereken temel sorumluluklar şunlardır:

  • İstek doğrulama: Gerçekten beklenen kaynaktan mı geliyor?
  • Hızlı yanıt: Gönderici timeout’a düşmeden 200 OK dönmeli
  • Idempotent işlem: Aynı event iki kez gelirse iki kez işlenmemeli
  • Hata yönetimi: Hata olursa sessizce yutmamalı, loglamalı
  • Güvenli loglama: Hassas veriyi loga yazmamalı

Ortam Hazırlığı

Örnekleri Python/Flask, Node.js ve basit bir Bash proxy üzerinden göstereceğiz. Üretim ortamında bu endpoint’ler genellikle Nginx veya Caddy arkasına alınır.

Önce Python ortamını hazırlayalım:

python3 -m venv webhook-env
source webhook-env/bin/activate
pip install flask gunicorn cryptography requests

# Node.js tarafı için
npm init -y
npm install express body-parser crypto dotenv

Temel dizin yapısını oluşturalım:

mkdir -p webhook-receiver/{logs,handlers,middleware}
cd webhook-receiver
touch app.py .env handlers/__init__.py middleware/__init__.py
chmod 750 logs/

.env dosyası içeriği:

WEBHOOK_SECRET=supersecretkey123
PORT=5000
LOG_LEVEL=INFO
MAX_PAYLOAD_SIZE=1048576

İmza Doğrulama: En Kritik Adım

Webhook’ların büyük çoğunluğu HMAC-SHA256 imzası kullanır. GitHub, Stripe, Shopify hepsi bu yöntemi benimsiyor. Temel mantık şu: gönderici, payload’ı gizli bir key ile imzalar ve bunu header’a koyar. Alıcı aynı hesaplamayı yaparak karşılaştırır.

import hmac
import hashlib
import time
from functools import wraps
from flask import request, abort

def verify_github_signature(payload_body, secret_token, signature_header):
    """GitHub webhook imzasını dogrular."""
    if not signature_header:
        return False
    
    hash_object = hmac.new(
        secret_token.encode('utf-8'),
        msg=payload_body,
        digestmod=hashlib.sha256
    )
    expected_signature = "sha256=" + hash_object.hexdigest()
    
    # Timing attack'a karsi sabit sureli karsilastirma
    return hmac.compare_digest(expected_signature, signature_header)


def require_signature(secret_env_var='WEBHOOK_SECRET'):
    """Decorator: imza dogrulama middleware'i."""
    def decorator(f):
        @wraps(f)
        def decorated_function(*args, **kwargs):
            import os
            secret = os.environ.get(secret_env_var)
            if not secret:
                abort(500, "Webhook secret yapilandirilmamis")
            
            signature = request.headers.get('X-Hub-Signature-256')
            raw_data = request.get_data()
            
            if not verify_github_signature(raw_data, secret, signature):
                abort(401, "Gecersiz imza")
            
            return f(*args, **kwargs)
        return decorated_function
    return decorator

Dikkat edilmesi gereken nokta: İmza karşılaştırmasında == operatörü yerine hmac.compare_digest() kullanmak zorunludur. Normal string karşılaştırması timing attack’a açık kapı bırakır.

Ana Flask Uygulaması

import os
import json
import logging
from datetime import datetime
from flask import Flask, request, jsonify
from dotenv import load_dotenv

load_dotenv()

app = Flask(__name__)

# Loglama ayarlari
logging.basicConfig(
    level=getattr(logging, os.environ.get('LOG_LEVEL', 'INFO')),
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler('logs/webhook.log'),
        logging.StreamHandler()
    ]
)
logger = logging.getLogger(__name__)

# Payload boyut limiti (1MB)
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_PAYLOAD_SIZE', 1048576))


@app.before_request
def check_content_length():
    """Asiri buyuk payload'lari reddet."""
    if request.content_length and request.content_length > MAX_CONTENT_LENGTH:
        logger.warning(f"Cok buyuk payload reddedildi: {request.content_length} bytes, IP: {request.remote_addr}")
        return jsonify({"error": "Payload cok buyuk"}), 413


@app.route('/webhook/github', methods=['POST'])
@require_signature()
def github_webhook():
    """GitHub webhook alicisi."""
    event_type = request.headers.get('X-GitHub-Event', 'unknown')
    delivery_id = request.headers.get('X-GitHub-Delivery', 'no-id')
    
    logger.info(f"GitHub event alindi: {event_type}, delivery_id: {delivery_id}")
    
    try:
        payload = request.get_json(force=True)
        if payload is None:
            logger.error(f"Gecersiz JSON payload, delivery_id: {delivery_id}")
            return jsonify({"error": "Gecersiz JSON"}), 400
        
        # Event'i handler'a gonder, 200 hemen don
        handle_github_event(event_type, payload, delivery_id)
        
        return jsonify({
            "status": "accepted",
            "delivery_id": delivery_id,
            "event": event_type
        }), 200
        
    except Exception as e:
        logger.error(f"Webhook isleme hatasi: {str(e)}, delivery_id: {delivery_id}")
        # Gondericiye 200 don, hatayi ic sistemde isle
        return jsonify({"status": "accepted"}), 200


def handle_github_event(event_type, payload, delivery_id):
    """Event tipine gore handler'a yonlendir."""
    handlers = {
        'push': handle_push_event,
        'pull_request': handle_pr_event,
        'release': handle_release_event,
    }
    
    handler = handlers.get(event_type)
    if handler:
        handler(payload, delivery_id)
    else:
        logger.debug(f"Bilinmeyen event tipi: {event_type}")


if __name__ == '__main__':
    port = int(os.environ.get('PORT', 5000))
    app.run(host='0.0.0.0', port=port, debug=False)

Idempotency: Aynı Event İki Kez Gelirse Ne Olur?

Webhook göndericiler başarısız teslimatları yeniden dener. Stripe bazı durumlarda aynı event’i 3-4 kez gönderebilir. Sisteminiz bunu kaldırabilmeli.

import redis
from functools import wraps

redis_client = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)

def idempotent_webhook(ttl_seconds=86400):
    """
    Ayni delivery_id ile gelen event'leri tekrar isleme.
    TTL: 24 saat (gonderici genellikle bu sure icinde retry yapar)
    """
    def decorator(f):
        @wraps(f)
        def wrapper(*args, **kwargs):
            delivery_id = kwargs.get('delivery_id') or (args[2] if len(args) > 2 else None)
            
            if not delivery_id:
                return f(*args, **kwargs)
            
            redis_key = f"webhook:processed:{delivery_id}"
            
            # Daha once islendi mi?
            if redis_client.exists(redis_key):
                logger.info(f"Duplicate event atlandi: {delivery_id}")
                return {"status": "duplicate", "skipped": True}
            
            # Islemeyi kaydet (race condition icin SET NX kullan)
            acquired = redis_client.set(redis_key, "processing", nx=True, ex=ttl_seconds)
            
            if not acquired:
                logger.warning(f"Event zaten isleniyor: {delivery_id}")
                return {"status": "in_progress"}
            
            try:
                result = f(*args, **kwargs)
                redis_client.set(redis_key, "completed", ex=ttl_seconds)
                return result
            except Exception as e:
                # Hata olursa Redis key'ini temizle, yeniden denenebilsin
                redis_client.delete(redis_key)
                raise
                
        return wrapper
    return decorator


@idempotent_webhook(ttl_seconds=86400)
def handle_push_event(payload, delivery_id):
    """GitHub push event handler."""
    repo = payload.get('repository', {}).get('full_name', 'unknown')
    branch = payload.get('ref', '').replace('refs/heads/', '')
    commits = payload.get('commits', [])
    
    logger.info(f"Push alindi: {repo}, branch: {branch}, commit sayisi: {len(commits)}")
    
    # Sadece main/master branch'ini isle
    if branch not in ('main', 'master'):
        logger.debug(f"Degisken branch atlandi: {branch}")
        return
    
    # CI/CD tetikle, Slack bildir vs.
    trigger_deployment(repo, branch, commits)

Asenkron İşlem Kuyruğu

Webhook handler’ı asla uzun süren işlemler yapmamalı. Veritabanı sorguları, dış API çağrıları, dosya işlemleri hepsi kuyruğa alınmalı.

import threading
import queue
from concurrent.futures import ThreadPoolExecutor

# Basit in-memory kuyruk (production'da Celery/RQ kullanin)
event_queue = queue.Queue(maxsize=1000)
executor = ThreadPoolExecutor(max_workers=4)

def enqueue_event(event_type, payload, delivery_id):
    """Event'i asenkron isleme kuyruğuna ekle."""
    try:
        event_queue.put_nowait({
            'type': event_type,
            'payload': payload,
            'delivery_id': delivery_id,
            'received_at': datetime.utcnow().isoformat()
        })
        logger.info(f"Event kuyruga eklendi: {delivery_id}")
    except queue.Full:
        logger.error(f"Kuyruk dolu! Event atlandi: {delivery_id}")
        # Alert gonder
        send_alert(f"Webhook kuyruğu dolu, event kaybi: {delivery_id}")


def worker():
    """Kuyruktan event'leri cekerek isle."""
    while True:
        try:
            event = event_queue.get(timeout=5)
            logger.info(f"Event isleniyor: {event['delivery_id']}")
            
            handlers = {
                'push': handle_push_event,
                'pull_request': handle_pr_event,
            }
            
            handler = handlers.get(event['type'])
            if handler:
                executor.submit(handler, event['payload'], event['delivery_id'])
            
            event_queue.task_done()
        except queue.Empty:
            continue
        except Exception as e:
            logger.error(f"Worker hatasi: {str(e)}")


# Worker thread'i baslat
worker_thread = threading.Thread(target=worker, daemon=True)
worker_thread.start()

Stripe Webhook Alıcısı: Gerçek Dünya Örneği

Stripe’ın webhook sistemi biraz farklı çalışıyor. Timestamp kontrolü de yapılması gerekiyor, replay attack’a karşı.

import stripe
from flask import Blueprint

stripe_bp = Blueprint('stripe', __name__)
stripe.api_key = os.environ.get('STRIPE_SECRET_KEY')
STRIPE_WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET')

@stripe_bp.route('/webhook/stripe', methods=['POST'])
def stripe_webhook():
    payload = request.data
    sig_header = request.headers.get('Stripe-Signature')
    
    try:
        # Stripe SDK imzayi ve timestamp'i kendisi dogrular
        # tolerance=300 demek: 5 dakikadan eski event'leri reddet
        event = stripe.Webhook.construct_event(
            payload, sig_header, STRIPE_WEBHOOK_SECRET,
            tolerance=300
        )
    except ValueError:
        logger.error("Stripe: Gecersiz payload")
        return jsonify({"error": "Gecersiz payload"}), 400
    except stripe.error.SignatureVerificationError as e:
        logger.error(f"Stripe: Imza dogrulama hatasi: {str(e)}")
        return jsonify({"error": "Gecersiz imza"}), 401
    
    event_type = event['type']
    event_id = event['id']
    
    logger.info(f"Stripe event: {event_type}, id: {event_id}")
    
    # Event tipine gore isle
    if event_type == 'payment_intent.succeeded':
        payment_intent = event['data']['object']
        amount = payment_intent['amount'] / 100  # Kurustan TL'ye
        currency = payment_intent['currency'].upper()
        customer_id = payment_intent.get('customer', 'misafir')
        
        logger.info(f"Odeme basarili: {amount} {currency}, musteri: {customer_id}")
        enqueue_event('payment_succeeded', payment_intent, event_id)
        
    elif event_type == 'customer.subscription.deleted':
        subscription = event['data']['object']
        logger.info(f"Abonelik iptal edildi: {subscription['id']}")
        enqueue_event('subscription_cancelled', subscription, event_id)
    
    # Stripe icin her zaman 200 don, hata logla
    return jsonify({"received": True}), 200

Nginx Konfigürasyonu ve Rate Limiting

Endpoint’i doğrudan internete açmak yerine Nginx arkasına almak şart.

# /etc/nginx/sites-available/webhook

upstream webhook_app {
    server 127.0.0.1:5000;
    keepalive 32;
}

# Webhook gondericileri icin rate limit zone
limit_req_zone $binary_remote_addr zone=webhook_limit:10m rate=30r/m;

server {
    listen 443 ssl http2;
    server_name webhooks.example.com;

    ssl_certificate /etc/letsencrypt/live/webhooks.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/webhooks.example.com/privkey.pem;

    # Sadece POST kabul et
    location /webhook/ {
        limit_except POST {
            deny all;
        }

        # Rate limiting (dakikada 30 istek)
        limit_req zone=webhook_limit burst=10 nodelay;
        limit_req_status 429;

        # Maksimum body boyutu
        client_max_body_size 1m;

        # Timeout ayarlari
        proxy_connect_timeout 5s;
        proxy_read_timeout 30s;
        proxy_send_timeout 30s;

        proxy_pass http://webhook_app;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Orijinal body'yi koru (imza dogrulama icin kritik)
        proxy_request_buffering on;
    }

    # IP whitelist ornegi (GitHub IP araligini ekleyebilirsiniz)
    # allow 192.30.252.0/22;
    # deny all;

    access_log /var/log/nginx/webhook_access.log combined;
    error_log /var/log/nginx/webhook_error.log warn;
}

Nginx’i yeniden yükleyelim ve test edelim:

nginx -t && systemctl reload nginx

# Test istegi gonder
curl -X POST https://webhooks.example.com/webhook/github 
  -H "Content-Type: application/json" 
  -H "X-GitHub-Event: push" 
  -H "X-Hub-Signature-256: sha256=test" 
  -d '{"ref": "refs/heads/main", "repository": {"full_name": "user/repo"}}'

# Log takibi
tail -f /var/log/nginx/webhook_access.log
tail -f /var/app/webhook-receiver/logs/webhook.log

Webhook Test Aracı

Geliştirme sırasında gerçek webhook’ları beklemek yerine kendi test scriptinizi yazın:

#!/bin/bash
# webhook-test.sh - Webhook endpoint test araci

ENDPOINT="${1:-http://localhost:5000/webhook/github}"
SECRET="${WEBHOOK_SECRET:-supersecretkey123}"
EVENT_TYPE="${2:-push}"

# Test payload
PAYLOAD='{
  "ref": "refs/heads/main",
  "repository": {
    "full_name": "testuser/testrepo",
    "name": "testrepo"
  },
  "commits": [
    {
      "id": "abc123",
      "message": "Test commit",
      "author": {"name": "Test User"}
    }
  ],
  "pusher": {"name": "testuser"}
}'

# HMAC-SHA256 imza hesapla
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print "sha256="$2}')

echo "Endpoint: $ENDPOINT"
echo "Event: $EVENT_TYPE"
echo "Signature: $SIGNATURE"
echo "---"

# Istegi gonder
RESPONSE=$(curl -s -w "nHTTP_STATUS:%{http_code}" 
  -X POST "$ENDPOINT" 
  -H "Content-Type: application/json" 
  -H "X-GitHub-Event: $EVENT_TYPE" 
  -H "X-GitHub-Delivery: test-$(date +%s)" 
  -H "X-Hub-Signature-256: $SIGNATURE" 
  -d "$PAYLOAD")

HTTP_STATUS=$(echo "$RESPONSE" | grep "HTTP_STATUS:" | cut -d: -f2)
BODY=$(echo "$RESPONSE" | grep -v "HTTP_STATUS:")

echo "HTTP Status: $HTTP_STATUS"
echo "Response: $BODY"

if [ "$HTTP_STATUS" = "200" ]; then
  echo "BASARILI: Webhook kabul edildi"
else
  echo "HATA: Beklenen 200, alindi $HTTP_STATUS"
  exit 1
fi

Monitoring ve Alerting

Webhook endpoint’ini izlemek için basit bir health check ve metrik toplama ekleyin:

from flask import Blueprint
import time
from collections import defaultdict, deque

metrics_bp = Blueprint('metrics', __name__)

# Basit metrik toplama
webhook_metrics = {
    'total_received': 0,
    'total_processed': 0,
    'total_errors': 0,
    'total_duplicates': 0,
    'last_received': None,
    'recent_events': deque(maxlen=100)
}

def record_metric(metric_name, event_type=None):
    """Metrik kaydet."""
    webhook_metrics[metric_name] = webhook_metrics.get(metric_name, 0) + 1
    webhook_metrics['last_received'] = datetime.utcnow().isoformat()
    
    if event_type:
        webhook_metrics['recent_events'].appendleft({
            'type': event_type,
            'time': datetime.utcnow().isoformat()
        })


@metrics_bp.route('/health', methods=['GET'])
def health_check():
    """Basit health check endpoint'i."""
    return jsonify({
        "status": "healthy",
        "timestamp": datetime.utcnow().isoformat(),
        "metrics": {
            "total_received": webhook_metrics['total_received'],
            "total_errors": webhook_metrics['total_errors'],
            "last_received": webhook_metrics['last_received'],
            "queue_size": event_queue.qsize() if 'event_queue' in globals() else 0
        }
    }), 200


@metrics_bp.route('/metrics', methods=['GET'])
def get_metrics():
    """Prometheus formatinda metrikler (sadece ic agdan erisim)."""
    # IP kontrolu
    if request.remote_addr not in ('127.0.0.1', '::1'):
        abort(403)
    
    metrics_text = f"""# HELP webhook_total_received Toplam alinan webhook sayisi
# TYPE webhook_total_received counter
webhook_total_received {webhook_metrics['total_received']}

# HELP webhook_total_errors Toplam hata sayisi
# TYPE webhook_total_errors counter
webhook_total_errors {webhook_metrics['total_errors']}

# HELP webhook_queue_size Mevcut kuyruk boyutu
# TYPE webhook_queue_size gauge
webhook_queue_size {event_queue.qsize()}
"""
    return metrics_text, 200, {'Content-Type': 'text/plain'}

Yaygın Hatalar ve Çözümleri

Production’da en sık karşılaşılan sorunlar ve nasıl önleneceği:

  • Raw body kaybolması: Flask’ta request.get_data() çağrıldıktan sonra request.get_json() çağrılırsa body boş gelir. Raw body’yi bir kez okuyup saklamalısınız.
  • Timeout hatası: Gönderici 10-30 saniye içinde yanıt almazsa başarısız sayar. Handler içinde hiçbir zaman senkron DB işlemi veya dış API çağrısı yapmayın.
  • Büyük payload’lar: Bazı sistemler megabaytlarca veri gönderebilir. MAX_CONTENT_LENGTH limiti hem Nginx hem uygulama katmanında set edilmeli.
  • SSL sertifika hatası: Self-signed sertifika kullanırsanız gönderici TLS doğrulamasını geçemez. Let’s Encrypt ile geçerli sertifika zorunlu.
  • Log’a hassas veri yazmak: Payload içinde kart numarası, şifre gibi alanlar olabilir. Loğlarken bu alanları maskeleyin.

Gunicorn ile Production Deployment

# gunicorn.conf.py
workers = 4
worker_class = "sync"
worker_connections = 1000
bind = "127.0.0.1:5000"
timeout = 30
keepalive = 5
max_requests = 1000
max_requests_jitter = 100
accesslog = "logs/access.log"
errorlog = "logs/error.log"
loglevel = "warning"
preload_app = True

# Baslatma
gunicorn -c gunicorn.conf.py app:app

# Systemd service
cat > /etc/systemd/system/webhook-receiver.service << 'EOF'
[Unit]
Description=Webhook Receiver Service
After=network.target redis.service

[Service]
Type=notify
User=webhook
Group=webhook
WorkingDirectory=/opt/webhook-receiver
Environment="PATH=/opt/webhook-receiver/webhook-env/bin"
ExecStart=/opt/webhook-receiver/webhook-env/bin/gunicorn -c gunicorn.conf.py app:app
ExecReload=/bin/kill -s HUP $MAINPID
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

systemctl enable webhook-receiver
systemctl start webhook-receiver
systemctl status webhook-receiver

Sonuç

Sağlam bir webhook alıcı endpoint’i yazmak, birkaç satır kod meselesi değil. İmza doğrulama, idempotency, asenkron işlem, rate limiting ve doğru loglama; bunların hepsi bir arada düşünülmesi gereken parçalar.

Bu yazıda ele aldığımız konuları özetlersek:

  • HMAC doğrulama olmadan hiçbir webhook endpoint’i production’a çıkmamalı
  • Idempotency için Redis tabanlı delivery ID takibi şart
  • Asenkron kuyruk olmadan timeout sorunlarından kaçış yok
  • Nginx arkasına almak ve rate limit koymak en temel güvenlik katmanı
  • Metrik toplama olmadan ne zaman sorun çıktığını anlayamazsınız

Bir sonraki adım olarak Celery ve Redis ile tam teşekküllü bir asenkron kuyruk sistemi kurmayı, ardından Prometheus ve Grafana ile dashboard oluşturmayı öneririm. Webhook alıcınız bir kez doğru yazılırsa, sisteminizin en güvenilir parçalarından biri haline gelir.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir