Saltar al contenido
ApiPay Hub · Docs

Payment intents y su ciclo de vida#

Un payment intent es el objeto que representa la intención de cobrar un monto concreto a un pagador concreto. Se crea una vez, cambia de estado un número acotado de veces y termina en un estado del que no sale nunca más. Todo lo demás de la API —confirmaciones, reembolsos, transacciones, webhooks— cuelga de él.

Su identificador público tiene prefijo pi_ y 24 caracteres base62 (pi_9f2c4a7d1b3e4f60a8c5d2e7). Es opaco: no lo parsees, no deduzcas nada de él, guárdalo tal cual junto a tu orden.


Los endpoints#

MétodoRutaAutenticaciónNota
POST/v1/payment-intentssk_Header Idempotency-Key obligatorio. Devuelve el intent en CREATED.
GET/v1/payment-intents/{id}sk_ · pk_ + X-Client-SecretCon pk_ la respuesta omite campos server-only y añade el bloque checkout.
POST/v1/payment-intents/{id}/confirmsk_ · pk_ + X-Client-SecretInicia el cobro en la pasarela. Puede devolver REQUIRES_ACTION, PROCESSING o SUCCEEDED.
POST/v1/payment-intents/{id}/cancelsk_Solo válido en CREATED y REQUIRES_ACTION. Sobre un intent terminal responde 409.

El client_secret viaja siempre en el header X-Client-Secret, nunca como parámetro de query: en la query acabaría en logs de proxy, en cabeceras Referer y en el historial del navegador.


Los siete estados#

EstadoQué significaTerminal
CREATEDIntent creado; todavía no se intentó cobrar nadaNo
REQUIRES_ACTIONEl pagador debe completar algo fuera de tu sitio (redirección a Webpay, 3DS). El intent trae redirect_urlNo
PROCESSINGEl cobro está en curso en la pasarela; el resultado llegará por webhookNo
SUCCEEDEDCobro aprobado. Es el único estado desde el que se puede reembolsar
FAILEDRechazado por el emisor o por la pasarela
EXPIREDEl intent caducó sin completarse
CANCELEDLo cancelaste antes de que hubiera cobro

Matriz de transiciones canónica#

Esta tabla es normativa. Cualquier transición que no aparezca con responde 409 invalid_state_transition.

Desde ↓ · Hacia →REQUIRES_ACTIONPROCESSINGSUCCEEDEDFAILEDEXPIREDCANCELED
CREATEDnono
REQUIRES_ACTIONnono
PROCESSINGnononono
SUCCEEDEDterminalterminalterminalterminalterminalterminal
FAILEDterminalterminalterminalterminalterminalterminal
EXPIREDterminalterminalterminalterminalterminalterminal
CANCELEDterminalterminalterminalterminalterminalterminal

Leído como grafo:

                 +-----------------+
                 |     CREATED     |
                 +--+---+---+---+--+
                    |   |   |   |
      +-------------+   |   |   +---------------+
      v                 v   v                   v
+-----------------+   +---------+   +-------------------------+
| REQUIRES_ACTION |-->| EXPIRED |   |        CANCELED         |
+--+-----+-----+--+   +---------+   +-------------------------+
   |     |     |            ^                    ^
   |     |     +------------+                    |
   |     +---------------------------------------+
   v
+------------+        +-----------+
| PROCESSING |------->| SUCCEEDED |
+-----+------+        +-----------+
      |
      v
   +--------+
   | FAILED |
   +--------+

Dos lecturas que conviene fijar:

  • A SUCCEEDED solo se llega desde PROCESSING. No hay atajo desde CREATED: incluso una aprobación instantánea pasa por PROCESSING. Si una pasarela resuelve el cobro en una sola llamada, el hub da el paso intermedio en lugar de saltárselo.
  • REQUIRES_ACTION no vuelve a REQUIRES_ACTION. Un segundo confirm sobre un intent que ya requiere acción no genera una redirección nueva: continúa el flujo (por ejemplo, el commit del token_ws de Webpay) y lo lleva a PROCESSING.

