Saltar al contenido
ApiPay Hub · Docs

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.

CapacidadWebpay Plus
FlujoRedirección top-level (REDIRECT_FLOW)
Captura diferidaNo: la autorización es la captura (venta directa)
Reembolso parcialSí (PARTIAL_REFUND)
Webhooks nativosNo, y no son confiables. El veredicto sale del commit
DivisasLas 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 webhook
Descargar el .drawio editable

Traducido 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:

  1. return_url del cuerpo del confirm.
  2. return_url persistida al crear el intent.
  3. Nada: 400 validation_error con extensión gateway_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ámetroSignificadoQué hacer
token_wsEl comprador completó el formularioSegundo confirm con ese token: es el commit
TBK_TOKEN (sin token_ws)El comprador anuló desde el formulario de WebpayNo 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 POST del 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} (un GET sí 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_codeSignificado en Transbankcode de ApiPaygateway_error_code
0Aprobada— (queda SUCCEEDED)
-1Rechazo por error en la tarjetacard_declinedCARD_DECLINED
-2Rechazo por error de conexión; el emisor pide reintentocard_declinedCARD_DECLINED
-3Error en la transacción, sin causa atribuiblegateway_rejectedUNKNOWN
-4Rechazo del emisorcard_declinedCARD_DECLINED
-5Rechazo por riesgo de fraudegateway_rejectedFRAUD_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:

  1. El commit, que es síncrono y entrega el veredicto en la misma llamada.
  2. fetchStatus, que usa el job de reconciliación para los retornos abandonados. Cuando el token caduca, Transbank responde 404 y 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 TransbankCuándoEstado del refund
REVERSEDReversa: dentro del mismo día contableSUCCEEDED
NULLIFIEDAnulación: fuera del mismo día contableSUCCEEDED

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_method es siempre null. El adaptador no enlaza el campo card_detail de 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 del last4.
  • Captura diferida. La autorización es la captura. capture sobre webpay es 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_url en el create (o en el primer confirm), HTTPS y accesible desde internet.
  • La página de retorno vuelve a montar el widget con el mismo client_secret.
  • TBK_TOKEN sin token_ws se 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 con onSuccess.
  • Ningún campo de tarjeta en tu página, ni en un ejemplo.