Saltar al contenido
ApiPay Hub · Docs

Checkout alojado: el botón «Pagar con ApiPay»#

El checkout alojado es la integración más corta que existe con ApiPay Hub: tu carrito crea un payment intent, recibe una URL y manda ahí al comprador. Todo lo demás —enseñar los medios de pago disponibles, hablar con la pasarela, gestionar la ida y la vuelta, pintar la pantalla de resultado— ocurre en una página de ApiPay. Tu sitio no monta ningún componente de pago, no maneja el client_secret en el navegador y no toca una sola línea de JavaScript nuestra.

Es el flujo que hace de ApiPay un hub y no un frontal de una sola pasarela: el intent nace sin pasarela, y quien la elige es el comprador.


Alojado y embebido son productos distintos#

No son dos configuraciones del mismo componente: son dos integraciones con superficies distintas. Elige una a conciencia.

Checkout alojado (esta guía)Checkout embebido (quickstart)
Dónde paga el compradorSale de tu sitio a una página de ApiPaySe queda en tu sitio, en un iframe
Qué monta tu frontendNada: un enlace o un redirect@apipay/checkout-js y un contenedor
Qué recibe tu servidor al crearcheckout_urlclient_secret
Quién elige el medio de pagoEl comprador, en el selector de ApiPayDepende: normalmente lo fija el comercio
Quién pinta la pantalla de resultadoApiPay, con el botón «Volver a la tienda»Tu sitio, con onSuccess / onError
Qué hay que registrar en el tenantLa clave publicableLa clave publicable y allowed_origins
Trabajo de integraciónUna llamada y una redirecciónLa llamada, el SDK del navegador y la página de retorno

El resto de esta guía asume el alojado. Si lo que quieres es que el comprador no salga de tu sitio, la guía correcta es el quickstart y luego confirmación.


El recorrido completo#

Checkout alojado, de la creación del intent a la vuelta a la tienda

El intent nace sin pasarela, el comprador elige en la página de ApiPay y vuelve a la tienda por merchant_return_url.

Renderizando el diagrama…

