czay.dev
Writing

Payment integration in Next.js: 3D Secure flow with iyzico and PayTR

Why is payment integration in Turkey harder than you think? How the 3D Secure flow actually works, handling callbacks correctly in Next.js App Router, never trusting the client with pricing, and wrapping four providers under a single interface.

Furkan ÖzaySeptember 2, 2026 · 9 min read

From the outside, payment integration looks like a simple API call: send the card details, get a "success" response, save the order. That's not how it works in Turkey. Card verification happens on the bank's own page, the user leaves your site, comes back, and the bank returns the result to you via a POST request. You lose control right in the middle of the flow, and that's exactly why integration is so tricky.

In this post, I'll first explain how the flow actually works, and then show you what code to write where in the Next.js App Router. Finally, I'll show you an open-source library I built that wraps four providers (iyzico, PayTR, ParamPOS, Akbank) behind a single interface—but you need to understand the mechanism first, because the library doesn't bypass the mechanism, it just eliminates the boilerplate.

There are two different flows: 2D and 3D Secure

2D (direct) payment: card details go from your server to the provider, and the response comes back instantly. One request, one response. Simple—but in Turkey, 3D Secure is practically mandatory for card payments, and some providers don't even offer 2D at all.

3D Secure: three steps, with the browser sitting right in the middle.

Explanation: 3DS flow step-by-step

  1. Initialization. Your server sends the payment request to the provider and gets back an HTML content block or a redirect URL. This HTML is an auto-submitting form that leads to the bank's verification page.
  2. Verification. The user leaves your site and enters the SMS code on the bank's page. At this stage, your application has zero control—the user could close the tab, their phone might die, or they could wait 4 minutes.
  3. Return. The bank redirects the user back to your callbackUrl using a POST request. You read the result from this request and complete the payment.

Two details in the third step are why most integrations fail on the first try:

Warning: Callback is a POST, not a GET

The user is not just redirected to the page—they arrive via a form submission. This means your callback address cannot be a simple page; it must be an endpoint that accepts POST. Furthermore, the body is not application/json, it is usually application/x-www-form-urlencoded. If you try to run await req.json(), you'll silently read empty data.

And second: successful 3DS verification does not mean the payment has been captured. After verification returns successfully, you must make a complete or capture call. Integrations that skip this step will look like they "work" in the test sandbox but won't actually charge any money in production.

Catching the callback in Next.js App Router

The callback must be a Route Handler. Not a page—because it receives a POST request.

Example: app/payment/callback/route.ts

TypeScript
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);
}

The 303 See Other detail is crucial: the default 307 redirect preserves the method, meaning the user would hit the result page with a POST request too, and refreshing the page would trigger the browser's "confirm form resubmission" prompt. 303 converts the request into a GET.

Never trust the client

This is the most expensive mistake in payment integration, and also the most common.

Caution: The amount never comes from the client

TypeScript
// 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);

Every number coming from the browser should be treated as hostile. Always recalculate the cart total from your own database.

The same logic applies to the callback: the returned data might contain an order ID and amount, but do not process them without comparing them against your own records first.

The double-entry problem

The callback might not arrive just once. The user might click back, the provider might retry after a timeout, or a network hiccup might occur. Receiving the callback twice for the same payment is a perfectly normal scenario.

Important: Idempotency

Write your order records by applying a uniqueness constraint on the payment ID. When a second callback arrives, it shouldn't create a second order; instead, it should silently return the same result. If you don't design this from the start, you will only notice the problem when a customer complains about being charged twice.

In practice: when initiating a payment, create a pending order record on your end. In the callback, find that record by its ID and update its status. Do not create a new record from scratch inside the callback.

Providers do the same thing differently

In Turkey, every payment provider enforces its own proprietary API: different naming, different signing methods, different error formats. Their feature sets aren't identical either:

Provider2D3D SecureRefundCancelBIN QueryInstallment
iyzico✓✓✓✓✓✓
PayTR—✓✓—✓✓
ParamPOS✓✓✓✓✓—
Akbank✓✓✓✓✓✓

This table also shows why writing your integration directly against a provider's SDK is risky: PayTR has no cancel endpoint, ParamPOS has no installment query. When you need to switch providers—which happens frequently due to commission rates, contracts, or downtime—you end up rewriting your payment code from scratch.

Wrapping them behind a single interface

I wrote this boilerplate once and open-sourced it: better-payment — an MIT-licensed package written in TypeScript that consolidates four providers under the same interface.

Example: Installation

Bash
npm install better-payment

1. Create the instance lazily

Example: lib/payment.ts

TypeScript
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;
}

The reason for not writing new BetterPayment(...) at the module level is specific to Next.js: environment variables might not be available during static page generation, which can crash the build. Wrapping it in a function defers execution until the first call.

2. A single catch-all route

The library brings its own HTTP handler; you don't need to write every endpoint manually.

Example: app/api/pay/[...path]/route.ts

TypeScript
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;

The reason it handles both content types is due to the callback detail mentioned above: payment requests come as JSON, while provider returns come form-encoded.

This single file exposes the following endpoints:

plaintext
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/health

3. Initialize and complete 3DS

Example: Server side

TypeScript
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,
});

When you want to switch providers, the call remains exactly the same; you just specify the target:

TypeScript
await payment.use("paytr").refund({ paymentId, price: "50.00", currency: "TRY", ip });

Before going to production

Summary: Checklist

  • Is the amount calculated on the server from the database?
  • Does the callback accept POST and successfully read the form-encoded body?
  • Is the complete (capture) call executed after the 3DS verification?
  • If the same callback arrives twice, is a duplicate order prevented? (It shouldn't be created.)
  • Are keys in .env and not leaking into the client bundle? (Make absolutely sure there is no NEXT_PUBLIC_ prefix.)
  • Has the sandbox baseUrl been replaced with the production URL?
  • What does the user see in case of a failed payment? It should be a user-friendly message, not the provider's raw error.
  • Are payment records being logged? But card data isn't being logged, right?

The last point is a double-edged sword: you need to monitor the payment flow, but card numbers, CVCs, and expiration dates must under no circumstances be written to your logs.

Conclusion

The difficulty of payment integration isn't the API itself; it's the fact that the user leaves your app and returns mid-flow. Once you set it up correctly, you never have to write it again—which is why I open-sourced better-payment. It's MIT licensed; feel free to use it in your own projects.

Tip: Let's work together

If you have integration work regarding payment infrastructure, accounting systems, or any other service, you can check out my integration and automation service or get in touch directly. The source code and package are available on GitHub.