Refund or void a payment
Undo a payment both ways: cancel one before the batch closes, then credit part of another back after it has settled, and read which of the two a transaction will actually accept.
Signed in, you can run this blueprint against your own sandbox merchant one call at a time, with the values from each call threaded into the next. Sign in to run it.
8 steps, 7 API callsPaymentsTransactions
Samples use {{API_KEY}} for your API key and {{BASE_URL}} for this instance's API address. Anything else in double braces is a value an earlier step gave you.
Set up your sandbox
1. Get an API key for a sandbox merchant
On your side
Every call below sends an api-key header. Create a key against a sandbox merchant in the application and keep it out of source control: the samples on this page leave it as a placeholder for exactly that reason. Nothing here reaches a card network, and nothing here is reversible from the cardholder's side, which is what makes the sandbox the right place to get the undo path wrong the first time.
Values this step gives you
Cancel a payment before it settles
2. Create a sale you are going to cancel
API call
POST /api/transactions
The same sale the first blueprint takes, at the sandbox's guaranteed approving amount. Keep both ids that come back: the transaction id names the charge, and the merchant id is a route segment on every follow-up operation.
Values this step gives you
{{transactionId}}The id of the created sale, from the response body's id property.{{merchantId}}The merchant the sale belongs to, from the response body's merchantId property. It's a segment of the follow-up operations route.
curl -X POST "{{BASE_URL}}/api/transactions" \
-H "api-key: {{API_KEY}}" \
-H "Content-Type: application/json" \
-d '{
"transactionType": "Sale",
"cardData": {
"cardNumber": "4111111111111111",
"nameOnCard": "Jane Doe",
"expirationMonth": 12,
"expirationYear": 2030,
"cvv": 123
},
"invoiceData": {
"amounts": { "base": 10.00, "total": 10.00 }
}
}'using var http = new HttpClient { BaseAddress = new Uri("{{BASE_URL}}") };
http.DefaultRequestHeaders.Add("api-key", "{{API_KEY}}");
var response = await http.PostAsJsonAsync("/api/transactions", new
{
transactionType = "Sale",
cardData = new
{
cardNumber = "4111111111111111",
nameOnCard = "Jane Doe",
expirationMonth = 12,
expirationYear = 2030,
cvv = 123
},
invoiceData = new
{
amounts = new { @base = 10.00m, total = 10.00m }
}
});
response.EnsureSuccessStatusCode();
var sale = await response.Content.ReadFromJsonAsync<JsonElement>();
var transactionId = sale.GetProperty("id").GetString();
var merchantId = sale.GetProperty("merchantId").GetString();What this step answers with
Abridged to the properties this step depends on. A real response carries more.
{
"id": "9f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
"merchantId": "3a7b1c9d-2e4f-4a6b-8c8d-9e0f1a2b3c4d",
"transactionType": "Sale",
"currentStage": "Authorized",
"resultCode": "Ok",
"authorizedAmount": 10.00,
"allowedActions": ["Reversal", "Repeat"],
"cumulativeRefundedAmount": 0.00,
"responseData": {
"resultCode": "Ok",
"resultMessage": "Approved"
}
}3. Read which undo the transaction allows
API call
GET /api/transactions/{{transactionId}}
Read the transaction and look at allowedActions before you undo anything. The platform works out which follow-up operations are valid right now from the settlement state and from what the merchant's processor supports, and you can't derive that answer from your own side. Branch on this list rather than assuming. A flow that always sends one operation type works in your sandbox and fails against the first merchant whose processor answers differently.
Values this step gives you
curl "{{BASE_URL}}/api/transactions/{{transactionId}}" \
-H "api-key: {{API_KEY}}"var sale = await http.GetFromJsonAsync<JsonElement>(
$"/api/transactions/{transactionId}");
var allowed = sale.GetProperty("allowedActions")
.EnumerateArray()
.Select(a => a.GetString())
.ToArray();
// Before the batch closes this is the cancel pair; after it closes it is Refund.
var undo = allowed.Contains("Reversal") ? "Reversal"
: allowed.Contains("Void") ? "Void"
: "Refund";What this step answers with
Abridged to the properties this step depends on. A real response carries more.
{
"id": "{{transactionId}}",
"merchantId": "3a7b1c9d-2e4f-4a6b-8c8d-9e0f1a2b3c4d",
"transactionType": "Sale",
"currentStage": "Authorized",
"resultCode": "Ok",
"authorizedAmount": 10.00,
"allowedActions": ["Reversal", "Repeat"],
"cumulativeRefundedAmount": 0.00,
"responseData": {
"resultCode": "Ok",
"resultMessage": "Approved"
}
}4. Cancel it before the batch closes
API call
POST /api/transactions/by-merchant/{{merchantId}}/{{transactionId}}/operations
Cancel the charge rather than crediting it, which is what the platform allows up to the batch close. Two operations do that, and the difference is whether the processor hears about it. A Reversal sends an online message that releases the issuer's hold and pulls the charge from the next clearing, and the processor can decline it. A Void is a ledger-only cancel that keeps the charge out of the next batch, and it's what the platform offers when the processor supports no online undo. The sandbox processor supports the online undo, so allowedActions offered Reversal above and that's what this step sends. The cardholder sees no charge either way, which is the whole reason to prefer this over a refund while you still can.
Values this step gives you
curl -X POST \
"{{BASE_URL}}/api/transactions/by-merchant/{{merchantId}}/{{transactionId}}/operations" \
-H "api-key: {{API_KEY}}" \
-H "Content-Type: application/json" \
-d '{
"operationType": "Reversal",
"reason": "Customer cancelled before shipping"
}'var cancel = await http.PostAsJsonAsync(
$"/api/transactions/by-merchant/{merchantId}/{transactionId}/operations",
new
{
operationType = "Reversal",
reason = "Customer cancelled before shipping"
});
cancel.EnsureSuccessStatusCode();
var outcome = await cancel.Content.ReadFromJsonAsync<JsonElement>();
var succeeded = outcome.GetProperty("success").GetBoolean();What this step answers with
Abridged to the properties this step depends on. A real response carries more.
{
"success": true,
"operationType": "Reversal",
"transactionId": "{{transactionId}}",
"timedOut": false
}Refund a payment after it settles
5. Create a second sale to refund later
API call
POST /api/transactions
The first sale is cancelled and terminal, so the refund half needs its own charge. This is the same request again; only the id you keep is different. It belongs to the same merchant, so the merchant id captured above is still the one the operations route takes.
Values this step gives you
{{settledTransactionId}}The id of the second sale, from the response body's id property. This is the charge the refund is issued against once it has settled.
curl -X POST "{{BASE_URL}}/api/transactions" \
-H "api-key: {{API_KEY}}" \
-H "Content-Type: application/json" \
-d '{
"transactionType": "Sale",
"cardData": {
"cardNumber": "4111111111111111",
"nameOnCard": "Jane Doe",
"expirationMonth": 12,
"expirationYear": 2030,
"cvv": 123
},
"invoiceData": {
"amounts": { "base": 10.00, "total": 10.00 }
}
}'var second = await http.PostAsJsonAsync("/api/transactions", new
{
transactionType = "Sale",
cardData = new
{
cardNumber = "4111111111111111",
nameOnCard = "Jane Doe",
expirationMonth = 12,
expirationYear = 2030,
cvv = 123
},
invoiceData = new
{
amounts = new { @base = 10.00m, total = 10.00m }
}
});
second.EnsureSuccessStatusCode();
var settledTransactionId =
(await second.Content.ReadFromJsonAsync<JsonElement>())
.GetProperty("id").GetString();What this step answers with
Abridged to the properties this step depends on. A real response carries more.
{
"id": "5c8d2e1f-3a4b-4d6c-9e0f-1a2b3c4d5e6f",
"merchantId": "3a7b1c9d-2e4f-4a6b-8c8d-9e0f1a2b3c4d",
"transactionType": "Sale",
"currentStage": "Authorized",
"resultCode": "Ok",
"authorizedAmount": 10.00,
"allowedActions": ["Reversal", "Repeat"],
"cumulativeRefundedAmount": 0.00,
"responseData": {
"resultCode": "Ok",
"resultMessage": "Approved"
}
}6. Close your sandbox batch
API call
POST /api/transactions/settlements/sandbox/close
Close the batch so the charge settles, because a refund is only valid once it has. In production the close is scheduled, so a real integration reads allowedActions and waits for Refund to appear. In the sandbox you can close the batch yourself, and this call does it synchronously. It returns once the batch has cleared, so the next step can run immediately. It settles everything your merchant has outstanding, not only the sale above, and it's refused for a live key, so nothing you learn here changes when a production batch closes. Read failedBatchCount off the response before you rely on it. A merchant with more than one processor gets one batch per processor, and a non-zero count means one of them didn't settle, so a refund against a sale in that batch is still refused.
Values this step gives you
curl -X POST "{{BASE_URL}}/api/transactions/settlements/sandbox/close" \
-H "api-key: {{API_KEY}}"var close = await http.PostAsync(
"/api/transactions/settlements/sandbox/close", content: null);
close.EnsureSuccessStatusCode();
var closed = await close.Content.ReadFromJsonAsync<JsonElement>();
var settledCount = closed.GetProperty("settledTransactionCount").GetInt32();
// One batch per processor. A non-zero count means one of them did not settle,
// so the sales it carried are still unsettled and still cannot be refunded.
if (closed.GetProperty("failedBatchCount").GetInt32() > 0)
{
throw new InvalidOperationException(
"Part of the batch did not settle. Read the transaction back and check "
+ "allowedActions before refunding.");
}What this step answers with
Abridged to the properties this step depends on. A real response carries more.
{
"merchantId": "3a7b1c9d-2e4f-4a6b-8c8d-9e0f1a2b3c4d",
"settledTransactionCount": 1,
"settledTotalAmount": 10.00,
"failedBatchCount": 0
}7. Refund part of it once it has settled
API call
POST /api/transactions/by-merchant/{{merchantId}}/{{settledTransactionId}}/operations
Send a Refund, which is what the undo becomes after the batch closes. The money has moved by then, so cancelling it no longer means anything. A refund is a credit sent back to the cardholder, not a state change on the original charge. The platform spawns a new Return transaction linked back to the parent, so what you get back is a second transaction id in newTransactionId, and the parent keeps a running cumulativeRefundedAmount. Sending an amount of 5.00 against a sale of 10.00 refunds part of it and leaves the rest refundable later. Against a sale that hasn't settled, this same call is refused with OperationNotAllowedInState, which is what the step before this one exists to prevent. Read allowedActions again if you want to see Refund appear where Reversal used to be.
Values this step gives you
{{refundTransactionId}}The newTransactionId from the refund result: the credit is its own transaction, not a flag on the sale.
curl -X POST \
"{{BASE_URL}}/api/transactions/by-merchant/{{merchantId}}/{{settledTransactionId}}/operations" \
-H "api-key: {{API_KEY}}" \
-H "Content-Type: application/json" \
-d '{
"operationType": "Refund",
"amount": 5.00,
"reason": "Returned one item"
}'var refund = await http.PostAsJsonAsync(
$"/api/transactions/by-merchant/{merchantId}/{settledTransactionId}/operations",
new
{
operationType = "Refund",
amount = 5.00m,
reason = "Returned one item"
});
refund.EnsureSuccessStatusCode();
var result = await refund.Content.ReadFromJsonAsync<JsonElement>();
var refundTransactionId = result.GetProperty("newTransactionId").GetString();What this step answers with
Abridged to the properties this step depends on. A real response carries more.
{
"success": true,
"operationType": "Refund",
"transactionId": "{{settledTransactionId}}",
"newTransactionId": "5c8d2e1f-3a4b-4d6c-9e0f-1a2b3c4d5e6f",
"timedOut": false
}8. Read the credit back
API call
GET /api/transactions/{{refundTransactionId}}
Read the refund by its own id and you get a Return transaction that ran through the same authorization and settlement pipeline the sale did. That's the point worth taking away. A refund can be declined and it settles on its own schedule, so treating it as done the moment the operation call returns is the reconciliation bug this step exists to prevent.
Values this step gives you
curl "{{BASE_URL}}/api/transactions/{{refundTransactionId}}" \
-H "api-key: {{API_KEY}}"var credit = await http.GetFromJsonAsync<JsonElement>(
$"/api/transactions/{refundTransactionId}");What this step answers with
Abridged to the properties this step depends on. A real response carries more.
{
"id": "{{refundTransactionId}}",
"merchantId": "{{merchantId}}",
"transactionType": "Return",
"currentStage": "Authorized",
"resultCode": "Ok",
"authorizedAmount": 5.00,
"primaryChargeTransactionId": "{{settledTransactionId}}",
"responseData": {
"resultCode": "Ok",
"resultMessage": "Approved"
}
}