Table of contents

Advanced refund management

Overview

This guide provides comprehensive instructions for implementing refunds. You'll learn how to initiate refunds, handle multiple refund scenarios, manage refund statuses, and handle webhook notifications.

Key Concepts

  • Partial Refunds: You can refund a purchase in multiple transactions, up to the original purchase amount
  • Refundable Amount: The remaining amount available for refund, which decreases with each successful refund
  • Async Processing: Refunds are processed asynchronously - you'll receive webhook notifications when status changes occur
  • Idempotency: Each refund request creates a new refund transaction

Prerequisites

Before implementing refunds, ensure you have:

  1. API Credentials: Your API authorization token
  2. Webhook Endpoint: A configured endpoint to receive refund status updates
  3. Purchase ID: The UUID of the purchase you want to refund

Base URL

https://gate.reviopay.com/api/v1

Refund Workflow

Standard Refund Flow

flowchart TD
    Start([Initiate Refund]) --> API[POST /refund API]
    API --> Response{Response}
    Response -->|200 OK| Pending[Status: Pending]
    Response -->|400 Error| Error[Error]
    Pending --> Process{Processing}
    Process -->|Success| Success[Status: Success]
    Process -->|Failed| Failed[Status: Failed]
    Success --> End([Complete])
    Failed --> Retry{Retry?}
    Retry -->|Yes| Start
    Retry -->|No| End
    Error --> End

State Diagram

stateDiagram-v2
    [*] --> Pending: API Call
    Pending --> Success: Approved
    Pending --> Failed: Rejected
    Failed --> Pending: Retry
    Success --> [*]
    Failed --> [*]: Cannot Retry

API Reference

Initiate Refund

Endpoint

POST /purchases/{purchase_id}/refund/

Headers

Content-Type: application/json
Authorization: Bearer YOUR_API_TOKEN

Request Body

JSON

{
 "amount": 100
}

Parameters

|Parameter|Type|Required|Description| |---|---|---|---| |`purchase_id`|UUID|Yes|The ID of the original purchase to refund| |`amount`|Integer|Yes|The amount to refund (in smallest currency unit, e.g., cents)|

Success Response (200 OK)

JSON

{
 "id": "9c4fa29b-ee35-430c-a412-60e5de77483e",
 "type": "payment",
 "status": "success",
 "payment": {
   "is_outgoing": true,
   "payment_type": "refund",
   "amount": 100,
   "currency": "ZAR",
   "net_amount": 100,
   "fee_amount": 0,
   "description": "Refund: RV238",
   "paid_on": 1770212782
 },
 "related_to": {
   "type": "purchase",
   "id": "347472e8-21ad-42fc-a9b5-241963fb0cb1",
   "reference": "RV238"
 },
 "reference_generated": "46",
 "refund_availability": "none",
 "created_on": 1770212782,
 "updated_on": 1770212782
}

Key Response Fields

|Field|Description| |---|---| |`id`|Unique identifier for this refund transaction| |`status`|Current status: `pending`, `success`, or `failed`| |`payment.amount`|The refund amount processed| |`related_to.id`|The original purchase ID| |`reference_generated`|System-generated reference number|

Error Response (400 Bad Request)

JSON

{
 "__all__": [
   {
     "message": "Could not refund the payment! Consult `.transaction_data` for possible reason.",
     "code": "purchase_refund_error"
   }
 ]
}

Common error codes:

  • purchase_refund_error: General refund failure (check transaction_data for details)
  • purchase_refund_amount_too_large: Refund amount exceeds available refundable amount
  • purchase_already_fully_refunded: No refundable amount remaining
  • purchase_refund_wrong_status: Purchase is not in a refundable status (must be: settled, cleared, paid, retrieved, or refunded)
  • purchase_refund_timeout: Cannot refund same purchase more than once every 60 seconds

Webhook Events

Your system will receive webhook notifications for refund status changes. Configure your webhook endpoint to handle these events.

Webhook Event Types

|Event Type|Description|When Triggered| |---|---|---| |`purchase.pending_refund`|Refund initiated and pending|Immediately after refund request| |`payment.refunded`|Refund completed successfully|When refund is approved and processed| |`purchase.refund_failure`|Refund failed|When refund is rejected or encounters an error|

