Saltar al contenido
ApiPay Hub · Docs

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étodoRutaAutenticaciónNota
POST/v1/refundssk_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:

QuieresEnvíasQué monto se devuelve
Reembolso totalpayment_intent_id sin amount_minorEl saldo reembolsable que quede en el momento de procesarlo
Reembolso parcialpayment_intent_id con amount_minorExactamente 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#

EstadoQué significaTerminal
PENDINGAceptado por ApiPay y en curso en la pasarelaNo
SUCCEEDEDLa pasarela liquidó la devolución
FAILEDLa pasarela rechazó la devolución

Tres cosas que conviene tener claras:

  • Un 201 no es dinero devuelto, es «aceptado». La liquidación la confirma la pasarela y te llega por webhook, a veces horas después. gateway_reference se 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 FAILED no 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#

Eventodata.objectCuándo
refund.succeededrefundEl refund pasó a SUCCEEDED
refund.failedrefundEl 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ódigoEstadoCuándo
validation_error400Falta payment_intent_id, o amount_minor no es un entero positivo
resource_not_found404El pi_ no existe para tu tenant
invalid_state_transition409El intent no está en SUCCEEDED
amount_below_minimum422El monto está por debajo del mínimo que acepta la pasarela
amount_exceeds_refundable422Supera el saldo reembolsable
gateway_unavailable503Circuito 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#