Issue a Refund
Prepare a UAE payment for a refund, then either ask Spare to pay it or read the payer account and pay it yourself.
Once a UAE payment has settled, you can return money to the payer. Prepare the original payment so Spare knows which account to pay, wait until the payer has authorized it, and then choose how the money goes back.
The two choices are on this page. Refunds explains when a refund is allowed, including partial refunds and more than one refund of the same payment.
If You Already Know the Payer's Account
Send debtorAccount on POST /payment-requests. You do not add ReadRefundAccount. Spare keeps that account with the payment, and after the bank accepts the payment you can create the refund directly.
This is a single instant payment of 125.50 AED. The payer's IBAN is already on the request, so the body has no consentPermissions.
{
"type": "SingleInstantPayment",
"creditorType": "MERCHANT",
"creditorReference": "INV10042",
"merchantReference": "order-10042",
"purpose": "GDDS",
"creditorAccount": {
"schemeName": "IBAN",
"identification": "AE070331234567890123456",
"name": "Acme Trading LLC"
},
"debtorAccount": {
"schemeName": "IBAN",
"identification": "AE460090000000123456789",
"name": "Fatima Al Hashimi"
},
"successRedirectUrl": "https://merchant.example/payments/success",
"failureRedirectUrl": "https://merchant.example/payments/failure",
"instructions": {
"amount": { "amount": "125.50", "currency": "AED" }
}
}The field rules for debtorAccount are on Debtor Account.
If You Do Not Know the Payer's Account
Leave debtorAccount out, and add ReadRefundAccount to consentPermissions. That permission is what lets Spare read the refund account from the payer's bank after they authorize. Without it, Spare has no account to pay, and the refund cannot start.
The sample below is the same 125.50 AED payment, with the permission and no debtor account. Tell the payer in your own screen why you need the refund account. Spare also explains the permission during the hosted flow.
{
"type": "SingleInstantPayment",
"creditorType": "MERCHANT",
"creditorReference": "INV10042",
"merchantReference": "order-10042",
"purpose": "GDDS",
"consentPermissions": ["ReadRefundAccount"],
"creditorAccount": {
"schemeName": "IBAN",
"identification": "AE070331234567890123456",
"name": "Acme Trading LLC"
},
"successRedirectUrl": "https://merchant.example/payments/success",
"failureRedirectUrl": "https://merchant.example/payments/failure",
"instructions": {
"amount": { "amount": "125.50", "currency": "AED" }
}
}The other permission codes are on Consent Permissions.
After the Payer Authorizes
Wait until the payment status is AcceptedCreditSettlementCompleted or AcceptedWithoutPosting. Keep the payment id from that payment. You send it as paymentId on the refund.
You then choose one of the two ways below.
Ask Spare to Pay the Refund
POST /payment/refunds creates a single instant payment from your bank account to the original payer. You sign the JSON body with x-signature. The x-idempotency-key header is not part of that signed body. Spare signs the validated body. Write the currency in upper case, and leave no leading or trailing spaces on string values. A body signed with "currency": "aed" fails with Invalid payment request signature. See Request signing.
If you sent debtorAccount on the original payment request, you can make this call without ReadRefundAccount. If you did not send debtorAccount, the original consent must include ReadRefundAccount, or Spare rejects the refund.
By default the refund leaves the account that received the original payment. To send it from a different account you have registered, set debtorBankAccountId. That choice is below.
This example refunds 42.50 AED of the 125.50 AED payment and leaves debtorBankAccountId out. Spare sends the refund from the account that received the original payment. That account must allow debits. A new idempotency key creates another refund of the same payment. The same key with the same body returns the refund you already created.
curl -X POST "https://api.sandbox.tryspare.ae/payment/refunds" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "x-tenant: UAE" \
-H "x-signature: $X_SIGNATURE" \
-H "x-idempotency-key: refund-10042-1" \
-H "Content-Type: application/json" \
-d '{
"paymentId": "3f2a9c14-7b6e-4d11-9a20-6c8e1b4d0f77",
"amount": { "amount": "42.50", "currency": "AED" },
"merchantReference": "refund-10042-1",
"successRedirectUrl": "https://merchant.example/refunds/success",
"failureRedirectUrl": "https://merchant.example/refunds/failure"
}'const res = await fetch("https://api.sandbox.tryspare.ae/payment/refunds", {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"x-tenant": "UAE",
"x-signature": xSignature,
"x-idempotency-key": "refund-10042-1",
"Content-Type": "application/json",
},
body: JSON.stringify({
paymentId: "3f2a9c14-7b6e-4d11-9a20-6c8e1b4d0f77",
amount: { amount: "42.50", currency: "AED" },
merchantReference: "refund-10042-1",
successRedirectUrl: "https://merchant.example/refunds/success",
failureRedirectUrl: "https://merchant.example/refunds/failure",
}),
});
const { data } = await res.json();import requests
res = requests.post(
"https://api.sandbox.tryspare.ae/payment/refunds",
headers={
"Authorization": f"Bearer {access_token}",
"x-tenant": "UAE",
"x-signature": x_signature,
"x-idempotency-key": "refund-10042-1",
},
json={
"paymentId": "3f2a9c14-7b6e-4d11-9a20-6c8e1b4d0f77",
"amount": {"amount": "42.50", "currency": "AED"},
"merchantReference": "refund-10042-1",
"successRedirectUrl": "https://merchant.example/refunds/success",
"failureRedirectUrl": "https://merchant.example/refunds/failure",
},
)
data = res.json()["data"]HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.sandbox.tryspare.ae/payment/refunds"))
.header("Authorization", "Bearer " + accessToken)
.header("x-tenant", "UAE")
.header("x-signature", xSignature)
.header("x-idempotency-key", "refund-10042-1")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("""
{
"paymentId": "3f2a9c14-7b6e-4d11-9a20-6c8e1b4d0f77",
"amount": { "amount": "42.50", "currency": "AED" },
"merchantReference": "refund-10042-1",
"successRedirectUrl": "https://merchant.example/refunds/success",
"failureRedirectUrl": "https://merchant.example/refunds/failure"
}
"""))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());using var client = new HttpClient();
var request = new HttpRequestMessage(
HttpMethod.Post,
"https://api.sandbox.tryspare.ae/payment/refunds");
request.Headers.Add("Authorization", $"Bearer {accessToken}");
request.Headers.Add("x-tenant", "UAE");
request.Headers.Add("x-signature", xSignature);
request.Headers.Add("x-idempotency-key", "refund-10042-1");
request.Content = new StringContent(
"""
{
"paymentId": "3f2a9c14-7b6e-4d11-9a20-6c8e1b4d0f77",
"amount": { "amount": "42.50", "currency": "AED" },
"merchantReference": "refund-10042-1",
"successRedirectUrl": "https://merchant.example/refunds/success",
"failureRedirectUrl": "https://merchant.example/refunds/failure"
}
""",
Encoding.UTF8,
"application/json");
var response = await client.SendAsync(request);body := []byte(`{
"paymentId": "3f2a9c14-7b6e-4d11-9a20-6c8e1b4d0f77",
"amount": {"amount": "42.50", "currency": "AED"},
"merchantReference": "refund-10042-1",
"successRedirectUrl": "https://merchant.example/refunds/success",
"failureRedirectUrl": "https://merchant.example/refunds/failure"
}`)
req, err := http.NewRequest(http.MethodPost, "https://api.sandbox.tryspare.ae/payment/refunds", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("x-tenant", "UAE")
req.Header.Set("x-signature", xSignature)
req.Header.Set("x-idempotency-key", "refund-10042-1")
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)The response is a payment request. status starts at New, originalPaymentId is the payment you are refunding, and redirectUrl is the hosted link. Open that link and authorize the debit at your bank. The payer account on creditorAccount is masked. debtorAccount is your own account.
{
"data": {
"id": "9b8e1c20-5a44-4d7f-8e11-2c6a0d4f7b90",
"status": "New",
"type": "SingleInstantPayment",
"originalPaymentId": "3f2a9c14-7b6e-4d11-9a20-6c8e1b4d0f77",
"amount": "42.50",
"currency": "AED",
"internalReference": "Kp9mQx2nRwT",
"redirectUrl": "https://web.sandbox.tryspare.ae/pis/Kp9mQx2nRwT",
"creditorType": "USER",
"isCopVerified": true,
"creditorAccount": {
"schemeName": "IBAN",
"identification": "AE** **** **** **** *** 6789",
"name": "Fatima Al Hashimi"
},
"debtorAccount": {
"id": "4a1c8e30-2b6d-4f55-9c10-7e3a0d5b8c66",
"schemeName": "IBAN",
"identification": "AE070331234567890123456",
"name": "Acme Trading LLC"
}
}
}Track it the same way you track any other payment. Reconciliation reads the refund payment request together with its payment. The payer's bank can still reject the payment when you authorize it.
Pay From a Different Account
Send debtorBankAccountId when the refund should leave a different account from the one that received the original payment. The value is the id of a bank account you registered with Spare. See Setup your Bank Account.
That account must allow debits. Its usage is DEBIT or BOTH. The headers stay the same as the call above, including x-signature and x-idempotency-key. Use a new idempotency key for this body. You still open redirectUrl and authorize the debit at your bank. The response shape does not change. debtorAccount on that response is the account you named.
{
"paymentId": "3f2a9c14-7b6e-4d11-9a20-6c8e1b4d0f77",
"amount": { "amount": "42.50", "currency": "AED" },
"debtorBankAccountId": "4a1c8e30-2b6d-4f55-9c10-7e3a0d5b8c66",
"merchantReference": "refund-10042-2",
"successRedirectUrl": "https://merchant.example/refunds/success",
"failureRedirectUrl": "https://merchant.example/refunds/failure"
}Pay the Payer Yourself
Call POST /payment/refunds/account when you will send the money outside Spare, for example from your own bank. The body is the original paymentId. This call is not signed.
This call needs ReadRefundAccount on the original consent, because Spare reads the account from the bank. A payment request that only stored debtorAccount, and never asked for the permission, cannot use this call.
The response is the payer account you can match against your own records. An IBAN in maskedIdentification keeps the country prefix and the last four characters.
curl -X POST "https://api.sandbox.tryspare.ae/payment/refunds/account" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "x-tenant: UAE" \
-H "Content-Type: application/json" \
-d '{ "paymentId": "3f2a9c14-7b6e-4d11-9a20-6c8e1b4d0f77" }'const res = await fetch(
"https://api.sandbox.tryspare.ae/payment/refunds/account",
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"x-tenant": "UAE",
"Content-Type": "application/json",
},
body: JSON.stringify({
paymentId: "3f2a9c14-7b6e-4d11-9a20-6c8e1b4d0f77",
}),
},
);
const { data } = await res.json();import requests
res = requests.post(
"https://api.sandbox.tryspare.ae/payment/refunds/account",
headers={
"Authorization": f"Bearer {access_token}",
"x-tenant": "UAE",
},
json={"paymentId": "3f2a9c14-7b6e-4d11-9a20-6c8e1b4d0f77"},
)
data = res.json()["data"]HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.sandbox.tryspare.ae/payment/refunds/account"))
.header("Authorization", "Bearer " + accessToken)
.header("x-tenant", "UAE")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
"{\"paymentId\":\"3f2a9c14-7b6e-4d11-9a20-6c8e1b4d0f77\"}"))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());using var client = new HttpClient();
var request = new HttpRequestMessage(
HttpMethod.Post,
"https://api.sandbox.tryspare.ae/payment/refunds/account");
request.Headers.Add("Authorization", $"Bearer {accessToken}");
request.Headers.Add("x-tenant", "UAE");
request.Content = new StringContent(
"""{"paymentId":"3f2a9c14-7b6e-4d11-9a20-6c8e1b4d0f77"}""",
Encoding.UTF8,
"application/json");
var response = await client.SendAsync(request);body := []byte(`{"paymentId":"3f2a9c14-7b6e-4d11-9a20-6c8e1b4d0f77"}`)
req, err := http.NewRequest(
http.MethodPost,
"https://api.sandbox.tryspare.ae/payment/refunds/account",
bytes.NewReader(body),
)
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("x-tenant", "UAE")
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req){
"data": {
"id": "8d4b2e61-0c5a-4f93-a178-6e2d9b0c3f55",
"maskedIdentification": "AE** **** **** **** *** 6789",
"accountHolderName": "Fatima Al Hashimi",
"scheme": "IBAN",
"provider": {
"id": "6c2e8a10-1b4d-4f77-9a33-0d5e7c1b8a42",
"code": "FAB",
"name": "First Abu Dhabi Bank"
}
}
}When a Refund Cannot Be Issued
Spare rejects the call when the payment is not in a refundable state, the consent cannot supply an account, or the account that would send the money cannot be debited. Branch on code. When the amount or x-idempotency-key fails validation, the message includes the text in that row. Every other row is the message Spare returns. See Error codes.
| Situation | code | Message |
|---|---|---|
Unknown paymentId, or a payment owned by another client | not_found | Payment not found |
Status is not AcceptedCreditSettlementCompleted or AcceptedWithoutPosting | invalid_argument | Payment status does not allow a refund |
| Currency does not match the original payment | invalid_argument | Refund currency must match the original payment |
| Amount is missing | invalid_argument | amount is required |
Amount is not a positive value with two decimal places, up to 99999999.99 | invalid_argument | amount.amount must be a positive value with two decimal places up to 99999999.99 |
You did not send debtorAccount, and the consent omits ReadRefundAccount | invalid_argument | Payment has no debtor account and the consent does not include ReadRefundAccount |
You read the account, and the consent omits ReadRefundAccount | invalid_argument | Payment has no debtor account and the consent does not include ReadRefundAccount |
| The bank reports the account closed, suspended, or restricted | failed_precondition | Refund account is closed, suspended, or restricted |
| The bank refuses the account read | permission_denied | Consent does not allow retrieval of the refund account |
debtorBankAccountId is not one of your accounts | invalid_argument | Debtor bank account not found |
The supplied account usage is not DEBIT or BOTH | invalid_argument | Debtor bank account usage must be DEBIT or BOTH |
You omitted debtorBankAccountId and the receiving account is credit-only | invalid_argument | The bank account can only be used for credit |
| The sending account's bank is not a provider Spare can pay from | invalid_argument | Debtor bank account is not an onboarded provider |
x-signature is missing | permission_denied | x-signature header is required |
x-signature does not match the body | permission_denied | Invalid payment request signature |
x-idempotency-key is missing | invalid_argument | x-idempotency-key is required |
x-idempotency-key is longer than 64 characters | invalid_argument | x-idempotency-key must be at most 64 characters |
The same x-idempotency-key was sent with a different body | already_exists | x-idempotency-key was already used with a different refund request |
If the payer's bank no longer returns a refund account, both calls that need that account fail with the bank rows above. A refund payment request you already created stays. Read it with the id from the create response.