Ver la fuente mermaid
sequenceDiagram
    autonumber
    participant EC as Comercio (backend sk_test_ y tienda)
    participant BR as Navegador del comprador
    participant CO as Checkout alojado de ApiPay (modo página)
    participant PA as payment-api
    participant PG as Pasarela que elige el comprador

    EC->>PA: POST /v1/payment-intents SIN gateway_id, con Idempotency-Key, return_url y merchant_return_url
    Note over EC,PA: crear el intent sin pasarela es lo que distingue a un hub de un frontal de una sola pasarela:<br/>PaymentIntentService.doCreate acepta gateway nulo a propósito, para que el selector tenga algo que ofrecer
    PA-->>EC: 201 con pi_..., client_secret y checkout_url
    Note over PA,EC: checkout_url SOLO se emite en el 201. Es el único instante en que el client_secret existe en claro,<br/>así que un GET posterior NO puede reconstruirla y omite el campo: si el comercio la pierde, crea otro intent.<br/>También se omite si el despliegue no tiene base de checkout o si el tenant no declaró clave publicable
    EC-->>BR: manda al comprador a checkout_url
    BR->>CO: GET /session?pk=pk_test_...#client_secret=pi_..._secret_...
    Note over BR,CO: la forma exacta de checkout_url la arma PaymentIntentController.hostedCheckoutUrl.<br/>El client_secret viaja tras la almohadilla a propósito: un FRAGMENTO no llega a los logs del servidor<br/>ni a la cabecera Referer cuando el navegador salta a la pasarela.<br/>El checkout lo lee, lo guarda en sessionStorage y lo borra del historial con history.replaceState()
    CO->>PA: GET /v1/payment-intents/pi_... con pk_test_ y header X-Client-Secret
    PA-->>CO: 200 con checkout.payment_methods = TODAS las pasarelas habilitadas del tenant
    Note over PA,CO: CheckoutSessionService.configFor recorta la lista a una sola pasarela cuando el intent YA tiene una.<br/>Sin pasarela devuelve la lista completa, que es justo lo que el selector necesita.<br/>Ojo: en modo hub gateway_id viaja null hasta que el comprador elige, aunque el contrato lo declare required
    alt más de una pasarela habilitada
        CO->>BR: selector de medio de pago
        BR->>CO: el comprador elige la pasarela
    else exactamente una
        Note over CO: mapIntentToUiState va directo a gateway_flow: el selector no llega a aparecer
    end
    CO->>PA: POST /v1/payment-intents/pi_.../confirm con el gateway_id elegido — PRIMER confirm
    PA->>PG: createPayment con la return_url que se persistió al crear el intent
    PG-->>PA: referencia de la transacción y URL de su formulario de pago
    PA-->>CO: 200 con status REQUIRES_ACTION y redirect_url
    CO->>BR: redirección top-level al formulario de la pasarela
    BR->>PG: el comprador paga con su tarjeta en el sitio de la pasarela
    Note over BR,PG: el PAN y el CVV no atraviesan ningún componente de ApiPay (PCI DSS SAQ A)
    PG-->>BR: vuelve a la return_url, que en checkout alojado es el propio checkout: es el único que puede cerrar el cobro
    BR->>CO: retorno con el token de la pasarela y nada más
    Note over BR,CO: por eso existe hostedSession: el client_secret se recupera de sessionStorage.<br/>Sin él la página de retorno no sabría qué intent cerrar y la pasarela abortaría un cobro ya hecho
    CO->>PA: POST /v1/payment-intents/pi_.../confirm con el token — SEGUNDO confirm
    PA->>PG: commit del token
    PG-->>PA: aprobada o rechazada
    PA-->>CO: 200 con el estado final del intent
    CO->>BR: página de resultado, con el botón "Volver a la tienda" si se declaró merchant_return_url
    BR->>EC: el comprador vuelve a la tienda por merchant_return_url
    Note over EC,PA: NO preselecciones gateway_id al crear, y el motivo es contraintuitivo.<br/>resolveConfirmGateway solo deja cambiar de pasarela MIENTRAS el intent no tiene gatewayReference:<br/>si nace atado a una, cualquier fallo obliga a crear otro intent. Sin preseleccionar, un fallo anterior<br/>a la referencia todavía permite elegir otra sobre el MISMO intent
    Note over PA,PG: un fallo POSTERIOR a la referencia —tarjeta rechazada en Webpay, que ya creó su transacción—<br/>sí obliga a empezar de nuevo, y eso es correcto: cambiar de pasarela con una transacción PENDING<br/>en la primera arriesga un doble cobro

En prosa, y sin ningún paso opcional:

  1. Tu servidor crea el intent sin gateway_id. POST /v1/payment-intents con tu clave sk_ y el header Idempotency-Key. Omitir la pasarela es lo que hace que el selector tenga algo que ofrecer.
  2. La respuesta 201 trae checkout_url. Es la única respuesta que la trae; más abajo hay un aviso entero dedicado a eso.
  3. Rediriges al comprador a esa URL. Con un 303, o con un <a href> si el botón de pago es un enlace. El comprador sale de tu sitio.
  4. El comprador elige y paga en la página de ApiPay. Si el comercio tiene más de una pasarela habilitada, ve el selector; si tiene una, va directo al flujo de esa pasarela.
  5. Vuelve a la página de resultado del checkout, que le ofrece el botón «Volver a la tienda» apuntando a tu merchant_return_url.
  6. Tu servidor da la orden por pagada cuando llega el webhook. No cuando el comprador vuelve: el comprador puede no volver nunca y el pago estar aprobado igualmente.

La forma de la URL, y por qué#

PaymentIntentController.hostedCheckoutUrl la arma así:

https://checkout.apipay.io/session?pk=pk_test_EJEMPLO000000000000000000#client_secret=pi_9f2c4a7d1b3e4f60a8c5d2e7_secret_Vt8yQ1kR3nZ

La clave publicable va en la query porque no es un secreto: identifica al tenant y solo sirve para leer y confirmar el intent cuyo client_secret se presenta a la vez. El client_secret va en el fragmento, detrás de la almohadilla, y eso sí es deliberado: el fragmento no se envía al servidor en ninguna petición y no viaja en la cabecera Referer cuando el navegador salta a la pasarela. Es el mismo criterio por el que en la API el secreto viaja en el header X-Client-Secret y nunca como parámetro de query.

