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

  1. You create a refund with Create a refund, passing the reference of the original pay-in transaction.
  2. Atlas returns a refund object with its own reference (prefixed RFD_). A scheduled refund starts in SUBMITTED, and an instant refund starts in PROCESSING.
  3. Atlas sends a refund webhook to your configured endpoint at each step, through to PROCESSED.
  4. While a refund is still SUBMITTED, you can call Cancel a refund.
  5. If the transfer fails, failureReason is 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 example TRN_1234567890.
  • type: full to return the entire transaction amount, partial to return part of it.
  • amount: the amount to return. Required when type is partial, and ignored on a full refund.
  • fee_bearer: merchant if you absorb the refund fee, customer if it comes out of the amount returned.
  • currency: currency of the refund, which must match the currency of the original pay-in, for example NGN.
  • source_reference: your own reference for the refund, for example merchant-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 example 2026-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:

Create a refund response
{
  "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:

Cancel a refund response
{
  "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:

Retry a refund response
{
  "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.

EventStatusMeaning
REFUND_CREATED_EVENTSUBMITTED or PROCESSINGAtlas 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_EVENTPROCESSINGAtlas is sending the money to the destination account.
REFUND_PROCESSED_EVENTPROCESSEDThe money reached the destination account. This is a final state.
REFUND_FAILED_EVENTFAILEDThe 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.

REFUND_PROCESSED_EVENT
{
  "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:

REFUND_FAILED_EVENT
{
  "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:

Get a refund response
{
  "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 example TRN_1234567890.
  • status: return only refunds in this state, in lowercase, for example submitted.
  • type: return only full or only partial refunds.
  • search: free text to match refunds against.
  • sort: DESC for newest first, ASC for oldest first. Defaults to DESC.
  • page: the page to fetch. Defaults to 1.
  • limit: how many refunds to return per page. Defaults to 15.

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:

List refunds response
{
  "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:

Refunds for a transaction response
{
  "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"
    }
  ]
}

How is this guide?

Last updated on

On this page