Event: purchase.refunded

Payload Structure

JSON

{
 "id": "9c4fa29b-ee35-430c-a412-60e5de77483e",
 "type": "payment",
 "event_type": "payment.refunded",
 "status": "success",
 "payment": {
   "amount": 100,
   "currency": "ZAR",
   "payment_type": "refund",
   "is_outgoing": true,
   "description": "Refund: RV238",
   "paid_on": 1770212782
 },
 "related_to": {
   "id": "347472e8-21ad-42fc-a9b5-241963fb0cb1",
   "type": "purchase",
   "reference": "RV238"
 },
 "transaction_data": {
   "payment_method": "mastercard",
   "extra": {
     "response_code": "00",
     "masked_pan": "541282******2835"
   }
 },
 "created_on": 1770212782,
 "updated_on": 1770212782
}

Event: purchase.pending_refund

Payload Structure

JSON

{
 "id": "347472e8-21ad-42fc-a9b5-241963fb0cb1",
 "type": "purchase",
 "event_type": "purchase.pending_refund",
 "status": "pending_refund",
 "refundable_amount": 230,
 "transaction_data": {
   "attempts": [
     {
       "type": "refund",
       "amount": 150,
       "successful": null,
       "processing_time": 1770213226
     }
   ]
 }
}

Event: purchase.refund_failure

Payload Structure

JSON

{
 "id": "347472e8-21ad-42fc-a9b5-241963fb0cb1",
 "type": "purchase",
 "event_type": "purchase.refund_failure",
 "status": "refunded",
 "refundable_amount": 230,
 "transaction_data": {
   "attempts": [
     {
       "type": "refund",
       "amount": 150,
       "successful": false,
       "processing_time": 1770212876,
       "error": {
         "message": "Refund limit exceeded",
         "code": "REFUND_LIMIT_EXCEEDED"
       }
     }
   ]
 }
}

Failed refund details are found in the transaction_data.attempts array. Look for entries where type is 'refund' and successful is false.

Error Handling

Common Error Scenarios

1. Refund Amount Too Large

Error Response

JSON

{
 "__all__": [
   {
     "message": "You can only refund this Purchase for 80 (check `.refundable_amount`).",
     "code": "purchase_refund_amount_too_large"
   }
 ]
}

How to Handle: Query the purchase details to get the current refundable_amount before attempting the refund.

2. General Refund Error

Error Response

JSON

{
 "__all__": [
   {
     "message": "Could not refund the payment! Consult `.transaction_data` for possible reason.",
     "code": "purchase_refund_error"
   }
 ]
}

How to Handle:

  • Check the transaction_data field in the webhook for specific failure reasons
  • Wait for the purchase.refund_failure webhook which will contain detailed error information
  • Implement retry logic for transient errors

3. Purchase Already Fully Refunded

How to Detect: When refundable_amount equals 0 in the purchase details.

Prevention: Always check refundable_amount before initiating a refund.

Best Practices

1. Check Refundable Amount First

Before initiating a refund, query the purchase to get the current refundable amount:

BASH

curl --location 'https://gate.reviopay.com/api/v1/purchases/{purchase_id}' \
--header 'Authorization: Bearer YOUR_API_TOKEN'

Look for the refundable_amount field in the response.

2. Store Refund Transaction IDs

Maintain a mapping between your internal refund requests and the platform's refund transaction IDs:

JSON

{
 "your_refund_id": "REF-12345",
 "platform_refund_id": "9c4fa29b-ee35-430c-a412-60e5de77483e",
 "status": "pending",
 "amount": 100,
 "initiated_at": "2024-01-15T10:30:00Z"
}

3. Implement Webhook Validation

Verify webhook authenticity by:

  • Checking the source IP address
  • Validating the payload signature (if provided)
  • Verifying the company_id matches your account

4. Handle Idempotency

The platform does not support idempotency keys for refunds. To prevent duplicate refunds:

  • Track initiated refunds in your database
  • Check if a refund is already pending/successful before creating a new one
  • Use the related_to.id field to link refunds to purchases

