Refund and reverse#
Give money back, or cancel a payment before the money ever moves.
Two operations do this, and choosing the wrong one is the most common mistake here. VOID cancels a transaction on the same financial day and moves no funds. CREDITVOID returns funds that have settled, or releases an authorisation hold.
Every example below uses the same sample credentials so the hashes are reproducible: payer_email of [email protected], a PASSWORD of SANDBOX_PASSWORD, and the sandbox test card. Payload values are illustrative, but every field name, its presence or absence, and every hash is exact.
Which one to send#
Use VOID for a same-day cancellation, where no funds movement is wanted at all.
Use CREDITVOID for a refund after settlement day, or to reverse an authorisation hold.
VOID is available only for transactions in SETTLED status that came from SALE, CAPTURE or RECURRING_SALE, and only on the same financial day they were taken. Outside that window the operation is CREDITVOID.
Refunding with CREDITVOID#
CREDITVOID handles both reversals, which cancel an authorisation hold, and refunds, which return settled funds. Full and partial are both supported, and unlike a capture, multiple partial refunds are allowed.
That difference is worth holding on to. One partial capture is permitted and the remainder of the authorisation is then gone. Partial refunds have no such limit, so you can refund a basket line by line as goods are returned.
The request
Only four fields. The card is not among them.
https://{PAYMENT_URL}/postcurl -X POST https://{PAYMENT_URL}/post \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "action=CREDITVOID" \
-d "client_key={CLIENT_KEY}" \
-d "trans_id=f6a7b8c9-0d1e-4f2a-b3c4-d5e6f7a8b9c0" \
-d "hash=d2bd8cb00c527171af3812ff9aae9ef8"$url = 'https://{PAYMENT_URL}/post';
$fields = [
'action' => 'CREDITVOID',
'client_key' => '{CLIENT_KEY}',
'trans_id' => 'f6a7b8c9-0d1e-4f2a-b3c4-d5e6f7a8b9c0',
'hash' => 'd2bd8cb00c527171af3812ff9aae9ef8',
];
$body = http_build_query($fields);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
$result = json_decode($response, true);import requests
from urllib.parse import urlencode
url = 'https://{PAYMENT_URL}/post'
fields = {
'action': 'CREDITVOID',
'client_key': '{CLIENT_KEY}',
'trans_id': 'f6a7b8c9-0d1e-4f2a-b3c4-d5e6f7a8b9c0',
'hash': 'd2bd8cb00c527171af3812ff9aae9ef8',
}
body = urlencode(fields)
response = requests.post(
url,
data=body,
headers={'Content-Type': 'application/x-www-form-urlencoded'},
)
result = response.json()const url = 'https://{PAYMENT_URL}/post'
const fields = {
'action': 'CREDITVOID',
'client_key': '{CLIENT_KEY}',
'trans_id': 'f6a7b8c9-0d1e-4f2a-b3c4-d5e6f7a8b9c0',
'hash': 'd2bd8cb00c527171af3812ff9aae9ef8',
}
const body = new URLSearchParams(fields).toString()
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
const result = await response.json()Omit amount to refund the full sum. Include it to refund less, and send the request again for each further partial.
The signature is Formula 2, over payer_email, PASSWORD, trans_id and the card's first six and last four digits. You are not sending the card, so those digits come from your own record of the original payment. Store the mask when you take the payment and this is straightforward. Fail to store it and you cannot sign a refund at all.
The synchronous response
{
"action": "CREDITVOID",
"result": "ACCEPTED",
"order_id": "TAP-1001",
"trans_id": "f6a7b8c9-0d1e-4f2a-b3c4-d5e6f7a8b9c0"
}ACCEPTED means the request was taken, not that the money moved. Wait for the callback.
The callbacks
Four shapes, all signed with Formula 2.
Success, full refund. result: SUCCESS with status: REFUND or REVERSAL, plus order_id, trans_id, creditvoid_date, amount and hash. Which status you get tells you whether funds were returned or a hold was released.
Success, partial refund. The same fields, but status: SETTLED, because the original transaction keeps its settled status. Do not read SETTLED here as "the refund did not happen". It means the original is still settled, which is exactly right when only part of it came back.
Declined. result: DECLINED with order_id, trans_id, decline_reason and hash. No status.
Undefined. result: UNDEFINED with status: SETTLED, order_id, trans_id, creditvoid_date, amount and hash.
action=CREDITVOID result=SUCCESS status=REFUND order_id=TAP-1001 trans_id=f6a7b8c9-0d1e-4f2a-b3c4-d5e6f7a8b9c0 creditvoid_date=2026-07-25 16:20:04 amount=120000 hash=d2bd8cb00c527171af3812ff9aae9ef8
If your account has extended data enabled, success callbacks also carry connector_name, rrn, approval_code and related acquirer fields.
The request hash and the callback hash are the same digest, because both hash the same email, password, trans_id and card fragment. Seeing the value you sent come back is correct, not a replay.
Cancelling with VOID#
VOID cancels the transaction entirely. It does not process a refund, and no funds move.
The request
https://{PAYMENT_URL}/postcurl -X POST https://{PAYMENT_URL}/post \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "action=VOID" \
-d "client_key={CLIENT_KEY}" \
-d "trans_id=f6a7b8c9-0d1e-4f2a-b3c4-d5e6f7a8b9c0" \
-d "hash=d2bd8cb00c527171af3812ff9aae9ef8"$url = 'https://{PAYMENT_URL}/post';
$fields = [
'action' => 'VOID',
'client_key' => '{CLIENT_KEY}',
'trans_id' => 'f6a7b8c9-0d1e-4f2a-b3c4-d5e6f7a8b9c0',
'hash' => 'd2bd8cb00c527171af3812ff9aae9ef8',
];
$body = http_build_query($fields);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
$result = json_decode($response, true);import requests
from urllib.parse import urlencode
url = 'https://{PAYMENT_URL}/post'
fields = {
'action': 'VOID',
'client_key': '{CLIENT_KEY}',
'trans_id': 'f6a7b8c9-0d1e-4f2a-b3c4-d5e6f7a8b9c0',
'hash': 'd2bd8cb00c527171af3812ff9aae9ef8',
}
body = urlencode(fields)
response = requests.post(
url,
data=body,
headers={'Content-Type': 'application/x-www-form-urlencoded'},
)
result = response.json()const url = 'https://{PAYMENT_URL}/post'
const fields = {
'action': 'VOID',
'client_key': '{CLIENT_KEY}',
'trans_id': 'f6a7b8c9-0d1e-4f2a-b3c4-d5e6f7a8b9c0',
'hash': 'd2bd8cb00c527171af3812ff9aae9ef8',
}
const body = new URLSearchParams(fields).toString()
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
const result = await response.json()Signed with Formula 2, exactly as CREDITVOID is. trans_id accepts up to 255 characters here.
The synchronous response
| Result | Status | Meaning |
|---|---|---|
SUCCESS | VOID | Transaction voided |
DECLINED | SETTLED | Void rejected, with decline_reason. The original remains settled. |
UNDEFINED | PENDING or SETTLED | Outcome not yet known. Await the callback. |
A declined void leaves you with a settled transaction and a customer expecting their money back. Handle that branch explicitly: the follow-up is a CREDITVOID, not a retry of the void.
The callbacks
Success. result: SUCCESS, status: VOID, plus order_id, trans_id, trans_date and hash.
Declined. result: DECLINED, status: SETTLED, plus order_id, trans_id, trans_date, decline_reason and hash.
Undefined. result: UNDEFINED, status: PENDING or SETTLED, plus order_id, trans_id, trans_date and hash.
The VOID callback signs differently#
This is the one genuine trap on this page.
Every other callback in this guide verifies with Formula 2. A VOID callback does not. It uses the Void signature:
hash = md5(strtoupper(strrev(trans_id)) . PASSWORD)
Note what that is and is not. The uppercasing applies to the reversed trans_id only, and PASSWORD is appended after it, not folded into the uppercased string. There is no email and no card fragment. Unlike Formulas 1 to 8, it is a plain MD5 with no SHA1 wrapper.
The VOID request itself still signs with Formula 2. So a single void involves two different hash constructions, one outbound and one inbound, and an integration that reuses the request hash to verify the callback will reject every void it ever receives.
action=VOID result=SUCCESS status=VOID order_id=TAP-1001 trans_id=f6a7b8c9-0d1e-4f2a-b3c4-d5e6f7a8b9c0 trans_date=2026-07-25 16:22:31 hash=561b94d2dd370a7c3fc08d2f6675d6a4
Both digests on this page derive from the same trans_id, and they differ because the constructions differ. If yours match each other, you have used the wrong one twice.
Check this one against a mixed-case password
With an all-uppercase password the two constructions coincide, so the vector above cannot tell a correct implementation from a common mistake. Check yours against a mixed-case password as well. With PASSWORD of Sandbox_Pass1 and the same trans_id, the correct construction gives a435c9eec99409dee814616f62800808. Uppercasing the password along with the reversed trans_id gives d36684521d7ee51e935cf8f2c3fe1323, which is the error this vector exists to catch.
What next#
Take a payment
covers the sale, authorise and capture, and saving a card.
Handle 3D Secure
covers the redirect.
The API reference
lists every parameter of every action, and operations covers callback delivery, chargebacks and the go-live checklist.
Technical questions go to [email protected].