Safira Paydocs
Integration Guides

PIX Refund-In (Reversal)

Overview

The PIX Refund-In endpoint allows you to reverse (refund) PIX payments received through charges generated via Cash-In. Refunds can be full or partial and must be requested within 89 days of receipt.

This endpoint requires a valid Bearer token. See the authentication documentation for more details.

Features

  • Full or partial refunds
  • Multiple partial refunds for the same transaction
  • Up to 89-day window
  • Instant processing
  • Tracking by refund reason

When to Use Refunds

Full Refund

Returns 100% of the received amount to the original payer.

Use cases:

  • Complete order cancellation
  • Product not shipped
  • Duplicate payment
  • Incorrect charge amount

Partial Refund

Returns only part of the received amount.

Use cases:

  • Return of specific items
  • Compensation for product/service issues
  • Value adjustments
  • Retroactive discount

Endpoint

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

Requests the refund of a received payment.

Required Headers

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

Path Parameters

idstringobrigatorio

ID of the original transaction (Cash-In) to be refunded.

Example: "7845"

Request Body

{
  "refundValue": 75.00,
  "reason": "Cliente solicitou devolução de 1 item do pedido"
}

Request

curl -X POST https://api.safirapay.com/api/pix/refund-in/7845 \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
  -H "Content-Type: application/json" \
  -d '{
    "refundValue": 75.00,
    "reason": "Cliente solicitou devolução de 1 item do pedido"
  }'

Response (201 Created)

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

Request Parameters

refundValuenumberobrigatorio

Amount to be refunded in BRL (Brazilian Reais). Must have at most 2 decimal places.

Validations:

  • Must be greater than or equal to 0.01
  • Cannot exceed the amount available for refund
  • Sum of all refunds cannot exceed the original amount

Example: 75.00

reasonstring

Refund reason (optional, but recommended).

Maximum: 255 characters

Example: "Cliente solicitou devolução de 1 item do pedido"

Recommendation: Always provide a clear reason for auditing purposes

externalIdstring

External ID for refund identification (optional).

In the BACEN API, corresponds to the 'id' URL parameter.

Example: "D123456789"

Response Structure

transactionIdstringsempre presente

ID of the new refund transaction generated.

Example: "7846"

Note: This is a different ID from the original transaction

externalIdstringsempre presente

External ID of the refund transaction.

Example: "D123456789"

statusstringsempre presente

Current refund transaction status.

Possible values:

  • PENDING: Refund being processed
  • CONFIRMED: Refund confirmed and completed
  • ERROR: Processing error

Example: "PENDING"

refundValuenumbersempre presente

Refund amount in BRL.

Example: 75.00

providerTransactionIdstringsempre presente

Transaction ID at the provider (used for correlation with webhooks).

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

generateTimestringsempre presente

Refund generation date and time (ISO 8601 UTC).

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

Implementation Examples

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 || 'Estorno solicitado pelo cliente'
  };

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

    console.log('PIX refund initiated successfully!');
    console.log(`Refund Transaction ID: ${response.data.transactionId}`);
    console.log(`Original External ID: ${response.data.externalId}`);
    console.log(`Refund Amount: R$ ${response.data.refundValue.toFixed(2)}`);
    console.log(`Status: ${response.data.status}`);

    return response.data;
  } catch (error) {
    if (axios.isAxiosError(error)) {
      const errorData = error.response?.data;
      console.error('Error processing refund:', errorData);

      // Handle specific errors
      if (error.response?.status === 400) {
        if (errorData?.message?.includes('prazo excedido')) {
          throw new Error('The 89-day refund window has expired');
        }
        if (errorData?.message?.includes('valor inválido')) {
          throw new Error('Refund amount exceeds the available amount for refund');
        }
      }

      if (error.response?.status === 404) {
        throw new Error('Original transaction not found');
      }

      throw new Error(errorData?.message || 'Error processing refund');
    }
    throw error;
  }
}

// Usage - Full Refund
async function fullRefund(token: string, transactionId: string, originalValue: number) {
  return await refundPixPayment(
    token,
    transactionId,
    originalValue,
    'Cancelamento total do pedido'
  );
}

// Usage - Partial Refund
async function partialRefund(token: string, transactionId: string, itemValue: number) {
  return await refundPixPayment(
    token,
    transactionId,
    itemValue,
    'Devolução de 1 item do pedido'
  );
}

// Practical example
const token = 'your_token_here';
const transactionId = '7845';

