Refunds
Return funds to a customer after a completed pay-in, then track it with webhooks, retry, or cancel the refund.
A refund returns money to a customer for a pay-in that has already completed on Atlas. You create a refund against the original transaction reference, choose whether to return the full amount or part of it, and optionally name the account the money should land in. Atlas then processes the transfer and sends a webhook at each stage, so you can follow the refund to completion, retry it if it fails, or cancel it before it is processed.
How a refund moves
- You create a refund with Create a refund, passing the reference of the original pay-in transaction.
- Atlas returns a refund object with its own
reference(prefixedRFD_). A scheduled refund starts inSUBMITTED, and an instant refund starts inPROCESSING. - Atlas sends a refund webhook to your configured endpoint at each step, through to
PROCESSED. - While a refund is still
SUBMITTED, you can call Cancel a refund. - If the transfer fails,
failureReasonis populated and you can call Retry a failed refund, optionally sending different account details.
Creating a refund
Only some transactions can be refunded. The transaction must be a credit, it must have reached its final successful state, and it must still be inside the refund window agreed for your business during onboarding. The refund also has to be raised in the same currency the pay-in was received in.
How the refund is processed depends on the same onboarding configuration. Some businesses can only refund instantly, some can only schedule refunds for a later date, and some can do both. If you are unsure which applies to you, check with your account manager before you build against one or the other.
Send a POST request to Create a refund. For an instant refund, the body needs:
transaction_reference: reference of the completed pay-in being refunded, for exampleTRN_1234567890.type:fullto return the entire transaction amount,partialto return part of it.amount: the amount to return. Required whentypeispartial, and ignored on afullrefund.fee_bearer:merchantif you absorb the refund fee,customerif it comes out of the amount returned.currency: currency of the refund, which must match the currency of the original pay-in, for exampleNGN.source_reference: your own reference for the refund, for examplemerchant-refund-001. Use it to reconcile against your records.
Destination account
You can name the account the money should land in with account_number,
account_name, bank_code, and bank_name. All four are optional, and Atlas
returns the money to the account that made the original payment when you leave
them out.
To schedule the refund instead, send everything above plus:
scheduled_for: ISO 8601 timestamp of when Atlas should process the refund, for example2026-09-01T09:00:00+00:00. Omitting it processes the refund immediately.
{
"transaction_reference": "TRN_1234567890",
"type": "partial",
"amount": 5000,
"fee_bearer": "merchant",
"currency": "NGN",
"account_number": "0123456789",
"account_name": "Jane Doe",
"bank_code": "058",
"bank_name": "Guaranty Trust Bank",
"source_reference": "merchant-refund-001"
}The examples below send the partial refund:
curl -X POST "https://atlas.tryduplo.com/api/v1/refund" \
-H "Authorization: Bearer <YOUR_API_KEY>" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"transaction_reference": "TRN_1234567890",
"type": "partial",
"amount": 5000,
"fee_bearer": "merchant",
"currency": "NGN",
"account_number": "0123456789",
"account_name": "Jane Doe",
"bank_code": "058",
"bank_name": "Guaranty Trust Bank",
"source_reference": "merchant-refund-001"
}'A successful request returns the created refund:
{
"requestId": "019a1639-ce25-70b7-ab20-16fcb2bac023",
"requestTimestamp": "2026-08-31 09:00:00.000",
"message": "Refund request submitted successfully.",
"statusCode": 201,
"data": [
{
"id": "019a1639-ce25-70b7-ab20-16fcb2bac023",
"reference": "RFD_A1B2C3D4E5F6",
"originalTransactionReference": "TRN_A1B2C3D4E5F6",
"businessState": "test",
"type": "partial",
"status": "PROCESSING",
"amount": {
"value": 5000,
"currency": "NGN",
"formatted": "NGN5000"
},
"currency": "NGN",
"sessionId": null,
"destinationAccountName": "Jane Doe",
"destinationAccountNumber": "0123456789",
"destinationBankName": "Guaranty Trust Bank",
"destinationBankCode": "058",
"initiator": {
"id": "019a1639-ce25-70b7-ab20-16fcb2bac023",
"name": "Jane Doe",
"email": "jane@example.com",
"role": "business_owner"
},
"scheduledFor": null,
"failureReason": null,
"createdAt": "2026-08-31T09:00:00+00:00"
}
]
}Store the refund reference
Keep the reference returned in the response. Every other refund operation,
fetching, retrying, and cancelling, is addressed by that reference rather than
by the original transaction reference.
You can raise more than one partial refund against the same transaction, up to the amount of the original pay-in. List refunds for a transaction shows every refund already raised against it, which is the quickest way to check how much is left before you raise another.
A scheduled refund sits in SUBMITTED until its due time, so you can still cancel it up to that point. The timestamp you sent is echoed back on the refund object as scheduledFor, and is null on a refund processed immediately.
Cancelling a refund
Cancel a refund stops a refund that is still SUBMITTED. In practice that means a scheduled refund, since an instant refund starts in PROCESSING. It takes no request body.
curl -X POST "https://atlas.tryduplo.com/api/v1/refund/RFD_1234567890/cancel" \
-H "Authorization: Bearer <YOUR_API_KEY>" \
-H "Accept: application/json"The cancelled refund comes back under data:
{
"requestId": "019a1639-ce25-70b7-ab20-16fcb2bac023",
"requestTimestamp": "2026-08-31 09:00:00.000",
"message": "Refund cancelled successfully.",
"statusCode": 200,
"data": [
{
"id": "019a1639-ce25-70b7-ab20-16fcb2bac023",
"reference": "RFD_A1B2C3D4E5F6",
"originalTransactionReference": "TRN_A1B2C3D4E5F6",
"businessState": "test",
"type": "partial",
"status": "CANCELLED",
"amount": {
"value": 5000,
"currency": "NGN",
"formatted": "NGN5000"
},
"currency": "NGN",
"sessionId": null,
"destinationAccountName": "Jane Doe",
"destinationAccountNumber": "0123456789",
"destinationBankName": "Guaranty Trust Bank",
"destinationBankCode": "058",
"initiator": {
"id": "019eb5f3-6b6e-7013-ac9f-b0710bdce482",
"name": "Jane Doe",
"email": "jane@example.com",
"role": "business_owner"
},
"scheduledFor": null,
"failureReason": null,
"createdAt": "2026-08-31T09:00:00+00:00"
}
]
}Cancellable states
A refund can only be cancelled while it is still SUBMITTED. Once Atlas has
started processing it, cancelling returns 422.
Retrying a failed refund
When a refund fails, Atlas sends a REFUND_FAILED_EVENT and failureReason on the refund object explains why. If the cause was the destination account, send corrected details in the body of Retry a failed refund. Every field is optional, and an empty body retries against the account already on the refund:
account_number: replacement destination account number.account_name: replacement destination account name.bank_code: replacement destination bank code.bank_name: replacement destination bank name.
curl -X POST "https://atlas.tryduplo.com/api/v1/refund/RFD_1234567890/retry" \
-H "Authorization: Bearer <YOUR_API_KEY>" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"account_number": "0123456789",
"account_name": "Jane Doe",
"bank_code": "058",
"bank_name": "Guaranty Trust Bank"
}'The refund goes back into PROCESSING and moves through the same events again:
{
"requestId": "019a1639-ce25-70b7-ab20-16fcb2bac023",
"requestTimestamp": "2026-08-31 09:00:00.000",
"message": "Refund retry submitted successfully.",
"statusCode": 200,
"data": [
{
"id": "019a1639-ce25-70b7-ab20-16fcb2bac023",
"reference": "RFD_A1B2C3D4E5F6",
"originalTransactionReference": "TRN_A1B2C3D4E5F6",
"businessState": "test",
"type": "partial",
"status": "PROCESSING",
"amount": {
"value": 5000,
"currency": "NGN",
"formatted": "NGN5000"
},
"currency": "NGN",
"sessionId": null,
"destinationAccountName": "Jane Doe",
"destinationAccountNumber": "0123456789",
"destinationBankName": "Guaranty Trust Bank",
"destinationBankCode": "058",
"initiator": {
"id": "019eb5f3-6b6e-7013-ac9f-b0710bdce482",
"name": "Jane Doe",
"email": "jane@example.com",
"role": "business_owner"
},
"scheduledFor": null,
"failureReason": null,
"createdAt": "2026-08-31T09:00:00+00:00"
}
]
}Retryable states
Only a refund that has failed can be retried, and anything else returns 422.
Use the status on the last refund webhook you received, or Get a
refund, to confirm where the refund stands before
you retry it.
Tracking a refund
Refund status changes reach you over webhooks. Configure a webhook URL on your Atlas dashboard, and Atlas posts an event to it at each stage of the refund.
| Event | Status | Meaning |
|---|---|---|
REFUND_CREATED_EVENT | SUBMITTED or PROCESSING | Atlas accepted the refund request. A scheduled refund is SUBMITTED and scheduledFor carries the time it will be processed. An instant refund is already PROCESSING. |
REFUND_PROCESSING_EVENT | PROCESSING | Atlas is sending the money to the destination account. |
REFUND_PROCESSED_EVENT | PROCESSED | The money reached the destination account. This is a final state. |
REFUND_FAILED_EVENT | FAILED | The refund could not be paid out. failureReason explains why, and you can retry it. |
The data object on each event is the same refund object that Get a refund returns, so data.reference is the refund reference and data.originalTransactionReference is the pay-in it was raised against.
{
"event_type": "REFUND_PROCESSED_EVENT",
"data": {
"id": "019a1639-ce25-70b7-ab20-16fcb2bac023",
"reference": "RFD_A1B2C3D4E5F6",
"originalTransactionReference": "TRN_A1B2C3D4E5F6",
"businessState": "test",
"type": "partial",
"status": "PROCESSED",
"amount": {
"value": 5000,
"currency": "NGN",
"formatted": "NGN5000"
},
"currency": "NGN",
"sessionId": null,
"destinationAccountName": "Jane Doe",
"destinationAccountNumber": "0123456789",
"destinationBankName": "Guaranty Trust Bank",
"destinationBankCode": "058",
"initiator": {
"id": "019eb5f3-6b6e-7013-ac9f-b0710bdce482",
"name": "Jane Doe",
"email": "jane@example.com",
"role": "business_owner"
},
"scheduledFor": null,
"failureReason": null,
"createdAt": "2026-08-31T09:00:00.000000Z"
}
}A failed refund carries the reason on failureReason:
{
"event_type": "REFUND_FAILED_EVENT",
"data": {
"id": "019a1639-ce25-70b7-ab20-16fcb2bac023",
"reference": "RFD_A1B2C3D4E5F6",
"originalTransactionReference": "TRN_A1B2C3D4E5F6",
"businessState": "test",
"type": "partial",
"status": "FAILED",
"amount": {
"value": 5000,
"currency": "NGN",
"formatted": "NGN5000"
},
"currency": "NGN",
"sessionId": null,
"destinationAccountName": "Jane Doe",
"destinationAccountNumber": "0123456789",
"destinationBankName": "Guaranty Trust Bank",
"destinationBankCode": "058",
"initiator": {
"id": "019eb5f3-6b6e-7013-ac9f-b0710bdce482",
"name": "Jane Doe",
"email": "jane@example.com",
"role": "business_owner"
},
"scheduledFor": null,
"failureReason": "Reason for failure",
"createdAt": "2026-08-31T09:00:00.000000Z"
}
}Match the incoming event to your own record with data.reference. REFUND_PROCESSED_EVENT is final, while REFUND_FAILED_EVENT is not, since a retry sends the same refund through REFUND_PROCESSING_EVENT again. See refund events for the full payload of every event, and verifying the origin of an Atlas webhook for how to confirm an event came from Atlas before you act on it.
Webhooks are not retried forever
Your endpoint must return a 200 response to acknowledge a refund event, and
it should be idempotent, since the same event can be delivered more than once.
Atlas gives up after three failed attempts, and you can retry a delivery
yourself once the
cause is fixed.
Looking up refunds
Reading a refund back is useful for reconciliation, or to confirm where a refund stands on demand, alongside the refund webhooks that report status changes as they happen.
A single refund
Retrieve one refund by its reference with Get a refund.
curl -X GET "https://atlas.tryduplo.com/api/v1/refund/RFD_1234567890" \
-H "Authorization: Bearer <YOUR_API_KEY>" \
-H "Accept: application/json"Unlike the other refund endpoints, this one returns a single object under data rather than an array:
{
"requestId": "019a1639-ce25-70b7-ab20-16fcb2bac023",
"requestTimestamp": "2026-08-31 09:00:00.000",
"message": "Refund fetched successfully.",
"statusCode": 200,
"data": {
"id": "019a1639-ce25-70b7-ab20-16fcb2bac023",
"reference": "RFD_A1B2C3D4E5F6",
"originalTransactionReference": "TRN_A1B2C3D4E5F6",
"businessState": "test",
"type": "partial",
"status": "PROCESSED",
"amount": {
"value": 5000,
"currency": "NGN",
"formatted": "NGN5000"
},
"currency": "NGN",
"sessionId": "100004260112000222109745068228",
"destinationAccountName": "Jane Doe",
"destinationAccountNumber": "0123456789",
"destinationBankName": "Guaranty Trust Bank",
"destinationBankCode": "058",
"initiator": {
"id": "019eb5f3-6b6e-7013-ac9f-b0710bdce482",
"name": "Jane Doe",
"email": "jane@example.com",
"role": "business_owner"
},
"scheduledFor": null,
"failureReason": null,
"createdAt": "2026-08-31T09:00:00+00:00"
}
}Every refund on your business
List refunds returns every refund raised by your business, newest first, in pages. Every query parameter is optional, and they combine:
transaction_id: return only refunds raised against the pay-in with this internal transaction id.transaction_reference: return only refunds raised against this pay-in reference, for exampleTRN_1234567890.status: return only refunds in this state, in lowercase, for examplesubmitted.type: return onlyfullor onlypartialrefunds.search: free text to match refunds against.sort:DESCfor newest first,ASCfor oldest first. Defaults toDESC.page: the page to fetch. Defaults to1.limit: how many refunds to return per page. Defaults to15.
The examples below combine several of them to page through partial refunds that are still submitted, newest first:
curl -X GET "https://atlas.tryduplo.com/api/v1/refund?status=submitted&type=partial&sort=DESC&page=1&limit=15" \
-H "Authorization: Bearer <YOUR_API_KEY>" \
-H "Accept: application/json"The response carries the refunds in data and paging details in meta:
{
"requestId": "019a1639-ce25-70b7-ab20-16fcb2bac023",
"requestTimestamp": "2026-08-31 09:00:00.000",
"message": "Refunds fetched successfully.",
"statusCode": 200,
"data": [],
"links": {},
"meta": {
"current_page": 1,
"from": 1,
"last_page": 1,
"per_page": 15,
"to": 1,
"total": 1
}
}Walk through the pages by incrementing page until current_page reaches last_page. from and to are null when a page holds no refunds.
Every refund on one transaction
To see every refund raised against one pay-in, call List refunds for a transaction with the original transaction reference rather than a refund reference.
curl -X GET "https://atlas.tryduplo.com/api/v1/refund/TRN_1234567890/transaction" \
-H "Authorization: Bearer <YOUR_API_KEY>" \
-H "Accept: application/json"data is an array of refund objects, and this response is not paginated:
{
"requestId": "019a1639-ce25-70b7-ab20-16fcb2bac023",
"requestTimestamp": "2026-08-31 09:00:00.000",
"message": "Refund history fetched successfully.",
"statusCode": 200,
"data": [
{
"id": "019a1639-ce25-70b7-ab20-16fcb2bac023",
"reference": "RFD_A1B2C3D4E5F6",
"originalTransactionReference": "TRN_A1B2C3D4E5F6",
"businessState": "test",
"type": "partial",
"status": "PROCESSED",
"amount": {
"value": 5000,
"currency": "NGN",
"formatted": "NGN5000"
},
"currency": "NGN",
"sessionId": "100004260112000222109745068228",
"destinationAccountName": "Jane Doe",
"destinationAccountNumber": "0123456789",
"destinationBankName": "Guaranty Trust Bank",
"destinationBankCode": "058",
"initiator": {
"id": "019eb5f3-6b6e-7013-ac9f-b0710bdce482",
"name": "Jane Doe",
"email": "jane@example.com",
"role": "business_owner"
},
"scheduledFor": null,
"failureReason": null,
"createdAt": "2026-08-31T09:00:00+00:00"
}
]
}Related guides
How is this guide?
Last updated on