Authentication
How to sign a request with your API key trio, in six languages, and what every authentication failure means.
PayDirect authenticates each request individually with an HMAC signature. Your secret key is never transmitted - it only ever acts as the HMAC key on your own server. A captured request cannot be replayed after five minutes, and cannot be modified into a different request without the secret.
Your three credentials
| Credential | Looks like | Role |
|---|---|---|
| Public key | pk_test_… / pk_live_… |
Identifies which key is calling. Sent on every request. |
| Raw API key | an opaque string | The message being signed. Never sent. |
| Secret key | sk_test_… / sk_live_… |
The HMAC key. Never sent. |
All three are issued together. The secret key is shown in full only in that response; the public
key and raw API key can be read back later from the key listing. The pk_test_ / pk_live_ prefix is also
what selects the environment - see Environments.
The three headers
| Header | Value |
|---|---|
x-api-key-id |
Your public key, verbatim |
x-api-timestamp |
Current time in milliseconds since the Unix epoch |
x-api-signature |
Lowercase hex HMAC-SHA256 of "<rawApiKey>:<timestamp>", keyed with your secret key |
The signed message is exactly the raw API key, a colon, and the same timestamp string you send in the header. Compute a fresh timestamp and signature for every request - they are not reusable.
Caution
The API reference's Authorize dialog asks for your raw API key and secret key. That is a docs-browser convenience only: the browser uses them to compute a signature for "Try it out", and they are not sent as headers. Your integration sends only the three headers above. Never put the raw key or the secret key in a header.
Your PayDirect client
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);
}
}
Tip
Pick your language in the tabs above and it stays selected across every guide on this site.
Warning
The timestamp is in milliseconds. A seconds-precision timestamp -
time.time(),date +%s,time()in PHP - is the single most common integration bug, and it presents as a401 Request expiredon every request, because a seconds value reads as 1970 in milliseconds.
Authentication failures
Every one of these is returned in the standard envelope with "status": "error".
| HTTP | message |
Cause | Fix |
|---|---|---|---|
400 |
Invalid headers! |
One of the three headers is missing or empty. | Send all three on every request. |
401 |
Request expired (Replay attack prevention) |
The timestamp is more than 5 minutes old. | Use milliseconds; check server clock drift (NTP). |
401 |
Invalid Key ID |
No active key matches this x-api-key-id. |
Check for a copy/paste truncation, and that the key is for the environment you are calling. |
401 |
API key has been revoked |
The key was revoked. | Issue a new key. |
401 |
API key has expired |
The key passed its expiry date. | Issue a new key, or ask us to extend it. |
401 |
Signature verification failed |
The computed HMAC does not match. | See the checklist below. |
403 |
Integrator account is suspended or disabled |
Your tenant is paused, not your key. | Contact support. |
403 |
Request IP is not on this Integrator's allowlist |
You set an allowlist and this IP is not on it. | Add the IP, or call from an allowed one. See IP allowlisting. |
403 |
Account is blocked or inactive |
The user the key is attributed to is blocked. | Contact support. |
429 |
(plain text) Too many requests for this integrator… |
Over your per-minute budget. | See Rate limits. |
When the signature does not match
Work down this list - it is ordered by how often each one is the culprit:
- Seconds instead of milliseconds. Covered above, but it also produces signature mismatches when only one of the two places is wrong.
- A different timestamp in the header than in the signed message. Compute the timestamp once, into a variable, and use that same variable for both.
- Signing the public key instead of the raw API key. The message is
rawApiKey:timestamp.pk_test_…is not the raw key. - Swapping the HMAC arguments. The secret key is the key;
rawApiKey:timestampis the message. Some libraries take them in the opposite order to Node's. - Uppercase hex, or base64. The signature must be lowercase hex.
- Whitespace. A trailing newline in a credential read from a file or an environment variable changes the digest. Trim on load.
Note
Signatures are compared in constant time, so a wrong signature takes the same time to reject as a right one. You cannot infer anything from response timing, and neither can an attacker.
Verifying it locally
Given the raw key abc123 and timestamp 1700000000000, signing with secret s3cr3t must give:
printf 'abc123:1700000000000' | openssl dgst -sha256 -hmac 's3cr3t' -hex
# SHA2-256(stdin)= 2144b65c9058d964b759ff48c4785c4d1668126093f080ac5b66fcda121db7f8
If your code produces that digest for those inputs, your signing is correct and any remaining
401 is about the credentials or the clock, not the algorithm.
Bearer sessions
Some endpoints also accept a Authorization: Bearer <jwt> session from POST /auth/login - that
is the dashboard path, used by a human logged into a UI. Server-to-server integrations should use
signed API keys throughout: they are scopable, IP-restrictable, individually revocable, and
carry no session to expire mid-job.
Next
Environments and test mode - what changes between pk_test_ and pk_live_.