docs
Guías de Integración

PIX Refund-In (Devolución)

Descripción General

El endpoint PIX Refund-In le permite devolver (reembolsar) pagos PIX recibidos (transacciones del tipo PAYMENT con estado CONFIRMED). Las devoluciones pueden ser totales o parciales y deben solicitarse dentro del plazo de 89 días posteriores a la recepción. El valor devuelto se debita del saldo de su cuenta.

Este endpoint requiere un token Bearer válido. Consulte la documentación de autenticación para más detalles.

Características

  • Devoluciones totales o parciales
  • Múltiples devoluciones parciales de la misma transacción
  • Plazo de hasta 89 días
  • Resultado final notificado por el webhook CashInReversal
  • Rastreo por motivo de la devolución

Cuándo Usar Devoluciones

Devolución Total

Devuelve el 100% del valor recibido al pagador original.

Casos de uso:

  • Cancelación completa del pedido
  • Producto no enviado
  • Pago duplicado
  • Error en el valor cobrado

Devolución Parcial

Devuelve solo una parte del valor recibido.

Casos de uso:

  • Devolución de ítems específicos
  • Compensación por problemas con el producto/servicio
  • Ajuste de valores
  • Descuento retroactivo

Endpoint

POST /api/pix/refund-in/{id}

Solicita la devolución de un pago recibido.

Encabezados Requeridos

Authorization: Bearer {token}
Content-Type: application/json

Parámetros de Ruta

idstringobrigatorio

Identificador de la transacción original (Cash-In) a devolver: el transactionId numérico o el End-to-End ID (endToEndId) del PIX recibido.

Ejemplo: "7845"

Cuerpo de la Solicitud

{
  "refundValue": 75.00,
  "reason": "El cliente solicitó la devolución de 1 ítem del pedido"
}

Solicitud

curl -X POST https://api.fypay.me/api/pix/refund-in/7845 \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
  -H "Content-Type: application/json" \
  -d '{
    "refundValue": 75.00,
    "reason": "El cliente solicitó la devolución de 1 ítem del pedido"
  }'

Respuesta (201 Created)

{
  "transactionId": "7846",
  "externalId": "D123456789",
  "status": "PENDING",
  "refundValue": 75.00,
  "providerTransactionId": "7ef4fc3f-a187-495e-857c-e84d70612761",
  "generateTime": "2024-01-19T16:30:00.000Z"
}

Parámetros de la Solicitud

refundValuenumberobrigatorio

Valor a devolver en reales (BRL). Debe tener como máximo 2 decimales.

Validaciones:

  • Debe ser mayor o igual a 0.01
  • No puede exceder el valor disponible para devolución (refundableAmount en GET /api/pix/transaction/\{id\})
  • La suma de todas las devoluciones no puede exceder el valor original
  • La cuenta debe tener saldo disponible para cubrir la devolución

Ejemplo: 75.00

reasonstring

Motivo de la devolución (opcional, pero recomendado).

Máximo: 140 caracteres

Ejemplo: "El cliente solicitó la devolución de 1 ítem del pedido"

Recomendación: Proporcione siempre un motivo claro para fines de auditoría

externalIdstring

ID externo para identificar la devolución (opcional). Debe ser único por cuenta: si se repite, la API devuelve 409 con code PIX_DUPLICATE_EXTERNAL_ID. Cuando se omite, la plataforma genera uno con el formato refund-<uuid>. No puede empezar con MIRROR: (prefijo reservado).

En la API BACEN, corresponde al parámetro 'id' de la URL.

Máximo: 100 caracteres

Ejemplo: "D123456789"

Estructura de la Respuesta

transactionIdstringsempre presente

ID de la nueva transacción de devolución generada.

Ejemplo: "7846"

Nota: Es un ID distinto al de la transacción original

externalIdstringsempre presente

ID externo de la transacción de devolución.

Ejemplo: "D123456789"

statusstringsempre presente

Estado actual de la transacción de devolución.

Valores posibles:

  • PENDING: Devolución en procesamiento
  • CONFIRMED: Devolución confirmada y finalizada
  • ERROR: Error en el procesamiento

Ejemplo: "PENDING"

refundValuenumbersempre presente

Valor de la devolución en reales.

Ejemplo: 75.00

providerTransactionIdstringsempre presente

ID de la transacción en el proveedor (usado para la correlación con webhooks).

Ejemplo: "7ef4fc3f-a187-495e-857c-e84d70612761"

generateTimestringsempre presente

Fecha y hora de generación de la devolución (ISO 8601 UTC).

