Webpay Plus (Transbank)#
gateway_id: "webpay". Es Transbank Webpay Plus integrado vía Servipag, y es la pasarela con el
flujo más distinto de todas: no tiene hosted fields. El comprador sale de tu sitio, paga en el
formulario de Transbank y vuelve. Ese paréntesis es exactamente lo que modela el estado
REQUIRES_ACTION.
| Capacidad | Webpay Plus |
|---|---|
| Flujo | Redirección top-level (REDIRECT_FLOW) |
| Captura diferida | No: la autorización es la captura (venta directa) |
| Reembolso parcial | Sí (PARTIAL_REFUND) |
| Webhooks nativos | No, y no son confiables. El veredicto sale del commit |
| Divisas | Las del comercio en Transbank; en Chile, CLP (exponente 0) |
El flujo completo#
Flujo de redirección de Webpay Plus
Del intent creado a la vuelta del comprador con token_ws y el segundo confirm que liquida el cobro.
Renderizando el diagrama…
Ver la fuente mermaid
sequenceDiagram
autonumber
participant EC as Backend del comercio (sk_test_)
participant BR as Navegador del comprador
participant WG as checkout-widget (iframe)
participant PA as payment-api
participant DB as PostgreSQL 17 (RLS)
participant WP as Webpay Plus (Servipag)
participant OB as Outbox, Kafka y webhook-dispatcher
EC->>PA: POST /v1/payment-intents con X-API-Key sk_test_, Idempotency-Key y return_url
PA->>DB: SET LOCAL app.tenant_id, INSERT payment_intents (CREATED) + outbox_events en UNA transacción
Note over PA,DB: la return_url se persiste en la clave reservada apipay_return_url,<br/>que nunca aparece en el objeto metadata que ve el comercio
PA-->>EC: 201 con id pi_... y client_secret
EC-->>BR: renderiza su checkout y entrega el client_secret al widget
BR->>WG: monta el iframe de checkout.apipay.io (handshake postMessage con origin estricto y nonce)
WG->>PA: GET /v1/payment-intents/pi_... con pk_test_ y header X-Client-Secret
PA-->>WG: 200 intent CREATED con las pasarelas habilitadas del tenant
WG->>PA: POST /v1/payment-intents/pi_.../confirm — PRIMER confirm, sin token
PA->>WP: createPayment vía gateway-webpay con la return_url persistida
WP-->>PA: token_ws y URL del formulario de pago
PA->>DB: CREATED -> REQUIRES_ACTION, publica apipay_redirect_url + outbox_events
PA-->>WG: 200 con status REQUIRES_ACTION y redirect_url
WG->>BR: redirección top-level al formulario de Webpay, fuera del iframe
BR->>WP: el comprador paga con su tarjeta en el sitio de Webpay
Note over BR,WP: el PAN y el CVV no atraviesan ningún componente de ApiPay (PCI DSS SAQ A)
WP-->>BR: 302 hacia la return_url del comercio con token_ws en la query
BR->>WG: vuelve a la página de checkout con el token_ws
WG->>PA: POST /v1/payment-intents/pi_.../confirm con token_ws — SEGUNDO confirm
Note over WG,PA: el token_ws puede llegar en gateway_params o en la query string:<br/>el controlador fusiona ambos y la query gana
PA->>DB: REQUIRES_ACTION -> PROCESSING + outbox_events
PA->>WP: commit del token_ws vía gateway-webpay
alt autorización aprobada
WP-->>PA: aprobada
PA->>DB: PROCESSING -> SUCCEEDED + transaction APPROVED + outbox_events
else autorización rechazada
WP-->>PA: rechazada
PA->>DB: PROCESSING -> FAILED + transaction REJECTED + outbox_events
end
PA-->>WG: 200 con el estado final del intent
PA->>OB: el relay drena outbox_events y publica payment_intent.succeeded o .failed
OB->>EC: POST firmado con ApiPay-Signature al webhook_endpoint del comercio
Note over PA,WP: si el comprador nunca vuelve, el guard de conciliación llama a fetchStatus<br/>y lleva el intent a EXPIRED al vencer su TTL
Note over WG,PA: el token_ws es de un solo uso: ningún SDK reintenta este POST automáticamente.<br/>Webpay entrega el veredicto en el commit, no por webhookTraducido a estados canónicos del intent:
CREATED --(1.er confirm: init en Transbank)--> REQUIRES_ACTION
REQUIRES_ACTION --(2.o confirm con token_ws)--> PROCESSING
PROCESSING --(commit AUTHORIZED, response_code 0)--> SUCCEEDED
PROCESSING --(commit rechazado)--> FAILED
REQUIRES_ACTION --(token vencido, reconciliación)--> EXPIRED
El paso REQUIRES_ACTION → PROCESSING ocurre antes de pedir el commit, a propósito: si el
navegador reenvía el retorno (un F5 en la página de vuelta), el intent ya no está esperando al
comprador y la operación no se lanza dos veces.
Fase 1: crear el intent con return_url#
return_url es la URL de tu sitio a la que Transbank devuelve al comprador. Sin ella el flujo de
redirección no puede operar: createPayment falla antes de llamar a Transbank y la API responde
400 validation_error.
import { ApiPay } from '@apipay/node';
const apipay = new ApiPay({ apiKey: process.env.APIPAY_SECRET_KEY ?? '' });
const intent = await apipay.paymentIntents.create({
amount_minor: 1499000, // CLP tiene exponente 0: son $1.499.000
currency: 'CLP',
gateway_id: 'webpay',
description: 'Orden 4831 - Tienda Andina',
return_url: 'https://tienda-andina.cl/checkout/retorno',
metadata: { order_id: '4831' },
});
// Persistir la clave junto a la orden: el SDK no reintenta POST.
await guardarOrden('4831', intent.id, intent.idempotencyKey);La return_url se persiste al crear el intent en el canal de metadata reservada del agregado, y
el confirm puede sustituirla: si el cuerpo del confirm trae return_url, esa gana y se
vuelve a persistir. Esto no es un detalle cosmético: el segundo confirm (el del commit) necesita la
misma URL, y si la primera llamada la trajo sólo en el cuerpo y no se persistiera, la segunda se
quedaría sin ella.
Orden de precedencia, de mayor a menor:
return_urldel cuerpo delconfirm.return_urlpersistida al crear el intent.- Nada:
400 validation_errorcon extensióngateway_id: "webpay".
Fase 2: la redirección es del top window#
El widget de checkout vive dentro de un iframe de checkout.apipay.io. Cuando el primer confirm
devuelve redirect_url, la navegación no puede ocurrir dentro del iframe: Transbank rechaza ser
enmarcado y el comprador tiene que ver la barra de direcciones de Transbank para poder confiar en el
formulario. Por eso el widget ejecuta window.top.location.assign(redirect_url), algo que el iframe
sólo puede hacer porque @apipay/checkout-js lo monta con
sandbox="… allow-top-navigation-by-user-activation" y la navegación nace de un click real del
comprador.
La redirect_url que devuelve la API ya trae el token_ws como query param sobre la URL del
formulario de Transbank. No la manipules ni le añadas parámetros: úsala tal cual.
Fase 3: el retorno con token_ws y el reenvío al iframe#
Transbank devuelve al comprador a tu return_url con uno de estos dos parámetros:
| Parámetro | Significado | Qué hacer |
|---|---|---|
token_ws | El comprador completó el formulario | Segundo confirm con ese token: es el commit |
TBK_TOKEN (sin token_ws) | El comprador anuló desde el formulario de Webpay | No hay commit; el intent se cancela o expira |
Lo único que tienes que hacer en tu página de retorno es volver a montar el widget con el mismo
client_secret. El SDK y el widget se encargan del resto:
<!-- https://tienda-andina.cl/checkout/retorno?token_ws=e9d0f1... -->
<div id="apipay-checkout"></div>
<script src="https://checkout.apipay.io/sdk/v1.0.0/apipay.js" crossorigin="anonymous" defer></script>
<script defer>
addEventListener('DOMContentLoaded', function () {
ApiPay.init({ publicKey: 'pk_test_8Kq3ZmT2vXw1' }).checkout({
// El MISMO client_secret del intent, inyectado de nuevo por tu servidor.
clientSecret: window.__PI_SECRET__,
container: '#apipay-checkout',
locale: 'es-CL',
onSuccess: function (r) {
location.assign('/gracias?pi=' + encodeURIComponent(r.paymentIntentId));
},
onCancel: function () {
location.assign('/carro');
},
});
});
</script>
El widget detecta el token_ws (en el querystring o en el fragment), dispara una sola vez el
segundo confirm, muestra el estado procesando y sondea el intent hasta que quede
SUCCEEDED o FAILED.
Hacer el commit desde tu servidor#
Si tu integración no usa el widget —por ejemplo, la página de retorno es una vista de servidor que ya
recibe token_ws como query param— el commit se pide con el mismo endpoint, autenticado con sk_, y
el token viaja en gateway_params:
curl -sS -X POST https://api.apipay.io/v1/payment-intents/pi_9f2c4a7d1b3e4f60a8c5d2e7/confirm \
-H "X-API-Key: sk_test_EJEMPLO000000000000000000000000" \
-H "Content-Type: application/json" \
-d '{"gateway_params":{"token_ws":"e9d0f1a2b3c4d5e6"}}'
payment-api también fusiona los parámetros de query de la propia llamada con gateway_params,
y los de query ganan. Es decir, esto es equivalente y a veces más cómodo:
curl -sS -X POST "https://api.apipay.io/v1/payment-intents/pi_9f2c4a7d1b3e4f60a8c5d2e7/confirm?token_ws=e9d0f1a2b3c4d5e6" \
-H "X-API-Key: sk_test_EJEMPLO000000000000000000000000"
// El array del cuerpo admite gateway_params sin ceremonia.
$confirmado = $apipay->paymentIntents->confirm($intentId, [
'gateway_params' => ['token_ws' => $_GET['token_ws']],
]);
if ('SUCCEEDED' === $confirmado->status) {
// Cobrado. Aun asi, la fuente de verdad para liberar la orden es el webhook.
}El commit no se reintenta#
Un commit consume el token_ws: repetirlo sobre un token ya usado es un error, y en el peor caso
duplica el cobro. Por eso:
- El adaptador no reintenta el commit automáticamente, ni siquiera ante un timeout.
- Los SDKs nunca reintentan un
POSTdel que se recibió respuesta (ver Referencia de SDKs). - Si el commit se queda sin respuesta, no lo repitas: lee el intent con
GET /v1/payment-intents/{id}(unGETsí se reintenta solo) y decide con el estado real.
Rechazos: response_code de Transbank#
Un commit puede responder 200 con un response_code negativo: la llamada fue bien, el pago no. La
API lo traduce a 402 con el catálogo de códigos estable y añade el código crudo de Transbank en las
extensiones del problem+json:
response_code | Significado en Transbank | code de ApiPay | gateway_error_code |
|---|---|---|---|
0 | Aprobada | — (queda SUCCEEDED) | — |
-1 | Rechazo por error en la tarjeta | card_declined | CARD_DECLINED |
-2 | Rechazo por error de conexión; el emisor pide reintento | card_declined | CARD_DECLINED |
-3 | Error en la transacción, sin causa atribuible | gateway_rejected | UNKNOWN |
-4 | Rechazo del emisor | card_declined | CARD_DECLINED |
-5 | Rechazo por riesgo de fraude | gateway_rejected | FRAUD_SUSPECTED |
{
"type": "https://api.apipay.io/problems/card-declined",
"title": "Payment Required",
"status": 402,
"detail": "webpay rejected the payment on commit (status=FAILED)",
"code": "card_declined",
"gateway_id": "webpay",
"gateway_error_code": "CARD_DECLINED",
"gateway_raw_code": "-4"
}
Ramifica siempre por code, nunca por detail. Y usa gateway_raw_code sólo para soporte y
auditoría: es el valor con el que Transbank puede correlacionar la operación, no un identificador
para tu lógica de negocio.
Webpay no manda webhooks (y por eso hay reconciliación)#
Webpay Plus no emite webhooks salientes confiables. El endpoint
POST /v1/gateways/webpay/webhooks existe y acepta cualquier aviso fuera de banda que un integrador
ofrezca, pero el adaptador lo persiste en inbound_webhook_events y no transiciona nada: el
estado no puede depender de un canal que no está garantizado.
La verdad sale de dos sitios:
- El commit, que es síncrono y entrega el veredicto en la misma llamada.
fetchStatus, que usa el job de reconciliación para los retornos abandonados. Cuando el token caduca, Transbank responde404y deja de conocer la transacción: el intent se cierra como EXPIRED.
Tus webhooks de ApiPay (payment_intent.succeeded, payment_intent.failed,
payment_intent.expired) sí funcionan con normalidad: los emite ApiPay, no Transbank, y son la
fuente de verdad para liberar la orden. Ver Webhooks.
Reembolsos y cancelación#
POST /v1/refunds funciona igual que con cualquier otra pasarela, total o parcial. Lo que cambia es
el vocabulario de Transbank:
| Tipo de Transbank | Cuándo | Estado del refund |
|---|---|---|
REVERSED | Reversa: dentro del mismo día contable | SUCCEEDED |
NULLIFIED | Anulación: fuera del mismo día contable | SUCCEEDED |
Webpay no emite un id de devolución. El campo gateway_reference del refund lleva el código de
autorización de la anulación y, si la reversa no lo trae, el propio token de la transacción. Sigue
identificando la operación de forma única, pero no es un id de Transbank con vida propia.
POST /v1/payment-intents/{id}/cancel se comporta en dos modos:
- Antes del commit no hay nada que anular en la pasarela: el token caduca solo y el intent queda CANCELED.
- Después del commit la única cancelación posible es una reversa por el total. Si Transbank la
rechaza, la API responde
402.
Modo test#
Las claves sk_test_ operan contra la misma api.apipay.io, con partición estricta por livemode.
Para probar el flujo de redirección de verdad necesitas credenciales del ambiente de integración
de Transbank en la configuración del tenant.
Las tarjetas y montos de prueba de Transbank no se copian aquí a propósito: se desactualizan. Consulta la documentación oficial del ambiente de integración de Webpay (se abre en una pestaña nueva).
Si lo que quieres es probar tu propio código —los dos confirm, el manejo de
REQUIRES_ACTION, tu deduplicación de webhooks— sin depender del ambiente de
Transbank, usa la pasarela sandbox, que reproduce un REQUIRES_ACTION con redirect_url propia con
sólo elegir un monto terminado en 42. Ver Modo test.
Lo que Webpay nunca te va a dar#
payment_methodes siemprenull. El adaptador no enlaza el campocard_detailde Transbank (los últimos cuatro dígitos) ni a un DTO: por alcance PCI DSS SAQ A, ese dato no existe siquiera en memoria dentro del hub. No construyas UI que dependa de la marca o dellast4.- Captura diferida. La autorización es la captura.
capturesobrewebpayes un error de configuración, no un fallo del pago. - Datos de tarjeta. Nunca renderices un formulario con número de tarjeta o CVV alrededor del widget: eso saca a tu comercio del alcance SAQ A. La captura ocurre en el formulario de Transbank.
Checklist de integración#
-
return_urlen elcreate(o en el primerconfirm), HTTPS y accesible desde internet. - La página de retorno vuelve a montar el widget con el mismo
client_secret. -
TBK_TOKENsintoken_wsse trata como abandono, no como error. - El commit no se reintenta: ante un timeout se consulta con
GET. - La orden se libera con el webhook
payment_intent.succeeded, no cononSuccess. - Ningún campo de tarjeta en tu página, ni en un ejemplo.