Reembolsos totales y parciales#
Un refund devuelve dinero de un payment intent que ya está en SUCCEEDED. Es el único camino de vuelta: un intent aprobado no se anula ni se cancela, se reembolsa.
Su identificador público tiene prefijo re_ y 24 caracteres base62
(re_5b1d9c3a7e2f4a08b6c1d9e3).
Los endpoints#
| Método | Ruta | Autenticación | Nota |
|---|---|---|---|
| POST | /v1/refunds | sk_ | Header Idempotency-Key obligatorio. Sin amount_minor reembolsa el saldo total. |
| GET | /v1/refunds/{id} | sk_ | Solo visible para el tenant propietario; nunca se revela la existencia cross-tenant. |
Una clave publicable pk_ no puede reembolsar: intentarlo responde
403 insufficient_permissions. Los reembolsos se piden siempre desde tu servidor.
Total frente a parcial#
La diferencia está en un solo campo:
| Quieres | Envías | Qué monto se devuelve |
|---|---|---|
| Reembolso total | payment_intent_id sin amount_minor | El saldo reembolsable que quede en el momento de procesarlo |
| Reembolso parcial | payment_intent_id con amount_minor | Exactamente ese monto, en unidades menores |
// Parcial: 500.000 pesos de una compra de 1.499.000.
const parcial = await apipay.refunds.create({
payment_intent_id: 'pi_9f2c4a7d1b3e4f60a8c5d2e7',
amount_minor: 500000,
reason: 'requested_by_customer',
metadata: { ticket: 'SOP-1102' },
});
// Total: se omite amount_minor y la plataforma calcula el saldo.
const total = await apipay.refunds.create({
payment_intent_id: 'pi_9f2c4a7d1b3e4f60a8c5d2e7',
reason: 'duplicate',
});
// Persistir la clave: reintentar con una clave NUEVA es devolver el dinero dos veces.
await tickets.saveIdempotencyKey('SOP-1102', parcial.idempotencyKey);La respuesta es el recurso refund, normalmente en PENDING:
{
"id": "re_5b1d9c3a7e2f4a08b6c1d9e3",
"object": "refund",
"payment_intent_id": "pi_9f2c4a7d1b3e4f60a8c5d2e7",
"amount_minor": 500000,
"currency": "CLP",
"status": "PENDING",
"reason": "requested_by_customer",
"gateway_reference": null,
"metadata": { "ticket": "SOP-1102" },
"livemode": false,
"created_at": "2026-08-07T16:05:12Z"
}
currency es siempre la del payment intent. No existe reembolso en otra divisa.
Estados del refund#
| Estado | Qué significa | Terminal |
|---|---|---|
| PENDING | Aceptado por ApiPay y en curso en la pasarela | No |
| SUCCEEDED | La pasarela liquidó la devolución | Sí |
| FAILED | La pasarela rechazó la devolución | Sí |
Tres cosas que conviene tener claras:
- Un
201no es dinero devuelto, es «aceptado». La liquidación la confirma la pasarela y te llega por webhook, a veces horas después.gateway_referencese puebla cuando la pasarela emite su comprobante. - El payment intent no cambia de estado. Sigue en SUCCEEDED aunque esté
reembolsado al 100%, porque los estados terminales no tienen salida. El reembolso es un objeto aparte,
con su propia transacción de tipo
REFUND. - Un
FAILEDno se reintenta solo. Es una decisión de la pasarela: cuenta cerrada, operación fuera de plazo, tarjeta dada de baja. Se resuelve por otro canal, no reintentando el mismo POST.
Eventos#
| Evento | data.object | Cuándo |
|---|---|---|
refund.succeeded | refund | El refund pasó a SUCCEEDED |
refund.failed | refund | El refund pasó a FAILED |
PENDING no emite evento: es el estado con el que nace, y ya lo conoces por la respuesta del POST. Cómo
recibir y verificar estos eventos está en la guía de webhooks.
Saldo reembolsable y el 422 amount_exceeds_refundable#
El saldo reembolsable de un intent es su amount_minor menos la suma de los reembolsos PENDING y
SUCCEEDED que ya tiene. Los parciales se acumulan:
Intent SUCCEEDED por 1.499.000 CLP
reembolso 1 amount_minor 400000 SUCCEEDED -> saldo 1.099.000
reembolso 2 amount_minor 600000 SUCCEEDED -> saldo 499.000
reembolso 3 amount_minor 600000 -> 422 amount_exceeds_refundable
reembolso 3' sin amount_minor -> devuelve 499.000, saldo 0
Pedir más que el saldo responde 422:
{
"type": "https://api.apipay.io/problems/amount-exceeds-refundable",
"title": "Unprocessable Entity",
"status": 422,
"detail": "El monto solicitado supera el saldo reembolsable del payment intent.",
"instance": "/v1/refunds",
"code": "amount_exceeds_refundable"
}
import { AmountExceedsRefundableError } from '@apipay/node';
try {
await apipay.refunds.create({ payment_intent_id: intentId, amount_minor: monto });
} catch (error) {
if (error instanceof AmountExceedsRefundableError) {
// No reintentar con el mismo monto: el saldo no va a crecer. Recalcular desde
// tus propios registros de reembolso y avisar al operador.
return avisarSaldoInsuficiente(intentId, monto);
}
throw error;
}Otros errores que puede devolver POST /v1/refunds:
| Código | Estado | Cuándo |
|---|---|---|
validation_error | 400 | Falta payment_intent_id, o amount_minor no es un entero positivo |
resource_not_found | 404 | El pi_ no existe para tu tenant |
invalid_state_transition | 409 | El intent no está en SUCCEEDED |
amount_below_minimum | 422 | El monto está por debajo del mínimo que acepta la pasarela |
amount_exceeds_refundable | 422 | Supera el saldo reembolsable |
gateway_unavailable | 503 | Circuito abierto hacia la pasarela; respeta Retry-After |
La idempotencia aquí importa el doble#
Si prefieres controlar la clave tú, pásala explícitamente y derívala de algo estable de tu dominio —el id del ticket de devolución, no un timestamp ni un aleatorio nuevo—:
const refund = await apipay.refunds.create(
{ payment_intent_id: intentId, amount_minor: 500000 },
{ idempotencyKey: 'devolucion-SOP-1102' },
);Leer un reembolso#
const refund = await apipay.refunds.retrieve('re_5b1d9c3a7e2f4a08b6c1d9e3');
if (refund.status === 'SUCCEEDED') {
await tickets.cerrar('SOP-1102', refund.gateway_reference);
}GET es idempotente, así que este sí lo reintentan los SDKs ante 429 y 5xx, con backoff exponencial
y jitter completo. Aun así, la vía normal para enterarte del desenlace es el evento refund.succeeded o
refund.failed, no encuestar.
Probar reembolsos en modo test#
Con la pasarela sandbox los reembolsos son inmediatos y gratuitos. El escenario lo sigue eligiendo el
monto del intent original: crea el intent con un monto acabado en 00 para que se apruebe, y
reembólsalo. Los detalles están en modo test y sandbox.
Y para ensayar tu handler de refund.failed sin depender de una pasarela, dispara el evento desde el
backoffice contra un endpoint de modo test: llega con firma auténtica y livemode: false.
Siguientes pasos#
- Webhooks:
refund.succeededyrefund.failedpaso a paso. - Payment intents: por qué el intent sigue en
SUCCEEDEDtras reembolsar. - Manejo de errores: el catálogo completo de los 17
code. - Referencia de API: esquema exacto de
CreateRefundRequestyRefund.