Ejemplo: "2024-01-19T16:30:00.000Z"

Ejemplos de Implementación

Node.js / TypeScript

import axios from 'axios';

interface RefundRequest {
  refundValue: number;
  reason?: string;
  externalId?: string;
}

interface RefundResponse {
  transactionId: string;
  externalId: string;
  status: 'PENDING' | 'CONFIRMED' | 'ERROR';
  refundValue: number;
  providerTransactionId: string;
  generateTime: string;
}

async function refundPixPayment(
  token: string,
  originalTransactionId: string,
  refundAmount: number,
  reason?: string
): Promise<RefundResponse> {
  const payload: RefundRequest = {
    refundValue: refundAmount,
    reason: reason || 'Devolución solicitada por el cliente'
  };

  try {
    const response = await axios.post<RefundResponse>(
      `https://api.fypay.me/api/pix/refund-in/${originalTransactionId}`,
      payload,
      {
        headers: {
          'Authorization': `Bearer ${token}`,
          'Content-Type': 'application/json'
        }
      }
    );

    console.log('¡Devolución PIX iniciada con éxito!');
    console.log(`ID de la Transacción de Devolución: ${response.data.transactionId}`);
    console.log(`ID Externo Original: ${response.data.externalId}`);
    console.log(`Valor de la Devolución: R$ ${response.data.refundValue.toFixed(2)}`);
    console.log(`Estado: ${response.data.status}`);

    return response.data;
  } catch (error) {
    if (axios.isAxiosError(error)) {
      const errorData = error.response?.data;
      console.error('Error al procesar la devolución:', errorData);

      // Tratar errores específicos
      if (error.response?.status === 400) {
        if (errorData?.code === 'PIX_REFUND_DEADLINE_EXCEEDED') {
          throw new Error('Se excedió el plazo de 89 días para la devolución');
        }
        if (errorData?.code === 'PIX_REFUND_EXCEEDS_AVAILABLE') {
          throw new Error('El valor de la devolución excede el disponible para devolución');
        }
      }

      if (error.response?.status === 404) {
        throw new Error('Transacción original no encontrada');
      }

      throw new Error(errorData?.message || 'Error al procesar la devolución');
    }
    throw error;
  }
}

// Uso - Devolución Total
async function fullRefund(token: string, transactionId: string, originalValue: number) {
  return await refundPixPayment(
    token,
    transactionId,
    originalValue,
    'Cancelación total del pedido'
  );
}

// Uso - Devolución Parcial
async function partialRefund(token: string, transactionId: string, itemValue: number) {
  return await refundPixPayment(
    token,
    transactionId,
    itemValue,
    'Devolución de 1 ítem del pedido'
  );
}

// Ejemplo práctico
const token = 'su_token_aqui';
const transactionId = '7845';

// Devolver R$ 75,00 de una transacción de R$ 150,00
refundPixPayment(token, transactionId, 75.00, 'El cliente solicitó una devolución parcial');

Python

import requests
from datetime import datetime
from typing import Dict, Optional

def refund_pix_payment(
    token: str,
    original_transaction_id: str,
    refund_amount: float,
    reason: Optional[str] = None
) -> Dict:
    """
    Devuelve un pago PIX recibido

    Args:
        token: Token Bearer válido
        original_transaction_id: ID de la transacción original (Cash-In)
        refund_amount: Valor a devolver
        reason: Motivo de la devolución (opcional)

    Returns:
        Datos de la devolución creada
    """
    url = f'https://api.fypay.me/api/pix/refund-in/{original_transaction_id}'

    payload = {
        'refundValue': round(refund_amount, 2),
        'reason': reason or 'Devolución solicitada por el cliente'
    }

    headers = {
        'Authorization': f'Bearer {token}',
        'Content-Type': 'application/json'
    }

    try:
        response = requests.post(url, json=payload, headers=headers)
        response.raise_for_status()

        data = response.json()

        print('¡Devolución PIX iniciada con éxito!')
        print(f"ID de la Transacción de Devolución: {data['transactionId']}")
        print(f"ID Externo Original: {data['externalId']}")
        print(f"Valor de la Devolución: R$ {data['refundValue']:.2f}")
        print(f"Estado: {data['status']}")

        return data

    except requests.exceptions.HTTPError as e:
        error_data = e.response.json() if e.response else {}
        error_message = error_data.get('message', str(e))

        # Tratar errores específicos
        if e.response.status_code == 400:
            if error_data.get('code') == 'PIX_REFUND_DEADLINE_EXCEEDED':
                raise Exception('Se excedió el plazo de 89 días para la devolución')
            if error_data.get('code') == 'PIX_REFUND_EXCEEDS_AVAILABLE':
                raise Exception('El valor de la devolución excede el disponible para devolución')
            raise Exception(f'Datos inválidos: {error_message}')

        if e.response.status_code == 404:
            raise Exception('Transacción original no encontrada')

        raise Exception(f'Error al procesar la devolución: {error_message}')

