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/jsonPath Parameters
idstringobrigatorioID 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
refundValuenumberobrigatorioAmount 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
reasonstringRefund 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
externalIdstringExternal ID for refund identification (optional).
In the BACEN API, corresponds to the 'id' URL parameter.
Example: "D123456789"
Response Structure
transactionIdstringsempre presenteID of the new refund transaction generated.
Example: "7846"
Note: This is a different ID from the original transaction
externalIdstringsempre presenteExternal ID of the refund transaction.
Example: "D123456789"
statusstringsempre presenteCurrent refund transaction status.
Possible values:
PENDING: Refund being processedCONFIRMED: Refund confirmed and completedERROR: Processing error
Example: "PENDING"
refundValuenumbersempre presenteRefund amount in BRL.
Example: 75.00
providerTransactionIdstringsempre presenteTransaction ID at the provider (used for correlation with webhooks).
Example: "7ef4fc3f-a187-495e-857c-e84d70612761"
generateTimestringsempre presenteRefund 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
| Code | Description | Meaning |
|---|---|---|
201 | Refund Created | PIX refund initiated successfully |
400 | Invalid Amount | Refund amount exceeds the available amount |
400 | Window Exceeded | 89-day refund window has expired |
401 | Invalid Token | Token not provided, expired, or invalid |
404 | Transaction Not Found | Parent 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