Skip to content
PayDirect Docs

Docs › Getting started

IP allowlisting

Restrict which source addresses may use your API keys, per environment, and change the list without locking yourself out.

An IP allowlist limits which source addresses may sign requests with your keys. If a key leaks, a request from anywhere else is refused before it can do anything. It is optional, and it works alongside good key storage rather than replacing it.

How it works

  • Per environment. TEST and LIVE each have their own list. The TEST list covers your test (DEVELOPMENT) keys; the LIVE list covers your sandbox and production keys. A LIVE entry has no effect on TEST traffic, and the reverse.
  • Empty means open. While an environment's list is empty, requests from any address are accepted. That is the default. The first entry you add switches that environment to allow-only.
  • Signed API-key requests only. The list applies to every request signed with one of your keys. Requests made from a signed-in dashboard session are not checked against it.
  • Whole Integrator. The list belongs to your Integrator, not to a single key, so every key in that environment is covered.

What you can add

Formats An exact IPv4 address (203.0.113.15) or an IPv4 CIDR range (198.51.100.0/24)
Limit 20 entries per environment
Environments TEST or LIVE
Reason Optional free text, up to 500 characters - recorded with the change

Warning

Only IPv4 is supported. Once an environment's list has at least one entry, a request that reaches us over IPv6 is refused, because no entry can match it. If your servers are dual-stack, make sure calls to PayDirect leave over IPv4.

The address that must be on the list is your public egress address: the address our servers see, not a private address inside your network. Behind a NAT gateway, a load balancer or a hosting provider's shared egress, that is the gateway's public address. Ask your hosting provider or network team if you are not sure what it is. Many cloud platforms let you pin outbound traffic to a fixed address for exactly this purpose.

Entries are compared as written, so 203.0.113.15 and 203.0.113.15/32 count as two separate entries even though they match the same address. Pick one form and stick to it.

Managing the list

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

# Allow a single address and a range for LIVE traffic
paydirect POST /integrators/$INTEGRATOR_ID/allowed-ips \
  '{ "ip": "203.0.113.15", "environment": "LIVE", "reason": "prod egress NAT" }'

paydirect POST /integrators/$INTEGRATOR_ID/allowed-ips \
  '{ "ip": "198.51.100.0/24", "environment": "LIVE" }'

Node.js

// Add the new address FIRST and confirm a request from it succeeds, before removing the old one.
await payDirect.post(`/integrators/${integratorId}/allowed-ips`, {
  ip: '203.0.113.15',
  environment: 'LIVE',
  reason: 'prod egress NAT',
});

await payDirect.post(`/integrators/${integratorId}/allowed-ips`, {
  ip: '198.51.100.0/24', // exact IPv4 or an IPv4 CIDR range
  environment: 'LIVE',
});

Python

# Add the new address FIRST and confirm a request from it succeeds, before removing the old one.
post(f"/integrators/{integrator_id}/allowed-ips", {
    "ip": "203.0.113.15",
    "environment": "LIVE",
    "reason": "prod egress NAT",
})

post(f"/integrators/{integrator_id}/allowed-ips", {
    "ip": "198.51.100.0/24",  # exact IPv4 or an IPv4 CIDR range
    "environment": "LIVE",
})

PHP

<?php
$payDirect = new PayDirect();

// Add the new address FIRST and confirm a request from it succeeds, before removing the old one.
$payDirect->post("/integrators/{$integratorId}/allowed-ips", [
    'ip'          => '203.0.113.15',
    'environment' => 'LIVE',
    'reason'      => 'prod egress NAT',
]);

$payDirect->post("/integrators/{$integratorId}/allowed-ips", [
    'ip'          => '198.51.100.0/24', // exact IPv4 or an IPv4 CIDR range
    'environment' => 'LIVE',
]);

Java

PayDirect payDirect = new PayDirect();

// Add the new address FIRST and confirm a request from it succeeds, before removing the old one.
payDirect.post("/integrators/" + integratorId + "/allowed-ips",
        """
        { "ip": "203.0.113.15", "environment": "LIVE", "reason": "prod egress NAT" }
        """);

