SpareSpare Docs
GuidesAPI Reference

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.

SituationcodeMessage
Unknown paymentId, or a payment owned by another clientnot_foundPayment not found
Status is not AcceptedCreditSettlementCompleted or AcceptedWithoutPostinginvalid_argumentPayment status does not allow a refund
Currency does not match the original paymentinvalid_argumentRefund currency must match the original payment
Amount is missinginvalid_argumentamount is required
Amount is not a positive value with two decimal places, up to 99999999.99invalid_argumentamount.amount must be a positive value with two decimal places up to 99999999.99
You did not send debtorAccount, and the consent omits ReadRefundAccountinvalid_argumentPayment has no debtor account and the consent does not include ReadRefundAccount
You read the account, and the consent omits ReadRefundAccountinvalid_argumentPayment has no debtor account and the consent does not include ReadRefundAccount
The bank reports the account closed, suspended, or restrictedfailed_preconditionRefund account is closed, suspended, or restricted
The bank refuses the account readpermission_deniedConsent does not allow retrieval of the refund account
debtorBankAccountId is not one of your accountsinvalid_argumentDebtor bank account not found
The supplied account usage is not DEBIT or BOTHinvalid_argumentDebtor bank account usage must be DEBIT or BOTH
You omitted debtorBankAccountId and the receiving account is credit-onlyinvalid_argumentThe bank account can only be used for credit
The sending account's bank is not a provider Spare can pay frominvalid_argumentDebtor bank account is not an onboarded provider
x-signature is missingpermission_deniedx-signature header is required
x-signature does not match the bodypermission_deniedInvalid payment request signature
x-idempotency-key is missinginvalid_argumentx-idempotency-key is required
x-idempotency-key is longer than 64 charactersinvalid_argumentx-idempotency-key must be at most 64 characters
The same x-idempotency-key was sent with a different bodyalready_existsx-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.

On this page