5. Implement Retry Logic for Failed Refunds

When a refund fails:

  • Wait for the purchase.refund_failure webhook
  • Analyse the failure reason from transaction_data.attempts array (look for entries where type is "refund" and successful is false)
  • If it's a transient error (network, timeout), retry after a delay
  • If it's a permanent error (insufficient funds, limits), adjust the amount or notify the user

Important: You cannot make refunds for the same purchase more than once every 60 seconds. Track the processing_time from failed attempts and wait at least 60 seconds before retrying. If you attempt too soon, you'll receive error code purchase_refund_timeout.

6. Monitor Refund Status

Create a polling mechanism as a fallback:

JAVASCRIPT

async function monitorRefundStatus(refundId, maxAttempts = 10) {
 for (let i = 0; i < maxAttempts; i++) {
   const refund = await getRefundDetails(refundId);
   if (refund.status === 'success' || refund.status === 'failed') {
     return refund;
   }
   await sleep(5000);
 }
 throw new Error('Refund status check timeout');
}

Complete Integration Example

Scenario: Multiple Refunds with Error Handling

This example demonstrates handling a purchase of 330 ZAR with multiple refund attempts.

Step 1: Initial Successful Refund (100 ZAR)

Request

BASH

curl --location 'https://gate.reviopay.com/api/v1/purchases/347472e8-21ad-42fc-a9b5-241963fb0cb1/refund/' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_API_TOKEN' \
--data '{"amount": 100}'

Response (200 OK)

JSON

{
 "id": "9c4fa29b-ee35-430c-a412-60e5de77483e",
 "status": "success",
 "payment": {
   "amount": 100,
   "payment_type": "refund"
 },
 "related_to": {
   "id": "347472e8-21ad-42fc-a9b5-241963fb0cb1"
 }
}

Result: Refundable amount is now 230 ZAR (330 - 100)

Step 2: Failed Refund Attempt (150 ZAR - Exceeds Limit)

Request

BASH

curl --location 'https://gate.reviopay.com/api/v1/purchases/347472e8-21ad-42fc-a9b5-241963fb0cb1/refund/' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_API_TOKEN' \
--data '{"amount": 150}'

Response (400 Bad Request)

JSON

{
 "__all__": [
   {
     "message": "Could not refund the payment! Consult `.transaction_data` for possible reason.",
     "code": "purchase_refund_error"
   }
 ]
}

Result: Refund failed due to the percentage limit. Refundable amount remains 230 ZAR.

Step 3: Another Failed Attempt (200 ZAR)

Following the same pattern, a 200 ZAR refund attempt also fails due to limits.

Refundable amount remains: 230 ZAR

Step 4: Successful Retry (150 ZAR)

BASH

curl --location 'https://gate.reviopay.com/api/v1/purchases/347472e8-21ad-42fc-a9b5-241963fb0cb1/refund/' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_API_TOKEN' \
--data '{"amount": 150}'

Response (200 OK)

JSON

{
 "id": "db068bb0-5a36-4eeb-b671-78f8828805de",
 "status": "success",
 "payment": {"amount": 150}
}

Result: Refundable amount is now 80 ZAR (230 - 150)

Step 5: Final Refund Attempt (200 ZAR - Amount Too Large)

Response (400 Bad Request)

JSON

{
 "__all__": [
   {
     "message": "You can only refund this Purchase for 80 (check `.refundable_amount`).",
     "code": "purchase_refund_amount_too_large"
   }
 ]
}

Action: Adjust the refund amount to 80 ZAR.

Step 6: Final Successful Refund (80 ZAR)

Response (200 OK)

JSON

{
 "id": "eca06fa0-45a9-4d36-a95e-2f6adaa2ee98",
 "status": "success",
 "payment": {"amount": 80},
 "refund_availability": "none"
}

Result: The purchase has now been fully refunded. refundable_amount is 0 ZAR.

Implementation Code Examples

Node.js Implementation

JAVASCRIPT

const axios = require('axios');

