Stripe Ödeme Bildirimleri: Webhook ile Gerçek Zamanlı Alma
Ödeme sistemleriyle uğraşan herkes şunu bilir: kullanıcı “Ödeme Yap” butonuna bastıktan sonra ne olduğunu takip etmek, bazen ödemenin kendisinden daha karmaşık bir hal alabilir. Stripe’ın webhook sistemi tam da bu noktada devreye giriyor. Gerçek zamanlı ödeme bildirimleri almak, abonelik durumlarını güncel tutmak ve başarısız ödemelere anında tepki vermek için webhook’lar vazgeçilmez bir araç. Bu yazıda, Stripe webhook’larını sıfırdan kuracak, güvenliğini sağlayacak ve production ortamında nasıl yöneteceğimizi konuşacağız.
Webhook Nedir, Neden Kullanmalısın?
Klasik polling mantığını düşün: her 30 saniyede bir Stripe API’ına “ödeme geldi mi?” diye soruyorsun. Bu hem gereksiz API çağrısı yaratır hem de anlık tepki veremezsin. Webhook ise tam tersine çalışır: Stripe, bir olay gerçekleştiğinde senin belirlediğin URL’ye HTTP POST isteği gönderir.
Stripe’ın webhook sistemiyle şu olayları yakalayabilirsin:
- payment_intent.succeeded: Ödeme başarıyla tamamlandı
- payment_intent.payment_failed: Ödeme başarısız oldu
- customer.subscription.created: Yeni abonelik oluşturuldu
- customer.subscription.deleted: Abonelik iptal edildi
- invoice.payment_succeeded: Fatura ödemesi alındı
- charge.refunded: İade işlemi gerçekleşti
- charge.dispute.created: Ödeme itirazı açıldı
Bunlar en sık kullanılanlar. Stripe’ın toplam 250’den fazla event tipi var, dolayısıyla ne kadar granüler bir kontrol istersen o kadar derine inebilirsin.
Stripe Dashboard’da Webhook Oluşturma
İlk adım her zaman Stripe Dashboard’dan geçer. Buradan endpoint URL’ini tanımlayacak ve hangi olayları dinlemek istediğini seçeceksin.
Adımlar şöyle:
- Stripe Dashboard’a giriş yap
- Sol menüden Developers > Webhooks bölümüne git
- Add endpoint butonuna tıkla
- Endpoint URL’ini gir (örneğin:
https://api.siteniz.com/webhooks/stripe) - Dinlemek istediğin olayları seç
- Add endpoint ile kaydet
Endpoint oluşturduktan sonra sana bir Signing Secret verilecek. Bu anahtarı güvenli bir yerde sakla çünkü gelen isteklerin gerçekten Stripe’dan geldiğini doğrulamak için kullanacaksın. Genellikle whsec_ ile başlar.
Geliştirme Ortamı: Stripe CLI ile Local Test
Production’a geçmeden önce yerel ortamda test etmen şart. Bunun için Stripe CLI kullanacağız.
# Stripe CLI kurulumu (Linux)
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-stretch stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list
sudo apt update && sudo apt install stripe
# Stripe hesabına giriş
stripe login
# Yerel webhook dinleyiciyi başlat
stripe listen --forward-to localhost:3000/webhooks/stripe
Bu komut çalıştıktan sonra terminal sana geçici bir webhook signing secret verir. Bu secret’ı .env dosyana kaydet:
# .env dosyası
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxx
Farklı bir terminalde ise test eventleri tetikleyebilirsin:
# Başarılı ödeme eventi tetikle
stripe trigger payment_intent.succeeded
# Abonelik oluşturma eventi
stripe trigger customer.subscription.created
# Belirli bir event'i detaylı görmek için
stripe events list --limit 5
Node.js ile Webhook Handler Yazma
En yaygın kullanım senaryosu olduğu için Node.js/Express ile başlayalım. Temel bir webhook handler şöyle görünür:
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const app = express();
// ÖNEMLİ: Webhook route'u için raw body gerekiyor
// express.json() kullanma, bu route için özel middleware lazım
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'];
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
} catch (err) {
console.error(`Webhook imza doğrulama hatası: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Event tipine göre işlem yap
switch (event.type) {
case 'payment_intent.succeeded':
const paymentIntent = event.data.object;
await handleSuccessfulPayment(paymentIntent);
break;
case 'payment_intent.payment_failed':
const failedPayment = event.data.object;
await handleFailedPayment(failedPayment);
break;
case 'customer.subscription.deleted':
const subscription = event.data.object;
await handleSubscriptionCancelled(subscription);
break;
default:
console.log(`İşlenmeyen event tipi: ${event.type}`);
}
// Stripe'a 200 döndür, aksi halde tekrar deneyecek
res.json({ received: true });
}
);
Burada kritik bir nokta var: express.raw() middleware’i. Stripe imza doğrulaması için ham (raw) request body’e ihtiyaç duyuyor. Eğer express.json() ile body’yi parse edersen, imza doğrulaması başarısız olur. Bu en çok yapılan hatalardan biri.
İmza Doğrulaması ve Güvenlik
Webhook endpoint’in internete açık bir URL olduğu için güvenlik kritik önem taşır. Stripe, her istekle birlikte stripe-signature header’ı gönderir. Bu header’ı doğrulamadan gelen isteklere güvenme.
async function verifyStripeWebhook(req) {
const sig = req.headers['stripe-signature'];
if (!sig) {
throw new Error('stripe-signature header bulunamadı');
}
// Header içeriğini parse et
// Format: t=timestamp,v1=hash,v0=hash
const sigParts = sig.split(',');
const timestamp = sigParts.find(p => p.startsWith('t=')).split('=')[1];
// Replay attack koruması: 5 dakikadan eski istekleri reddet
const currentTime = Math.floor(Date.now() / 1000);
const tolerance = 300; // 5 dakika
if (currentTime - parseInt(timestamp) > tolerance) {
throw new Error('Webhook isteği çok eski, replay attack olabilir');
}
try {
const event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
return event;
} catch (err) {
throw new Error(`İmza doğrulaması başarısız: ${err.message}`);
}
}
Stripe SDK’sı constructEvent metodunda zaten timestamp kontrolü yapıyor ama neyin neden çalıştığını anlamak için bu detayı bilmekte fayda var. Varsayılan tolerance 300 saniye (5 dakika) olarak ayarlı.
Python/Flask ile Webhook Handler
Python kullanıyorsan Flask ile şöyle yazabilirsin:
import stripe
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
stripe.api_key = os.environ.get('STRIPE_SECRET_KEY')
webhook_secret = os.environ.get('STRIPE_WEBHOOK_SECRET')
@app.route('/webhooks/stripe', methods=['POST'])
def stripe_webhook():
payload = request.get_data(as_text=False)
sig_header = request.headers.get('Stripe-Signature')
try:
event = stripe.Webhook.construct_event(
payload, sig_header, webhook_secret
)
except ValueError as e:
# Geçersiz payload
print(f'Geçersiz payload: {e}')
return jsonify(error=str(e)), 400
except stripe.error.SignatureVerificationError as e:
# Geçersiz imza
print(f'İmza doğrulama hatası: {e}')
return jsonify(error=str(e)), 400
# Event işleme
if event['type'] == 'payment_intent.succeeded':
payment_intent = event['data']['object']
handle_payment_success(payment_intent)
elif event['type'] == 'invoice.payment_failed':
invoice = event['data']['object']
handle_invoice_payment_failed(invoice)
elif event['type'] == 'customer.subscription.updated':
subscription = event['data']['object']
handle_subscription_update(subscription)
return jsonify(success=True), 200
def handle_payment_success(payment_intent):
customer_id = payment_intent.get('customer')
amount = payment_intent.get('amount')
metadata = payment_intent.get('metadata', {})
order_id = metadata.get('order_id')
print(f'Ödeme başarılı - Müşteri: {customer_id}, '
f'Tutar: {amount/100:.2f} TL, Sipariş: {order_id}')
# Veritabanını güncelle, email gönder, vs.
update_order_status(order_id, 'paid')
send_confirmation_email(customer_id, order_id)
def handle_invoice_payment_failed(invoice):
customer_id = invoice.get('customer')
subscription_id = invoice.get('subscription')
attempt_count = invoice.get('attempt_count')
print(f'Fatura ödemesi başarısız - Müşteri: {customer_id}, '
f'Deneme: {attempt_count}')
if attempt_count >= 3:
# 3 denemeden sonra aboneliği askıya al
suspend_subscription(subscription_id)
send_suspension_email(customer_id)
else:
send_retry_notification(customer_id, attempt_count)
Idempotency: Aynı Event’i İki Kez İşleme Problemi
Stripe, başarısız teslimat durumunda webhook’u birden fazla kez gönderebilir. Yani aynı ödeme eventi için handler’ın 2-3 kez çalışabilir. Bu durumda müşteriye iki kez email atmak, siparişi iki kez işlemek gibi sorunlar çıkar.
const processedEvents = new Set(); // Production'da Redis kullan
async function handleWebhookEvent(event) {
const eventId = event.id;
// Bu event daha önce işlendi mi?
const alreadyProcessed = await checkEventProcessed(eventId);
if (alreadyProcessed) {
console.log(`Event ${eventId} zaten işlendi, atlanıyor`);
return { skipped: true };
}
try {
// Event'i işle
await processEvent(event);
// İşlendi olarak işaretle (Redis ile)
await markEventAsProcessed(eventId);
return { success: true };
} catch (err) {
console.error(`Event işleme hatası: ${err.message}`);
throw err;
}
}
// Redis tabanlı idempotency kontrolü
async function checkEventProcessed(eventId) {
const result = await redisClient.get(`stripe:event:${eventId}`);
return result !== null;
}
async function markEventAsProcessed(eventId) {
// 24 saat sonra otomatik expire olsun
await redisClient.setex(`stripe:event:${eventId}`, 86400, '1');
}
Production’da mutlaka Redis veya benzeri bir persistence katmanı kullan. In-memory Set kullanmak uygulama restart’ta tüm veriyi kaybettirir.
Gerçek Dünya Senaryosu: SaaS Abonelik Yönetimi
Diyelim ki bir SaaS ürünün var ve Stripe ile abonelik yönetimi yapıyorsun. İşte pratik bir handler implementasyonu:
async function handleSubscriptionEvents(event) {
const subscription = event.data.object;
const customerId = subscription.customer;
// Stripe customer ID'den internal user ID'yi bul
const user = await db.users.findOne({
stripeCustomerId: customerId
});
if (!user) {
console.error(`Kullanıcı bulunamadı: ${customerId}`);
return;
}
switch (event.type) {
case 'customer.subscription.created':
await db.users.update(
{ id: user.id },
{
plan: subscription.items.data[0].price.lookup_key,
subscriptionStatus: 'active',
subscriptionId: subscription.id,
currentPeriodEnd: new Date(subscription.current_period_end * 1000)
}
);
await sendEmail({
to: user.email,
template: 'subscription_welcome',
data: { username: user.name }
});
break;
case 'customer.subscription.updated':
const previousAttributes = event.data.previous_attributes;
// Plan değişikliği mi?
if (previousAttributes.items) {
const newPlan = subscription.items.data[0].price.lookup_key;
const oldPlan = user.plan;
await db.users.update(
{ id: user.id },
{ plan: newPlan }
);
// Upgrade mı downgrade mı?
if (isPlanUpgrade(oldPlan, newPlan)) {
await sendEmail({
to: user.email,
template: 'plan_upgraded',
data: { newPlan }
});
} else {
await sendEmail({
to: user.email,
template: 'plan_downgraded',
data: { newPlan }
});
}
}
break;
case 'customer.subscription.deleted':
await db.users.update(
{ id: user.id },
{
subscriptionStatus: 'cancelled',
plan: 'free'
}
);
await sendEmail({
to: user.email,
template: 'subscription_cancelled',
data: { username: user.name }
});
break;
}
}
Webhook Logları ve Monitoring
Production’da webhook’larını izlemek hayat kurtarır. Stripe Dashboard’dan her webhook isteğinin detayını görebilirsin ama kendi tarafında da loglama yapman şart:
# Nginx log formatını webhook'lar için özelleştir
# /etc/nginx/nginx.conf içine ekle
log_format webhook_log '$time_local | $status | $request_time | '
'$http_x_stripe_signature | $request_body';
# Webhook endpoint için özel access log
server {
location /webhooks/stripe {
access_log /var/log/nginx/stripe_webhooks.log webhook_log;
# Body size limitini ayarla
client_max_body_size 1m;
proxy_pass http://app:3000;
proxy_set_header X-Real-IP $remote_addr;
}
}
# Webhook loglarını gerçek zamanlı izle
tail -f /var/log/nginx/stripe_webhooks.log | grep -v "200"
# Son 1 saatteki başarısız webhook'ları say
grep "$(date -d '1 hour ago' '+%d/%b/%Y:%H')" /var/log/nginx/stripe_webhooks.log | grep -v " 200 " | wc -l
# Stripe CLI ile event history'e bak
stripe events list --limit 20 --type payment_intent.payment_failed
Hata Durumları ve Retry Mekanizması
Stripe, başarısız webhook teslimatlarını otomatik olarak retry eder. Retry schedule şu şekilde işler:
- İlk başarısızlıktan sonra: Hemen tekrar dener
- Sonraki denemeler: Üstel artış ile 3 gün boyunca dener
- Maksimum deneme: 3 gün içinde yaklaşık 15-17 kez
Bu yüzden handler’ın 5XX yerine 2XX dönmesi önemli. Bazen internal bir hata olsa bile Stripe’a 200 dönüp hatayı kendi queue’nda işlemek daha mantıklı:
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
// İmza hatası - Stripe retry yapmasın, 400 dön
return res.status(400).json({ error: err.message });
}
// Hemen 200 dön, işlemi async yap
// Bu şekilde Stripe timeout'a düşmez
res.json({ received: true });
// Event'i background queue'ya at
try {
await eventQueue.add('stripe-webhook', {
eventId: event.id,
eventType: event.type,
data: event.data
}, {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000
}
});
} catch (err) {
// Queue hatası - alert gönder
await alertService.send({
severity: 'high',
message: `Stripe webhook queue hatası: ${err.message}`,
eventId: event.id
});
}
}
);
Bu pattern özellikle database işlemleri veya dış API çağrıları içeren handler’larda çok işe yarıyor. Stripe’ın 30 saniyelik timeout limitini aşma riskini ortadan kaldırıyor.
Production Checklist
Webhook sistemini production’a almadan önce şu maddeleri kontrol et:
- HTTPS zorunlu: HTTP üzerinden webhook alma, tüm iletişim şifreli olmalı
- İmza doğrulaması aktif:
constructEventher zaman çağrılmalı - Raw body korunmalı: JSON parse middleware webhook route’unda çalışmamalı
- Idempotency uygulandı: Aynı event birden fazla işlenmemeli
- Async işleme: Uzun süren işlemler queue’ya alınmalı
- Loglama eksiksiz: Her event ID ve tipi loglanmalı
- Monitoring kurulu: Başarısız webhook’lar için alert var
- Test coverage yazıldı: Mock event’lerle unit test yapıldı
- Signing secret production’a özel: Test secret production’da kullanılmamalı
- Rate limiting yok webhook’ta: Stripe’ın IP’lerini whitelist’e al ya da bu endpoint’i rate limit dışında tut
Sonuç
Stripe webhook sistemi doğru kurulduğunda son derece güvenilir çalışır. Temel mesele şu: imzayı doğrula, idempotency’yi sağla, hızlıca 200 dön ve ağır işi background’a at. Bu dört prensibi uyguladığında, milyonlarca lira dönen ödeme sistemlerinde bile güvenle kullanabilirsin.
Stripe CLI, geliştirme sürecinde gerçek bir zamandan tasarrufu sağlıyor. Production’a geçmeden önce her event tipini Stripe CLI ile test et, edge case’leri zorla. Başarısız ödeme senaryolarını, abonelik değişikliklerini ve iade işlemlerini mutlaka simüle et.
Son olarak, webhook log’larını düzenli gözden geçir. Stripe Dashboard’daki Developers > Webhooks bölümünde her endpoint’in teslimat geçmişini görebilirsin. Sürekli başarısız olan event’ler varsa bunları araştır, çünkü genellikle uygulama tarafında ciddi bir sorunun işareti olurlar.
