API reference

Idempotency

Send a request again without doing it twice. Which requests need an Idempotency-Key, and what the API answers to a repeat.

Open as Markdown
On this page
  1. Send a key
  2. What a repeat answers
  3. Retry safely
  4. On the dashboard

A network can fail after the API has done the work but before you read the answer. Did the payment get created? With an idempotency key you do not need to know: send the same request again with the same key, and the API answers what it answered the first time, without doing the work twice.

Send a key

Add an Idempotency-Key header to a POST. The key is yours: up to 255 printable ASCII characters, unique for each operation you mean to do once. A random UUID works; so does a key built from your own references, such as order-1042-create. A key that is longer, or holds other characters, is refused with 400, parameter_invalid.

Generate the key before the first try and keep it for the retries of that operation. A new key means a new operation.

Required on the requests that move money:

  • POST /v1/payment_intents
  • POST /v1/payment_intents/{id}/confirm
  • POST /v1/checkout/sessions
  • POST /v1/refunds

Without it they answer 400, idempotency_key_missing. Every other POST accepts a key and does not require one. GET and DELETE ignore it: they are safe to repeat as they are.

  • curl
  • Node.js
  • TypeScript
  • Python
  • PHP
  • Ruby
  • Go
  • Java
  • C#
  • Rust
create.mjscreate.tscreate.pycreate.phpcreate.rbcreate.goCreate.javaCreate.csmain.rs
curl https://pay-api.adpromax.com/v1/payment_intents \
  -u "$APX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14" \
  -d '{"amount": 25000, "currency": "mad",
       "payment_method_types": ["card", "cash"]}'
import { randomUUID } from 'node:crypto'

// One key per operation, made before the first try.
const idempotencyKey = randomUUID()

async function createPaymentIntent() {
  const response = await fetch('https://pay-api.adpromax.com/v1/payment_intents', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey,
    },
    body: JSON.stringify({
      amount: 25000,
      currency: 'mad',
      payment_method_types: ['card', 'cash'],
    }),
  })
  return { response, body: await response.json() }
}
import { randomUUID } from 'node:crypto'

interface PaymentIntent {
  id: string
  object: 'payment_intent'
  amount: number
  currency: string
  status: string
}

interface ApiError {
  error: { type: string, code: string, message: string, request_id: string }
}

interface Created {
  response: Response
  body: PaymentIntent | ApiError
}

// One key per operation, made before the first try.
const idempotencyKey = randomUUID()

async function createPaymentIntent(): Promise<Created> {
  const response = await fetch('https://pay-api.adpromax.com/v1/payment_intents', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey,
    },
    body: JSON.stringify({
      amount: 25000,
      currency: 'mad',
      payment_method_types: ['card', 'cash'],
    }),
  })
  const body = (await response.json()) as PaymentIntent | ApiError
  return { response, body }
}
import os
import uuid

import requests

# One key per operation, made before the first try.
idempotency_key = str(uuid.uuid4())


def create_payment_intent():
    return requests.post(
        "https://pay-api.adpromax.com/v1/payment_intents",
        auth=(os.environ["APX_SECRET_KEY"], ""),
        headers={"Idempotency-Key": idempotency_key},
        json={
            "amount": 25000,
            "currency": "mad",
            "payment_method_types": ["card", "cash"],
        },
        timeout=30,
    )
<?php
// One key per operation, made before the first try.
$idempotencyKey = bin2hex(random_bytes(16));

function create_payment_intent(string $idempotencyKey): array
{
    $ch = curl_init('https://pay-api.adpromax.com/v1/payment_intents');
    curl_setopt_array($ch, [
        CURLOPT_USERPWD => getenv('APX_SECRET_KEY') . ':',
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/json',
            'Idempotency-Key: ' . $idempotencyKey,
        ],
        CURLOPT_POSTFIELDS => json_encode([
            'amount' => 25000,
            'currency' => 'mad',
            'payment_method_types' => ['card', 'cash'],
        ]),
        CURLOPT_RETURNTRANSFER => true,
    ]);
    $body = json_decode(curl_exec($ch), true);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    return [$status, $body];
}
require 'json'
require 'net/http'
require 'securerandom'

# One key per operation, made before the first try.
IDEMPOTENCY_KEY = SecureRandom.uuid

