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étodo | Ruta | Autenticación | Nota |
|---|---|---|---|
| POST | /v1/payment-intents | sk_ | Header Idempotency-Key obligatorio. Devuelve el intent en CREATED. |
| GET | /v1/payment-intents/{id} | sk_ · pk_ + X-Client-Secret | Con pk_ la respuesta omite campos server-only y añade el bloque checkout. |
| POST | /v1/payment-intents/{id}/confirm | sk_ · pk_ + X-Client-Secret | Inicia el cobro en la pasarela. Puede devolver REQUIRES_ACTION, PROCESSING o SUCCEEDED. |
| POST | /v1/payment-intents/{id}/cancel | sk_ | 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#
| Estado | Qué significa | Terminal |
|---|---|---|
| CREATED | Intent creado; todavía no se intentó cobrar nada | No |
| REQUIRES_ACTION | El pagador debe completar algo fuera de tu sitio (redirección a Webpay, 3DS). El intent trae redirect_url | No |
| PROCESSING | El cobro está en curso en la pasarela; el resultado llegará por webhook | No |
| SUCCEEDED | Cobro aprobado. Es el único estado desde el que se puede reembolsar | Sí |
| FAILED | Rechazado por el emisor o por la pasarela | Sí |
| EXPIRED | El intent caducó sin completarse | Sí |
| CANCELED | Lo cancelaste antes de que hubiera cobro | Sí |
Matriz de transiciones canónica#
Esta tabla es normativa. Cualquier transición que no aparezca con sí responde
409 invalid_state_transition.
| Desde ↓ · Hacia → | REQUIRES_ACTION | PROCESSING | SUCCEEDED | FAILED | EXPIRED | CANCELED |
|---|---|---|---|---|---|---|
| CREATED | sí | sí | no | no | sí | sí |
| REQUIRES_ACTION | no | sí | no | sí | sí | sí |
| PROCESSING | no | no | sí | sí | no | no |
| SUCCEEDED | terminal | terminal | terminal | terminal | terminal | terminal |
| FAILED | terminal | terminal | terminal | terminal | terminal | terminal |
| EXPIRED | terminal | terminal | terminal | terminal | terminal | terminal |
| CANCELED | terminal | terminal | terminal | terminal | terminal | terminal |
Leído como grafo:
+-----------------+
| CREATED |
+--+---+---+---+--+
| | | |
+-------------+ | | +---------------+
v v v v
+-----------------+ +---------+ +-------------------------+
| REQUIRES_ACTION |-->| EXPIRED | | CANCELED |
+--+-----+-----+--+ +---------+ +-------------------------+
| | | ^ ^
| | +------------+ |
| +---------------------------------------+
v
+------------+ +-----------+
| PROCESSING |------->| SUCCEEDED |
+-----+------+ +-----------+
|
v
+--------+
| FAILED |
+--------+
Dos lecturas que conviene fijar:
- A
SUCCEEDEDsolo se llega desdePROCESSING. No hay atajo desdeCREATED: incluso una aprobación instantánea pasa porPROCESSING. Si una pasarela resuelve el cobro en una sola llamada, el hub da el paso intermedio en lugar de saltárselo. REQUIRES_ACTIONno vuelve aREQUIRES_ACTION. Un segundoconfirmsobre un intent que ya requiere acción no genera una redirección nueva: continúa el flujo (por ejemplo, el commit deltoken_wsde Webpay) y lo lleva aPROCESSING.
Quién provoca cada transición#
| Transición | La provoca |
|---|---|
CREATED → REQUIRES_ACTION | Tu POST /confirm con una pasarela de redirección |
CREATED → PROCESSING | Tu POST /confirm con un token de hosted fields |
REQUIRES_ACTION → PROCESSING | El segundo confirm al volver el pagador de la pasarela |
PROCESSING → SUCCEEDED | FAILED | La pasarela, de forma síncrona en el confirm o por su webhook entrante |
REQUIRES_ACTION → FAILED | La pasarela cuando el pagador anula o el emisor rechaza |
no terminal → EXPIRED | El propio hub, cuando el intent supera su ventana de vida |
CREATED | REQUIRES_ACTION → CANCELED | Tu 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ón | Evento |
|---|---|
→ REQUIRES_ACTION | payment_intent.requires_action |
→ SUCCEEDED | payment_intent.succeeded |
→ FAILED | payment_intent.failed |
→ EXPIRED | payment_intent.expired |
→ CANCELED | payment_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
CREATEDaPROCESSING. - 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.