// Refund R$ 75.00 from a R$ 150.00 transaction
refundPixPayment(token, transactionId, 75.00, 'Cliente solicitou devolução 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:
    """
    Refund a received PIX payment

    Args:
        token: Valid Bearer token
        original_transaction_id: Original transaction ID (Cash-In)
        refund_amount: Amount to be refunded
        reason: Refund reason (optional)

    Returns:
        Created refund data
    """
    url = f'https://api.safirapay.com/api/pix/refund-in/{original_transaction_id}'

    payload = {
        'refundValue': round(refund_amount, 2),
        'reason': reason or 'Estorno solicitado pelo 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('PIX refund initiated successfully!')
        print(f"Refund Transaction ID: {data['transactionId']}")
        print(f"Original External ID: {data['externalId']}")
        print(f"Refund Amount: R$ {data['refundValue']:.2f}")
        print(f"Status: {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))

        # Handle specific errors
        if e.response.status_code == 400:
            if 'prazo excedido' in error_message:
                raise Exception('The 89-day refund window has expired')
            if 'valor inválido' in error_message:
                raise Exception('Refund amount exceeds the available amount for refund')
            raise Exception(f'Invalid data: {error_message}')

        if e.response.status_code == 404:
            raise Exception('Original transaction not found')

        raise Exception(f'Error processing refund: {error_message}')

# Usage
token = 'your_token_here'
transaction_id = '7845'

# Partial refund
refund = refund_pix_payment(
    token=token,
    original_transaction_id=transaction_id,
    refund_amount=75.00,
    reason='Cliente solicitou devolução de 1 item do pedido'
)

# Full refund
def full_refund(token: str, transaction_id: str, original_value: float):
    """Performs a full refund"""
    return refund_pix_payment(
        token=token,
        original_transaction_id=transaction_id,
        refund_amount=original_value,
        reason='Cancelamento total do pedido'
    )

PHP

<?php

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

    $payload = [
        'refundValue' => round($refundAmount, 2),
        'reason' => $reason ?? 'Estorno solicitado pelo 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 (stripos($errorMessage, 'prazo excedido') !== false) {
                throw new Exception('The 89-day refund window has expired');
            }
            if (stripos($errorMessage, 'valor inválido') !== false) {
                throw new Exception('Refund amount exceeds the available amount for refund');
            }
        }

        if ($httpCode === 404) {
            throw new Exception('Original transaction not found');
        }

        throw new Exception("Error processing refund: $errorMessage");
    }

    $data = json_decode($response, true);

    echo "PIX refund initiated successfully!" . PHP_EOL;
    echo "Refund Transaction ID: {$data['transactionId']}" . PHP_EOL;
    echo "Original External ID: {$data['externalId']}" . PHP_EOL;
    echo "Refund Amount: R$ " . number_format($data['refundValue'], 2, ',', '.') . PHP_EOL;
    echo "Status: {$data['status']}" . PHP_EOL;

    return $data;
}

// Usage
$token = 'your_token_here';
$transactionId = '7845';

// Partial refund
$refund = refundPixPayment(
    $token,
    $transactionId,
    75.00,
    'Cliente solicitou devolução de 1 item do pedido'
);

Use Cases

1. E-commerce - Product Returns

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

  async processItemReturn(orderId: string, returnedItems: OrderItem[]) {
    // Fetch the original order transaction
    const originalTransaction = await this.getTransactionByOrderId(orderId);

    // Calculate total amount to refund
    const refundAmount = returnedItems.reduce(
      (sum, item) => sum + (item.price * item.quantity),
      0
    );

    // Check if it does not exceed the original transaction value
    const availableForRefund = await this.getAvailableRefundAmount(
      originalTransaction.id
    );

    if (refundAmount > availableForRefund) {
      throw new Error(
        `Requested amount (R$ ${refundAmount.toFixed(2)}) exceeds the available ` +
        `refund amount (R$ ${availableForRefund.toFixed(2)})`
      );
    }

    // Generate refund description
    const itemsDescription = returnedItems
      .map(item => `${item.name} (${item.quantity}x)`)
      .join(', ');

    // Process refund
    const refund = await refundPixPayment(
      this.token,
      originalTransaction.id,
      refundAmount,
      `Devolução de itens: ${itemsDescription}`
    );

    // Update order status
    await this.updateOrderStatus(orderId, 'PARTIALLY_REFUNDED', refund);

    // Notify customer
    await this.notifyCustomerRefund(orderId, refundAmount);

    return refund;
  }

  async getAvailableRefundAmount(transactionId: string): Promise<number> {
    // Fetch original transaction and all existing refunds
    const transaction = await this.getTransaction(transactionId);
    const existingRefunds = await this.getTransactionRefunds(transactionId);

    const totalRefunded = existingRefunds.reduce(
      (sum, refund) => sum + refund.value,
      0
    );

    return transaction.value - totalRefunded;
  }
}

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

const refundSystem = new OrderRefundSystem('your_token_here');

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

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

2. SaaS - Prorated Refund

from datetime import datetime, timedelta
from decimal import Decimal