Al entrar, la página del checkout lee el fragmento, lo guarda en el sessionStorage de su origen y lo borra del historial con history.replaceState(). Ese guardado no es un adorno: cuando la pasarela devuelve al comprador, lo devuelve con su token y nada más, y sin recordar el client_secret la página de retorno no sabría qué intent cerrar.


Crear el intent y redirigir#

Un solo endpoint, y ninguna llamada más por tu parte hasta el webhook:

MétodoRutaAutenticaciónNota
POST/v1/payment-intentssk_Sin gateway_id. Header Idempotency-Key obligatorio. El 201 trae checkout_url.
import { ApiPay } from '@apipay/node';

const apipay = new ApiPay({ apiKey: process.env.APIPAY_SECRET_KEY ?? '' });

app.post('/carro/pagar', async (req, res) => {
  const created = await apipay.paymentIntents.create({
    amount_minor: 1499000, // CLP tiene exponente 0: son $1.499.000
    currency: 'CLP',
    // SIN gateway_id: el intent nace sin pasarela y la elige el comprador.
    description: 'Orden 4831 - Tienda Andina',
    customer_email: 'cliente@example.com',
    // Donde vuelve LA PASARELA: el propio checkout, el unico que puede cerrar el cobro.
    return_url: 'https://checkout.apipay.io/session?pk=pk_test_EJEMPLO000000000000000000',
    // Donde vuelve EL COMPRADOR cuando ya hay desenlace: tu tienda.
    merchant_return_url: 'https://tienda-andina.cl/pedidos/4831',
    metadata: { order_id: '4831' },
  });

  // Persistir la clave ANTES de redirigir: el SDK no reintenta POST, y esta es la
  // unica forma segura de repetir la creacion sin duplicar el cobro.
  await orders.guardar('4831', created.id, created.idempotencyKey);

  if (created.checkout_url === undefined) {
    // Falta la base de checkout del despliegue o la clave publicable del tenant.
    throw new Error(`El intent ${created.id} no trae checkout_url`);
  }

  // 303 y no 302: convierte el POST del formulario del carro en un GET.
  res.redirect(303, created.checkout_url);
});

La respuesta, en estado CREATED:

{
  "id": "pi_9f2c4a7d1b3e4f60a8c5d2e7",
  "object": "payment_intent",
  "amount_minor": 1499000,
  "currency": "CLP",
  "status": "CREATED",
  "client_secret": "pi_9f2c4a7d1b3e4f60a8c5d2e7_secret_Vt8yQ1kR3nZ",
  "gateway_id": null,
  "payment_method": null,
  "redirect_url": null,
  "metadata": { "order_id": "4831" },
  "livemode": false,
  "created_at": "2026-08-12T14:30:00Z",
  "merchant_return_url": "https://tienda-andina.cl/pedidos/4831",
  "checkout_url": "https://checkout.apipay.io/session?pk=pk_test_EJEMPLO000000000000000000#client_secret=pi_9f2c4a7d1b3e4f60a8c5d2e7_secret_Vt8yQ1kR3nZ"
}

checkout_url solo existe en la respuesta del create#

El campo también se omite —sin error, simplemente no está— en dos casos de configuración:

FaltaConsecuenciaSe arregla
La base del checkout del despliegue (APIPAY_CHECKOUT_BASE_URL)El 201 llega sin checkout_urlEn el despliegue de payment-api
La clave publicable declarada por el tenantEl 201 llega sin checkout_urlEn la metadata del tenant, en checkout.publishable_key

Una URL a medias sería peor que ninguna: sin clave publicable la página no puede autenticarse contra la API, y el comprador vería un error en vez de un formulario de pago. Por eso el campo se omite entero. Comprueba siempre que viene antes de redirigir, como hacen los cuatro ejemplos de arriba.


Preseleccionar la pasarela, o no#

Mandar gateway_id al crear el intent salta el selector: el intent nace atado a esa pasarela y CheckoutSessionService.configFor recorta la lista de métodos a esa sola, así que el comprador ya no ve alternativas.

