Escritos

Integración de pagos en Next.js: flujo 3D Secure con iyzico y PayTR

¿Por qué integrar pagos en Turquía es más difícil de lo que crees? Cómo funciona realmente el flujo 3D Secure, cómo manejar bien los callbacks en el App Router de Next.js, por qué nunca debes confiar en el cliente para los precios y cómo envolver cuatro proveedores bajo una sola interfaz.

Furkan Özay2 de septiembre de 2026 · 10 min de lectura
czCZAY.DEV // EDITORIALES
MODERN WEB & REACT

#Ödeme Entegrasyonu · 10 MIN DE LECTURA

FURKAN ÖZAYVOL. 2026 // #2918

Desde fuera, la integración de pagos parece una simple llamada a una API: envías los datos de la tarjeta, recibes una respuesta de "éxito" y guardas el pedido. En Turquía no funciona así. La verificación de la tarjeta ocurre en la propia página del banco, el usuario sale de tu sitio, vuelve, y el banco te devuelve el resultado mediante una solicitud POST. Pierdes el control justo en mitad del flujo, y por eso la integración es tan complicada.

En este artículo, primero explicaré cómo funciona realmente el flujo y después te mostraré qué código escribir y dónde en el App Router de Next.js. Por último, te presentaré una librería de código abierto que creé y que envuelve cuatro proveedores (iyzico, PayTR, ParamPOS, Akbank) bajo una sola interfaz. Pero primero necesitas entender el mecanismo, porque la librería no se salta el mecanismo, solo elimina el código repetitivo.

Hay dos flujos distintos: 2D y 3D Secure

Pago 2D (directo): los datos de la tarjeta van de tu servidor al proveedor y la respuesta vuelve al instante. Una solicitud, una respuesta. Sencillo, pero en Turquía el 3D Secure es prácticamente obligatorio para los pagos con tarjeta, y algunos proveedores ni siquiera ofrecen 2D.

3D Secure: tres pasos, con el navegador justo en medio.

Explicación: El flujo 3DS paso a paso

  1. Inicialización. Tu servidor envía la solicitud de pago al proveedor y recibe un bloque de contenido HTML o una URL de redirección. Este HTML es un formulario que se envía automáticamente y lleva a la página de verificación del banco.
  2. Verificación. El usuario sale de tu sitio e introduce el código SMS en la página del banco. En esta fase, tu aplicación no tiene ningún control: el usuario podría cerrar la pestaña, su teléfono podría quedarse sin batería o podría tardar 4 minutos.
  3. Retorno. El banco redirige al usuario de vuelta a tu callbackUrl mediante una solicitud POST. Lees el resultado de esa solicitud y completas el pago.

Dos detalles del tercer paso son la razón por la que la mayoría de las integraciones fallan al primer intento:

Advertencia: El callback es un POST, no un GET

El usuario no solo es redirigido a la página: llega mediante el envío de un formulario. Esto significa que la dirección de tu callback no puede ser una página normal; debe ser un endpoint que acepte POST. Además, el cuerpo no es application/json, normalmente es application/x-www-form-urlencoded. Si intentas ejecutar await req.json(), leerás datos vacíos sin recibir ningún error.

Y en segundo lugar: que la verificación 3DS sea correcta no significa que el pago se haya capturado. Después de que la verificación devuelva un resultado satisfactorio, debes hacer una llamada de completar o capturar (capture). Las integraciones que se saltan este paso parecerán "funcionar" en el sandbox de pruebas, pero en producción no cobrarán nada de dinero real.

Capturar el callback en el App Router de Next.js

El callback debe ser un Route Handler. No una página, porque recibe una solicitud POST.

Ejemplo: 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);
}

El detalle del 303 See Other es crucial: la redirección 307 por defecto conserva el método, lo que significaría que el usuario llegaría a la página de resultado también con una solicitud POST, y al recargar la página el navegador mostraría el aviso de "confirmar reenvío del formulario". El 303 convierte la solicitud en un GET.

Nunca confíes en el cliente

Este es el error más caro en una integración de pagos, y también el más común.

Precaución: El importe nunca viene del cliente

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

Trata como hostil cada número que venga del navegador. Recalcula siempre el total del carrito a partir de tu propia base de datos.

La misma lógica se aplica al callback: los datos devueltos pueden contener un ID de pedido y un importe, pero no los proceses sin compararlos antes con tus propios registros.

El problema de la doble entrada

Puede que el callback no llegue una sola vez. El usuario podría pulsar atrás, el proveedor podría reintentar tras un timeout o podría haber un fallo de red. Recibir el callback dos veces para el mismo pago es un escenario perfectamente normal.