class SubscriptionRefundManager:
    """Manages prorated subscription refunds"""

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

    def calculate_prorated_refund(
        self,
        payment_date: datetime,
        cancellation_date: datetime,
        monthly_value: float
    ) -> float:
        """Calculates prorated refund based on unused days"""

        # Calculate billing period days (30 days)
        billing_period_days = 30

        # Calculate days used
        days_used = (cancellation_date - payment_date).days

        # Calculate unused days
        days_unused = billing_period_days - days_used

        if days_unused <= 0:
            return 0.0

        # Calculate prorated amount
        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:
        """Processes cancellation with prorated refund"""

        # Fetch subscription data
        subscription = self.get_subscription(subscription_id)

        # Calculate prorated refund
        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': 'No amount to refund'}

        # Process refund
        refund = refund_pix_payment(
            token=self.token,
            original_transaction_id=transaction_id,
            refund_amount=refund_amount,
            reason=f'Cancelamento de assinatura - Reembolso proporcional'
        )

        # Update subscription status
        self.update_subscription_status(subscription_id, 'CANCELLED')

        return refund

# Usage
manager = SubscriptionRefundManager('your_token_here')

# Customer paid R$ 99.00 on Jan 1st and cancelled on Jan 15th
# Prorated refund: 15 unused days
refund = manager.process_subscription_cancellation(
    subscription_id='SUB-12345',
    transaction_id='7845'
)

3. Marketplace - Issue Compensation

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

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

    // Define compensation amount based on issue type
    const compensationPercent = compensationRules[issueType] || 0;
    const compensationAmount = order.value * (compensationPercent / 100);

    if (compensationAmount === 0) {
      throw new Error('Issue type not eligible for compensation');
    }

    // Perform partial refund as compensation
    const refund = await refundPixPayment(
      this.token,
      order.transactionId,
      compensationAmount,
      `Compensação por ${issueType} - ${compensationPercent}% de desconto`
    );

    // Record compensation
    await this.recordCompensation(orderId, issueType, compensationAmount);

    return refund;
  }

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

// Usage
const compensation = new MarketplaceCompensation('your_token_here');

// Product arrived damaged - compensate with 20%
await compensation.compensateForIssue('ORDER-12345', 'PRODUTO_AVARIADO');

Validations and Business Rules

Check Available Refund Amount

async function validateRefundAmount(
  transactionId: string,
  requestedAmount: number
): Promise<boolean> {
  // Fetch original transaction
  const transaction = await getTransaction(transactionId);

  // Fetch all existing refunds
  const refunds = await getRefundsByTransaction(transactionId);

  // Calculate total already refunded
  const totalRefunded = refunds.reduce((sum, refund) => sum + refund.value, 0);

  // Calculate available amount
  const availableForRefund = transaction.value - totalRefunded;

  // Validate
  if (requestedAmount > availableForRefund) {
    throw new Error(
      `Requested amount (R$ ${requestedAmount.toFixed(2)}) exceeds the available ` +
      `refund amount (R$ ${availableForRefund.toFixed(2)}). ` +
      `Total already refunded: R$ ${totalRefunded.toFixed(2)}`
    );
  }

  return true;
}

Check Refund Window

from datetime import datetime, timedelta

def can_refund_transaction(transaction_date: datetime) -> bool:
    """Checks if the transaction is still within the refund window"""
    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'Refund window exceeded. '
            f'Transaction made {days_passed} days ago. '
            f'Maximum window: {max_refund_days} days.'
        )

    return True

# Usage
try:
    can_refund_transaction(datetime(2024, 1, 1))
    print('Transaction can be refunded')
except Exception as e:
    print(f'Error: {e}')

Refund Monitoring

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('Refund confirmed!');
        await this.onRefundConfirmed(refundTransactionId);
        return true;
      }

      if (status === 'ERROR') {
        await this.onRefundFailed(refundTransactionId);
        throw new Error('Refund failed');
      }

      // Wait 3 seconds before checking again
      await new Promise(resolve => setTimeout(resolve, 3000));
    }

    throw new Error('Timeout: Refund not confirmed within expected time');
  }

  async onRefundConfirmed(refundTransactionId: string) {
    // Update database
    // Notify customer
    // Record log
  }

  async onRefundFailed(refundTransactionId: string) {
    // Notify support team
    // Record incident
    // Create ticket for manual review
  }
}

Response Codes

CodeDescriptionMeaning
201Refund CreatedPIX refund initiated successfully
400Invalid AmountRefund amount exceeds the available amount
400Window Exceeded89-day refund window has expired
401Invalid TokenToken not provided, expired, or invalid
404Transaction Not FoundParent transaction not found

See the API Reference for full response field details.

Best Practices

Important Notes

Refunds cannot be cancelled once initiated. Make sure the amounts are correct before processing.

  • Maximum window: 89 days after receipt
  • Minimum amount: R$ 0.01
  • Multiple refunds: Allowed, as long as the sum does not exceed the original amount

Next Steps

On this page