Skip to content
PayDirect Docs

Docs › Core concepts

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.

text
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

A key replays an accepted response; a rejected request can be retried under the same key.
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-Key has 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

  1. Persist the intent and its idempotency key.
  2. Attempt. On 2xx, record the outcome and stop.
  3. On a timeout, a connection error, 409, or 5xx: back off exponentially with jitter and retry with the same key.
  4. On 400 or 422: do not retry blindly - the request itself is wrong. Fix it, and use a new key if the payload legitimately changed.
  5. After your retry budget is exhausted, reconcile rather than resubmit - see Reconciliation.

Next

Transactions and statuses - what PENDING, SUCCESS and FAILED guarantee.