Medios de pago guardados (Oneclick)#
gateway_id: "oneclick". Es Transbank Oneclick Mall, y sirve para lo que ninguna otra pasarela
del hub hace: cobrar sin el titular delante. La tarjeta se inscribe una vez, y desde entonces el
comercio cobra referenciando un identificador pm_ —suscripciones, renovaciones, compras de un
clic— sin redirección, sin formulario y sin que nadie tenga que estar mirando la pantalla.
| Capacidad | Oneclick |
|---|---|
| Capacidades publicadas en el catálogo | STORED_CREDENTIAL, PARTIAL_REFUND |
| Flujo del cobro | Sin redirección: confirm autoriza y entrega el desenlace en la misma llamada |
| Captura diferida | No (ver Lo que hoy no funciona) |
| Reembolso parcial | Sí (PARTIAL_REFUND), con POST /v1/refunds como cualquier otra pasarela |
| Divisas | Las del comercio en Transbank; en Chile, CLP (exponente 0) |
| Credencial de la API | Todo es sk_. El widget no participa en el guardado de un medio |
Es una pasarela separada de Webpay Plus aunque el proveedor sea el mismo: otra ruta de API, otros códigos de comercio y otro ciclo de vida. Inscribir una tarjeta no es cobrar un pago.
Alcance PCI: aquí no viaja ningún dato de tarjeta#
Los cinco endpoints#
| Método | Ruta | Autenticación | Nota |
|---|---|---|---|
| POST | /v1/payment-methods/inscriptions | sk_ | Idempotency-Key obligatoria. Responde 201 con el redirect a la pasarela. |
| POST | /v1/payment-methods/inscriptions/{id}/confirm | sk_ | Idempotency-Key obligatoria. Responde 201 con el medio de pago (pm_). |
| GET | /v1/payment-methods | sk_ | Solo los VIGENTES, paginados por cursor. Filtro opcional customer_ref. |
| GET | /v1/payment-methods/{id} | sk_ | Uno por su id, REVOCADOS INCLUIDOS. |
| DELETE | /v1/payment-methods/{id} | sk_ | Baja blanda. Devuelve el recurso ya revocado, no un 204 vacío. |
Las cinco están en los cuatro SDKs server-side. Una clave publicable pk_ no puede tocar
ninguna: guardar un medio de pago no abre superficie nueva de client_secret, y el widget de
checkout no participa en este flujo.
Prefijos de identificador: ins_ para la inscripción (el estado intermedio) y pm_ para el
medio de pago guardado (el recurso con el que se cobra). No son intercambiables, y confundirlos
falla en el borde: la API comprueba el prefijo antes de consultar nada y responde
400 validation_error, no un 404. Un 404 resource_not_found significa otra cosa —un id bien
formado que no existe para tu tenant— y conviene no confundir los dos al depurar.
El SDK de PHP además comprueba el prefijo en el cliente y te ahorra la petición; los otros tres no lo validan a propósito, porque el SDK no es quien debe decidir qué formas de id existen y una validación local demasiado lista rompería integraciones el día que la plataforma introduzca un formato nuevo.
El ciclo completo#
Ciclo de un medio de pago guardado en Oneclick
Abrir la inscripción, llevar al titular con un POST de formulario, cerrarla, cobrar sin él delante, y las rutas de listado, lectura y baja.
Renderizando el diagrama…
Ver la fuente mermaid
sequenceDiagram
autonumber
participant EC as Backend del comercio (sk_test_)
participant BR as Navegador del titular
participant PA as payment-api
participant TB as Transbank Oneclick
EC->>PA: RUTA 1 de 5 · POST /v1/payment-methods/inscriptions con sk_test_ e Idempotency-Key
Note over EC,PA: cuerpo: gateway_id, customer_ref, email y return_url. Aquí NO viaja ningún dato de tarjeta.<br/>La Idempotency-Key es obligatoria: sin ella un reintento abriría otra inscripción y dejaría la anterior huérfana.<br/>Los cuatro SDKs la generan si el comercio no la pasa
PA->>TB: abre la inscripción
TB-->>PA: URL del formulario y token de un solo uso
PA-->>EC: 201 ins_... en REQUIRES_ACTION, con redirect { method, url, field_name, token }
Note over PA,EC: EL ERROR MÁS FÁCIL DE COMETER: redirect NO es una URL para saltar con un GET.<br/>Hay que hacer un POST DE FORMULARIO a url, con un campo llamado field_name cuyo valor es token.<br/>En Transbank ese campo es TBK_TOKEN. Por eso el objeto lleva method y field_name y no solo la URL
EC-->>BR: formulario auto-enviado hacia la pasarela (method POST, action url, input field_name = token)
BR->>TB: POST del formulario
Note over BR,TB: el titular teclea su tarjeta en el sitio de Transbank: el PAN no atraviesa ApiPay (PCI DSS SAQ A)
TB-->>BR: vuelve a la return_url del comercio con el token
BR->>EC: retorno a la return_url
EC->>PA: RUTA 2 de 5 · POST /v1/payment-methods/inscriptions/ins_.../confirm con Idempotency-Key
PA->>TB: cierra la inscripción y recoge la credencial
TB-->>PA: tbk_user y los datos no sensibles de la tarjeta
PA-->>EC: 201 con el medio de pago pm_...
Note over PA,EC: el tbk_user NO se publica: el comercio cobra referenciando el id pm_...,<br/>y no hay ningún campo con el que reconstruir la tarjeta
EC->>PA: cobro · POST /v1/payment-intents con payment_method_id pm_... y SIN gateway_id
Note over EC,PA: la pasarela se DERIVA del medio —una tarjeta inscrita en Oneclick solo se cobra en Oneclick—,<br/>así que mandar además un gateway_id distinto responde 400 en vez de adivinar cuál gana.<br/>En Oneclick la clave reservada installments de metadata fija el número de cuotas
EC->>PA: RUTA 3 de 5 · GET /v1/payment-methods — solo los VIGENTES, paginado por cursor
EC->>PA: RUTA 4 de 5 · GET /v1/payment-methods/pm_... — lee uno, REVOCADOS INCLUIDOS
Note over EC,PA: la asimetría es deliberada: el listado oculta los revocados para no invitar a cobrar con ellos,<br/>pero la lectura por id los devuelve porque los intents históricos los referencian.<br/>El estado se lee en el campo revoked
EC->>PA: RUTA 5 de 5 · DELETE /v1/payment-methods/pm_...
PA->>TB: da de baja la credencial en la pasarela
TB-->>PA: baja confirmada
PA-->>EC: 200 con el recurso ya revocado, no un 204 vacío
Note over PA,EC: la fila se CONSERVA: borrarla perdería el rastro de con qué se cobró.<br/>La operación es idempotente, así que repetirla no es un errorDos llamadas tuyas y una visita del titular a la pasarela bastan para guardar la tarjeta; a partir de ahí el cobro es server-to-server y ni un solo dato de tarjeta pasa por tu sistema. La parte que hay que leer dos veces es la del retorno hacia la pasarela: es un formulario, no una redirección.
Fase 1 · Abrir la inscripción#
import { ApiPay } from '@apipay/node';
const apipay = new ApiPay({ apiKey: process.env.APIPAY_SECRET_KEY ?? '' });
const inscripcion = await apipay.paymentMethods.openInscription({
gateway_id: 'oneclick',
// customer_ref es TU identificador de cliente. No viaja a Transbank: alla se manda
// un identificador opaco que genera ApiPay, unico por medio de pago.
customer_ref: 'cus_42',
email: 'cliente@example.com',
return_url: 'https://tienda-andina.cl/tarjetas/vuelta?ins=pendiente',
});
// Persistir el id junto al cliente: es lo unico que hace falta para cerrarla al volver.
await guardarInscripcion('cus_42', inscripcion.id, inscripcion.idempotencyKey);La respuesta es un 201 con el recurso payment_method_inscription:
{
"id": "ins_Kw3pR8sYt5Nc2Hj7QmZxVb",
"object": "payment_method_inscription",
"status": "REQUIRES_ACTION",
"gateway_id": "oneclick",
"customer_ref": "cus_42",
"expires_at": "2026-08-12T18:47:03Z",
"created_at": "2026-08-12T18:32:03Z",
"redirect": {
"method": "POST",
"url": "https://webpay3gint.transbank.cl/webpayserver/bp_multicode_inscription.cgi",
"field_name": "TBK_TOKEN",
"token": "TOKEN_DE_EJEMPLO_NO_REAL"
}
}
Cuatro cosas que conviene tener claras antes de seguir:
redirectsólo aparece constatus: "REQUIRES_ACTION". Es el único estado en el que hay algo que hacer con el titular; enCOMPLETED,FAILEDyEXPIREDel campo se omite.- La inscripción caduca a los 15 minutos (
expires_at). El token de Transbank vive menos, así que ese vencimiento propio existe para poder barrer las que nadie terminó sin preguntarle a la pasarela. customer_refes tuyo, no de Transbank. A la pasarela va un identificador opaco que genera ApiPay, único por medio de pago. Reutilizar uno por cliente haría que Transbank invalidase la tarjeta anterior en cada inscripción nueva, que es exactamente el bug que nadie quiere depurar en producción.return_urlse le entrega a Transbank tal cual (viaja como suresponse_url). Es tu URL, no una de ApiPay.
Fase 2 · Llevar al titular con un POST de formulario#
Este es el paso que se hace mal. redirect no describe una redirección: describe un formulario.
La forma soportada es emitir una página mínima que se autoenvía.
<!DOCTYPE html>
<!-- Renderizado por TU servidor con los cuatro campos de `redirect` tal como llegaron. -->
<html lang="es-CL">
<body onload="document.forms[0].submit()">
<form method="POST" action="https://webpay3gint.transbank.cl/webpayserver/bp_multicode_inscription.cgi">
<!-- name = redirect.field_name · value = redirect.token -->
<input type="hidden" name="TBK_TOKEN" value="TOKEN_DE_EJEMPLO_NO_REAL" />
<noscript><button type="submit">Continuar a Transbank</button></noscript>
</form>
</body>
</html>
Reglas de esa página:
methodsiempre esPOST. El contrato no admite otro valor. Si algún día una pasarela necesitara unGET, lo diría en ese campo; hasta entonces, no lo asumas al revés.- No hardcodees
TBK_TOKENni la URL. Léelos deredirect.field_nameyredirect.url. Son específicos de Transbank hoy, y el campo existe precisamente para que tu código no tenga que saberlo. - Navegación de nivel superior, nunca dentro de un iframe tuyo: el titular tiene que ver la barra de direcciones de Transbank para poder confiar en el formulario.
- Deja el
<noscript>. Sin JavaScript, el autoenvío no ocurre y el botón salva el flujo.
Fase 3 · Cerrar la inscripción#
const medio = await apipay.paymentMethods.confirmInscription('ins_Kw3pR8sYt5Nc2Hj7QmZxVb');
// medio.id (pm_...) es lo que hay que persistir contra tu cliente.
await guardarMedio('cus_42', medio.id, { marca: medio.brand, last4: medio.last4 });201 con el recurso payment_method, que ya es el que sirve para cobrar:
{
"id": "pm_Vd7kQ2mXp9Lr4TnB6yWzAe",
"object": "payment_method",
"gateway_id": "oneclick",
"customer_ref": "cus_42",
"type": "card",
"brand": "Visa",
"last4": "6623",
"revoked": false,
"created_at": "2026-08-12T18:34:11Z"
}
| Qué pasó | Respuesta | Estado en que queda la inscripción |
|---|---|---|
| El titular completó el formulario | 201 con el pm_ | COMPLETED |
| Transbank rechazó la inscripción | 402 gateway_rejected, con last_error poblado | FAILED |
| Ya se había cerrado antes | 409 invalid_state_transition | la que tuviera (terminal) |
| Pasaron más de 15 minutos | 409 invalid_state_transition | EXPIRED |
El ins_ no existe para tu tenant | 404 resource_not_found | — |
Fase 4 · Cobrar con el medio guardado#
Aquí es donde Oneclick se diferencia de todo lo demás del hub: createPayment no devuelve
redirección ni iframe. Autoriza contra la credencial guardada y entrega el desenlace en la misma
llamada. El intent pasa de CREATED a SUCCEEDED (o
FAILED) sin pasar por REQUIRES_ACTION.
const intent = await apipay.paymentIntents.create({
amount_minor: 24990, // CLP tiene exponente 0: son $24.990
currency: 'CLP',
// La pasarela se DERIVA del medio: no se manda gateway_id.
payment_method_id: 'pm_Vd7kQ2mXp9Lr4TnB6yWzAe',
description: 'Suscripcion Plan Pro - agosto 2026',
metadata: { order_id: '9012' },
});
const cobrado = await apipay.paymentIntents.confirm(intent.id);
if (cobrado.status === 'SUCCEEDED') {
// Aun asi, la fuente de verdad para liberar el servicio es el webhook.
}Dos comprobaciones más que hace la plataforma, y que son las que te van a dar un 400:
- Al crear, el medio tiene que existir para tu tenant y estar vigente. Uno inexistente o revocado
responde
400 validation_error. - Al confirmar, el medio se vuelve a leer. Entre crear y cobrar puede haber pasado un
DELETE, y cobrar contra una credencial ya dada de baja es exactamente lo que el titular pidió que no ocurriera. Así que un intent creado con un medio que se revocó después falla en elconfirm, también con400 validation_error.
El resultado definitivo se libera con el webhook payment_intent.succeeded, igual que en cualquier
otro cobro. Ver Webhooks.
Cuotas: la clave reservada installments#
En un cobro con medio guardado de Oneclick, la clave installments de metadata fija el número
de cuotas. metadata es un mapa de string a string, así que el valor va entrecomillado:
await apipay.paymentIntents.create({
amount_minor: 240000,
currency: 'CLP',
payment_method_id: 'pm_Vd7kQ2mXp9Lr4TnB6yWzAe',
// Valor en TEXTO: metadata es Record<string, string>.
metadata: { order_id: '9012', installments: '3' },
});Valor de metadata.installments | Qué hace |
|---|---|
| Ausente o vacío | Se cobra sin cuotas (el adaptador envía 1) |
Entero no negativo, como "3" | Ese número de cuotas |
Cualquier otra cosa: "tres", "3.5", "-1" | 400 validation_error con la extensión gateway_id: "oneclick" |
Las cuotas son de Oneclick. En las demás pasarelas del hub esa clave no significa nada en el mismo sitio, así que no la trates como un campo general del contrato.
Listar, leer y revocar#
// Listar: solo los VIGENTES, con auto-paginacion por cursor.
for await (const medio of apipay.paymentMethods.list({ customer_ref: 'cus_42' })) {
console.log(medio.id, medio.brand, medio.last4);
}
// O pagina a pagina, si prefieres el envelope crudo:
for await (const pagina of apipay.paymentMethods.list({ limit: 50 }).pages()) {
procesarLote(pagina.data);
}
// Leer uno: REVOCADOS INCLUIDOS.
const medio = await apipay.paymentMethods.retrieve('pm_Vd7kQ2mXp9Lr4TnB6yWzAe');
if (medio.revoked) {
// Sirve para explicar un cobro historico, no para cobrar.
}
// Revocar: devuelve el recurso ya revocado, no un 204 vacio.
const dadoDeBaja = await apipay.paymentMethods.revoke('pm_Vd7kQ2mXp9Lr4TnB6yWzAe');Filtros del listado: customer_ref y limit (1..100, default del servidor 20). cursor está
deliberadamente ausente de la superficie de los cuatro SDKs, igual que en transactions: exponerlo
invitaría a paginar a mano, que es lo que la auto-paginación elimina.
Listar y leer no devuelven lo mismo, y es a propósito#
| Operación | Qué devuelve | Para qué es |
|---|---|---|
GET /v1/payment-methods | Sólo los vigentes | Pintarle al cliente las tarjetas con las que puede pagar |
GET /v1/payment-methods/{id} | Todos, revocados incluidos (revoked: true, revoked_at) | Resolver con qué se cobró un intent histórico |
El motivo de la asimetría: la fila de un medio revocado se conserva —payment_intents la
referencia con ON DELETE RESTRICT, y borrarla perdería el rastro de con qué se cobró—, pero
publicarla en el listado invitaría a intentar cobrar con ella. Así que el listado la esconde y la
lectura por id la muestra, porque cada una responde a una pregunta distinta: «¿con qué puede pagar
este cliente hoy?» frente a «¿con qué se pagó esto?».
Consecuencia práctica: no construyas la pantalla de «mis tarjetas» con retrieve en bucle sobre
ids que guardaste tú. Usa el listado, que es el que sabe cuáles siguen vivas.
Oneclick es un producto de mall: hacen falta DOS códigos de comercio#
Esto es configuración del tenant, no de tu integración, pero explica el error más desconcertante de la puesta en marcha: el cobro falla como error de configuración y no llega a salir de ApiPay.
Oneclick es un producto de mall. Transbank entrega dos códigos de comercio distintos, por separado, y hacen falta los dos:
| Código | Dónde se usa |
|---|---|
| Padre (el del mall) | Va en la cabecera de autenticación de todas las llamadas a la API de Oneclick |
| Hijo (el de la tienda) | Cada línea del cobro se imputa a él |
Sin el hijo configurado, el cobro se rechaza como configuración del tenant antes de contactar con la
pasarela. Los cuatro valores que hay que tener a mano —flag de encendido, código padre, código hijo y
la API key— están en Docs/RETOMAR-AQUI.md §12.2, con los del ambiente de integración.
También hay un detalle de trazabilidad que ayuda a leer las liquidaciones: la orden de compra se recorta a 26 caracteres y el mismo valor viaja en la orden del mall y en la de la tienda hija. No colisionan porque son códigos de comercio distintos, y permite reconstruir la orden hija en la devolución sin guardar un segundo identificador.
Lo que hoy NO funciona, y hay que saber antes de probar#
Por qué falla cerrada y no al revés: la baja va primero en la pasarela y luego en la base. Si se
marcara antes la baja local y fallara la remota, quedaría un medio de pago vivo en Transbank que
ApiPay ya no muestra: cobrable y sin dueño visible. Al revés, un fallo local deja un medio ya inútil
en la pasarela, que es el error inofensivo de los dos. Qué hacer en producción cuando la baja remota
falla —revocar igualmente en local o mantener el fallo cerrado— es una decisión pendiente,
anotada en Docs/RETOMAR-AQUI.md §12.3.
Lo otro que no existe hoy:
- Captura diferida. El catálogo llegó a anunciar
SEPARATE_CAPTUREantes de que existiera el adaptador de cobro; ya se corrigió y las capacidades publicadas sonSTORED_CREDENTIALyPARTIAL_REFUND. La captura de Oneclick exige el código de autorización de la autorización previa, que el SPI no transporta, y depende de que el código de comercio esté contratado en modalidad diferida, un dato por tenant que la plataforma no modela. Anunciar la capacidad sin poder cumplirla dejaría pagos autorizados y nunca capturados. - Flujo alojado para guardar tarjetas. Todo el ciclo es
sk_desde tu servidor. El widget de checkout no inscribe medios de pago.
Checklist de integración#
- Oneclick habilitado para el tenant, con los dos códigos de comercio (padre e hijo).
-
Idempotency-Keyen las dos rutasPOST, persistida junto al cliente. - El
redirectde la primera respuesta se guarda: al repetir la clave ya no viene. - El
redirectse emite como POST de formulario, leyendofield_nameyurlde la respuesta; nunca como unGET. - Tu propia referencia viaja en la
return_urlpara saber quéins_cerrar al volver. - La inscripción se cierra dentro de los 15 minutos; pasado ese plazo se abre otra.
- El cobro manda
payment_method_idy no mandagateway_id. -
installmentsva como texto dentro demetadata, y sólo con Oneclick. - La pantalla de «mis tarjetas» usa el listado, no
retrieveen bucle. - Se asume que el
DELETEfalla en integración y la tarjeta sigue vigente. - La orden se libera con el webhook
payment_intent.succeeded, no con la respuesta delconfirm. - Ningún campo de tarjeta en tu página, ni en un ejemplo.
Siguientes pasos#
- Payment intents: la matriz de transiciones y el ciclo de vida completo.
- Confirmación: por qué el hub nunca recibe datos de tarjeta.
- Webpay Plus: la otra pasarela de Transbank, con el flujo de redirección.
- Manejo de errores: los códigos de
problem+jsonque aparecen en esta guía. - Referencia de SDKs: política de reintentos, idempotencia y auto-paginación.