POST /api/v1/checkout/sessions
Hosted-checkout flow — dos modos: SELECTOR (cliente elige método) y ONE-SHOT (vos elegís el método).
Endpoint dedicado para el flujo hosted checkout. Tiene dos modos según si mandás paymentMethodId en el body o no — los dos terminan en una página hosted donde el cliente paga.
paymentMethodId. Nosotros minteamos una sesión cs_xxx, devolvemos un /checkout/<token>. El cliente ve la grilla premium con TODOS los métodos del shop (logo, bandera, límites, fee), elige uno, y el pago se inicia automático contra el provider. Cero líneas de UI de tu lado.paymentMethodId. Saltás el selector. Creamos el tx directo + devolvemos /c/<txId> que redirige al hosted form del provider. Útil cuando vos sabés qué método quiere el cliente y no querés mostrar la grilla.Modo SELECTOR (recomendado)
El integrator manda solo amount + currency + (opcional) country + (opcional) datos pre-llenados del cliente. NO mandes paymentMethodId. La respuesta trae checkoutUrl = /checkout/<token> — redirigís al cliente ahí y nosotros mostramos la grilla premium.
curl https://api.ionea.io/api/v1/checkout/sessions \
-H "Authorization: Bearer sk_test_51N8mP...exampleK3Y" \
-H "Content-Type: application/json" \
-d '{
"amount": 100.00,
"currency": "USD",
"country": "MX",
"customer": {
"firstName": "Carlos",
"lastName": "Pérez",
"email": "carlos@example.com",
"phone": "+52 55 1234 5678"
},
"merchantOrderId": "ORD-12345",
"returnUrl": "https://your-store.com/orders/1234"
}'Respuesta (selector mode)
{
"sessionId": "cs_91c5474e524b4108b9a29cec7443",
"checkoutUrl": "https://api.ionea.io/checkout/cs_91c5474e524b4108b9a29cec7443",
"paymentMethodId": null,
"status": "awaiting_method",
"amount": 100,
"currency": "USD",
"country": "MX",
"expiresAt": "2026-05-27T17:03:37.702Z"
}Campos aceptados (selector mode)
amount(requerido) — número.currency(opcional, default USD) — código ISO-4217 (USD, MXN, BRL, etc.).country(opcional) — ISO-2 o ISO-3. Sin esto, la grilla muestra TODOS los métodos del shop. Con esto, filtra a ese país.customer(requerido) — datos del comprador. Validado server-side; si falta cualquier campo requerido devolvemos 400 concode: invalid_request.customer.firstName(requerido, 1-60 chars) — nombre.customer.lastName(requerido, 1-60 chars) — apellido.customer.email(requerido, formato email válido, max 254).customer.phone(requerido, 1-40 chars) — incluí el código país, e.g.+52 55 1234 5678.customer.documentId(opcional, max 40) — RFC / CPF / CURP / DNI. Algunos métodos (OXXO, voucher) lo requieren; el provider lo pide en su hosted form cuando aplica.
merchantOrderId(opcional) — tu propio id de orden. Echo back en webhooks.returnUrl(opcional) — donde mandamos al cliente cuando termina o cancela.
/checkout/<token> (o hace click en un método), leemos su IP de los headers (x-forwarded-for primer hop, luego x-real-ip). La persistimos en la sesión y la forwardeamos al provider en userIp al crear el tx. El IP del integrator (server-to-server del POST inicial) NO se usa — sería el IP de tu backend, inútil para fraud scoring.Flujo end-to-end
1. Integrator POST /api/v1/checkout/sessions (sin paymentMethodId)
→ { sessionId, checkoutUrl }
2. Integrator redirect customer to checkoutUrl
3. Customer ve grilla premium con métodos del shop
(logo + bandera + canal + límites + fee)
picks one → click
4. Selector page POST /api/v1/checkout/sessions/<token>/select
{ paymentMethodId: "1001" }
→ /select crea el tx vía payments-create
→ tx tiene paymentFormUrl del provider
5. Browser redirect a /c/<txId>
6. /c/<txId> page server-side 307 redirect a tx.paymentFormUrl
(hosted form del provider con CLABE/QR/form real)
7. Customer paga ahí, vuelve a returnUrl
8. Webhook payment.completed (o failed) llega a tu endpointGET /api/v1/checkout/sessions/<token>
Endpoint público (token-auth) para consultar el estado de una sesión. Usado por la página del selector internamente; también podés usarlo desde tu backend para verificar que una sesión sigue válida antes de mandar el link al cliente.
{
"token": "cs_91c5474e524b4108b9a29cec7443",
"amount": 100,
"currency": "USD",
"country": "MX",
"customer": {
"firstName": "Carlos",
"lastName": "Pérez",
"email": "carlos@example.com",
"phone": "+52 55 1234 5678"
},
"customerIp": "189.205.42.18",
"returnUrl": "https://your-store.com/orders/1234",
"expiresAt": "2026-05-27T17:03:37.702Z",
"completedTxId": null
}completedTxId es null mientras el cliente no eligió método. Cuando elige, queda con el txId — re-visitas al link redirigen directo a /c/<txId>.
POST /api/v1/checkout/sessions/<token>/select
Endpoint que la página del selector llama cuando el cliente elige un método. La sesión se marca completada + se crea el tx vía payments-create internamente. Devuelve el /c/<txId> URL.
{
"paymentMethodId": "1001",
"customer": {
"email": "carlos@example.com",
"fullName": "Carlos Pérez"
}
}Respuesta exitosa — el browser redirige a checkoutUrl:
{
"sessionId": "cs_91c5474e524b4108b9a29cec7443",
"transactionId": "TXN-MPMUKN9G-8CA2",
"checkoutUrl": "https://api.ionea.io/c/TXN-MPMUKN9G-8CA2?returnUrl=https%3A%2F%2Fyour-store.com%2Forders%2F1234",
"status": "pending"
}Modo ONE-SHOT (back-compat)
Si ya sabés qué método quiere el cliente (lo elegiste en tu propio UI), mandá paymentMethodId en el POST inicial. Saltamos el selector y devolvemos directamente /c/<txId>.
curl https://api.ionea.io/api/v1/checkout/sessions \
-H "Authorization: Bearer sk_test_51N8mP...exampleK3Y" \
-H "Content-Type: application/json" \
-d '{
"amount": 50,
"paymentMethodId": "1001",
"country": "MEX",
"userEmail": "carlos@example.com",
"merchantOrderId": "ORD-12345",
"returnUrl": "https://your-store.com/orders/1234"
}'{
"sessionId": "TXN-MP25SDIS-ZQ68",
"checkoutUrl": "https://api.ionea.io/c/TXN-MP25SDIS-ZQ68?returnUrl=https%3A%2F%2Fyour-store.com%2Forders%2F1234",
"paymentMethodId": "1001",
"status": "pending",
"amount": 50,
"amountLocal": 882.17,
"currencyLocal": "MXN",
"expiresAt": "2026-05-12T16:00:00.000Z"
}Errores
invalid_request(400) — falta un campo requerido o un campo tiene formato inválido (e.g. email mal formado, phone vacío). La respuesta trae per-field guidance endetails.issues[], una lista plana endetails.missingFieldsy undetails.helpUrla este doc.checkout_session_not_found(404) — el token expiró (TTL 24h) o nunca existió. Creá una nueva sesión.checkout_session_already_completed(409) — la sesión ya inició un pago. La respuesta incluyedetails.existingTxId— redirigí al cliente ahí en vez de tratar como error.amount_out_of_limits(400) — el monto está fuera del rango del método elegido. Mostrá otra opción al cliente.cascade_exhausted(422) — ningún provider pudo procesar el pago. Revisádetails.reasony el listing de métodos.
Ejemplo de respuesta cuando faltan campos
Si mandás un POST con customer incompleto (e.g. el usuario no tiene phone guardado en tu DB), recibís 400 invalid_request con esta forma — lee message primero y details.issues después para identificar qué campo arreglar:
{
"error": {
"code": "invalid_request",
"type": "invalid_request_error",
"message": "Missing or invalid customer fields: customer.phone. See `details.issues` below for per-field guidance, or hit the helpUrl for the full spec.",
"requestId": "req_mpmxkpwb_okxsrotc",
"details": {
"issues": [
{
"path": "customer.phone",
"message": "customer.phone is required (e.g. '+52 55 1234 5678'). Pass a non-empty string."
}
],
"missingFields": ["customer.phone"],
"helpUrl": "https://api.ionea.io/docs/endpoints/hosted-checkout#selector-fields"
}
}
}$user->firstName / lastName / email / phone ANTES de hacer la llamada. Si falta alguno, mostrále al usuario "completá tu perfil" y no hagas la POST. Eso evita el roundtrip + te ahorra logs ruidosos. El validation 400 que devolvemos sigue siendo la red de seguridad si algo se escapa.- Sesiones single-use con TTL de 24h. Cuando el cliente elige método, la sesión queda lockeada a ese tx.
- El
checkoutUrlsiempre usaapi.ionea.io(canonical APP_URL) — nunca devolvemos hosts derivados de headers (PR #102 cerró ese bug). - La página
/c/<txId>hace server-side redirect alpaymentFormUrldel provider (PR #103). Datos reales del provider: CLABE real, QR real, card form real. Nunca datos demo. - Legacy
POST /api/v1/paymentsconhostedCheckout: truesigue funcionando idéntico al modo one-shot — usá el endpoint que prefieras.