def create_payment_intent
  uri = URI('https://pay-api.adpromax.com/v1/payment_intents')
  request = Net::HTTP::Post.new(uri)
  request.basic_auth(ENV.fetch('APX_SECRET_KEY'), '')
  request['Content-Type'] = 'application/json'
  request['Idempotency-Key'] = IDEMPOTENCY_KEY
  request.body = JSON.generate(
    amount: 25000,
    currency: 'mad',
    payment_method_types: %w[card cash]
  )
  Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
    http.request(request)
  end
end
package main

import (
	"bytes"
	"crypto/rand"
	"encoding/json"
	"net/http"
	"os"
)

// One key per operation, made before the first try.
var idempotencyKey = rand.Text()

func createPaymentIntent() (*http.Response, error) {
	body, err := json.Marshal(map[string]any{
		"amount":               25000,
		"currency":             "mad",
		"payment_method_types": []string{"card", "cash"},
	})
	if err != nil {
		return nil, err
	}
	req, err := http.NewRequest(http.MethodPost,
		"https://pay-api.adpromax.com/v1/payment_intents", bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.SetBasicAuth(os.Getenv("APX_SECRET_KEY"), "")
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Idempotency-Key", idempotencyKey)
	return http.DefaultClient.Do(req)
}
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.UUID;

public class Create {
    // One key per operation, made before the first try.
    static final String KEY = UUID.randomUUID().toString();

    static HttpResponse<String> createPaymentIntent()
            throws IOException, InterruptedException {
        String body = """
            {
              "amount": 25000,
              "currency": "mad",
              "payment_method_types": ["card", "cash"]
            }
            """;
        HttpRequest request = HttpRequest.newBuilder(
                URI.create("https://pay-api.adpromax.com/v1/payment_intents"))
            .header("Authorization", "Bearer " + System.getenv("APX_SECRET_KEY"))
            .header("Content-Type", "application/json")
            .header("Idempotency-Key", KEY)
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build();
        return HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.ofString());
    }
}
using System.Net.Http.Headers;
using System.Net.Http.Json;

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("APX_SECRET_KEY"));

// One key per operation, made before the first try.
var idempotencyKey = Guid.NewGuid().ToString();

async Task<HttpResponseMessage> CreatePaymentIntent()
{
    using var request = new HttpRequestMessage(
        HttpMethod.Post, "https://pay-api.adpromax.com/v1/payment_intents");
    request.Headers.Add("Idempotency-Key", idempotencyKey);
    request.Content = JsonContent.Create(new
    {
        amount = 25000,
        currency = "mad",
        payment_method_types = new[] { "card", "cash" },
    });
    return await http.SendAsync(request);
}
// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
//             serde_json = "1"
//             uuid = { version = "1", features = ["v4"] }
use reqwest::blocking::{Client, Response};
use serde_json::json;
use uuid::Uuid;

fn create_payment_intent(key: &str) -> reqwest::Result<Response> {
    Client::new()
        .post("https://pay-api.adpromax.com/v1/payment_intents")
        .bearer_auth(std::env::var("APX_SECRET_KEY").unwrap_or_default())
        .header("Idempotency-Key", key)
        .json(&json!({
            "amount": 25000,
            "currency": "mad",
            "payment_method_types": ["card", "cash"]
        }))
        .send()
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // One key per operation, made before the first try.
    let idempotency_key = Uuid::new_v4().to_string();
    let response = create_payment_intent(&idempotency_key)?;
    println!("{}", response.status());
    Ok(())
}

What a repeat answers

The API keeps each key for 24 hours, separately for each organization and each mode: a test key and a live key never share a key's answer.