Importante: Idempotencia

Escribe tus registros de pedido aplicando una restricción de unicidad sobre el ID de pago. Cuando llegue un segundo callback, no debería crear un segundo pedido; en su lugar, debería devolver el mismo resultado en silencio. Si no lo diseñas así desde el principio, solo notarás el problema cuando un cliente se queje de que le cobraron dos veces.

En la práctica: al iniciar un pago, crea por tu parte un registro de pedido pending. En el callback, busca ese registro por su ID y actualiza su estado. No crees un registro nuevo desde cero dentro del callback.

Los proveedores hacen lo mismo de forma distinta

En Turquía, cada proveedor de pagos impone su propia API propietaria: nombres distintos, métodos de firma distintos, formatos de error distintos. Tampoco sus conjuntos de funciones son idénticos:

Proveedor2D3D SecureReembolsoCancelaciónConsulta BINCuotas
iyzico✓✓✓✓✓✓
PayTR—✓✓—✓✓
ParamPOS✓✓✓✓✓—
Akbank✓✓✓✓✓✓

Esta tabla también muestra por qué escribir tu integración directamente contra el SDK de un proveedor es arriesgado: PayTR no tiene endpoint de cancelación y ParamPOS no tiene consulta de cuotas. Cuando necesitas cambiar de proveedor, algo que ocurre con frecuencia por las comisiones, los contratos o las caídas del servicio, acabas reescribiendo tu código de pagos desde cero.

Envolverlos tras una sola interfaz

Escribí este código repetitivo una vez y lo publiqué como código abierto: better-payment, un paquete con licencia MIT escrito en TypeScript que unifica cuatro proveedores bajo la misma interfaz.

Ejemplo: Instalación

Bash
npm install better-payment

1. Crea la instancia de forma perezosa (lazy)

Ejemplo: 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;
}

El motivo para no escribir new BetterPayment(...) a nivel de módulo es específico de Next.js: las variables de entorno pueden no estar disponibles durante la generación estática de páginas, lo que puede romper el build. Envolverlo en una función aplaza la ejecución hasta la primera llamada.

2. Una única ruta catch-all

La librería trae su propio manejador HTTP; no necesitas escribir cada endpoint a mano.

Ejemplo: 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;

Maneja ambos tipos de contenido por el detalle del callback mencionado antes: las solicitudes de pago llegan como JSON, mientras que los retornos del proveedor llegan codificados como formulario.

Este único archivo expone los siguientes 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. Inicializa y completa el 3DS

Ejemplo: Lado del servidor

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

Cuando quieras cambiar de proveedor, la llamada sigue siendo exactamente la misma; solo especificas el destino:

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

Antes de salir a producción

Resumen: Lista de verificación

  • ¿El importe se calcula en el servidor a partir de la base de datos?
  • ¿El callback acepta POST y lee correctamente el cuerpo codificado como formulario?
  • ¿Se ejecuta la llamada complete (capture) después de la verificación 3DS?
  • Si el mismo callback llega dos veces, ¿se evita un pedido duplicado? (No debería crearse.)
  • ¿Las claves están en .env y no se filtran al bundle del cliente? (Asegúrate de que no lleven el prefijo NEXT_PUBLIC_.)
  • ¿Se ha reemplazado el baseUrl del sandbox por la URL de producción?
  • ¿Qué ve el usuario si un pago falla? Debe ser un mensaje comprensible, no el error en bruto del proveedor.
  • ¿Se están registrando en logs los pagos? Pero no los datos de la tarjeta, ¿verdad?

El último punto es un arma de doble filo: necesitas monitorizar el flujo de pagos, pero los números de tarjeta, los CVC y las fechas de caducidad no deben escribirse bajo ningún concepto en tus logs.

Conclusión

La dificultad de la integración de pagos no es la API en sí; es que el usuario sale de tu app y vuelve a mitad del flujo. Una vez que lo configuras correctamente, no tienes que volver a escribirlo, y por eso publiqué better-payment como código abierto. Tiene licencia MIT; úsalo libremente en tus propios proyectos.

Consejo: Trabajemos juntos

Si tienes trabajo de integración relacionado con infraestructura de pagos, sistemas de contabilidad o cualquier otro servicio, puedes echar un vistazo a mi servicio de integración y automatización o ponerte en contacto directamente. El código fuente y el paquete están disponibles en GitHub.

Hoja de ruta y herramienta interactivas

Sigue leyendo