class RefundManager {
 constructor(apiToken, baseUrl = 'https://gate.reviopay.com/api/v1') {
   this.apiToken = apiToken;
   this.baseUrl = baseUrl;
   this.headers = {
     'Content-Type': 'application/json',
     'Authorization': `Bearer ${apiToken}`
   };
 }

 async getPurchaseDetails(purchaseId) {
   const response = await axios.get(
     `${this.baseUrl}/purchases/${purchaseId}`,
     { headers: this.headers }
   );
   return response.data;
 }

 async initiateRefund(purchaseId, amount) {
   const response = await axios.post(
     `${this.baseUrl}/purchases/${purchaseId}/refund/`,
     { amount },
     { headers: this.headers }
   );
   return response.data;
 }

 async safeRefund(purchaseId, requestedAmount) {
   const purchase = await this.getPurchaseDetails(purchaseId);
   const refundableAmount = purchase.refundable_amount;

   if (refundableAmount === 0) throw new Error('Purchase is fully refunded');
   if (requestedAmount > refundableAmount) requestedAmount = refundableAmount;

   return await this.initiateRefund(purchaseId, requestedAmount);
 }
}

Python Implementation

PYTHON

import requests
import time

class RefundManager:
   def __init__(self, api_token, base_url='https://gate.reviopay.com/api/v1'):
       self.api_token = api_token
       self.base_url = base_url
       self.headers = {
           'Content-Type': 'application/json',
           'Authorization': f'Bearer {api_token}'
       }

   def get_purchase_details(self, purchase_id):
       url = f'{self.base_url}/purchases/{purchase_id}'
       response = requests.get(url, headers=self.headers)
       response.raise_for_status()
       return response.json()

   def initiate_refund(self, purchase_id, amount):
       url = f'{self.base_url}/purchases/{purchase_id}/refund/'
       response = requests.post(url, json={'amount': amount}, headers=self.headers)
       response.raise_for_status()
       return response.json()

   def safe_refund(self, purchase_id, requested_amount):
       purchase = self.get_purchase_details(purchase_id)
       refundable_amount = purchase.get('refundable_amount', 0)

       if refundable_amount == 0:
           raise ValueError('Purchase is fully refunded')
       if requested_amount > refundable_amount:
           requested_amount = refundable_amount

       return self.initiate_refund(purchase_id, requested_amount)

Webhook Handler Example (Node.js/Express)

JAVASCRIPT

async function handleRefundFailed(event) {
 const failedAttempt = event.transaction_data?.attempts?.find(
   attempt => attempt.type === 'refund' && attempt.successful === false
 );

 if (failedAttempt) {
   console.log('Failure reason:', failedAttempt.error?.code);
   console.log('Failed amount:', failedAttempt.amount);
 }

 await updateRefundStatus(event.id, 'failed', 0, failedAttempt?.error?.message);

 if (shouldRetryRefund(failedAttempt)) {
   await queueRefundRetry(event.id, failedAttempt);
 }
}

function shouldRetryRefund(failedAttempt) {
 const retryableCodes = ['NETWORK_ERROR', 'TIMEOUT', 'TEMPORARY_ERROR'];
 return retryableCodes.includes(failedAttempt?.error?.code);
}

HTTP Status Codes

|Code|Meaning| |---|---| |200|Success - Refund initiated| |400|Bad Request - Invalid parameters or refund error| |401|Unauthorized - Invalid API token| |404|Not Found - Purchase ID doesn't exist| |429|Too Many Requests - Rate limit exceeded| |500|Internal Server Error - Contact support|

Key Fields Reference

|Field|Location|Description| |---|---|---| |`refundable_amount`|Purchase object|Amount available for refund| |`transaction_data.attempts`|Purchase object|Array of all refund transactions| |`status`|Purchase object|Array of all payment attempts including refunds| |`event_type`|Webhook payload|Type of webhook event| |`related_to.id`|Refund object|Original purchase ID| |`processing_time`|Attempt object|Unix timestamp when attempt was processed|

Next Steps

  • Error Codes Reference — Complete error code documentation
  • Code Examples — Implementation examples in multiple languages
  • Glossary — Payment terminology definitions
  • Troubleshooting — Common issues and solutions