Ödeme entegrasyonu, dışarıdan bakınca bir API çağrısı gibi görünür: kart bilgisini gönder, "başarılı" cevabını al, siparişi kaydet. Türkiye'de öyle işlemiyor. Kartın doğrulaması bankanın kendi sayfasında yapılıyor, kullanıcı siteden çıkıp geri dönüyor ve sonucu sana bir POST isteğiyle o dönüyor. Akışın ortasında kontrolü kaybediyorsun ve entegrasyonun zor tarafı tam olarak burası.
Bu yazıda önce akışın gerçekte nasıl çalıştığını, sonra Next.js App Router'da nereye ne yazılacağını anlatıyorum. Sonunda da dört sağlayıcıyı (iyzico, PayTR, ParamPOS, Akbank) tek arayüzün arkasına aldığım açık kaynak kütüphaneyi gösteriyorum — ama önce mekanizmayı anlaman gerekiyor, çünkü kütüphane o mekanizmayı ortadan kaldırmıyor, sadece tekrarını kaldırıyor.
İki farklı akış var: 2D ve 3D Secure
2D (doğrudan) ödeme: kart bilgisi sunucundan sağlayıcıya gider, cevap anında döner. Tek istek, tek cevap. Basit — ama Türkiye'de kartlı ödemede pratikte 3D Secure zorunlu kabul edilir ve bazı sağlayıcılar 2D'yi hiç sunmaz.
3D Secure: üç adımlı ve arada tarayıcı var.
- Başlatma. Sunucun sağlayıcıya ödeme isteğini gönderir ve karşılığında bir HTML içeriği ya da yönlendirme adresi alır. Bu HTML, bankanın doğrulama sayfasına giden otomatik gönderimli bir formdur.
- Doğrulama. Kullanıcı senin sitenden çıkar, bankanın sayfasında SMS kodunu girer. Bu aşamada senin uygulamanın hiçbir kontrolü yok — kullanıcı sekmeyi kapatabilir, telefonu ölebilir, 4 dakika bekleyebilir.
- Dönüş. Banka, kullanıcıyı senin verdiğin
callbackUrladresine POST ile geri gönderir. Sonucu bu istekten okur ve ödemeyi tamamlarsın.
Üçüncü adımdaki iki ayrıntı, entegrasyonların çoğunun ilk denemede çalışmamasının sebebi:
Kullanıcı sayfaya yönlendirilmiyor — form gönderimiyle geliyor. Yani
callback adresin bir sayfa değil, POST kabul eden bir uç nokta olmalı. Üstelik
gövde application/json değil, genellikle
application/x-www-form-urlencoded geliyor. await req.json() yazarsan
sessizce boş veri okursun.
Ve ikincisi: 3DS'in başarıyla tamamlanması ödemenin alındığı anlamına gelmez. Doğrulama başarılı döndükten sonra ödemeyi tamamlama çağrısını sen yapmak zorundasın. Bu adımı atlayan entegrasyonlar test ortamında "çalışıyor" görünüp canlıda para tahsil etmiyor.
Next.js App Router'da callback'i yakalamak
Callback bir Route Handler olmalı. Sayfa değil — çünkü POST alıyor.
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
// Sağlayıcı form-encoded gönderiyor; req.json() burada işe yaramaz
const form = await req.formData();
const data = Object.fromEntries(form.entries()) as Record<string, string>;
// ... ödemeyi tamamla, siparişi güncelle ...
// Kullanıcıyı bir sonuç sayfasına 303 ile gönder:
// POST'tan sonra GET'e geçmenin doğru yolu bu.
return NextResponse.redirect(new URL("/siparis/tamam", req.url), 303);
}303 See Other ayrıntısı önemli: varsayılan 307 yönlendirmesi metodu korur,
yani kullanıcı sonuç sayfasına da POST ile gider ve yenilediğinde tarayıcı
"formu yeniden gönder" diye sorar. 303, isteği GET'e çevirir.
Asla istemciye güvenme
Bu, ödeme entegrasyonundaki en pahalı hata ve en sık yapılanı.
// YANLIŞ — kullanıcı fiyatı değiştirebilir
const { price, basketItems } = await req.json();
await payment.initThreeDSPayment({ price, basketItems, ... });
// DOĞRU — istemci yalnız neyi satın aldığını söyler
const { cartId } = await req.json();
const cart = await db.cart.findUnique({ where: { id: cartId } });
const price = cart.items.reduce((t, i) => t + i.price * i.quantity, 0);Tarayıcıdan gelen her sayı düşmanca kabul edilir. Sepetin tutarını her zaman kendi veritabanından yeniden hesapla.
Aynı mantık callback için de geçerli: dönen veride sipariş kimliği ve tutar olabilir, ama onları da kendi kaydınla karşılaştırmadan işleme alma.
Çift kayıt sorunu
Callback bir kez gelmeyebilir. Kullanıcı geri tuşuna basar, sağlayıcı zaman aşımı sonrası yeniden dener, ağ kopar. Aynı ödeme için callback'in iki kez gelmesi normal bir durumdur.
Sipariş kaydını ödeme kimliğine bir tekillik kısıtı koyarak yaz. İkinci callback geldiğinde ikinci bir sipariş oluşmasın, sessizce aynı sonucu döndürsün. Bunu baştan kurmazsan, sorunu ilk kez müşteri iki kez tahsilat gördüğünde fark edersin.
Pratikte: ödemeyi başlatırken kendi tarafında pending bir sipariş kaydı aç,
callback'te o kaydı kimliğine göre bul ve durumunu güncelle. Callback'te sıfırdan
kayıt oluşturma.
Sağlayıcılar aynı şeyi farklı yapıyor
Türkiye'de her ödeme kuruluşu kendi API'sini dayatıyor: farklı isimlendirme, farklı imzalama yöntemi, farklı hata biçimi. Yetenek seti de aynı değil:
| Sağlayıcı | 2D | 3D Secure | İade | İptal | BIN sorgu | Taksit |
|---|---|---|---|---|---|---|
| iyzico | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| PayTR | — | ✓ | ✓ | — | ✓ | ✓ |
| ParamPOS | ✓ | ✓ | ✓ | ✓ | ✓ | — |
| Akbank | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Bu tablo, entegrasyonu doğrudan sağlayıcının SDK'sına yazmanın neden riskli olduğunu da gösteriyor: PayTR'de iptal uç noktası yok, ParamPOS'ta taksit sorgusu yok. Sağlayıcı değiştirmek gerektiğinde — komisyon oranı, sözleşme ya da kesinti sorunu yüzünden bu sık oluyor — ödeme kodunu baştan yazıyorsun.
Tek arayüzün arkasına almak
Bu tekrarı bir kez yazıp açık kaynağa bıraktım: better-payment — MIT lisanslı, TypeScript ile yazılmış, dört sağlayıcıyı aynı arayüzde toplayan bir paket.
npm install better-payment1. Örneği tembel oluştur
import { BetterPayment, ProviderType } from "better-payment";
let instance: BetterPayment | null = null;
export function getBetterPayment(): BetterPayment {
if (!instance) {
instance = new BetterPayment({
defaultProvider: ProviderType.IYZICO,
providers: {
iyzico: {
enabled: true,
config: {
apiKey: process.env.IYZICO_API_KEY!,
secretKey: process.env.IYZICO_SECRET_KEY!,
baseUrl:
process.env.IYZICO_BASE_URL ?? "https://sandbox-api.iyzipay.com",
},
},
},
});
}
return instance;
}Modül seviyesinde new BetterPayment(...) yazmamanın sebebi Next.js'e özgü:
statik sayfa üretimi sırasında ortam değişkenleri hazır olmayabiliyor ve derleme
çöküyor. Fonksiyona sararak ilk çağrıya erteliyorsun.
2. Tek bir catch-all rota
Kütüphane kendi HTTP işleyicisini getiriyor; her uç noktayı elle yazmıyorsun.
import { NextRequest, NextResponse } from "next/server";
import type { BetterPaymentRequest } from "better-payment";
import { getBetterPayment } from "@/lib/payment";
async function handler(req: NextRequest) {
const contentType = req.headers.get("content-type") ?? "";
let body: unknown;
if (req.method !== "GET" && req.method !== "HEAD") {
if (contentType.includes("application/json")) {
body = await req.json().catch(() => undefined);
} else if (contentType.includes("application/x-www-form-urlencoded")) {
const fd = await req.formData();
const obj: Record<string, string> = {};
fd.forEach((v, k) => {
obj[k] = v as string;
});
body = obj;
}
}
const request: BetterPaymentRequest = {
method: req.method,
url: req.url,
headers: Object.fromEntries(req.headers.entries()),
body,
};
const res = await getBetterPayment().handler.handle(request);
return NextResponse.json(res.body, { status: res.status, headers: res.headers });
}
export const GET = handler;
export const POST = handler;İki içerik tipini de ele almasının sebebi yukarıdaki callback ayrıntısı: ödeme istekleri JSON, sağlayıcı dönüşü form-encoded geliyor.
Bu tek dosya şu uç noktaları açıyor:
POST /api/pay/:provider/payment
POST /api/pay/:provider/payment/init-3ds
POST /api/pay/:provider/payment/complete-3ds
POST /api/pay/:provider/callback
POST /api/pay/:provider/refund
POST /api/pay/:provider/cancel
POST /api/pay/:provider/bin-check
POST /api/pay/:provider/installment
GET /api/pay/health3. 3DS'i başlat ve tamamla
const payment = getBetterPayment();
// Başlatma — dönen htmlContent'i tarayıcıya bas
const init = await payment.initThreeDSPayment({
price: "100.00",
paidPrice: "100.00",
currency: "TRY",
callbackUrl: `${origin}/api/pay/iyzico/callback`,
buyer: {
/* ... */
},
basketItems: [
/* ... */
],
});
// Callback içinde — ödemeyi tamamla
const done = await payment.completeThreeDSPayment({
paymentId: data.paymentId,
conversationId: data.conversationId,
});Sağlayıcı değiştirmek istediğinde çağrı aynı kalıyor, yalnız hedefi söylüyorsun:
await payment.use("paytr").refund({ paymentId, price: "50.00", currency: "TRY", ip });Canlıya geçmeden önce
- Tutar sunucuda, veritabanından hesaplanıyor mu?
- Callback POST kabul ediyor ve form-encoded gövdeyi okuyor mu?
- 3DS doğrulamasından sonra tamamlama çağrısı yapılıyor mu?
- Aynı callback iki kez gelirse ikinci sipariş oluşuyor mu? (Oluşmamalı.)
- Anahtarlar
.enviçinde ve istemci paketine sızmıyor mu? (NEXT_PUBLIC_öneki kesinlikle yok.) - Sandbox
baseUrl'i canlı adresle değiştirildi mi? - Başarısız ödemede kullanıcı ne görüyor? Sağlayıcının ham hata mesajı değil, anlaşılır bir metin olmalı.
- Ödeme kayıtları loglanıyor mu? Kart verisi loglanmıyor, değil mi?
Son madde iki yönlü: ödeme akışını izleyebilmen gerekiyor ama kart numarası, CVC ve son kullanma tarihi hiçbir koşulda loglara yazılmamalı.
Kapanış
Ödeme entegrasyonunun zorluğu API'nin kendisinde değil, akışın ortasında kullanıcının senin uygulamandan çıkıp geri dönmesinde. Bir kez doğru kurduğunda tekrar tekrar yazmana gerek kalmıyor — better-payment'ı bu yüzden açık kaynak bıraktım, MIT lisanslı, kendi projende de kullanabilirsin.
Ödeme altyapısı, muhasebe sistemi ya da başka bir servisle entegrasyon işi varsa entegrasyon ve otomasyon hizmetine bakabilir ya da doğrudan iletişime geçebilirsin. Kaynak kod ve paket GitHub'da.
