Skip to Content
ConceptosReembolsos

Reembolsos

Un Refund es una entidad de primera clase, no un flag en la Order. Puedes emitir reembolsos parciales, múltiples reembolsos contra la misma Order, o reembolsar + re-cobrar en el mismo flujo.

Dos maneras en que puede existir el registro de reembolso:

FlujoQuién rellena el formularioAuthAterriza en
Iniciado por el comercianteTu dashboard / tu backendHMAC (sk_…)APPROVED inmediatamente
Iniciado por el clienteEl comprador, en nuestra página alojadaToken de un solo uso (sin credenciales)PENDING: tú apruebas, o se cortocircuita si tu config auto-aprueba

El flujo iniciado por el cliente usa un token de solicitud de reembolso de corta duración. Emites un token (B2B o dashboard), entregas la URL al comprador como prefieras, y el comprador completa los detalles del reembolso en checkout.infraio.xyz/refund-request/:token. El comprador nunca toca tu API y nunca ve tu clave de comerciante.

Ciclo de vida del reembolso

EstadoSignifica
PENDINGReembolso registrado, a la espera de aprobación. Los reembolsos iniciados por el cliente siempre empiezan aquí.
APPROVEDAprobado para ejecución. Los reembolsos iniciados por el comerciante saltan aquí directamente.
REJECTEDReembolso denegado. El estado de la Order no cambia.
EXECUTEDTransferencia on-chain confirmada. La Order se mueve a PARTIALLY_REFUNDED / REFUNDED.

Iniciado por el comerciante

Decides reembolsar (p. ej., el comprador se quejó por chat). Llama al endpoint iniciado por el comerciante: se salta la revisión y aterriza en APPROVED inmediatamente.

