Errors and status codes
What every status code means, which ones mean no money moved, and which are safe to retry.
The most important question after a failed payment request is not "what went wrong" - it is "did money move?" PayDirect answers that with the HTTP status code alone, so your client can decide without inspecting anything else.
Did money move?
Warning
Note where
FAILEDsits: it arrives on a200, with"status": "error"in the envelope. It is a resolved failure, not a transport error - andresponse.okis true for it.
For a money-movement endpoint (collect, disburse, withdraw, pay-bill, send-money):
| Code | Meaning | Did money move? | Safe to retry as-is? |
|---|---|---|---|
200 / 201 · "status": "success" |
Accepted. Resolved SUCCESS or PENDING. |
Read data.status |
N/A |
200 · "status": "error" |
Dispatched and definitively FAILED. A ledger debit taken for it is returned to your balance. | No - resolved, not ambiguous | Resubmit with a new reference if you still want to pay |
400 |
Request body failed validation. Nothing dispatched. | No | Yes, once fixed |
401 |
Authentication failed. | No | Yes, once the signature or key is fixed |
403 |
Authenticated, but not permitted - scope, IP allowlist, the sandbox or unverified per-transaction limit, suspended tenant, blocked user. | No | Not until the permission is changed |
404 |
No such resource. On a read: we have never seen this id. | No | No |
409 |
A request with this Idempotency-Key is already in flight. |
Unknown - the first one is still running | Wait, then retry with the same key |
422 |
Rejected before dispatch by a business rule - for example a transaction limit, an unsupported wallet or insufficient ledger balance - or an Idempotency-Key reused with a different body. |
No | Yes, once the underlying issue is resolved |
429 |
Over your rate limit. Nothing processed. | No | Yes, with backoff |
502 |
The call to the payment rail itself errored with nothing we could persist. | Genuinely unknown - there is no transaction id to reconcile | No - treat as unknown, escalate if it recurs |
Warning
200with"status": "error"is the one that catches people out. It is a resolved failure - the payment reached the rail and was rejected - not a transport error. Readdata.status, never infer success fromresponse.ok.
502 is the only genuinely ambiguous outcome, and it is rare. Because no transaction row exists,
there is nothing to reconcile against; do not blind-retry a 502 on a money-movement endpoint
without an Idempotency-Key, and contact support if you see them cluster.
Handling it in code
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);
}
}
Node.js
async function submitPayout(payload, idempotencyKey) {
const { status, body } = await payDirect.post('/transactions/disburse', payload, idempotencyKey);
// 429 returns plain text, not the JSON envelope.
if (status === 429) return { outcome: 'retry-later' };
if (status === 409) return { outcome: 'in-flight' };
if (status === 502) return { outcome: 'unknown', escalate: true };
if (status >= 400) {
// 400 / 401 / 403 / 422 - nothing moved.
return { outcome: 'rejected', reason: body.message };
}
switch (body.data?.status) {
case 'SUCCESS': return { outcome: 'paid', id: body.data.id };
case 'PENDING': return { outcome: 'pending', id: body.data.id };
case 'FAILED': return { outcome: 'failed', id: body.data.id };
default: return { outcome: 'unknown', id: body.data?.id, escalate: true };
}
}
Python
def submit_payout(payload: dict, idempotency_key: str) -> dict:
status, body = post("/transactions/disburse", payload, idempotency_key)
# 429 returns plain text, not the JSON envelope.
if status == 429:
return {"outcome": "retry-later"}
if status == 409:
return {"outcome": "in-flight"}
if status == 502:
return {"outcome": "unknown", "escalate": True}
if status >= 400:
# 400 / 401 / 403 / 422 - nothing moved.
return {"outcome": "rejected", "reason": body.get("message")}
data = body.get("data") or {}
return {
"SUCCESS": {"outcome": "paid", "id": data.get("id")},
"PENDING": {"outcome": "pending", "id": data.get("id")},
"FAILED": {"outcome": "failed", "id": data.get("id")},
}.get(data.get("status"), {"outcome": "unknown", "id": data.get("id"), "escalate": True})
PHP
<?php
function submitPayout(array $payload, string $idempotencyKey): array
{
$result = (new PayDirect())->post('/transactions/disburse', $payload, $idempotencyKey);
$status = $result['status'];
$body = $result['body'];
// 429 returns plain text, not the JSON envelope.
if ($status === 429) return ['outcome' => 'retry-later'];
if ($status === 409) return ['outcome' => 'in-flight'];
if ($status === 502) return ['outcome' => 'unknown', 'escalate' => true];
if ($status >= 400) {
// 400 / 401 / 403 / 422 - nothing moved.
return ['outcome' => 'rejected', 'reason' => $body['message'] ?? null];
}
return match ($body['data']['status'] ?? null) {
'SUCCESS' => ['outcome' => 'paid', 'id' => $body['data']['id']],
'PENDING' => ['outcome' => 'pending', 'id' => $body['data']['id']],
'FAILED' => ['outcome' => 'failed', 'id' => $body['data']['id']],
default => ['outcome' => 'unknown', 'escalate' => true],
};
}
Java
Outcome submitPayout(String payload) throws Exception {
HttpResponse<String> response = new PayDirect().post("/transactions/disburse", payload);
int status = response.statusCode();
// 429 returns plain text, not the JSON envelope.
if (status == 429) return Outcome.RETRY_LATER;
if (status == 409) return Outcome.IN_FLIGHT;
if (status == 502) return Outcome.UNKNOWN;
// 400 / 401 / 403 / 422 - nothing moved.
if (status >= 400) return Outcome.REJECTED;
// Read data.status from the body; never infer success from the HTTP code alone.
return switch (readDataStatus(response.body())) {
case "SUCCESS" -> Outcome.PAID;
case "PENDING" -> Outcome.PENDING;
case "FAILED" -> Outcome.FAILED;
default -> Outcome.UNKNOWN;
};
}
C#
async Task<Outcome> SubmitPayoutAsync(object payload, string idempotencyKey)
{
var response = await new PayDirect().PostAsync("/transactions/disburse", payload, idempotencyKey);
var status = (int)response.StatusCode;
// 429 returns plain text, not the JSON envelope.
if (status == 429) return Outcome.RetryLater;
if (status == 409) return Outcome.InFlight;
if (status == 502) return Outcome.Unknown;
// 400 / 401 / 403 / 422 - nothing moved.
if (status >= 400) return Outcome.Rejected;
// Read data.status from the body; never infer success from the HTTP code alone.
var envelope = await response.Content.ReadFromJsonAsync<Envelope>();
return envelope?.Data?.Status switch
{
"SUCCESS" => Outcome.Paid,
"PENDING" => Outcome.Pending,
"FAILED" => Outcome.Failed,
_ => Outcome.Unknown,
};
}
Treat an unrecognised data.status as unknown and resolve it before acting - never as success.
Authentication errors
The full 401/403 catalogue, with causes and fixes, is in
Authentication.
Reads
| Code | Meaning |
|---|---|
200 |
Found. For a transaction, data.status carries its state - including FAILED. |
404 |
We have no record of this id. |
The distinction matters during reconciliation: a 404 means your submission never created
anything, so it is safe to submit again; a 200 with FAILED means it existed and resolved, so
resubmitting would be a second payment attempt, not a retry.
Error messages
message is written for humans. It is safe to log and to quote in a support ticket, but do not
branch on its text - wording changes without notice. Branch on the HTTP status code and
data.status.
What to send us
When something is genuinely wrong, the fastest path to an answer is:
- the transaction
id(or theIdempotency-Key, if no id came back), - the timestamp of the request, with timezone,
- the HTTP status code and the full response envelope,
- and, if the transaction exists, the output of
GET /transactions/{id}/journey-snapshot.
See Support.
Next
Collections - take your first real payment.