You sendThe API answers
A new keyRuns the request and keeps its answer.
The same key and the same request, after it finishedThe kept answer, with the same status and body, and the headers Idempotent-Replayed: true and Original-Request: req_… (the first request's ID). Nothing runs again.
The same key with another request (another path, query string or body)400, idempotency_error, idempotency_key_reused. Nothing runs.
The same key while the first request still runs409, idempotency_error, idempotency_in_progress. Try again in a moment.

"The same request" means the same method, path, query string and body. The body is compared as the API read it, not byte for byte: the order of its fields and its spacing do not matter, and a field you leave out counts like the same field sent with its default value. Headers are not compared.

What is kept, and what is not

  • A result is kept, including a refusal on the merits: a payment refused with amount_too_small answers amount_too_small again for the same key. Fix the request and send it with a new key.
  • A replay is the first answer, byte for byte. A replayed error still carries the first request's request_id, the one Original-Request names; the replay's own ID is in its Request-Id header.
  • A request refused before it ran is not kept: a bad key, a rate limit (429), or a parameter missing or invalid. Correct it and send it again with the same key.
  • An answer that says "try again" is not kept: a 5xx, a 429, any answer with a Retry-After header, and a refusal whose code ends in _busy. The same key runs the request again.
  • A secret shown once is not kept. The replay of a webhook endpoint's creation answers the same endpoint with "secret": null: store the secret from the first answer.
  • An answer over 64 KB is not kept: the same key would run the request again. The answers of the requests that move money are far smaller.
  • curl
  • Node.js
  • TypeScript
  • Python
  • PHP
  • Ruby
  • Go
  • Java
  • C#
  • Rust
replay.mjsreplay.tsreplay.pyreplay.phpreplay.rbreplay.goReplay.javaReplay.csmain.rs
# the same command again: the answer is replayed
curl -i https://pay-api.adpromax.com/v1/payment_intents \
  -u "$APX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14" \
  -d '{"amount": 25000, "currency": "mad",
       "payment_method_types": ["card", "cash"]}'
// The same request again, with the same key: the answer is replayed.
const response = await fetch('https://pay-api.adpromax.com/v1/payment_intents', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': '8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14',
  },
  body: JSON.stringify({
    amount: 25000,
    currency: 'mad',
    payment_method_types: ['card', 'cash'],
  }),
})
console.log(response.status)
for (const name of ['Idempotent-Replayed', 'Original-Request', 'Request-Id'])
  console.log(`${name}: ${response.headers.get(name)}`)
// The same request again, with the same key: the answer is replayed.
const response: Response = await fetch('https://pay-api.adpromax.com/v1/payment_intents', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': '8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14',
  },
  body: JSON.stringify({
    amount: 25000,
    currency: 'mad',
    payment_method_types: ['card', 'cash'],
  }),
})
const names: string[] = ['Idempotent-Replayed', 'Original-Request', 'Request-Id']
console.log(response.status)
for (const name of names)
  console.log(`${name}: ${response.headers.get(name)}`)
import os

import requests

# The same request again, with the same key: the answer is replayed.
response = requests.post(
    "https://pay-api.adpromax.com/v1/payment_intents",
    auth=(os.environ["APX_SECRET_KEY"], ""),
    headers={"Idempotency-Key": "8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14"},
    json={
        "amount": 25000,
        "currency": "mad",
        "payment_method_types": ["card", "cash"],
    },
)
print(response.status_code)
for name in ("Idempotent-Replayed", "Original-Request", "Request-Id"):
    print(f"{name}: {response.headers.get(name)}")
<?php
// The same request again, with the same key: the answer is replayed.
$headers = [];
$ch = curl_init('https://pay-api.adpromax.com/v1/payment_intents');
curl_setopt_array($ch, [
    CURLOPT_USERPWD => getenv('APX_SECRET_KEY') . ':',
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
        'Idempotency-Key: 8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'amount' => 25000,
        'currency' => 'mad',
        'payment_method_types' => ['card', 'cash'],
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) {
        $parts = explode(':', $line, 2);
        if (count($parts) === 2) {
            $headers[strtolower($parts[0])] = trim($parts[1]);
        }
        return strlen($line);
    },
]);
curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_RESPONSE_CODE), PHP_EOL;
foreach (['Idempotent-Replayed', 'Original-Request', 'Request-Id'] as $name) {
    echo $name, ': ', $headers[strtolower($name)] ?? '', PHP_EOL;
}
require 'json'
require 'net/http'

# The same request again, with the same key: the answer is replayed.
uri = URI('https://pay-api.adpromax.com/v1/payment_intents')
request = Net::HTTP::Post.new(uri)
request.basic_auth(ENV.fetch('APX_SECRET_KEY'), '')
request['Content-Type'] = 'application/json'
request['Idempotency-Key'] = '8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14'
request.body = JSON.generate(
  amount: 25000,
  currency: 'mad',
  payment_method_types: %w[card cash]
)

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end
puts response.code
%w[Idempotent-Replayed Original-Request Request-Id].each do |name|
  puts "#{name}: #{response[name]}"
end
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
)