La recomendación es no preseleccionar, y el motivo no es de experiencia de usuario sino de recuperación ante fallos. Es contraintuitivo, así que conviene entenderlo:

resolveConfirmGateway solo permite cambiar de pasarela mientras el intent no tiene gatewayReference, es decir, mientras ninguna pasarela ha creado todavía su transacción. Y de ahí salen dos comportamientos muy distintos:

  • Sin preseleccionar. El comprador elige Webpay, y Webpay rechaza la creación de la transacción (credenciales mal, circuito abierto, divisa no soportada). El confirm completo es transaccional y la excepción lo revierte: el intent sigue en CREATED y sin pasarela. El comprador puede elegir MercadoPago sobre el mismo intent, con el mismo importe y el mismo order_id. Tu orden no se entera de nada.
  • Preseleccionando. Ese mismo fallo deja al comprador atrapado: el intent ya está atado a Webpay, la lista de métodos está recortada a Webpay, y la única salida es que tu servidor cree otro intent. Has cambiado un reintento gratis por un ciclo completo de tu backend.

Preselecciona solo cuando no haya nadie delante que pueda elegir: cobros server-to-server, cobros recurrentes o cobros con un medio de pago guardado. En ese último caso ni siquiera se manda gateway_id: se manda payment_method_id y la pasarela se deriva del medio; mandar además un gateway_id distinto responde 400 validation_error.


Qué ve el comprador si el comercio tiene una sola pasarela#

El selector no aparece. El checkout mira cuántas pasarelas habilitadas trae la sesión y decide:

Pasarelas habilitadas del tenantQué ve el comprador
Dos o másEl selector, con las pasarelas ordenadas por la prioridad del tenant
Exactamente unaEl flujo de esa pasarela, directamente. Ni un click de más
Ninguna utilizableUn error gateway_unavailable. Es un fallo de configuración del tenant, no del pago

La regla vive en mapIntentToUiState (enabledMethodCount(intent) > 1) y es del checkout, no de la API: la API devuelve la lista completa de las habilitadas y el checkout decide si merece la pena enseñarla. Obligar a elegir entre una sola opción no es una elección, es un paso.

Con una sola pasarela, el confirm sale sin gateway_id y el servidor resuelve la de mayor preferencia del tenant —priority más bajo gana, empates por orden alfabético del código—, que con una sola habilitada es esa misma. El resultado es idéntico; simplemente no hubo nada que elegir.


Cuando el pago falla y el comprador quiere probar otro medio#

Todo depende de un solo dato: si la pasarela llegó a crear su transacción. El límite lo marca gatewayReference.

Caso 1 · El fallo fue antes de la referencia#

La pasarela rechazó el arranque: credenciales inválidas, 503 gateway_unavailable con el circuito abierto, 422 currency_not_supported. El confirm es una transacción y la excepción la revierte entera, así que el intent sigue vivo: CREATED y sin pasarela asociada.

  • En tu servidor no hay que hacer nada. No hay orden que revertir ni intent que reemplazar.
  • En el checkout, el error aparece dentro del flujo de la pasarela y el botón de pagar sigue disponible para reintentar con la misma. Para volver al selector y elegir otra, el comprador recarga la página del checkout: la URL …/session?pk=… funciona sola, porque el client_secret se recuperó del sessionStorage al entrar. Como el intent sigue sin pasarela, el selector vuelve a ofrecer la lista completa.

Caso 2 · El fallo fue después de la referencia#

La tarjeta se rechazó en el formulario de la pasarela, o el emisor denegó la operación. El intent queda FAILED, que es terminal: no se reintenta, no se reabre, no se cambia de pasarela.

El camino correcto es crear otro payment intent, otra vez sin gateway_id, y redirigir a la checkout_url nueva. Reutiliza el mismo order_id en metadata para que tus dos intentos queden correlacionados; usa una Idempotency-Key distinta, porque es un cobro distinto.

Señal que recibesQué significaQué haces
Webhook payment_intent.failedLa pasarela rechazó el cobroOfreces reintentar: intent nuevo, checkout_url nueva
Webhook payment_intent.expiredEl comprador nunca terminóIgual: intent nuevo si la orden sigue viva
Webhook payment_intent.succeededCobradoLiberas la orden. Esta es la única señal válida
El comprador vuelve por merchant_return_urlVolvió a tu tiendaMuestras el estado que ya tengas. No des la orden por pagada aquí