payDirect.post("/integrators/" + integratorId + "/allowed-ips",
        """
        { "ip": "198.51.100.0/24", "environment": "LIVE" }
        """);

C#

var payDirect = new PayDirect();

// Add the new address FIRST and confirm a request from it succeeds, before removing the old one.
await payDirect.PostAsync($"/integrators/{integratorId}/allowed-ips", new
{
    ip          = "203.0.113.15",
    environment = "LIVE",
    reason      = "prod egress NAT",
});

await payDirect.PostAsync($"/integrators/{integratorId}/allowed-ips", new
{
    ip          = "198.51.100.0/24", // exact IPv4 or an IPv4 CIDR range
    environment = "LIVE",
});
Endpoint What it does
GET /integrators/{id}/allowed-ips List every entry, for both environments
POST /integrators/{id}/allowed-ips Add one entry - returns 201 with the new entry
DELETE /integrators/{id}/allowed-ips/{ipId} Remove one entry - returns the entry that was removed

There is no edit. To change an entry, add the new one, confirm it works, then delete the old one. DELETE accepts an optional { "reason": "..." } body, recorded with the change.

A listed entry looks like this. Filter on environment yourself, since the list covers both environments in one response:

json
{
  "code": 200,
  "status": "success",
  "data": [
    {
      "id": "…",
      "integratorId": "…",
      "environment": "LIVE",
      "ip": "203.0.113.15",
      "createdAt": "2026-09-29T10:15:00.000Z",
      "updatedAt": "2026-09-29T10:15:00.000Z"
    }
  ]
}

Who can change it

You can change the list from a signed-in dashboard session, or with an API key that has not been restricted to a narrower set of scopes than PayDirect issued it with. A key narrowed to only some scopes can read the list but cannot change it. See API keys. A key must also be issued to a person, the owner or a member of your Integrator, so each change is attributed.

Responses

Status Message Meaning
201 Allowed IP added The entry is live immediately.
400 Validation message Not IPv4 or IPv4 CIDR, or an unknown environment.
400 An Integrator may have at most 20 allowed IPs per environment Remove an entry first, or use a range.
403 This API key has restricted scopes and cannot manage Integrator configuration Use an unrestricted key or the dashboard.
404 Allowed IP entry not found On DELETE: the id is wrong or belongs to another Integrator.
409 This IP or range is already on the allowlist for this environment Nothing to do.

Changing addresses without locking yourself out

Caution

An API-key request that manages the list is checked against the list like any other. Remove the entry for the address you are calling from, and every further API-key call from there, including one to put it back, is refused.

When your egress address changes:

  1. Add the new address, or its range.
  2. Send a request from the new address and confirm it succeeds.
  3. Remove the old entry.

If you do lock yourself out, sign in to the dashboard. Dashboard sessions are not subject to the list, so you can fix it from there. Or contact support.

When a request is refused

A signed request from an address that is not on the list gets:

json
{
  "code": 403,
  "status": "error",
  "message": "Request IP is not on this Integrator's allowlist"
}

The check happens before the request does anything, so nothing was created or moved, and it is safe to retry from an allowed address. Refused requests appear in your key's usage events with authOutcome=IP_NOT_ALLOWED:

cURL

paydirect GET "/apikey/$KEY_ID/usage/events?authOutcome=IP_NOT_ALLOWED"

Node.js

const { status, body } = await payDirect.get(`/apikey/${keyId}/usage/events?authOutcome=IP_NOT_ALLOWED`);

Python

status, body = get(f"/apikey/{key_id}/usage/events?authOutcome=IP_NOT_ALLOWED")

PHP

<?php
$result = (new PayDirect())->get("/apikey/{$keyId}/usage/events?authOutcome=IP_NOT_ALLOWED");

Java

HttpResponse<String> response = new PayDirect().get("/apikey/" + keyId + "/usage/events?authOutcome=IP_NOT_ALLOWED");

C#

var response = await new PayDirect().GetAsync($"/apikey/{keyId}/usage/events?authOutcome=IP_NOT_ALLOWED");

A burst of these from an address you do not recognise means someone else holds your credentials. Rotate the key straight away. See Rotating a key.

Next

Requests and responses - the envelope, status codes and pagination that every endpoint shares.