Idempotency
Retry any write safely with the Idempotency-Key header - how it behaves, how to choose a key, and what the 409 and 422 replies mean.
Networks time out. Processes restart mid-request. A response can be lost after the work on our
side already happened. Idempotency-Key is how you retry without paying twice.
The rule
Send an Idempotency-Key header on every write, and unconditionally on anything that moves
money.
POST /transactions/disburse
Idempotency-Key: 6f1b6b9e-6c1a-4e2a-9a3a-1b2c3d4e5f60
Content-Type: application/json
Any string up to 255 characters. Keys are scoped to your Integrator and to the endpoint, so your values can never collide with another integrator's, and the same key sent to two different endpoints counts as two separate keys.
What each replay does
| Situation | Result |
|---|---|
| Same key, different body | 422 - the key is already bound to a different request. |
| Same key, identical body, original still in flight | 409 - the first request has not resolved yet. |
Same key, identical body, original answered below 400 |
The original response, replayed verbatim. Nothing re-executes. |
Same key, identical body, original answered 400 or above |
Executes again, as if it were the first attempt. |
| New key | Executes as a new request. |
A response below 400 includes a 200 carrying a FAILED transaction, so a definitively failed
payout is replayed, not retried. To pay again after a FAILED result, use a new key. A request
that was rejected - a 4xx or 5xx - was never accepted, so the same key is free to try again
once you have fixed the cause.
A replay returns the original response including its original status code and transaction id. That is the whole point: your retry path and your success path converge on the same answer.
Show the PayDirect client (defined once in Authentication)
PayDirect has no SDK, so write this once. Every example in these guides calls the client defined here, which means signing lives in exactly one place - and this block is itself a single shared file, so if it changes, it changes on every page at once.
Each version reads PAYDIRECT_HOST, PAYDIRECT_PUBLIC_KEY, PAYDIRECT_RAW_KEY and
PAYDIRECT_SECRET_KEY from the environment, signs every request, and sends an Idempotency-Key
on writes (see Idempotency).
cURL
# paydirect.sh - source this, then call `paydirect POST /transactions/collect "$BODY"`
# Works for GET, POST, PATCH and DELETE; a body is optional.
paydirect() {
local method="$1" path="$2" body="${3:-}"
local ts sig
ts=$(date +%s000) # milliseconds, not seconds
sig=$(printf '%s:%s' "$PAYDIRECT_RAW_KEY" "$ts" \
| openssl dgst -sha256 -hmac "$PAYDIRECT_SECRET_KEY" -hex \
| awk '{print $2}')
curl -sS -X "$method" "$PAYDIRECT_HOST$path" \
-H "Content-Type: application/json" \
-H "x-api-key-id: $PAYDIRECT_PUBLIC_KEY" \
-H "x-api-timestamp: $ts" \
-H "x-api-signature: $sig" \
${body:+-H "Idempotency-Key: $(uuidgen)"} \
${body:+-d "$body"}
}
Node.js
// paydirect.js - Node 18+, no dependencies
const crypto = require('crypto');
function signedHeaders() {
const timestamp = Date.now().toString(); // milliseconds
const signature = crypto
.createHmac('sha256', process.env.PAYDIRECT_SECRET_KEY)
.update(`${process.env.PAYDIRECT_RAW_KEY}:${timestamp}`)
.digest('hex');
return {
'Content-Type': 'application/json',
'x-api-key-id': process.env.PAYDIRECT_PUBLIC_KEY,
'x-api-timestamp': timestamp,
'x-api-signature': signature,
};
}
async function request(method, path, body, idempotencyKey) {
const headers = signedHeaders();
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
const response = await fetch(`${process.env.PAYDIRECT_HOST}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
return { status: response.status, body: await response.json() };
}
const payDirect = {
get: path => request('GET', path),
post: (path, body, idempotencyKey) => request('POST', path, body, idempotencyKey),
patch: (path, body) => request('PATCH', path, body),
del: path => request('DELETE', path),
};
module.exports = { payDirect };
Python
# paydirect.py - requires `requests`
import hashlib, hmac, os, time, uuid
import requests
HOST = os.environ["PAYDIRECT_HOST"]
def _signed_headers() -> dict:
timestamp = str(int(time.time() * 1000)) # milliseconds, not seconds
signature = hmac.new(
os.environ["PAYDIRECT_SECRET_KEY"].encode(),
f'{os.environ["PAYDIRECT_RAW_KEY"]}:{timestamp}'.encode(),
hashlib.sha256,
).hexdigest()
return {
"Content-Type": "application/json",
"x-api-key-id": os.environ["PAYDIRECT_PUBLIC_KEY"],
"x-api-timestamp": timestamp,
"x-api-signature": signature,
}
def request(method: str, path: str, body: dict | None = None, idempotency_key: str | None = None):
headers = _signed_headers()
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
response = requests.request(method, f"{HOST}{path}", json=body, headers=headers, timeout=30)
return response.status_code, response.json()
def get(path: str):
return request("GET", path)
def post(path: str, body: dict, idempotency_key: str | None = None):
return request("POST", path, body, idempotency_key or str(uuid.uuid4()))
def patch(path: str, body: dict):
return request("PATCH", path, body)
def delete(path: str):
return request("DELETE", path)
PHP
<?php
// PayDirect.php - requires ext-curl, no packages
final class PayDirect
{
public function get(string $path): array
{
return $this->request('GET', $path);
}
public function post(string $path, array $body, ?string $idempotencyKey = null): array
{
return $this->request('POST', $path, $body, $idempotencyKey ?? bin2hex(random_bytes(16)));
}
public function patch(string $path, array $body): array
{
return $this->request('PATCH', $path, $body);
}
public function delete(string $path): array
{
return $this->request('DELETE', $path);
}
private function signedHeaders(): array
{
$timestamp = (string) round(microtime(true) * 1000); // milliseconds
$signature = hash_hmac(
'sha256',
getenv('PAYDIRECT_RAW_KEY') . ':' . $timestamp,
getenv('PAYDIRECT_SECRET_KEY')
);
return [
'Content-Type: application/json',
'x-api-key-id: ' . getenv('PAYDIRECT_PUBLIC_KEY'),
'x-api-timestamp: ' . $timestamp,
'x-api-signature: ' . $signature,
];
}
private function request(string $method, string $path, ?array $body = null, ?string $key = null): array
{
$headers = $this->signedHeaders();
if ($key !== null) {
$headers[] = 'Idempotency-Key: ' . $key;
}
$ch = curl_init(getenv('PAYDIRECT_HOST') . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 30,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return ['status' => $status, 'body' => json_decode($raw, true)];
}
}
Java
// PayDirect.java - JDK 11+, no dependencies
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.UUID;
public final class PayDirect {
private static final String HOST = System.getenv("PAYDIRECT_HOST");
private final HttpClient http = HttpClient.newHttpClient();
public HttpResponse<String> get(String path) throws Exception {
return request("GET", path, null, null);
}
public HttpResponse<String> post(String path, String jsonBody) throws Exception {
return request("POST", path, jsonBody, UUID.randomUUID().toString());
}
public HttpResponse<String> patch(String path, String jsonBody) throws Exception {
return request("PATCH", path, jsonBody, null);
}
public HttpResponse<String> delete(String path) throws Exception {
return request("DELETE", path, null, null);
}
private static String hmacHex(String message, String secret) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
StringBuilder hex = new StringBuilder();
for (byte b : mac.doFinal(message.getBytes(StandardCharsets.UTF_8))) {
hex.append(String.format("%02x", b)); // lowercase hex
}
return hex.toString();
}
private HttpResponse<String> request(String method, String path, String body, String key)
throws Exception {
String timestamp = String.valueOf(System.currentTimeMillis()); // milliseconds
String signature = hmacHex(System.getenv("PAYDIRECT_RAW_KEY") + ":" + timestamp,
System.getenv("PAYDIRECT_SECRET_KEY"));
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(HOST + path))
.timeout(Duration.ofSeconds(30))
.header("Content-Type", "application/json")
.header("x-api-key-id", System.getenv("PAYDIRECT_PUBLIC_KEY"))
.header("x-api-timestamp", timestamp)
.header("x-api-signature", signature)
.method(method, body == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body));
if (key != null) {
builder.header("Idempotency-Key", key);
}
return http.send(builder.build(), HttpResponse.BodyHandlers.ofString());
}
}
C#
// PayDirect.cs - .NET 6+, no packages
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
public sealed class PayDirect
{
private static readonly HttpClient Http = new();
private static readonly string Host = Environment.GetEnvironmentVariable("PAYDIRECT_HOST")!;
public Task<HttpResponseMessage> GetAsync(string path) =>
SendAsync(HttpMethod.Get, path, null, null);
public Task<HttpResponseMessage> PostAsync(string path, object body, string? idempotencyKey = null) =>
SendAsync(HttpMethod.Post, path, body, idempotencyKey ?? Guid.NewGuid().ToString());
public Task<HttpResponseMessage> PatchAsync(string path, object body) =>
SendAsync(HttpMethod.Patch, path, body, null);
public Task<HttpResponseMessage> DeleteAsync(string path) =>
SendAsync(HttpMethod.Delete, path, null, null);
private static string HmacHex(string message, string secret)
{
using var mac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = mac.ComputeHash(Encoding.UTF8.GetBytes(message));
return Convert.ToHexString(hash).ToLowerInvariant(); // lowercase hex
}
private static async Task<HttpResponseMessage> SendAsync(
HttpMethod method, string path, object? body, string? idempotencyKey)
{
// Milliseconds since the epoch, not seconds.
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
var signature = HmacHex(
$"{Environment.GetEnvironmentVariable("PAYDIRECT_RAW_KEY")}:{timestamp}",
Environment.GetEnvironmentVariable("PAYDIRECT_SECRET_KEY")!);
using var request = new HttpRequestMessage(method, Host + path);
request.Headers.Add("x-api-key-id", Environment.GetEnvironmentVariable("PAYDIRECT_PUBLIC_KEY"));
request.Headers.Add("x-api-timestamp", timestamp);
request.Headers.Add("x-api-signature", signature);
if (idempotencyKey is not null)
{
request.Headers.Add("Idempotency-Key", idempotencyKey);
}
if (body is not null)
{
request.Content = JsonContent.Create(body);
}
return await Http.SendAsync(request);
}
}
cURL
# A 409 means the first attempt is still running. Back off and retry the SAME key.
STATUS=$(paydirect POST /transactions/disburse "$PAYLOAD" -o /tmp/out.json -w '%{http_code}')
if [ "$STATUS" = "409" ]; then
sleep 5 && echo "still in flight - retry with the same Idempotency-Key"
fi
Node.js
async function disburse(payload, idempotencyKey) {
const { status, body } = await payDirect.post('/transactions/disburse', payload, idempotencyKey);
if (status === 409) {
// Still running. Back off and retry the SAME key - never mint a new one here.
return { retryAfterBackoff: true };
}
return body;
}
Python
def disburse(payload: dict, idempotency_key: str):
status, body = post("/transactions/disburse", payload, idempotency_key)
if status == 409:
# Still running. Back off and retry the SAME key - never mint a new one here.
return {"retry_after_backoff": True}
return body
PHP
<?php
function disburse(array $payload, string $idempotencyKey): array
{
$result = (new PayDirect())->post('/transactions/disburse', $payload, $idempotencyKey);
if ($result['status'] === 409) {
// Still running. Back off and retry the SAME key - never mint a new one here.
return ['retryAfterBackoff' => true];
}
return $result['body'];
}
Java
HttpResponse<String> response = new PayDirect().post("/transactions/disburse", payload);
if (response.statusCode() == 409) {
// Still running. Back off and retry with the SAME key - never mint a new one here.
scheduleRetry(idempotencyKey);
}
C#
var response = await new PayDirect()
.PostAsync("/transactions/disburse", payload, idempotencyKey);
if (response.StatusCode == HttpStatusCode.Conflict)
{
// Still running. Back off and retry with the SAME key - never mint a new one here.
ScheduleRetry(idempotencyKey);
}
Choosing a key
Derive it from the thing you are paying for, not from the attempt.
Node.js
// Good - stable across retries, unique per payout
const key = `payout:${vendorInvoiceId}`;
// Also good - generated once, persisted with the job before the first attempt
const key = job.idempotencyKey ?? (job.idempotencyKey = crypto.randomUUID());
// Wrong - a new key per attempt defeats the entire mechanism
const key = crypto.randomUUID(); // inside the retry loop
Python
# Good - stable across retries, unique per payout
key = f"payout:{vendor_invoice_id}"
# Also good - generated once, persisted with the job before the first attempt
key = job.idempotency_key or job.assign_idempotency_key(str(uuid.uuid4()))
# Wrong - a new key per attempt defeats the entire mechanism
key = str(uuid.uuid4()) # inside the retry loop
PHP
<?php
// Good - stable across retries, unique per payout
$key = "payout:{$vendorInvoiceId}";
// Also good - generated once, persisted with the job before the first attempt
$key = $job->idempotencyKey ?? $job->assignIdempotencyKey(bin2hex(random_bytes(16)));
// Wrong - a new key per attempt defeats the entire mechanism
$key = bin2hex(random_bytes(16)); // inside the retry loop
Java
// Good - stable across retries, unique per payout
String key = "payout:" + vendorInvoiceId;
// Also good - generated once, persisted with the job before the first attempt
String key = job.idempotencyKey() != null
? job.idempotencyKey()
: job.assignIdempotencyKey(UUID.randomUUID().toString());
// Wrong - a new key per attempt defeats the entire mechanism
String key = UUID.randomUUID().toString(); // inside the retry loop
C#
// Good - stable across retries, unique per payout
var key = $"payout:{vendorInvoiceId}";
// Also good - generated once, persisted with the job before the first attempt
var key = job.IdempotencyKey ?? job.AssignIdempotencyKey(Guid.NewGuid().ToString());
// Wrong - a new key per attempt defeats the entire mechanism
var key = Guid.NewGuid().ToString(); // inside the retry loop
The test is simple: if your process crashed and restarted, would it compute the same key? If not, the key cannot protect you from the failure mode you most need protection from.
Warning
Generate and persist the key before the first attempt, in the same write that records your intent to pay. A key held only in memory disappears with the process that was about to retry.
The 422: same key, different body
This is a safety interlock, not an error to work around. It means you reused a key for a
materially different request - commonly because the key was derived from something too coarse
(invoice-42 reused for a corrected amount), or because a retry rebuilt the payload with a new
timestamp or a re-serialised field order.
When you genuinely want a different payment, use a different key. When you are retrying, send byte-identical JSON.
Without the header
PayDirect has its own safeguards against sending the same payout twice, but they are a backstop,
not a substitute for the header: they do not give you back your original response the way
Idempotency-Key does, so your client is left guessing what happened. Always send the header.
Note
Idempotency-Keyhas no effect on requests authenticated with a Bearer session. It applies to signed API-key requests, which is what a server-to-server integration uses anyway.
A retry policy that works
- Persist the intent and its idempotency key.
- Attempt. On
2xx, record the outcome and stop. - On a timeout, a connection error,
409, or5xx: back off exponentially with jitter and retry with the same key. - On
400or422: do not retry blindly - the request itself is wrong. Fix it, and use a new key if the payload legitimately changed. - After your retry budget is exhausted, reconcile rather than resubmit - see Reconciliation.
Next
Transactions and statuses - what PENDING, SUCCESS and FAILED guarantee.