POST /b2b/v1/merchants/{merchant_id}/refunds { "order_id": "ord_01J5K…", "amount": "49.00", // parcial o total, en la moneda de visualización de la orden "reason": "customer complaint #4521", "refund_to_address": "0xBUYER…", // requerido para rails cripto "refund_network": "polygon", // slug de red; ver Conceptos → Cadenas "refund_token_address":"0xUSDC_CONTRACT" // contrato ERC-20 a devolver; normalmente el token original }

No hay campo currency en la solicitud de reembolso: los reembolsos siempre heredan la moneda de visualización de la orden (USD hoy). La terna (refund_to_address, refund_network, refund_token_address) es el destino on-chain; payment-service las usa para impulsar el saga cripto. Se ignoran para rails fiat (auto-enrutadas por el proveedor).

La orden conserva su estado existente hasta que ejecutas la transferencia on-chain (ver Ejecutar un reembolso cripto).


Iniciado por el cliente — tokens de solicitud de reembolso

El comprador rellena el formulario de reembolso en nuestra página alojada, no en la tuya. Tu único trabajo es emitir un token y entregar la URL.

Ciclo de vida del token

EstadoSignificaLa URL del cliente renderiza
ACTIVEEl token está activo, now < expires_atEl formulario de reembolso (refund_to_address, reason, amount, nota opcional → metadata.note)
SUBMITTEDEl comprador completó el formulario; existe una fila RefundTarjeta de estado reflejando /r/:linkToken
EXPIRED_UNUSEDTTL transcurrido antes de que el comprador enviasePrompt: “Este enlace ha expirado. Solicita uno nuevo”
RENEWAL_REQUESTEDEl comprador pidió un enlace nuevoAviso de espera: “Tu comerciante ha sido notificado”
RENEWEDEl comerciante aprobó la renovación y emitió un reemplazo”Este enlace ha sido reemplazado: revisa tu email para el nuevo enlace” (el nuevo token no se revela aquí, para frustrar ataques de enlaces reenviados)
CANCELEDEl comerciante revocó el token desde el dashboardSimple “Esta solicitud de reembolso fue cancelada”

Los tokens son de un solo uso. Una vez SUBMITTED, la URL sigue siendo válida para que el comprador compruebe el estado pero no puede usarse para enviar de nuevo. Para emitir un segundo reembolso contra la misma orden, emite un nuevo token.

TTL por defecto

Fuente de emisiónTTL por defectoPor qué
POST /b2b/v1/merchants/{merchant_id}/refund-requests (HMAC)30 minutosProgramático: se asume que se entrega al comprador inmediatamente.
POST /payment/v1/merchants/{merchant_id}/refund-requests (JWT del dashboard)24 horasManual: el comerciante pega la URL en un email / SMS.

Ambos endpoints aceptan un campo ttl_seconds en el body si quieres sobreescribir. No hay límite mínimo/máximo duro aplicado en el lado del servidor hoy: valores comunes son de 1 minuto a 7 días. Mantente dentro de ese rango para evitar sorpresas a los compradores o retener capacidad en tokens cancelados.

Emitir vía la API B2B

Para backends que quieren generar programáticamente un enlace de reembolso justo tras una conversación de soporte, un flujo de cancelación de orden, etc.

POST /b2b/v1/merchants/{merchant_id}/refund-requests Content-Type: application/json X-Client-ID: pk_live_… X-Timestamp: 1729536000 X-Signature: 4f2a1b9c8d3e2f1a0b9c8d7e6f5a4b3c… { "ref_type": "order_id", // obligatorio: order_id | order_number | session_id | session_key "ref_value": "ord_01J5K…", // obligatorio: coincide con ref_type "amount": "49.00", // obligatorio — bloquea el máximo que el comprador puede enviar "ttl_seconds": 1800, // opcional — por defecto 1800 (30 min) "metadata": { "support_ticket": "4521" }, // opcional — clave/valor estilo Stripe "hide_summary": false, // banderas opcionales de UI para el formulario alojado "hide_header": false }

La firma de la petición B2B es hex en minúsculas crudo sin prefijo sha256=: ese prefijo solo aparece en firmas de webhook entrantes (InfraIO → tu servidor). La cadena de firma B2B saliente es METHOD\nPATH\nTIMESTAMP\nBODY; consulta Autenticación para el algoritmo canónico.

El importe está en el body de emisión y es obligatorio. Bloquea el techo que el comprador puede enviar en el formulario: puede enviar por menos pero nunca por más. (Para reembolsos parciales, emite un token con el importe parcial; para reembolsos totales, emite con el total de la orden.)

La forma heredada { "order_id": "..." } sigue aceptándose por compatibilidad hacia atrás: internamente se mapea a (ref_type=order_id, ref_value=...), pero las nuevas integraciones deberían usar el par explícito ref_type + ref_value.

Respuesta:

{ "token": "rfqt_01J7P3Q9R…", "refund_url": "https://checkout.infraio.xyz/refund-request/rfqt_01J7P3Q9R…", "expires_at": "2026-05-28T10:32:00Z" }

Dispara refund_request.created a tus endpoints de webhook (para que puedas registrar / auditar qué token está actualmente activo para una orden).

Emitir vía dashboard

El modal Emitir Reembolso en el dashboard del comerciante  expone un toggle: Ejecutar ahora vs Enviar enlace al cliente. Elegir el segundo llama a POST /payment/v1/merchants/{merchant_id}/refund-requests por detrás (autenticado con JWT, misma forma de body que la variante B2B de arriba), luego te muestra la URL con un botón de copiar y un código QR. Pégalo en el canal que tenga sentido: email, chat de soporte, SMS.

Vía el SDK de JavaScript — openRefundRequest

Si ya tienes @lartech/infraio-checkout-js en tu stack y quieres que el comprador complete el reembolso dentro del flujo de tu propia página (no vía una URL externa), combina la emisión B2B con sdk.openRefundRequest():

// Lado del servidor: emite el token const { token } = await fetch("/api/mint-refund-token", { method: "POST" }).then(r => r.json()); // Lado del cliente: abre el formulario alojado const sdk = await loadInfraIo("pk_live_yourkeyhere"); const close = sdk.openRefundRequest({ token, mode: "popup", // o "redirect" | "embed" onSuccess: ({ linkToken, refundId }) => { // linkToken → página de estado /r/:linkToken para el comprador. // refundId → referencia de la API B2B para aprobar / rechazar. window.location.href = `/r/${linkToken}`; }, onCancel: () => { /* el comprador cerró el popup */ }, onError: (err) => { /* ver referencia del SDK */ }, });

Consulta la referencia del SDK → sdk.openRefundRequest() para la tabla completa de opciones.

Renovación del cliente — reemisión iniciada por el comprador

Si el comprador abre la URL tras expirar el token, la página ofrece un botón Solicitar nuevo enlace en lugar del formulario. Hacer clic en él:

  1. POSTea a /pub/v1/refund-requests/:token/request-renewal (sin credenciales: el propio token es el bearer-of-truth)
  2. Captura opcionalmente una nota de texto libre (customer_note) que el comprador puede dejar para el comerciante
  3. Mueve el token a RENEWAL_REQUESTED y dispara refund_request.renewal_requested a tu webhook

Tu dashboard muestra una insignia en el widget de solicitudes de renovación. Apruébalo (un clic) y el sistema emite un nuevo token ACTIVE, dispara refund_request.renewed, y te deja copiar la nueva URL para enviar de nuevo. La URL antigua sigue accesible pero renderiza “Reemplazado: revisa tu email” para que una copia reenviada de la URL antigua no pueda usarse para pescar la nueva.


Ejecutar un reembolso cripto

La API registra la intención: no mueve fondos. firmas y difundes la transferencia on-chain desde tu wallet de comerciante, luego sellas el tx hash de vuelta en el registro del reembolso:

POST /b2b/v1/refunds/:refund_id/submit-tx Content-Type: application/json { "tx_hash": "0xabcd…", "network": "ethereum", "token_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" }

Los tres campos del body son obligatorios: el mismo tx hash puede existir en cadenas diferentes, y puedes reembolsar en una stablecoin distinta a la que capturó el pago original.

Cuando el chain watcher ve que esa tx se confirma según el conteo de confirmaciones configurado (ver Cadenas y activos), el reembolso cambia a EXECUTED y se actualiza el total reembolsado de la Order.

Deliberadamente no tenemos custodia de los fondos del comerciante, lo que significa que no podemos ejecutar reembolsos en tu nombre. Integra el envío on-chain en tu tooling de admin: eth_sendRawTransaction desde un multisig o hot wallet, con un workflow que termine publicando el tx hash en la API de reembolso.


Eventos de webhook

El subsistema de reembolsos dispara dos familias de eventos:

Ciclo de vida del token (refund_request.*)

EventoSe dispara cuando
refund_request.createdSe emitió un token: data.source es b2b / dashboard / renewal
refund_request.renewal_requestedUn comprador hizo clic en “Solicitar nuevo enlace” tras expirar su token. Suscríbete a esto: es la señal para que el comerciante actúe.
refund_request.renewedAprobaste una renovación y un nuevo token reemplazó al antiguo. data.old_token / data.new_token forman la cadena de auditoría.
refund_request.canceledCambiaste un token a CANCELED desde el dashboard. Idempotente: solo la primera transición emite. data.reason es la nota opcional del comerciante.

Ciclo de vida del reembolso (payment.refund.*)

EventoSe dispara cuando
payment.refund.requestedExiste una nueva fila Refund: cualquier fuente (envío de formulario, API iniciada por el comerciante, dashboard).
payment.refund.approvedEl reembolso está aprobado: ya sea auto-aprobado (iniciado por el comerciante) o tras llamar a /approve sobre uno pendiente.
payment.refund.rejectedLlamaste a /reject sobre un reembolso pendiente.
payment.refund.executedLos fondos se han movido (tu tx hash cripto alcanzó las confirmaciones requeridas).

payment.failed no se dispara para un reembolso: los reembolsos tienen su propia serie de eventos bajo el prefijo payment.refund.*.

Qué sigue