Endpoints

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.

Modo SELECTOR (recomendado) — sin 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.
Modo ONE-SHOT (back-compat) — con 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.

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

json
{
  "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 con code: 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.
IP del cliente — capturada automáticamente. Cuando el cliente abre /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.
Trims automáticos. firstName / lastName / email / phone / documentId se hacen trim de whitespace antes de validar. Pegá los valores con espacios sobrantes sin problema.

Flujo end-to-end

text
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 endpoint

GET /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.

json
{
  "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.

json
{
  "paymentMethodId": "1001",
  "customer": {
    "email": "carlos@example.com",
    "fullName": "Carlos Pérez"
  }
}

Respuesta exitosa — el browser redirige a checkoutUrl:

json
{
  "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>.

bash
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"
  }'
json
{
  "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 en details.issues[], una lista plana en details.missingFields y un details.helpUrl a 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 incluye details.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.reason y 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:

json
{
  "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"
    }
  }
}
Patrón recomendado en tu integración: validá $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.
Notas:
  • Sesiones single-use con TTL de 24h. Cuando el cliente elige método, la sesión queda lockeada a ese tx.
  • El checkoutUrl siempre usa api.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 al paymentFormUrl del provider (PR #103). Datos reales del provider: CLABE real, QR real, card form real. Nunca datos demo.
  • Legacy POST /api/v1/payments con hostedCheckout: true sigue funcionando idéntico al modo one-shot — usá el endpoint que prefieras.