Retry a payment without a double charge
Send a payment under a key you chose, ask the platform what became of it, and retry a request you never got an answer to without charging the cardholder twice.
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.
7 steps, 4 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.
Send a payment under a key you chose
1. Choose a key for this payment
On your side
Generate a key your own system can reproduce for this one payment and no other. A UUID is fine, and so is an order identifier, as long as one logical request gets one key. Generate it before you send, not after: a key you make up while retrying is a different key, and a different key is a second charge. Use a fresh one each time you work through this page.
Values this step gives you
{{idempotencyKey}}The key this payment is sent under. You choose it, so nothing reads it off a response.
2. Send the sale with the key attached
API call
POST /api/transactions
Send an ordinary sale with idempotencyKey alongside it, at the sandbox's guaranteed approval amount. Read idempotencyStatus off the response before you go further. It reports what the key bought on this merchant: KeyAccepted means deduplication is on and this request claimed the key, so a repeat send inside the window replays this transaction instead of charging again. KeyIgnored means deduplication is off for the merchant, so the key is stored and available to look up but a repeat send charges again. Both are ordinary configurations, and the recovery below is correct under either.
Values this step gives you
{{transactionId}}The id of the sale, from the response body's id property.{{merchantId}}The merchant the sale belongs to, from the response body's merchantId property. The lookup below is merchant-scoped, and a key means nothing outside the merchant it was sent to.
curl -X POST "{{BASE_URL}}/api/transactions" \
-H "api-key: {{API_KEY}}" \
-H "Content-Type: application/json" \
-d '{
"transactionType": "Sale",
"idempotencyKey": "{{idempotencyKey}}",
"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}}");
// Chosen before the send, and kept, so a retry can reuse this exact value.
var idempotencyKey = "{{idempotencyKey}}";
var response = await http.PostAsJsonAsync("/api/transactions", new
{
transactionType = "Sale",
idempotencyKey,
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();
var idempotencyStatus = sale.GetProperty("idempotencyStatus").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",
"idempotencyStatus": "KeyAccepted",
"resultCode": "Ok",
"authorizedAmount": 10.00,
"responseData": {
"resultCode": "Ok",
"resultMessage": "Approved"
}
}Ask what became of that key
3. Look the key up and see it answer the same transaction
API call
POST /api/transactions/get-by-idempotency-key-async?merchantId={{merchantId}}&idempotencyKey={{idempotencyKey}}
Ask the platform what it did with that key. It answers with the transaction above, id and all, which is the whole recovery mechanism: a key you kept is a question you can still ask after your own process restarted. Run it now, while you know the answer, so you recognise it later when you don't. A key the merchant has never seen answers 404 instead, and that answer alone means nothing was charged.
Values this step gives you
# -G moves the --data-urlencode values into the query string and encodes each
# one, and -X POST keeps the method. A key you chose may carry a character that
# means something in a URL, and an unencoded one asks about a different key.
curl -X POST -G "{{BASE_URL}}/api/transactions/get-by-idempotency-key-async" \
--data-urlencode "merchantId={{merchantId}}" \
--data-urlencode "idempotencyKey={{idempotencyKey}}" \
-H "api-key: {{API_KEY}}"var lookup = await http.PostAsync(
$"/api/transactions/get-by-idempotency-key-async"
+ $"?merchantId={merchantId}&idempotencyKey={Uri.EscapeDataString(idempotencyKey)}",
content: null);
if (lookup.StatusCode == HttpStatusCode.NotFound)
{
// Nothing was charged under this key. This is the only answer that licenses
// a resend.
}
else
{
lookup.EnsureSuccessStatusCode();
var existing = await lookup.Content.ReadFromJsonAsync<JsonElement>();
var existingId = existing.GetProperty("id").GetString();
}What this step answers with
Abridged to the properties this step depends on. A real response carries more.
{
"id": "{{transactionId}}",
"merchantId": "{{merchantId}}",
"transactionType": "Sale",
"idempotencyStatus": "Replayed",
"resultCode": "Ok",
"authorizedAmount": 10.00,
"responseData": {
"resultCode": "Ok",
"resultMessage": "Approved"
}
}Lose the answer, then recover from it
4. Choose a key for the payment you are about to lose
On your side
A second key, for a second payment. Reusing the first one here would be the mistake the closing note is about: the key identifies a request, not a caller, and pointing it at a different payload asks the platform a question that has two answers. Keep this one too. The next step is written to make you glad you did.
Values this step gives you
{{retryKey}}The key the slow payment is sent under. Yours to choose, and different from the first.
5. Send a payment through a degraded processor
API call
POST /api/transactions
The same sale under the new key, with one extra custom field. Set "loopback.latencyProfile" to the value "slow" and the sandbox processor answers like a degraded one. This run still completes, and it's meant to. What it shows you is the shape of the problem: a request still in flight after you have stopped being sure of it. To lose the answer outright, send "timeout" instead and drive it from your own client with your own timeout set, rather than from this page. One constraint applies to the custom-field channel. If the merchant has defined any custom fields at all, every submitted custom-field name has to match one of those definitions, and a name that doesn't match is rejected with a 400. A sandbox merchant with no custom-field definitions accepts any name, which is the usual case. If you get a 400 naming the control field you sent, define a custom field with that name on the merchant.
Values this step gives you
curl -X POST "{{BASE_URL}}/api/transactions" \
-H "api-key: {{API_KEY}}" \
-H "Content-Type: application/json" \
-d '{
"transactionType": "Sale",
"idempotencyKey": "{{retryKey}}",
"cardData": {
"cardNumber": "4111111111111111",
"nameOnCard": "Jane Doe",
"expirationMonth": 12,
"expirationYear": 2030,
"cvv": 123
},
"customFields": [
{ "name": "loopback.latencyProfile", "value": "slow" }
],
"invoiceData": {
"amounts": { "base": 10.00, "total": 10.00 }
}
}'var retryKey = "{{retryKey}}";
try
{
var slow = await http.PostAsJsonAsync("/api/transactions", new
{
transactionType = "Sale",
idempotencyKey = retryKey,
cardData = new
{
cardNumber = "4111111111111111",
nameOnCard = "Jane Doe",
expirationMonth = 12,
expirationYear = 2030,
cvv = 123
},
customFields = new[]
{
new { name = "loopback.latencyProfile", value = "slow" }
},
invoiceData = new
{
amounts = new { @base = 10.00m, total = 10.00m }
}
});
slow.EnsureSuccessStatusCode();
}
catch (TaskCanceledException)
{
// Your client gave up. The request may still have completed, so the next step
// is the probe, never a resend.
}6. Probe with the key before you resend anything
API call
POST /api/transactions/get-by-idempotency-key-async?merchantId={{merchantId}}&idempotencyKey={{retryKey}}
This is the step that stands in for what your service does after a timeout. Ask the lookup about the key you sent. A transaction comes back, so the request completed and must not be sent again, whatever your own client reported. Only a 404 says the merchant has never seen the key, and only that licenses a resend, under the same key so the platform can recognise it. Probe, then decide. Never resend and hope.
Values this step gives you
curl -X POST -G "{{BASE_URL}}/api/transactions/get-by-idempotency-key-async" \
--data-urlencode "merchantId={{merchantId}}" \
--data-urlencode "idempotencyKey={{retryKey}}" \
-H "api-key: {{API_KEY}}"// The recovery path, as your service would run it: the send threw, so ask.
var probe = await http.PostAsync(
$"/api/transactions/get-by-idempotency-key-async"
+ $"?merchantId={merchantId}&idempotencyKey={Uri.EscapeDataString(retryKey)}",
content: null);
if (probe.StatusCode == HttpStatusCode.NotFound)
{
// Safe to resend, under the same key.
}
else
{
probe.EnsureSuccessStatusCode();
// It completed. Reconcile against this record and send nothing.
var completed = await probe.Content.ReadFromJsonAsync<JsonElement>();
var completedId = completed.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": "{{merchantId}}",
"transactionType": "Sale",
"idempotencyStatus": "Replayed",
"resultCode": "Ok",
"authorizedAmount": 10.00,
"responseData": {
"resultCode": "Ok",
"resultMessage": "Approved"
}
}Keep your keys disciplined
7. Give one logical request one key, and keep it
On your side
Three rules carry the whole practice. One key per logical request, so a key names a payment and not an attempt. Never reuse a key across different payloads, because a key pointed at two different requests is a question with two answers, and you won't like the one you get. Store the key with the order before you send, not after, so a process that died mid-request still knows what to ask about. Follow-up operations take an idempotencyKey too, on the endpoint below: a capture or a refund that runs twice is the same defect as a sale that does, and the same probe recovers it.