Von außen sieht eine Zahlungsintegration wie ein simpler API-Aufruf aus: Kartendaten senden, eine „Erfolg“-Antwort bekommen, Bestellung speichern. So läuft es in der Türkei nicht. Die Kartenverifizierung findet auf der Seite der Bank statt, der Nutzer verlässt deine Website, kommt zurück, und die Bank liefert dir das Ergebnis per POST-Anfrage. Mitten im Ablauf verlierst du die Kontrolle, und genau deshalb ist die Integration so knifflig.
In diesem Beitrag erkläre ich zuerst, wie der Ablauf tatsächlich funktioniert, und zeige dir dann, welchen Code du im Next.js App Router wohin schreibst. Zum Schluss zeige ich dir eine Open-Source-Bibliothek, die ich gebaut habe und die vier Anbieter (iyzico, PayTR, ParamPOS, Akbank) hinter einer einzigen Schnittstelle bündelt – aber den Mechanismus musst du zuerst verstehen, denn die Bibliothek umgeht ihn nicht, sie nimmt dir nur den Boilerplate ab.
Es gibt zwei verschiedene Abläufe: 2D und 3D Secure
2D-Zahlung (direkt): Die Kartendaten gehen von deinem Server an den Anbieter, und die Antwort kommt sofort zurück. Eine Anfrage, eine Antwort. Einfach – aber in der Türkei ist 3D Secure für Kartenzahlungen praktisch Pflicht, und manche Anbieter bieten 2D gar nicht erst an.
3D Secure: drei Schritte, mit dem Browser mittendrin.
Erklärung: 3DS-Ablauf Schritt für Schritt
- Initialisierung. Dein Server schickt die Zahlungsanfrage an den Anbieter und erhält einen HTML-Content-Block oder eine Redirect-URL zurück. Dieses HTML ist ein automatisch absendendes Formular, das zur Verifizierungsseite der Bank führt.
- Verifizierung. Der Nutzer verlässt deine Website und gibt auf der Seite der Bank den SMS-Code ein. In dieser Phase hat deine Anwendung null Kontrolle – der Nutzer könnte den Tab schließen, sein Handy könnte ausgehen, oder er wartet 4 Minuten.
- Rückkehr. Die Bank leitet den Nutzer per POST-Anfrage zurück an deine
callbackUrl. Aus dieser Anfrage liest du das Ergebnis und schließt die Zahlung ab.
Zwei Details im dritten Schritt sind der Grund, warum die meisten Integrationen beim ersten Versuch scheitern:
Warnung: Der Callback ist ein POST, kein GET
Der Nutzer wird nicht einfach auf die Seite weitergeleitet – er kommt per Formularübermittlung an. Das heißt, deine Callback-Adresse darf keine einfache Seite sein, sondern muss ein Endpoint sein, der POST akzeptiert. Außerdem ist der Body nicht application/json, sondern meist application/x-www-form-urlencoded. Wenn du await req.json() ausführst, liest du stillschweigend leere Daten.
Und zweitens: Eine erfolgreiche 3DS-Verifizierung bedeutet nicht, dass die Zahlung eingezogen wurde. Nachdem die Verifizierung erfolgreich zurückkam, musst du einen Complete- bzw. Capture-Aufruf ausführen. Integrationen, die diesen Schritt überspringen, wirken in der Test-Sandbox, als würden sie „funktionieren“, ziehen in Production aber tatsächlich kein Geld ein.
Den Callback im Next.js App Router abfangen
Der Callback muss ein Route Handler sein. Keine Seite – denn er empfängt eine POST-Anfrage.
Beispiel: app/payment/callback/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
// The provider sends form-encoded data; req.json() won't work here
const form = await req.formData();
const data = Object.fromEntries(form.entries()) as Record<string, string>;
// ... complete payment, update order ...
// Redirect user to a result page with 303:
// This is the correct way to transition from POST to GET.
return NextResponse.redirect(new URL("/siparis/tamam", req.url), 303);
}Das Detail mit 303 See Other ist entscheidend: Der Standard-Redirect 307 behält die Methode bei, der Nutzer würde die Ergebnisseite also ebenfalls per POST aufrufen, und ein Neuladen der Seite würde die Browser-Abfrage „Formularübermittlung erneut senden?“ auslösen. 303 macht aus der Anfrage ein GET.
Vertraue dem Client nie
Das ist der teuerste Fehler bei der Zahlungsintegration, und zugleich der häufigste.
Achtung: Der Betrag kommt nie vom Client
// WRONG — the user can manipulate the price
const { price, basketItems } = await req.json();
await payment.initThreeDSPayment({ price, basketItems, ... });
// CORRECT — the client only tells you what they are buying
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);Jede Zahl, die vom Browser kommt, solltest du als feindlich behandeln. Berechne die Warenkorbsumme immer neu aus deiner eigenen Datenbank.
Dieselbe Logik gilt für den Callback: Die zurückgelieferten Daten können eine Bestell-ID und einen Betrag enthalten, aber verarbeite sie nicht, ohne sie vorher mit deinen eigenen Datensätzen abzugleichen.
Das Problem der doppelten Verarbeitung
Der Callback kommt womöglich nicht nur einmal an. Der Nutzer klickt vielleicht auf „Zurück“, der Anbieter wiederholt nach einem Timeout, oder es gibt einen kurzen Netzwerkaussetzer. Denselben Callback für dieselbe Zahlung zweimal zu erhalten ist ein völlig normales Szenario.
Wichtig: Idempotenz
Schreibe deine Bestelldatensätze, indem du einen Eindeutigkeits-Constraint auf die Zahlungs-ID setzt. Kommt ein zweiter Callback an, sollte er keine zweite Bestellung anlegen, sondern stillschweigend dasselbe Ergebnis zurückgeben. Wenn du das nicht von Anfang an einplanst, merkst du das Problem erst, wenn sich ein Kunde beschwert, doppelt belastet worden zu sein.
In der Praxis: Lege beim Start einer Zahlung auf deiner Seite einen Bestelldatensatz im Status pending an. Finde im Callback diesen Datensatz anhand seiner ID und aktualisiere seinen Status. Lege im Callback keinen neuen Datensatz von Grund auf an.
Anbieter machen dasselbe unterschiedlich
In der Türkei erzwingt jeder Zahlungsanbieter seine eigene proprietäre API: andere Benennung, andere Signaturverfahren, andere Fehlerformate. Auch der Funktionsumfang ist nicht identisch:
| Anbieter | 2D | 3D Secure | Erstattung | Stornierung | BIN-Abfrage | Ratenzahlung |
|---|---|---|---|---|---|---|
| iyzico | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| PayTR | — | ✓ | ✓ | — | ✓ | ✓ |
| ParamPOS | ✓ | ✓ | ✓ | ✓ | ✓ | — |
| Akbank | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Diese Tabelle zeigt auch, warum es riskant ist, deine Integration direkt gegen das SDK eines Anbieters zu schreiben: PayTR hat keinen Cancel-Endpoint, ParamPOS keine Ratenabfrage. Wenn du den Anbieter wechseln musst – was wegen Gebühren, Verträgen oder Ausfällen häufig vorkommt –, schreibst du deinen Zahlungscode komplett neu.
Alles hinter einer einzigen Schnittstelle bündeln
Diesen Boilerplate habe ich einmal geschrieben und als Open Source veröffentlicht: better-payment – ein MIT-lizenziertes Paket in TypeScript, das vier Anbieter unter derselben Schnittstelle vereint.
Beispiel: Installation
npm install better-payment1. Die Instanz lazy erzeugen
Beispiel: lib/payment.ts
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;
}Dass man new BetterPayment(...) nicht auf Modulebene schreibt, hat einen Next.js-spezifischen Grund: Umgebungsvariablen sind bei der statischen Seitengenerierung womöglich nicht verfügbar, was den Build zum Absturz bringen kann. Wickelt man den Aufruf in eine Funktion, wird die Ausführung bis zum ersten Aufruf aufgeschoben.
2. Eine einzige Catch-all-Route
Die Bibliothek bringt ihren eigenen HTTP-Handler mit; du musst nicht jeden Endpoint von Hand schreiben.
Beispiel: app/api/pay/[...path]/route.ts
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;Dass beide Content-Types behandelt werden, liegt am oben erwähnten Callback-Detail: Zahlungsanfragen kommen als JSON, die Rückgaben der Anbieter dagegen formularkodiert.
Diese eine Datei stellt die folgenden Endpoints bereit:
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 initialisieren und abschließen
Beispiel: Serverseitig
const payment = getBetterPayment();
// Initialization — render the returned htmlContent to the browser
const init = await payment.initThreeDSPayment({
price: "100.00",
paidPrice: "100.00",
currency: "TRY",
callbackUrl: `${origin}/api/pay/iyzico/callback`,
buyer: {
/* ... */
},
basketItems: [
/* ... */
],
});
// Inside the callback — complete the payment
const done = await payment.completeThreeDSPayment({
paymentId: data.paymentId,
conversationId: data.conversationId,
});Wenn du den Anbieter wechseln willst, bleibt der Aufruf exakt gleich; du gibst nur das Ziel an:
await payment.use("paytr").refund({ paymentId, price: "50.00", currency: "TRY", ip });Vor dem Gang in Production
Zusammenfassung: Checkliste
- Wird der Betrag auf dem Server aus der Datenbank berechnet?
- Akzeptiert der Callback POST und liest den formularkodierten Body erfolgreich?
- Wird nach der 3DS-Verifizierung der Complete-Aufruf (Capture) ausgeführt?
- Wird verhindert, dass bei zweimaligem Eintreffen desselben Callbacks eine doppelte Bestellung entsteht? (Sie darf nicht angelegt werden.)
- Liegen die Keys in
.envund gelangen nicht ins Client-Bundle? (Stelle unbedingt sicher, dass es keinNEXT_PUBLIC_-Präfix gibt.) - Wurde die Sandbox-
baseUrldurch die Production-URL ersetzt? - Was sieht der Nutzer bei einer fehlgeschlagenen Zahlung? Es sollte eine nutzerfreundliche Meldung sein, nicht der rohe Fehler des Anbieters.
- Werden Zahlungsdatensätze geloggt? Kartendaten werden aber nicht mitgeloggt, oder?
Der letzte Punkt ist ein zweischneidiges Schwert: Du musst den Zahlungsablauf überwachen, aber Kartennummern, CVCs und Ablaufdaten dürfen unter keinen Umständen in deine Logs geschrieben werden.
Fazit
Das Schwierige an der Zahlungsintegration ist nicht die API selbst, sondern dass der Nutzer deine App mitten im Ablauf verlässt und zurückkehrt. Wenn du es einmal richtig aufgesetzt hast, musst du es nie wieder schreiben – deshalb habe ich better-payment als Open Source veröffentlicht. Es steht unter MIT-Lizenz; nutze es gern in deinen eigenen Projekten.
Tipp: Lass uns zusammenarbeiten
Wenn du Integrationsarbeit rund um Zahlungsinfrastruktur, Buchhaltungssysteme oder andere Dienste hast, wirf einen Blick auf meinen Integrations- und Automatisierungsservice oder nimm direkt Kontakt auf. Quellcode und Paket gibt es auf GitHub.
Interaktive Roadmap & Tool
Junior Frontend Developer 2026
Was du wirklich wissen musst, um 2026 einen Junior-Frontend-Job zu bekommen, und was warten kann – abgeleitet aus echten Stellenanzeigen.
SQL oder NoSQL?
Beantworte sechs Fragen, und ich sage dir, welche Art von Datenbank zu deinem Projekt passt und warum.