# Uso
token = 'su_token_aqui'
transaction_id = '7845'

# Devolución parcial
refund = refund_pix_payment(
    token=token,
    original_transaction_id=transaction_id,
    refund_amount=75.00,
    reason='El cliente solicitó la devolución de 1 ítem del pedido'
)

# Devolución total
def full_refund(token: str, transaction_id: str, original_value: float):
    """Realiza una devolución total"""
    return refund_pix_payment(
        token=token,
        original_transaction_id=transaction_id,
        refund_amount=original_value,
        reason='Cancelación total del pedido'
    )

PHP

<?php

function refundPixPayment(
    string $token,
    string $originalTransactionId,
    float $refundAmount,
    ?string $reason = null
): array {
    $url = "https://api.fypay.me/api/pix/refund-in/$originalTransactionId";

    $payload = [
        'refundValue' => round($refundAmount, 2),
        'reason' => $reason ?? 'Devolución solicitada por el cliente'
    ];

    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        'Authorization: Bearer ' . $token,
        'Content-Type: application/json'
    ]);

    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($httpCode !== 201) {
        $errorData = json_decode($response, true);
        $errorMessage = $errorData['message'] ?? "HTTP $httpCode";

        if ($httpCode === 400) {
            if (($errorData['code'] ?? '') === 'PIX_REFUND_DEADLINE_EXCEEDED') {
                throw new Exception('Se excedió el plazo de 89 días para la devolución');
            }
            if (($errorData['code'] ?? '') === 'PIX_REFUND_EXCEEDS_AVAILABLE') {
                throw new Exception('El valor de la devolución excede el disponible para devolución');
            }
        }

        if ($httpCode === 404) {
            throw new Exception('Transacción original no encontrada');
        }

        throw new Exception("Error al procesar la devolución: $errorMessage");
    }

    $data = json_decode($response, true);

    echo "¡Devolución PIX iniciada con éxito!" . PHP_EOL;
    echo "ID de la Transacción de Devolución: {$data['transactionId']}" . PHP_EOL;
    echo "ID Externo Original: {$data['externalId']}" . PHP_EOL;
    echo "Valor de la Devolución: R$ " . number_format($data['refundValue'], 2, ',', '.') . PHP_EOL;
    echo "Estado: {$data['status']}" . PHP_EOL;

    return $data;
}

// Uso
$token = 'su_token_aqui';
$transactionId = '7845';

// Devolución parcial
$refund = refundPixPayment(
    $token,
    $transactionId,
    75.00,
    'El cliente solicitó la devolución de 1 ítem del pedido'
);

Casos de Uso

1. E-commerce - Devolución de Productos

class OrderRefundSystem {
  constructor(private token: string) {}

  async processItemReturn(orderId: string, returnedItems: OrderItem[]) {
    // Buscar la transacción original del pedido
    const originalTransaction = await this.getTransactionByOrderId(orderId);

    // Calcular el valor total a devolver
    const refundAmount = returnedItems.reduce(
      (sum, item) => sum + (item.price * item.quantity),
      0
    );

    // Verificar que no exceda el valor de la transacción original
    const availableForRefund = await this.getAvailableRefundAmount(
      originalTransaction.id
    );

    if (refundAmount > availableForRefund) {
      throw new Error(
        `El valor solicitado (R$ ${refundAmount.toFixed(2)}) excede el disponible ` +
        `para devolución (R$ ${availableForRefund.toFixed(2)})`
      );
    }

    // Generar la descripción de la devolución
    const itemsDescription = returnedItems
      .map(item => `${item.name} (${item.quantity}x)`)
      .join(', ');

    // Realizar la devolución
    const refund = await refundPixPayment(
      this.token,
      originalTransaction.id,
      refundAmount,
      `Devolución de ítems: ${itemsDescription}`
    );

    // Actualizar el estado del pedido
    await this.updateOrderStatus(orderId, 'PARTIALLY_REFUNDED', refund);

    // Notificar al cliente
    await this.notifyCustomerRefund(orderId, refundAmount);

    return refund;
  }