func main() {
	body, err := json.Marshal(map[string]any{
		"amount":               25000,
		"currency":             "mad",
		"payment_method_types": []string{"card", "cash"},
	})
	if err != nil {
		log.Fatal(err)
	}
	// The same request again, with the same key: the answer is replayed.
	req, err := http.NewRequest(http.MethodPost,
		"https://pay-api.adpromax.com/v1/payment_intents", bytes.NewReader(body))
	if err != nil {
		log.Fatal(err)
	}
	req.SetBasicAuth(os.Getenv("APX_SECRET_KEY"), "")
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Idempotency-Key", "8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer res.Body.Close()

	fmt.Println(res.Status)
	names := []string{"Idempotent-Replayed", "Original-Request", "Request-Id"}
	for _, name := range names {
		fmt.Printf("%s: %s\n", name, res.Header.Get(name))
	}
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;

public class Replay {
    public static void main(String[] args) throws Exception {
        String body = """
            {
              "amount": 25000,
              "currency": "mad",
              "payment_method_types": ["card", "cash"]
            }
            """;
        // The same request again, with the same key: the answer is replayed.
        HttpRequest request = HttpRequest.newBuilder(
                URI.create("https://pay-api.adpromax.com/v1/payment_intents"))
            .header("Authorization", "Bearer " + System.getenv("APX_SECRET_KEY"))
            .header("Content-Type", "application/json")
            .header("Idempotency-Key", "8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14")
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build();
        HttpResponse<String> response = HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.ofString());

        System.out.println(response.statusCode());
        List<String> names = List.of(
            "Idempotent-Replayed", "Original-Request", "Request-Id");
        for (String name : names) {
            System.out.println(name + ": "
                + response.headers().firstValue(name).orElse(""));
        }
    }
}
using System.Net.Http.Headers;
using System.Net.Http.Json;

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("APX_SECRET_KEY"));

// The same request again, with the same key: the answer is replayed.
using var request = new HttpRequestMessage(
    HttpMethod.Post, "https://pay-api.adpromax.com/v1/payment_intents");
request.Headers.Add("Idempotency-Key", "8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14");
request.Content = JsonContent.Create(new
{
    amount = 25000,
    currency = "mad",
    payment_method_types = new[] { "card", "cash" },
});

using var response = await http.SendAsync(request);
Console.WriteLine((int)response.StatusCode);
string[] names = ["Idempotent-Replayed", "Original-Request", "Request-Id"];
foreach (var name in names)
{
    response.Headers.TryGetValues(name, out var values);
    Console.WriteLine($"{name}: {values?.FirstOrDefault()}");
}
// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
//             serde_json = "1"
use serde_json::json;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // The same request again, with the same key: the answer is replayed.
    let response = reqwest::blocking::Client::new()
        .post("https://pay-api.adpromax.com/v1/payment_intents")
        .bearer_auth(std::env::var("APX_SECRET_KEY")?)
        .header("Idempotency-Key", "8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14")
        .json(&json!({
            "amount": 25000,
            "currency": "mad",
            "payment_method_types": ["card", "cash"]
        }))
        .send()?;

    println!("{}", response.status());
    for name in ["Idempotent-Replayed", "Original-Request", "Request-Id"] {
        let value = response.headers().get(name).and_then(|v| v.to_str().ok());
        println!("{name}: {}", value.unwrap_or(""));
    }
    Ok(())
}
Response
HTTP/1.1 200 OK
Idempotent-Replayed: true
Original-Request: req_01k7d7m2n5b8v1c4x7z0q3w6es
Request-Id: req_01k7d7p9a2s5d8f1g4h7j0k3hm

Retry safely

Retry when the answer never came (a timeout, a dropped connection), on 409 idempotency_in_progress, on 429 after the Retry-After delay, and on a 5xx. Wait longer between tries, and keep the same key every time. Do not retry another 4xx: it will fail the same way. Do not sleep on a Retry-After of minutes or hours either: it is the daily cap of test mode, which lasts until midnight. See Rate limits.

  • curl
  • Node.js
  • TypeScript
  • Python
  • PHP
  • Ruby
  • Go
  • Java
  • C#
  • Rust