Probarlo en el ambiente de QA#

QA es la plataforma completa en una sola máquina, apuntando a los ambientes de integración de las pasarelas. El runbook operativo —arranque, variables, migraciones, diagnóstico— es Docs/AMBIENTE-QA.md del monorepo, y es la única fuente: aquí no se duplica nada de eso.

La máquina es apipay-web-dev.apipay.cl (149.56.29.192) y publica estos puertos:

ServicioPuerto publicadoPara qué lo necesitas aquí
payment-api18080Crear el intent con tu sk_test_
admin-api18081Crear el tenant, habilitar pasarelas y emitir claves
checkout-widget18082La página del checkout alojado: es la base de checkout_url
docs-portal18083Este portal
backoffice13000Consultar los pagos

Los puertos se configuran con las variables QA_PORT_*; su tabla canónica está en la sección 2.3 del runbook.

Dos condiciones para que checkout_url aparezca en QA#

Si el 201 llega sin el campo, es siempre una de estas dos, y ninguna es un fallo del código:

  1. APIPAY_CHECKOUT_BASE_URL en payment-api, con la URL del checkout tal como la ve el navegador del comprador —es decir, la del puerto 18082 de la máquina, no un nombre de la red de compose—. Está documentada en la sección 4.5 del runbook.
  2. La clave publicable del tenant, en metadata.checkout.publishable_key. El servidor no puede deducirla: de las claves de API solo persiste su hash.

Para ver el selector hacen falta además dos o más pasarelas habilitadas para el tenant. Con una sola, el checkout va directo a su flujo y no habrás probado lo que querías probar.

El ciclo mínimo#

Con un tenant y una sk_test_ ya creados (sección 6.2 del runbook, que explica cómo obtenerlos con admin-api):

API=http://apipay-web-dev.apipay.cl:18080
SK='sk_test_EJEMPLO000000000000000000000000'

# Crear el intent SIN gateway_id. La Idempotency-Key es obligatoria: sin ella, 400.
curl -s -X POST $API/v1/payment-intents \
  -H "X-API-Key: $SK" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H 'Content-Type: application/json' \
  -d '{"amount_minor":150000,"currency":"CLP",
       "description":"Prueba de checkout alojado",
       "return_url":"http://apipay-web-dev.apipay.cl:18082/session?pk=pk_test_EJEMPLO000000000000000000",
       "merchant_return_url":"https://tienda-andina.cl/pedidos/4831",
       "metadata":{"order_id":"4831"}}' | jq '{id, status, gateway_id, checkout_url}'

Lo que tiene que salir: "status": "CREATED", "gateway_id": null —eso confirma que el intent nació en modo hub— y una checkout_url no nula. Ábrela en el navegador y ahí empieza la prueba de verdad.


Lista de comprobación#

  • El create va sin gateway_id.
  • El create lleva Idempotency-Key y la clave se persiste junto a la orden.
  • return_url apunta al checkout (…/session?pk=…), no a tu tienda.
  • merchant_return_url apunta a tu tienda, y esa página tolera que el pago aún no esté resuelto.
  • Tu código comprueba que checkout_url viene antes de redirigir.
  • La redirección ocurre en la misma petición del create; nadie espera recuperar la URL con un retrieve.
  • Tu deserializador acepta gateway_id: null.
  • La orden se libera con el webhook payment_intent.succeeded, nunca con la vuelta del comprador.
  • Ningún campo de tarjeta en tu sitio, ni en un ejemplo.

Siguientes pasos#

  • Payment intents: la máquina de estados completa y por qué un estado terminal no se reabre.
  • Confirmación: qué ocurre dentro del checkout cuando el comprador ya eligió.
  • Webhooks: la única señal válida para dar una orden por pagada.
  • Webpay Plus: la pasarela con redirección, y el detalle de los dos confirm.
  • Medios de pago guardados: el otro extremo del producto, el cobro sin titular delante y sin selector.
  • Modo test: los escenarios de sandbox por los dos últimos dígitos del monto.
  • Manejo de errores: los code estables contra los que se escribe la lógica.