  async getAvailableRefundAmount(transactionId: string): Promise<number> {
    // GET /api/pix/transaction/{id} ya trae el saldo devolvible
    const transaction = await this.getTransaction(transactionId);
    return transaction.refundableAmount;
  }
}

// Uso
interface OrderItem {
  name: string;
  price: number;
  quantity: number;
}

const refundSystem = new OrderRefundSystem('su_token_aqui');

const returnedItems: OrderItem[] = [
  { name: 'Camiseta Azul', price: 49.90, quantity: 1 }
];

await refundSystem.processItemReturn('ORDER-12345', returnedItems);

2. SaaS - Reembolso Proporcional

from datetime import datetime, timedelta
from decimal import Decimal

class SubscriptionRefundManager:
    """Gestiona reembolsos proporcionales de suscripciones"""

    def __init__(self, token: str):
        self.token = token

    def calculate_prorated_refund(
        self,
        payment_date: datetime,
        cancellation_date: datetime,
        monthly_value: float
    ) -> float:
        """Calcula el reembolso proporcional basado en los días no utilizados"""

        # Calcular los días de la mensualidad (30 días)
        billing_period_days = 30

        # Calcular los días utilizados
        days_used = (cancellation_date - payment_date).days

        # Calcular los días no utilizados
        days_unused = billing_period_days - days_used

        if days_unused <= 0:
            return 0.0

        # Calcular el valor proporcional
        daily_rate = Decimal(str(monthly_value)) / Decimal(str(billing_period_days))
        refund_amount = float(daily_rate * Decimal(str(days_unused)))

        return round(refund_amount, 2)

    def process_subscription_cancellation(
        self,
        subscription_id: str,
        transaction_id: str
    ) -> dict:
        """Procesa la cancelación con reembolso proporcional"""

        # Buscar los datos de la suscripción
        subscription = self.get_subscription(subscription_id)

        # Calcular el reembolso proporcional
        refund_amount = self.calculate_prorated_refund(
            payment_date=subscription['last_payment_date'],
            cancellation_date=datetime.now(),
            monthly_value=subscription['monthly_value']
        )

        if refund_amount <= 0:
            return {'refund': None, 'message': 'Sin valor a reembolsar'}

        # Realizar la devolución
        refund = refund_pix_payment(
            token=self.token,
            original_transaction_id=transaction_id,
            refund_amount=refund_amount,
            reason=f'Cancelación de suscripción - Reembolso proporcional'
        )

        # Actualizar el estado de la suscripción
        self.update_subscription_status(subscription_id, 'CANCELLED')

        return refund

# Uso
manager = SubscriptionRefundManager('su_token_aqui')

# El cliente pagó R$ 99,00 el 01/01 y canceló el 15/01
# Reembolso proporcional: 15 días no utilizados
refund = manager.process_subscription_cancellation(
    subscription_id='SUB-12345',
    transaction_id='7845'
)

3. Marketplace - Compensación por Problemas

class MarketplaceCompensation {
  constructor(token) {
    this.token = token;
  }

  async compensateForIssue(orderId, issueType) {
    const order = await this.getOrder(orderId);
    const compensationRules = this.getCompensationRules();

    // Definir el valor de la compensación según el tipo de problema
    const compensationPercent = compensationRules[issueType] || 0;
    const compensationAmount = order.value * (compensationPercent / 100);

    if (compensationAmount === 0) {
      throw new Error('Tipo de problema no elegible para compensación');
    }

    // Realizar una devolución parcial como compensación
    const refund = await refundPixPayment(
      this.token,
      order.transactionId,
      compensationAmount,
      `Compensación por ${issueType} - ${compensationPercent}% de descuento`
    );

    // Registrar la compensación
    await this.recordCompensation(orderId, issueType, compensationAmount);

    return refund;
  }

  getCompensationRules() {
    return {
      'ATRASO_ENTREGA': 10,      // 10% de compensación
      'PRODUTO_AVARIADO': 20,     // 20% de compensación
      'ITEM_FALTANTE': 15,        // 15% de compensación
      'QUALIDADE_INFERIOR': 25    // 25% de compensación
    };
  }
}

// Uso
const compensation = new MarketplaceCompensation('su_token_aqui');

// El producto llegó dañado - compensar con 20%
await compensation.compensateForIssue('ORDER-12345', 'PRODUTO_AVARIADO');

Validaciones y Reglas de Negocio

Verificar el Valor Disponible para Devolución

El GET /api/pix/transaction/\{id\} de la transacción original ya trae refundedAmount (suma de las devoluciones confirmadas), refundableAmount (cuánto aún puede devolverse) y relatedTransactions (las devoluciones vinculadas):

