# Refunds
Request a full refund of a successful collection to its original sender. A request creates a tracked case; finance coordinates with the original provider, or an enabled provider integration processes the return automatically.
# Integration checklist
- Ask support to enable refunds for your merchant, collection routes, and currencies, and provision a refund signing secret.
- Create the request using your existing Bearer token and an HMAC signature. Store both your
ExternalReferenceand the returnedRFD-...reference. - Track the refund using Transaction Query or Pull Transactions. If you need callbacks, have support configure your HTTPS callback URL and separate callback signing secret.
- Mark the end user's refund as complete only when the API reports
COMPLETED.
# Create a refund
POST
https://api.sandbox.pesaway.com/api/v1/refunds/For collections with a recorded provider route, refund access must be enabled for your merchant and that route. Collections without a recorded route automatically create a manual refund case; finance establishes the original provider and verifies the return to the original sender. Use your existing Bearer token and the refund signing secret provisioned for your merchant.
{
"OriginalReference": "PHY-COLLECTION-001",
"ExternalReference": "MY-REFUND-001",
"Amount": "1000.50",
"Currency": "KES",
"Reason": "Third-party deposit"
}
| Field | Description |
|---|---|
| OriginalReference | Gateway reference of the successful original collection. |
| ExternalReference | Your unique refund identifier. Reuse it for retries. |
| Amount | Positive decimal string matching the full original collection amount; at most two decimal places. |
| Currency | Currency of the original collection. |
| Reason | Reason for requesting the refund, up to 500 characters. |
The destination is determined from the original payment. A replacement recipient cannot be supplied. Partial refunds are not supported. Sufficient merchant funds must be available; funding remains reserved while the provider outcome is pending. The initial policy retains the original collection fee and adds no refund fee.
# Sign the request
Send Authorization: Bearer {token}, Content-Type: application/json, X-Timestamp (Unix seconds), and X-Signature (hex HMAC-SHA256). Sign the exact body bytes with your merchant's refund secret:
import hashlib
import hmac
import json
import time
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
timestamp = str(int(time.time()))
signature = hmac.new(
secret.encode("utf-8"),
timestamp.encode("utf-8") + b"\n" + body,
hashlib.sha256,
).hexdigest()
# Send `body` unchanged, with timestamp and signature in the headers.
Requests must be within five minutes of the server time. Generate a fresh timestamp/signature for a retry while preserving the refund identifier and request details.
# Complete Python request example
Set PESAWAY_API_BASE to your provisioned API base URL, PESAWAY_TOKEN to your Bearer token, PESAWAY_REFUND_SECRET to your refund signing secret, and PESAWAY_REGION to your supported region. This example uses Python's standard library; replace the collection details before running it.
import hashlib
import hmac
import json
import os
import time
import urllib.error
import urllib.request
payload = {
"OriginalReference": "PHY-COLLECTION-001",
"ExternalReference": "MY-REFUND-001",
"Amount": "1000.50",
"Currency": "KES",
"Reason": "Third-party deposit",
}
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
timestamp = str(int(time.time()))
signature = hmac.new(
os.environ["PESAWAY_REFUND_SECRET"].encode("utf-8"),
timestamp.encode("utf-8") + b"\n" + body,
hashlib.sha256,
).hexdigest()
request = urllib.request.Request(
os.environ["PESAWAY_API_BASE"].rstrip("/") + "/api/v1/refunds/",
data=body,
method="POST",
headers={
"Authorization": "Bearer " + os.environ["PESAWAY_TOKEN"],
"Content-Type": "application/json",
"X-Region": os.environ["PESAWAY_REGION"],
"X-Timestamp": timestamp,
"X-Signature": signature,
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
print(response.status, json.loads(response.read()))
except urllib.error.HTTPError as error:
print(error.code, error.read().decode("utf-8"))
Keep the signing secret on your server. A network timeout does not establish whether the request was accepted: retry with the same payload and a fresh timestamp/signature, rather than creating a new identifier.
# Acceptance and retries
HTTP 201 returns an envelope with code: "200.001", status: "success", and data containing the refund. An identical retry returns HTTP 200 with the same gateway reference and its current status. Changed details under the same identifier return HTTP 409. Another refund identifier cannot refund the same collection again.
Example data fields:
{
"Reference": "RFD-0123456789ABCDEF0123456789ABCDEF",
"OriginalReference": "PHY-COLLECTION-001",
"ExternalReference": "MY-REFUND-001",
"TransactionType": "Refund",
"Amount": "1000.50",
"Currency": "KES",
"Status": "PROCESSING",
"Stage": "Requested",
"TransactionDate": "2026-09-07T10:00:00+00:00",
"UpdatedAt": "2026-09-07T10:00:00+00:00",
"CompletedAt": null,
"Receipt": null,
"FailureReason": ""
}
Acceptance does not mean funds have been returned. COMPLETED requires verified return to the original sender and finalized accounting. FAILED indicates rejection or definitive failure; an uncertain provider outcome remains PROCESSING.
# What happens after acceptance
The initial process is managed by finance. When recorded, the original collection route determines the provider. If no route is recorded, the refund is always manual and finance identifies the original provider. Merchants do not select a different provider or recipient in the refund request. Automatic processing is available only when a refund integration is enabled for that route.
| Step | What happens |
|---|---|
| Request accepted | A case is created and the full principal is reserved from your available wallet funds. |
| Finance notified | Telegram and email alerts are queued for the finance team. Finance reviews the case and coordinates the return with the original provider. |
| Provider return verified | Finance records proof that funds were returned to the original sender. A different reviewer completes a manual refund. |
| Completed | Accounting is finalized, the API reports COMPLETED, and the return appears in your reports. |
| Rejected or failed | Reserved funds are released. An uncertain provider outcome remains pending until it can be resolved. |
# Finance notifications
These are internal finance alerts; merchants do not need to integrate with Telegram or email. Both channels receive the following creation message, with the actual case values:
Hello Finance Team,
A refund has been requested with the following details:
Refund RFD-123456
Merchant: Example Merchant
Amount: 1000.00 KES
Original collection: TXN-789012
Provider: Example Provider
Mode: Manual
Action: created
Stage at event: Requested
Due: 2026-09-08T10:00:00+00:00
Email subject: Refund RFD-123456 — created. The due date is the configured finance handling target, not confirmation or a guarantee that the provider has completed the return. Alerts are delivered asynchronously and retried on failure. Merchant system notifications use the callbacks below.
# Track progress
- UI: open Payins → Refunds for your refund table and progress view. To start a request, choose Reverse Collection on a completed payin and enter a reason. This opens a tracked refund; it does not immediately approve the reversal. Repeated requests open the existing case. The action requires the authenticated user’s
can_reconcile_payinspermission, enforced by the backend. - Pull Transactions: use
TransType: "Refund"andStatus: "All","Processing","Complete", or"Failed". Omitted status defaults toComplete. Existing date and 100-row offset pagination rules apply. - Transaction Query: pass the gateway refund reference in
TransactionReferenceto the existing mobile-money transaction-query API.ResultCodeis101while processing,0when completed, or2001when failed.
Completed refunds also appear in Wallet Activity and Account Statement, linked to the original collection. Wallet Activity shows a Reversal debit, using the existing CollectionReversal transaction type. Account Statement uses the existing Reverse Collection entry. Account Statement follows its existing convention for wallet outflows (the credit column). Rejected or failed requests release the reserved funds without recording a completed return.
There are no separate refund list/detail GET endpoints. Existing payment and collection lookup behavior is unchanged.
# List requests, including pending refunds
Send POST to /api/v1/mobile-money/pull-transactions/ with your existing Bearer authentication, Content-Type: application/json, and X-Region:
{
"StartDate": "2026-09-01T00:00:00+03:00",
"EndDate": "2026-10-01T00:00:00+03:00",
"TransType": "Refund",
"OffsetValue": 0,
"Status": "All"
}
The date range filters request creation time: start inclusive, end exclusive. Each data item uses the refund fields shown in the acceptance example. Increase OffsetValue by 100 until fewer than 100 rows are returned. Include Status: "All" to see pending requests; omitting it lists only completed refunds. See Pull Transactions.
# Query one request
Send GET to /api/v1/mobile-money/transaction-query/ with the same authentication headers and this JSON body, following the existing Transaction Query contract:
{
"TransactionReference": "RFD-0123456789ABCDEF0123456789ABCDEF"
}
Use the gateway refund reference, not your external reference or the original collection reference. Refund responses include the refund fields above plus numeric ResultCode and ResultDesc.
Public Status | ResultCode | Possible Stage | Merchant action |
|---|---|---|---|
PROCESSING | 101 | Requested, Under Review, Submitted to Provider, Awaiting Confirmation, Proof Recorded, Accounting Review | Keep pending and query again or await a terminal callback. Proof Recorded is not completion. |
COMPLETED | 0 | Completed | Record the completed return. |
FAILED | 2001 | Rejected, Failed | Inspect FailureReason; contact support if follow-up is needed. |
A failed or rejected case still prevents a second refund request against the same collection. Contact support instead of retrying with a new identifier.
# Callbacks
Provision a refund callback URL and signing secret for your merchant. Events include refund.created, refund.completed, and refund.failed, with stable EventID, OccurredAt, refund/original/merchant references, amount, currency and uppercase refund Status. Deliveries are retried; deduplicate by EventID and return a 2xx response. Late delivery may describe an earlier transition, so use the transaction query for current state if needed.
Callbacks use X-Timestamp, X-Signature, and X-Event-ID. Verify the HMAC over the exact timestamp, newline and raw callback body using the provisioned callback secret. The callback signing secret is configured separately from the request signing secret.
Example completed callback body (illustrative identifiers):
{
"EventID": "7ee70b38-d8da-4d34-bb82-cc8ba9b06f29",
"EventType": "refund.completed",
"OccurredAt": "2026-09-08T09:30:00+00:00",
"Reference": "RFD-0123456789ABCDEF0123456789ABCDEF",
"OriginalReference": "PHY-COLLECTION-001",
"ExternalReference": "MY-REFUND-001",
"Amount": "1000.50",
"Currency": "KES",
"Status": "COMPLETED"
}
Check the signature with a constant-time comparison before processing the callback. Store the EventID durably with your update before acknowledging it. Duplicate deliveries must not repeat your accounting or customer notifications. Do not overwrite a terminal result with a delayed refund.created event; query current state when events conflict.
# Errors
| HTTP | Examples |
|---|---|
| 400 | Invalid fields, amount precision, or missing reason. |
| 401 | Invalid authentication or invalid/expired signature. |
| 404 | Original collection/refund not found in your merchant scope. |
| 409 | Conflicting identifier or existing refund/reversal. |
| 422 | Ineligible collection, amount/currency mismatch, unsupported route, or insufficient funds. |
| 503 | Refund signing or finance/provider configuration is not ready. |
Validation responses include a machine-readable code, a message, and status: "failed".