retry.mjsretry.tsretry.pyretry.phpretry.rbretry.goRetry.javaRetry.csmain.rs
# --retry sends it again after a timeout, a 429 or a 5xx, with the same key;
# --retry-max-time gives up when Retry-After is longer (test mode's daily cap)
curl https://pay-api.adpromax.com/v1/payment_intents \
  -u "$APX_SECRET_KEY:" \
  --retry 4 \
  --retry-max-time 60 \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8e0d2c5e-4f3a-4b8e-9c71-2a6f0d3b5e14" \
  -d '{"amount": 25000, "currency": "mad",
       "payment_method_types": ["card", "cash"]}'
const RETRY_STATUSES = new Set([409, 429, 500, 502, 503, 504])
const LONGEST_WAIT = 60 // seconds

// createPaymentIntent() and its key come from the step above.
async function withRetries(attempts = 4) {
  for (let attempt = 1; ; attempt++) {
    try {
      const { response, body } = await createPaymentIntent()
      if (!RETRY_STATUSES.has(response.status) || attempt === attempts)
        return body
      const wait = Number(response.headers.get('Retry-After')) || 2 ** attempt
      if (wait > LONGEST_WAIT) // test mode's daily cap, until midnight
        return body
      await new Promise(resolve => setTimeout(resolve, wait * 1000))
    }
    catch (error) { // no answer: the network failed
      if (attempt === attempts)
        throw error
      await new Promise(resolve => setTimeout(resolve, 2 ** attempt * 1000))
    }
  }
}
const RETRY_STATUSES = new Set([409, 429, 500, 502, 503, 504])
const LONGEST_WAIT = 60 // seconds

// createPaymentIntent() and its key come from the step above.
declare function createPaymentIntent(): Promise<{
  response: Response
  body: unknown
}>

function sleep(seconds: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, seconds * 1000))
}

async function withRetries(attempts = 4): Promise<unknown> {
  for (let attempt = 1; ; attempt++) {
    try {
      const { response, body } = await createPaymentIntent()
      if (!RETRY_STATUSES.has(response.status) || attempt === attempts)
        return body
      const wait = Number(response.headers.get('Retry-After')) || 2 ** attempt
      if (wait > LONGEST_WAIT) // test mode's daily cap, until midnight
        return body
      await sleep(wait)
    }
    catch (error) { // no answer: the network failed
      if (attempt === attempts)
        throw error
      await sleep(2 ** attempt)
    }
  }
}
import time

import requests

RETRY_STATUSES = {409, 429, 500, 502, 503, 504}
LONGEST_WAIT = 60  # seconds


# create_payment_intent() and its key come from the step above.
def with_retries(attempts=4):
    for attempt in range(1, attempts + 1):
        try:
            response = create_payment_intent()
        except requests.ConnectionError:  # no answer: the network failed
            if attempt == attempts:
                raise
            time.sleep(2 ** attempt)
            continue
        if response.status_code not in RETRY_STATUSES or attempt == attempts:
            return response.json()
        wait = int(response.headers.get("Retry-After", 2 ** attempt))
        if wait > LONGEST_WAIT:  # test mode's daily cap, until midnight
            return response.json()
        time.sleep(wait)
<?php
// create_payment_intent() and $idempotencyKey come from the step above.
function with_retries(string $idempotencyKey, int $attempts = 4): ?array
{
    $retry = [0, 409, 429, 500, 502, 503, 504]; // 0: no answer at all
    for ($attempt = 1; ; $attempt++) {
        [$status, $body] = create_payment_intent($idempotencyKey);
        if (!in_array($status, $retry, true) || $attempt === $attempts) {
            return $body;
        }
        sleep(2 ** $attempt);
    }
}
require 'json'
require 'net/http'

RETRY_STATUSES = [409, 429, 500, 502, 503, 504].freeze
LONGEST_WAIT = 60 # seconds

# create_payment_intent and its key come from the step above.
def with_retries(attempts = 4)
  1.upto(attempts) do |attempt|
    begin
      response = create_payment_intent
      retry_it = RETRY_STATUSES.include?(response.code.to_i)
      return JSON.parse(response.body) unless retry_it && attempt < attempts

      wait = (response['Retry-After'] || 2**attempt).to_i
      # A longer wait is test mode's daily cap, until midnight.
      return JSON.parse(response.body) if wait > LONGEST_WAIT

      sleep(wait)
    rescue SocketError, SystemCallError, Timeout::Error
      # no answer: the network failed
      raise if attempt == attempts

      sleep(2**attempt)
    end
  end
end
package main