async function validateRefundAmount(
  token: string,
  transactionId: string,
  requestedAmount: number
): Promise<boolean> {
  const response = await fetch(
    `https://api.fypay.me/api/pix/transaction/${transactionId}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  const transaction = await response.json();

  if (requestedAmount > transaction.refundableAmount) {
    throw new Error(
      `El valor solicitado (R$ ${requestedAmount.toFixed(2)}) excede el disponible ` +
      `para devolución (R$ ${transaction.refundableAmount.toFixed(2)}). ` +
      `Total ya devuelto: R$ ${transaction.refundedAmount.toFixed(2)}`
    );
  }

  return true;
}

Verificar el Plazo de Devolución

from datetime import datetime, timedelta

def can_refund_transaction(transaction_date: datetime) -> bool:
    """Verifica si la transacción aún está dentro del plazo de devolución"""
    max_refund_days = 89
    cutoff_date = datetime.now() - timedelta(days=max_refund_days)

    if transaction_date < cutoff_date:
        days_passed = (datetime.now() - transaction_date).days
        raise Exception(
            f'Plazo de devolución excedido. '
            f'Transacción realizada hace {days_passed} días. '
            f'Plazo máximo: {max_refund_days} días.'
        )

    return True

# Uso
try:
    can_refund_transaction(datetime(2024, 1, 1))
    print('La transacción puede devolverse')
except Exception as e:
    print(f'Error: {e}')

Monitoreo de Devoluciones

El resultado final de la devolución llega por el webhook CashInReversal (evento refund_in en la configuración de webhooks). Como alternativa, consulte GET /api/pix/transaction/\{id\} con el transactionId de la devolución:

class RefundMonitor {
  async monitorRefundStatus(refundTransactionId: string, timeout = 60000) {
    const startTime = Date.now();

    while (Date.now() - startTime < timeout) {
      const status = await this.checkRefundStatus(refundTransactionId);

      if (status === 'CONFIRMED') {
        console.log('¡Devolución confirmada!');
        await this.onRefundConfirmed(refundTransactionId);
        return true;
      }

      if (status === 'ERROR') {
        await this.onRefundFailed(refundTransactionId);
        throw new Error('La devolución falló');
      }

      // Esperar 3 segundos antes de verificar de nuevo
      await new Promise(resolve => setTimeout(resolve, 3000));
    }

    throw new Error('Timeout: Devolución no confirmada en el tiempo esperado');
  }

  async onRefundConfirmed(refundTransactionId: string) {
    // Actualizar la base de datos
    // Notificar al cliente
    // Registrar log
  }

  async onRefundFailed(refundTransactionId: string) {
    // Notificar al equipo de soporte
    // Registrar el incidente
    // Crear un ticket para análisis manual
  }
}

Códigos de Respuesta

CódigoDescripciónSignificado
201Devolución CreadaDevolución PIX iniciada con éxito
400Valor InválidoPIX_REFUND_EXCEEDS_AVAILABLE (valor por encima del disponible) o PIX_INVALID_REFUND_AMOUNT
400Plazo ExcedidoPIX_REFUND_DEADLINE_EXCEEDED: se excedió el plazo de 89 días para la devolución
400Transacción no devolviblePIX_INVALID_TRANSACTION_TYPE_FOR_REFUND (no es una recepción) o PIX_INVALID_TRANSACTION_STATE (recepción aún no confirmada)
400Saldo InsuficientePIX_INSUFFICIENT_BALANCE: la cuenta no tiene saldo para devolver el valor
401Token InválidoToken no proporcionado, expirado o inválido
404Transacción No EncontradaPIX_TRANSACTION_NOT_FOUND: transacción original no encontrada
409externalId RepetidoPIX_DUPLICATE_EXTERNAL_ID: ya existe una devolución con ese externalId
409Devolución BloqueadaPIX_REFUND_BLOCKED_MISMATCH_HELD: transacción en revisión por divergencia de valor; la devolución queda disponible después de la confirmación del valor recibido

Consulte la Referencia de la API para detalles completos de los campos de respuesta.

Mejores Prácticas

Notas Importantes

Las devoluciones no pueden cancelarse una vez iniciadas. Asegúrese de los valores antes de procesar.

  • Plazo máximo: 89 días después de la recepción
  • Valor mínimo: R$ 0,01
  • Múltiples devoluciones: Permitidas, siempre que la suma no exceda el valor original

Próximos Pasos

En esta página