Skip to Content
GuidesRefunds

Refunds

Process full or partial refunds across all payment providers with a unified API.

Quick Start

Full Refund

// Refund entire payment const refund = await client.createRefund('stripe:pi_abc123') console.log(refund.status) // 'PENDING' or 'SUCCEEDED' console.log(refund.money) // { amount: 5000, currency: 'USD' }

Partial Refund

// Refund ₹50 from ₹100 payment const partialRefund = await client.createRefund('razorpay:order_xyz', { amount: 5000, // ₹50 (in paise) reason: 'Customer requested partial refund' })

Creating Refunds

Basic Refund

const refund = await client.createRefund( unipayId, // Payment ID from createPayment() { amount?: number // Optional: defaults to full refund reason?: string // Optional: reason for refund refundId?: string // Optional: your reference ID metadata?: Record<string, string> // Optional: custom data idempotencyKey?: string // Optional: prevent duplicates } )

Full Refund Example

import { createPaymentClient } from '@uniipay/orchestrator' const client = createPaymentClient({ /* ... */ }) // Full refund with reason const refund = await client.createRefund('stripe:pi_abc123', { reason: 'Customer not satisfied with product', refundId: 'REF-2024-001', metadata: { orderId: 'ORD-12345', requestedBy: 'support@example.com' } }) console.log(`Refund ${refund.status}: ${refund.providerRefundId}`)

Partial Refund Example

// Original payment: $100 // Refund: $30 const partialRefund = await client.createRefund('stripe:pi_abc123', { amount: 3000, // $30 in cents reason: 'Partial cancellation - returned 1 of 2 items' }) // Can create multiple partial refunds up to total amount const secondRefund = await client.createRefund('stripe:pi_abc123', { amount: 2000, // $20 in cents reason: 'Additional discount applied' }) // Remaining refundable: $50

Refund Statuses

StatusMeaningTimeline
PENDINGRefund initiatedImmediate
PROCESSINGBeing processed by providerMinutes to hours
SUCCEEDED✅ Refund completed5-10 business days to customer
FAILED❌ Refund failedCheck failure reason

Checking Refund Status

// Get single refund const refund = await client.getRefund('STRIPE', 're_abc123') if (refund.status === 'SUCCEEDED') { console.log('Refund completed') console.log('Amount:', refund.money.amount / 100, refund.money.currency) } if (refund.status === 'FAILED') { console.error('Refund failed:', refund.failureReason) }

Listing Refunds

Get all refunds for a payment:

const refundList = await client.listRefunds('stripe:pi_abc123') console.log(`Total refunds: ${refundList.refunds.length}`) console.log(`Total refunded: ${refundList.totalRefunded.amount}`) console.log(`Payment amount: ${refundList.paymentAmount.amount}`) refundList.refunds.forEach(refund => { console.log(`- ${refund.providerRefundId}: ${refund.status} - ${refund.money.amount}`) })

Provider-Specific Features

Stripe

  • Multiple refunds: Yes
  • Partial refunds: Yes
  • Instant refunds: No (5-10 business days)
  • Refund limits: Up to payment amount
const refund = await client.createRefund('stripe:pi_abc123', { amount: 5000, reason: 'requested_by_customer', // Stripe-specific reason metadata: { internalNote: 'Approved by manager' } })

Razorpay

  • Multiple refunds: Yes
  • Partial refunds: Yes
  • Instant refunds: Yes (via metadata)
  • Refund limits: Up to payment amount
// Normal refund (5-7 business days) const normalRefund = await client.createRefund('razorpay:pay_abc', { amount: 10000, // ₹100 reason: 'Product return' }) // Instant refund (within minutes, via UPI/IMPS) const instantRefund = await client.createRefund('razorpay:pay_abc', { amount: 10000, metadata: { speed: 'optimum' // Razorpay instant refund } })

Error Handling

import { RefundCreationError, PaymentNotRefundableError, RefundExceedsPaymentError, PartialRefundNotSupportedError } from '@uniipay/orchestrator' try { const refund = await client.createRefund(unipayId, { amount: 5000 }) } catch (error) { if (error instanceof PaymentNotRefundableError) { console.error('Payment cannot be refunded - already refunded or too old') } else if (error instanceof RefundExceedsPaymentError) { console.error(`Refund amount exceeds remaining refundable amount`) console.error(`Payment: ${error.paymentAmount}`) console.error(`Already refunded: ${error.refundedAmount}`) console.error(`Requested: ${error.requestedAmount}`) } else if (error instanceof PartialRefundNotSupportedError) { console.error('Provider does not support partial refunds') } else if (error instanceof RefundCreationError) { console.error(`Refund failed on ${error.provider}: ${error.message}`) console.error(`Provider code: ${error.providerCode}`) } }

Best Practices

1. Validate Before Refunding

// Get payment first to check refundability const payment = await client.getPayment(unipayId) if (payment.status !== 'SUCCEEDED') { throw new Error('Payment must be successful to refund') } // Check if already fully refunded const refunds = await client.listRefunds(unipayId) if (refunds.totalRefunded.amount >= payment.money.amount) { throw new Error('Payment already fully refunded') } // Calculate remaining refundable amount const remainingAmount = payment.money.amount - refunds.totalRefunded.amount if (requestedAmount > remainingAmount) { throw new Error(`Can only refund up to ${remainingAmount}`) }

2. Use Idempotency

Prevent duplicate refunds:

const idempotencyKey = `refund-${orderId}-${Date.now()}` const refund = await client.createRefund(unipayId, { amount: 5000, idempotencyKey }) // If called again with same key, returns original refund

3. Store Refund References

const refund = await client.createRefund(unipayId, { amount: 5000, reason: 'Customer request', refundId: `REF-${orderId}-001` // Your internal reference }) // Save to database await db.refunds.create({ data: { orderId, unipayId: refund.unipayId, providerRefundId: refund.providerRefundId, amount: refund.money.amount, currency: refund.money.currency, status: refund.status, reason: refund.reason, createdAt: new Date() } })

4. Notify Customers

async function processRefund(orderId: string, amount: number) { const order = await db.orders.findUnique({ where: { id: orderId } }) const refund = await client.createRefund(order.unipayId, { amount, reason: 'Customer requested refund', metadata: { orderId } }) // Update order status await db.orders.update({ where: { id: orderId }, data: { status: 'REFUNDED' } }) // Send email notification await sendEmail({ to: order.customerEmail, subject: 'Refund Initiated', template: 'refund-initiated', data: { orderId, amount: amount / 100, currency: order.currency, refundId: refund.providerRefundId, expectedDate: getRefundExpectedDate(refund.provider) } }) } function getRefundExpectedDate(provider: PaymentProvider): string { // Stripe/Razorpay: 5-10 business days const days = provider === PaymentProvider.RAZORPAY ? 7 : 10 const date = new Date() date.setDate(date.getDate() + days) return date.toLocaleDateString() }

5. Handle Webhook Updates

Refund status changes are sent via webhooks:

async function handleRefundWebhook(event: WebhookEvent) { if (event.payload.type !== 'refund') return const { providerRefundId, status, money } = event.payload // Update database await db.refunds.update({ where: { providerRefundId }, data: { status } }) if (status === 'SUCCEEDED') { // Notify customer await sendEmail({ to: customer.email, subject: 'Refund Completed', template: 'refund-completed', data: { amount: money.amount / 100, currency: money.currency } }) } else if (status === 'FAILED') { // Alert admin await alertAdmin({ message: `Refund failed: ${providerRefundId}`, severity: 'HIGH' }) } }

Common Patterns

Auto-Refund on Cancellation

async function cancelOrder(orderId: string) { const order = await db.orders.findUnique({ where: { id: orderId } }) if (order.status === 'PAID') { // Auto-refund const refund = await client.createRefund(order.unipayId, { reason: 'Order cancelled by customer', metadata: { orderId } }) await db.orders.update({ where: { id: orderId }, data: { status: 'CANCELLED', refundId: refund.providerRefundId } }) } }

Partial Refund Calculator

async function calculatePartialRefund( unipayId: string, itemsToRefund: { itemId: string, quantity: number }[] ) { // Get original payment const payment = await client.getPayment(unipayId) // Get order details const order = await db.orders.findUnique({ where: { unipayId }, include: { items: true } }) // Calculate refund amount let refundAmount = 0 for (const item of itemsToRefund) { const orderItem = order.items.find(i => i.id === item.itemId) refundAmount += orderItem.price * item.quantity } // Check if exceeds payment if (refundAmount > payment.money.amount) { throw new Error('Refund amount exceeds payment') } // Create refund return client.createRefund(unipayId, { amount: refundAmount, reason: 'Partial order cancellation', metadata: { orderId: order.id, refundedItems: JSON.stringify(itemsToRefund) } }) }

Batch Refunds

async function batchRefund(orderIds: string[]) { const results = [] for (const orderId of orderIds) { try { const order = await db.orders.findUnique({ where: { id: orderId } }) const refund = await client.createRefund(order.unipayId, { reason: 'Bulk refund - promotion error', metadata: { orderId, batchId: 'BATCH-001' } }) results.push({ orderId, status: 'SUCCESS', refundId: refund.providerRefundId }) } catch (error) { results.push({ orderId, status: 'FAILED', error: error.message }) } } return results }

Refund Timelines

ProviderMethodTimeline
StripeCard5-10 business days
StripeBank transfer5-10 business days
RazorpayUPI1-3 business days
RazorpayCards5-7 business days
RazorpayNet banking5-7 business days
RazorpayInstant*Within minutes

* Razorpay instant refunds require speed: 'optimum' in metadata

Testing Refunds

Test Mode

All refunds in test mode are instant:

// In test environment const refund = await client.createRefund('stripe:pi_test_123', { amount: 5000 }) // Status immediately: SUCCEEDED (in test mode)

Production Testing

  1. Create small test payment ($0.50)
  2. Refund it
  3. Verify in provider dashboard
  4. Check webhook delivery
  5. Confirm customer email sent

Next Steps


Last updated on