Quién provoca cada transición#

TransiciónLa provoca
CREATEDREQUIRES_ACTIONTu POST /confirm con una pasarela de redirección
CREATEDPROCESSINGTu POST /confirm con un token de hosted fields
REQUIRES_ACTIONPROCESSINGEl segundo confirm al volver el pagador de la pasarela
PROCESSINGSUCCEEDED | FAILEDLa pasarela, de forma síncrona en el confirm o por su webhook entrante
REQUIRES_ACTIONFAILEDLa pasarela cuando el pagador anula o el emisor rechaza
no terminal → EXPIREDEl propio hub, cuando el intent supera su ventana de vida
CREATED | REQUIRES_ACTIONCANCELEDTu POST /cancel

Tú solo provocas dos de ellas: confirm y cancel. El resto llegan de la pasarela o del reloj, y te enteras por webhook.


El 409 invalid_state_transition#

Pedir una transición que la matriz no permite responde 409 con un documento problem+json:

{
  "type": "https://api.apipay.io/problems/invalid-state-transition",
  "title": "Conflict",
  "status": 409,
  "detail": "No se puede cancelar un payment intent en estado SUCCEEDED.",
  "instance": "/v1/payment-intents/pi_9f2c4a7d1b3e4f60a8c5d2e7/cancel",
  "code": "invalid_state_transition"
}

Ramifica siempre contra code, nunca contra detail: el texto es para humanos y puede cambiar sin aviso. Los cuatro SDKs lo materializan como una excepción propia:

import { InvalidStateTransitionError } from '@apipay/node';
try {
  await apipay.paymentIntents.cancel(intentId, { reason: 'requested_by_customer' });
} catch (error) {
  if (error instanceof InvalidStateTransitionError) {
    // El intent ya es terminal: la orden esta cobrada, fallida o vencida.
    const actual = await apipay.paymentIntents.retrieve(intentId);
    return conciliar(actual.status);
  }
  throw error;
}

Leer el estado sin encuestar#

GET /v1/payment-intents/{id} es idempotente y los SDKs lo reintentan ante 429 y 5xx (backoff exponencial con jitter completo, base 500 ms, tope 8 s, maxRetries 2). Úsalo para conciliar un caso concreto, no como mecanismo de notificación.

const intent = await apipay.paymentIntents.retrieve('pi_9f2c4a7d1b3e4f60a8c5d2e7');
if (intent.status === 'SUCCEEDED') {
  await orders.marcarPagada(intent.metadata?.order_id);
}

Qué eventos emite cada transición#

TransiciónEvento
REQUIRES_ACTIONpayment_intent.requires_action
SUCCEEDEDpayment_intent.succeeded
FAILEDpayment_intent.failed
EXPIREDpayment_intent.expired
CANCELEDpayment_intent.canceled

PROCESSING no emite evento propio: es un estado de tránsito y notificarlo solo añadiría ruido. El data.object del evento es el payment intent sin client_secret —ese valor jamás viaja en un webhook— y sin el bloque checkout. Cómo recibirlos está en la guía de webhooks.


Metadata: tu identificador, no el nuestro#

metadata es un mapa de hasta 50 pares de cadenas que viaja en el intent y vuelve en todos sus eventos. Es el sitio correcto para el id de tu orden:

{ "metadata": { "order_id": "4831", "canal": "web", "sucursal": "providencia" } }

Siguientes pasos#

  • Confirmación: las dos formas de llevar un intent de CREATED a PROCESSING.
  • Webhooks: cómo enterarte de las transiciones que no provocas tú.
  • Reembolsos: lo único que se puede hacer con un intent SUCCEEDED.
  • Manejo de errores: el catálogo completo de los 17 code.
  • Referencia de API: los esquemas exactos, generados desde el contrato OpenAPI.