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 comprador | Sale de tu sitio a una página de ApiPay | Se queda en tu sitio, en un iframe |
| Qué monta tu frontend | Nada: un enlace o un redirect | @apipay/checkout-js y un contenedor |
| Qué recibe tu servidor al crear | checkout_url | client_secret |
| Quién elige el medio de pago | El comprador, en el selector de ApiPay | Depende: normalmente lo fija el comercio |
| Quién pinta la pantalla de resultado | ApiPay, con el botón «Volver a la tienda» | Tu sitio, con onSuccess / onError |
| Qué hay que registrar en el tenant | La clave publicable | La clave publicable y allowed_origins |
| Trabajo de integración | Una llamada y una redirección | La 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 cobroEn prosa, y sin ningún paso opcional:
- Tu servidor crea el intent sin
gateway_id.POST /v1/payment-intentscon tu clavesk_y el headerIdempotency-Key. Omitir la pasarela es lo que hace que el selector tenga algo que ofrecer. - La respuesta
201traecheckout_url. Es la única respuesta que la trae; más abajo hay un aviso entero dedicado a eso. - 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. - 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.
- 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. - 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étodo | Ruta | Autenticación | Nota |
|---|---|---|---|
| POST | /v1/payment-intents | sk_ | 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:
| Falta | Consecuencia | Se arregla |
|---|---|---|
La base del checkout del despliegue (APIPAY_CHECKOUT_BASE_URL) | El 201 llega sin checkout_url | En el despliegue de payment-api |
| La clave publicable declarada por el tenant | El 201 llega sin checkout_url | En 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
confirmcompleto 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 mismoorder_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 tenant | Qué ve el comprador |
|---|---|
| Dos o más | El selector, con las pasarelas ordenadas por la prioridad del tenant |
| Exactamente una | El flujo de esa pasarela, directamente. Ni un click de más |
| Ninguna utilizable | Un 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 elclient_secretse recuperó delsessionStorageal 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 recibes | Qué significa | Qué haces |
|---|---|---|
Webhook payment_intent.failed | La pasarela rechazó el cobro | Ofreces reintentar: intent nuevo, checkout_url nueva |
Webhook payment_intent.expired | El comprador nunca terminó | Igual: intent nuevo si la orden sigue viva |
Webhook payment_intent.succeeded | Cobrado | Liberas la orden. Esta es la única señal válida |
El comprador vuelve por merchant_return_url | Volvió a tu tienda | Muestras 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:
| Servicio | Puerto publicado | Para qué lo necesitas aquí |
|---|---|---|
payment-api | 18080 | Crear el intent con tu sk_test_ |
admin-api | 18081 | Crear el tenant, habilitar pasarelas y emitir claves |
checkout-widget | 18082 | La página del checkout alojado: es la base de checkout_url |
docs-portal | 18083 | Este portal |
backoffice | 13000 | Consultar 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:
APIPAY_CHECKOUT_BASE_URLenpayment-api, con la URL del checkout tal como la ve el navegador del comprador —es decir, la del puerto18082de la máquina, no un nombre de la red de compose—. Está documentada en la sección 4.5 del runbook.- 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
createva singateway_id. - El
createllevaIdempotency-Keyy la clave se persiste junto a la orden. -
return_urlapunta al checkout (…/session?pk=…), no a tu tienda. -
merchant_return_urlapunta a tu tienda, y esa página tolera que el pago aún no esté resuelto. - Tu código comprueba que
checkout_urlviene antes de redirigir. - La redirección ocurre en la misma petición del
create; nadie espera recuperar la URL con unretrieve. - 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
sandboxpor los dos últimos dígitos del monto. - Manejo de errores: los
codeestables contra los que se escribe la lógica.