import (
	"net/http"
	"strconv"
	"time"
)

const longestWait = 60 * time.Second

var retryStatuses = map[int]bool{
	409: true, 429: true, 500: true, 502: true, 503: true, 504: true,
}

// createPaymentIntent() and its key come from the step above.
func withRetries(attempts int) (*http.Response, error) {
	for attempt := 1; ; attempt++ {
		wait := time.Duration(1<<attempt) * time.Second
		res, err := createPaymentIntent()
		if err != nil { // no answer: the network failed
			if attempt == attempts {
				return nil, err
			}
			time.Sleep(wait)
			continue
		}
		if !retryStatuses[res.StatusCode] || attempt == attempts {
			return res, nil
		}
		after, err := strconv.Atoi(res.Header.Get("Retry-After"))
		if err == nil {
			wait = time.Duration(after) * time.Second
		}
		if wait > longestWait { // test mode's daily cap, until midnight
			return res, nil
		}
		res.Body.Close()
		time.Sleep(wait)
	}
}
import java.io.IOException;
import java.net.http.HttpResponse;
import java.util.Set;

public class Retry {
    static final Set<Integer> RETRY_STATUSES =
        Set.of(409, 429, 500, 502, 503, 504);
    static final long LONGEST_WAIT = 60; // seconds

    // Create.createPaymentIntent() and its key come from the step above.
    static HttpResponse<String> withRetries(int attempts)
            throws IOException, InterruptedException {
        for (int attempt = 1; ; attempt++) {
            long wait = 1L << attempt;
            try {
                HttpResponse<String> response = Create.createPaymentIntent();
                boolean retry = RETRY_STATUSES.contains(response.statusCode());
                if (!retry || attempt == attempts) {
                    return response;
                }
                wait = response.headers()
                    .firstValueAsLong("Retry-After").orElse(wait);
                if (wait > LONGEST_WAIT) { // test mode's daily cap
                    return response;
                }
            } catch (IOException e) { // no answer: the network failed
                if (attempt == attempts) {
                    throw e;
                }
            }
            Thread.sleep(wait * 1000);
        }
    }
}
int[] retryStatuses = [409, 429, 500, 502, 503, 504];
var longestWait = TimeSpan.FromSeconds(60);

// CreatePaymentIntent() and its key come from the step above.
async Task<HttpResponseMessage> WithRetries(int attempts = 4)
{
    for (var attempt = 1; ; attempt++)
    {
        var wait = TimeSpan.FromSeconds(Math.Pow(2, attempt));
        try
        {
            var response = await CreatePaymentIntent();
            var retry = retryStatuses.Contains((int)response.StatusCode);
            if (!retry || attempt == attempts)
                return response;
            wait = response.Headers.RetryAfter?.Delta ?? wait;
            if (wait > longestWait) // test mode's daily cap, until midnight
                return response;
        }
        catch (HttpRequestException) when (attempt < attempts)
        {
            // no answer: the network failed
        }
        await Task.Delay(wait);
    }
}
// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
use std::{thread::sleep, time::Duration};

use reqwest::blocking::Response;

const RETRY_STATUSES: [u16; 6] = [409, 429, 500, 502, 503, 504];
const LONGEST_WAIT: u64 = 60; // seconds

// create_payment_intent() and its key come from the step above.
fn with_retries(key: &str, attempts: u32) -> reqwest::Result<Response> {
    let mut attempt = 1;
    loop {
        let wait = match create_payment_intent(key) {
            Ok(response) => {
                let status = response.status().as_u16();
                if !RETRY_STATUSES.contains(&status) || attempt == attempts {
                    return Ok(response);
                }
                let wait = response
                    .headers()
                    .get("Retry-After")
                    .and_then(|value| value.to_str().ok()?.parse().ok())
                    .unwrap_or(2u64.pow(attempt));
                if wait > LONGEST_WAIT {
                    // test mode's daily cap, until midnight
                    return Ok(response);
                }
                wait
            }
            // no answer: the network failed
            Err(error) if attempt == attempts => return Err(error),
            Err(_) => 2u64.pow(attempt),
        };
        sleep(Duration::from_secs(wait));
        attempt += 1;
    }
}

On the dashboard

The dashboard's own refund and cash-payment forms send a key for you: pressing the button twice makes one refund. Every request you send with a key shows it in the dashboard's request logs, next to its Request-Id.