Authentication

All Developer API requests (except registration, status check, code exchange, and documentation) must be authenticated using HMAC-SHA256 signatures. This ensures that requests are genuine and have not been tampered with in transit.

Required Headers

Every authenticated request must include these headers:

HeaderTypeRequiredDescription
X-API-KeystringYesYour API key (pk_live_...)
X-SignaturestringYesHMAC-SHA256 signature (64 hex characters)
X-TimestampstringYesCurrent Unix timestamp in milliseconds (e.g., 1708264800000)
X-Request-IdstringYesUnique request ID for deduplication and replay protection (UUID recommended)
Content-TypestringWith a bodyapplication/json. A request with a body and any other content type is rejected with 415 UNSUPPORTED_CONTENT_TYPE

Signing Algorithm

Follow these steps to sign a request:

Step 1: Get the Current Timestamp

Get the current time as a Unix timestamp in milliseconds (not seconds).

timestamp = "1708264800000"

Step 2: Prepare the Canonical Path and Request Body

Use the full request path as sent, starting with /api/v1, including any percent-encoding. A query string is canonicalized by sorting the pairs by key, then by value, and URL-encoding each key and value.

None of the signed endpoints read query parameters, so in practice the canonical value is just the path. If you add a query string anyway, it must match the server’s ordering (JavaScript localeCompare) and encoding (encodeURIComponent). The Python and PHP helpers below sort by byte value and encode !'()*, so they can differ for mixed-case keys or those characters.

method = "POST"
canonicalPath = "/api/v1/developer/users"
requestId = "550e8400-e29b-41d4-a716-446655440000"

If the request has a body, serialize it as a JSON string. The signature covers the exact bytes you send. For requests without a body (GET, DELETE), use an empty string.

body = '{"externalUserId":"user_123","username":"johndoe"}'

For GET requests:

body = ''

Step 3: Build the Signing String

Concatenate the signing fields with newline (\n) separators:

signingString = [
  "v1",
  method.toUpperCase(),
  canonicalPath,
  timestamp,
  requestId,
  body
].join("\n")

Example:

signingString = "v1\nPOST\n/api/v1/developer/users\n1708264800000\n550e8400-e29b-41d4-a716-446655440000\n{\"externalUserId\":\"user_123\",\"username\":\"johndoe\"}"

For GET requests the body field is empty, so the string ends with a newline:

signingString = "v1\nGET\n/api/v1/developer/users/user_123/balance\n1708264800000\n550e8400-e29b-41d4-a716-446655440000\n"

Step 4: Compute the HMAC

Compute HMAC-SHA256 of the signing string using your API secret (sk_live_...):

signature = HMAC-SHA256(secret, signingString)

Step 5: Hex-Encode the Result

Convert the HMAC output to a hexadecimal string. This is your X-Signature header value. It must be exactly 64 hex characters.

signature = "27c1e2115f76ac04ad904ec67571aa625118e7ae8841707c48035bc302a8d03c"

Complete Signing Examples

Node.js

const crypto = require('crypto');

function canonicalPathAndQuery(url) {
  const parsed = new URL(url);
  const pairs = [...parsed.searchParams.entries()].sort(([ak, av], [bk, bv]) =>
    ak === bk ? av.localeCompare(bv) : ak.localeCompare(bk)
  );
  const query = pairs
    .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
    .join('&');
  return query ? `${parsed.pathname}?${query}` : parsed.pathname;
}

function signRequest(secret, method, url, timestamp, requestId, bodyStr) {
  const signingString = [
    'v1',
    method.toUpperCase(),
    canonicalPathAndQuery(url),
    timestamp,
    requestId,
    bodyStr,
  ].join('\n');
  return crypto
    .createHmac('sha256', secret)
    .update(signingString)
    .digest('hex');
}

// Usage
const API_KEY = 'pk_live_YOUR_KEY_HERE';
const API_SECRET = 'sk_live_YOUR_SECRET_HERE';

async function makeRequest(method, url, body = null) {
  const timestamp = Date.now().toString();
  const requestId = crypto.randomUUID();
  const bodyStr = body ? JSON.stringify(body) : '';
  const signature = signRequest(API_SECRET, method, url, timestamp, requestId, bodyStr);

  const response = await fetch(url, {
    method,
    headers: {
      'X-API-Key': API_KEY,
      'X-Signature': signature,
      'X-Timestamp': timestamp,
      'X-Request-Id': requestId,
      'Content-Type': 'application/json',
    },
    body: bodyStr || undefined,
  });

  // DELETE responses are 204 with no body
  return response.status === 204 ? null : response.json();
}

// Example: Provision a user
const result = await makeRequest(
  'POST',
  'https://poker.example.com/api/v1/developer/users',
  { externalUserId: 'user_123', username: 'johndoe' }
);

Python

import hmac
import hashlib
import json
import time
import uuid
import requests
from urllib.parse import quote, urljoin, urlparse, parse_qsl

API_KEY = 'pk_live_YOUR_KEY_HERE'
API_SECRET = 'sk_live_YOUR_SECRET_HERE'
BASE_URL = 'https://poker.example.com'


def canonical_path_and_query(url: str) -> str:
    parsed = urlparse(url)
    pairs = sorted(parse_qsl(parsed.query, keep_blank_values=True))
    query = '&'.join(f"{quote(k, safe='')}={quote(v, safe='')}" for k, v in pairs)
    return f"{parsed.path}?{query}" if query else parsed.path


def sign_request(secret: str, method: str, url: str, timestamp: str, request_id: str, body_str: str) -> str:
    signing_string = "\n".join([
        "v1",
        method.upper(),
        canonical_path_and_query(url),
        timestamp,
        request_id,
        body_str,
    ])
    return hmac.new(
        secret.encode('utf-8'),
        signing_string.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()


def api_request(method: str, path: str, body: dict | None = None) -> dict | None:
    timestamp = str(int(time.time() * 1000))
    request_id = str(uuid.uuid4())
    body_str = json.dumps(body, separators=(',', ':')) if body else ''
    url = urljoin(BASE_URL, f"/api/v1{path}")
    signature = sign_request(API_SECRET, method, url, timestamp, request_id, body_str)

    headers = {
        'X-API-Key': API_KEY,
        'X-Signature': signature,
        'X-Timestamp': timestamp,
        'X-Request-Id': request_id,
        'Content-Type': 'application/json',
    }

    if method == 'GET':
        response = requests.get(url, headers=headers)
    elif method == 'POST':
        response = requests.post(url, headers=headers, data=body_str)
    elif method == 'PUT':
        response = requests.put(url, headers=headers, data=body_str)
    elif method == 'DELETE':
        response = requests.delete(url, headers=headers)
    else:
        raise ValueError(f"Unsupported method: {method}")

    response.raise_for_status()
    # DELETE responses are 204 with no body
    return None if response.status_code == 204 else response.json()


# Example: Provision a user
result = api_request('POST', '/developer/users', {
    'externalUserId': 'user_123',
    'username': 'johndoe',
})
print(f"User ID: {result['user']['id']}")

Important for Python: Use json.dumps(body, separators=(',', ':')) to produce compact JSON without spaces (matching JavaScript’s JSON.stringify). If your JSON serialization includes extra spaces, the signature will not match.

PHP

<?php

$apiKey    = 'pk_live_YOUR_KEY_HERE';
$apiSecret = 'sk_live_YOUR_SECRET_HERE';
$baseUrl   = 'https://poker.example.com';

function canonicalPathAndQuery(string $url): string {
    $parts = parse_url($url);
    $path = $parts['path'] ?? '/';
    $queryPairs = [];

    foreach (explode('&', $parts['query'] ?? '') as $part) {
        if ($part === '') {
            continue;
        }

        [$key, $value] = array_pad(explode('=', $part, 2), 2, '');
        $queryPairs[] = [rawurldecode($key), rawurldecode($value)];
    }

    usort($queryPairs, fn($a, $b) => $a[0] === $b[0]
        ? strcmp($a[1], $b[1])
        : strcmp($a[0], $b[0]));

    $query = implode('&', array_map(fn($pair) =>
        rawurlencode($pair[0]) . '=' . rawurlencode($pair[1]),
        $queryPairs
    ));

    return $query ? $path . '?' . $query : $path;
}

function signRequest(string $secret, string $method, string $url, string $timestamp, string $requestId, string $bodyStr): string {
    $signingString = implode("\n", [
        'v1',
        strtoupper($method),
        canonicalPathAndQuery($url),
        $timestamp,
        $requestId,
        $bodyStr,
    ]);
    return hash_hmac('sha256', $signingString, $secret);
}

function apiRequest(string $method, string $path, ?array $body = null): ?array {
    global $apiKey, $apiSecret, $baseUrl;

    $timestamp = (string)(int)(microtime(true) * 1000);
    $requestId = bin2hex(random_bytes(16));
    $bodyStr   = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : '';
    $url = $baseUrl . '/api/v1' . $path;
    $signature = signRequest($apiSecret, $method, $url, $timestamp, $requestId, $bodyStr);

    $headers = [
        'X-API-Key: ' . $apiKey,
        'X-Signature: ' . $signature,
        'X-Timestamp: ' . $timestamp,
        'X-Request-Id: ' . $requestId,
        'Content-Type: application/json',
    ];

    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

    if ($bodyStr) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyStr);
    }

    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    // DELETE responses are 204 with no body
    if ($httpCode === 204) {
        return null;
    }

    $data = json_decode($response, true);

    if ($httpCode >= 400) {
        throw new Exception("API error {$httpCode}: {$data['error']} — {$data['message']}");
    }

    return $data;
}

// Example: Provision a user
$result = apiRequest('POST', '/developer/users', [
    'externalUserId' => 'user_123',
    'username'       => 'johndoe',
]);

echo "User ID: " . $result['user']['id'] . "\n";

cURL

# Variables
API_KEY="pk_live_YOUR_KEY_HERE"
API_SECRET="sk_live_YOUR_SECRET_HERE"
BASE_URL="https://poker.example.com"
TIMESTAMP=$(($(date +%s) * 1000))  # Unix milliseconds
REQUEST_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')

# Request body
METHOD="POST"
PATH_AND_QUERY="/api/v1/developer/users"
BODY='{"externalUserId":"user_123","username":"johndoe"}'

# Compute HMAC-SHA256 signature. Pipe printf directly: storing the signing string
# in a shell variable would strip the trailing newline of a no-body request.
SIGNATURE=$(printf 'v1\n%s\n%s\n%s\n%s\n%s' "$METHOD" "$PATH_AND_QUERY" "$TIMESTAMP" "$REQUEST_ID" "$BODY" \
  | openssl dgst -sha256 -hmac "$API_SECRET" | awk '{print $NF}')

# Make the request
curl -X POST "${BASE_URL}/api/v1/developer/users" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: ${API_KEY}" \
  -H "X-Signature: ${SIGNATURE}" \
  -H "X-Timestamp: ${TIMESTAMP}" \
  -H "X-Request-Id: ${REQUEST_ID}" \
  -d "${BODY}"

For GET requests (no body):

TIMESTAMP=$(($(date +%s) * 1000))
REQUEST_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
METHOD="GET"
PATH_AND_QUERY="/api/v1/developer/users/user_123/balance"
SIGNATURE=$(printf 'v1\n%s\n%s\n%s\n%s\n%s' "$METHOD" "$PATH_AND_QUERY" "$TIMESTAMP" "$REQUEST_ID" "" \
  | openssl dgst -sha256 -hmac "$API_SECRET" | awk '{print $NF}')

curl -X GET "${BASE_URL}/api/v1/developer/users/user_123/balance" \
  -H "X-API-Key: ${API_KEY}" \
  -H "X-Signature: ${SIGNATURE}" \
  -H "X-Timestamp: ${TIMESTAMP}" \
  -H "X-Request-Id: ${REQUEST_ID}"

Full Request Example

Here is a complete POST request with all headers and body:

Request:

POST /api/v1/developer/users HTTP/1.1
Host: poker.example.com
Content-Type: application/json
X-API-Key: pk_live_YOUR_KEY_HERE
X-Signature: <64 hex characters computed as below>
X-Timestamp: 1708264800000
X-Request-Id: 550e8400-e29b-41d4-a716-446655440000

{"externalUserId":"user_123","username":"johndoe","email":"john@example.com"}

Signing computation:

signingString = "v1\nPOST\n/api/v1/developer/users\n1708264800000\n550e8400-e29b-41d4-a716-446655440000\n{\"externalUserId\":\"user_123\",\"username\":\"johndoe\",\"email\":\"john@example.com\"}"
signature     = hex(HMAC-SHA256("sk_live_...", signingString))

Response (201 Created):

{
  "user": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "externalUserId": "user_123",
    "username": "johndoe",
    "email": "john@example.com",
    "firstName": null,
    "lastName": null,
    "balance": "0",
    "virtualBalance": "0",
    "createdAt": "2026-02-18T12:00:00.000Z"
  },
  "isNew": true
}

Test Your Signature

To verify your signing implementation is correct:

  1. Use a known secret: sk_live_test_secret_000000000000000000000000000000000000000000000000
  2. Use a known timestamp: 1708264800000
  3. Use a known body: {"test":"hello"}
  4. Use request ID 550e8400-e29b-41d4-a716-446655440000 and path /api/v1/developer/test.
  5. The signing string is: v1\nPOST\n/api/v1/developer/test\n1708264800000\n550e8400-e29b-41d4-a716-446655440000\n{"test":"hello"}
  6. Compute HMAC-SHA256. The hex result must be fab0363375eb8bcfaf6ee2c511e7c259f055b3204b00e4fca49ecc2d3bc1966d.

You can verify using the Node.js crypto module:

const crypto = require('crypto');
const secret = 'sk_live_test_secret_000000000000000000000000000000000000000000000000';
const signingString = 'v1\nPOST\n/api/v1/developer/test\n1708264800000\n550e8400-e29b-41d4-a716-446655440000\n{"test":"hello"}';
const expected = crypto.createHmac('sha256', secret).update(signingString).digest('hex');
console.log('Expected signature:', expected);
// fab0363375eb8bcfaf6ee2c511e7c259f055b3204b00e4fca49ecc2d3bc1966d

Run this and compare the output with your implementation.

Request ID Deduplication

The required X-Request-Id header prevents duplicate processing and is included in the HMAC signing string. If you send two authenticated requests with the same X-Request-Id within 5 minutes, the second request returns:

{
  "error": "DUPLICATE_REQUEST",
  "message": "This request ID has already been processed"
}

Send a unique X-Request-Id (UUID v4 recommended) with every authenticated request. The ID is used up as soon as the signature verifies, even if the request then fails validation or returns an error, so every retry needs a new ID. To make a retried balance operation safe, reuse its reference (see Idempotency), not its request ID.

X-Request-Id must be 1–128 characters and match ^[A-Za-z0-9._:-]+$.

In Redis-enabled deployments, request-ID deduplication is backed by Redis so replay protection is shared across app instances. If that shared replay store is unavailable, HMAC-authenticated endpoints return HTTP 503 with REPLAY_STORAGE_UNAVAILABLE instead of falling back to per-process memory.

Order of Checks

Checks run in this order, and the first failure is returned:

  1. All four authentication headers are present (MISSING_HEADERS)
  2. A request with a body uses application/json (UNSUPPORTED_CONTENT_TYPE) and its raw body was captured (UNSIGNED_BODY)
  3. Request ID and signature format (INVALID_REQUEST_ID, INVALID_SIGNATURE)
  4. Timestamp (INVALID_TIMESTAMP, TIMESTAMP_EXPIRED, TIMESTAMP_FUTURE)
  5. API key (INVALID_API_KEY)
  6. Rate limit (RATE_LIMIT_EXCEEDED)
  7. IP allowlist (IP_NOT_ALLOWED)
  8. Scopes (INSUFFICIENT_SCOPES)
  9. Signature (INVALID_SIGNATURE)
  10. Request ID replay (REPLAY_STORAGE_UNAVAILABLE, DUPLICATE_REQUEST)

Every request made with a valid API key counts toward its rate limit, including ones later rejected for a bad signature, a disallowed IP or a missing scope.

Common Authentication Errors

MISSING_HEADERS (401)

One or more required headers are missing.

{
  "error": "MISSING_HEADERS",
  "message": "X-API-Key, X-Signature, X-Timestamp, and X-Request-Id headers are required"
}

Fix: Ensure all required authentication headers are present on every authenticated request.

INVALID_TIMESTAMP (401)

The X-Timestamp value is not a valid Unix millisecond timestamp.

{
  "error": "INVALID_TIMESTAMP",
  "message": "X-Timestamp must be a valid Unix millisecond timestamp"
}

Fix: Use Date.now() (JavaScript), int(time.time() * 1000) (Python), or (int)(microtime(true) * 1000) (PHP).

TIMESTAMP_EXPIRED (401)

The timestamp is more than 5 minutes in the past.

{
  "error": "TIMESTAMP_EXPIRED",
  "message": "Request timestamp is too old (>5 minutes)"
}

Fix: Synchronize your server clock using NTP. Ensure you are generating the timestamp at request time, not caching it. A timestamp in seconds instead of milliseconds also lands here.

TIMESTAMP_FUTURE (401)

The timestamp is more than 30 seconds in the future.

{
  "error": "TIMESTAMP_FUTURE",
  "message": "Request timestamp is too far in the future (>30 seconds)"
}

Fix: Synchronize your server clock. This usually indicates your server clock is ahead.

INVALID_REQUEST_ID (401)

The request ID is too long or contains disallowed characters.

{
  "error": "INVALID_REQUEST_ID",
  "message": "X-Request-Id must be 1-128 chars and match /^[A-Za-z0-9._:-]+$/"
}

Fix: Generate a new UUID for each request (e.g., crypto.randomUUID() in Node.js) and include it in both the signing string and X-Request-Id header.

INVALID_API_KEY (401)

The API key is not recognized, or the associated account has issues.

{
  "error": "INVALID_API_KEY",
  "message": "API key is invalid, inactive, expired, or partner is not active/approved"
}

Possible causes:

  • The key does not exist (typo or wrong key)
  • The key has been revoked
  • The key has expired
  • The partner account is suspended (isActive: false)
  • The partner account is not yet approved (isApproved: false)

Fix: Verify the API key, check with the platform admin if the partner account is active and approved.

INVALID_SIGNATURE (401)

The HMAC signature does not match the expected value, or the header is not 64 hex characters.

{
  "error": "INVALID_SIGNATURE",
  "message": "HMAC signature verification failed"
}

Common causes and fixes:

  1. Using the API key instead of the secret — Sign with sk_live_..., not pk_live_....
  2. Different JSON serialization — The body in the signing string must match the exact bytes sent in the HTTP body. Stringify once and reuse.
  3. Extra whitespace in JSON — Use compact JSON without spaces (e.g., JSON.stringify(body) not JSON.stringify(body, null, 2)).
  4. Wrong signing string format — Must be newline-separated v1, method, canonical path/query, timestamp, request ID, and raw body.
  5. Encoding issues — Ensure UTF-8 encoding for the signing string and secret.

IP_NOT_ALLOWED (403)

The request IP address is not in the API key’s allowlist.

{
  "error": "IP_NOT_ALLOWED",
  "message": "Request IP is not in the API key allowlist"
}

Fix: Add your server’s IP address to the API key’s IP allowlist in the admin panel. The allowlist supports both individual IPs and CIDR notation (e.g., 192.168.1.0/24).

INSUFFICIENT_SCOPES (403)

The API key does not have permission for this endpoint.

{
  "error": "INSUFFICIENT_SCOPES",
  "message": "API key is missing required scopes: balance:write",
  "missingScopes": ["balance:write"]
}

Fix: Request the missing scopes from the platform admin, or use an API key that has the required scopes.

DUPLICATE_REQUEST (409)

The X-Request-Id has already been seen within the last 5 minutes.

{
  "error": "DUPLICATE_REQUEST",
  "message": "This request ID has already been processed"
}

Fix: Generate a new UUID for each request. If you are retrying a failed request, either use a new request ID or wait 5 minutes.

UNSUPPORTED_CONTENT_TYPE (415)

The request has a body, but its Content-Type is not JSON.

Fix: Send Content-Type: application/json on every request with a body.

UNSIGNED_BODY (401)

The request has a body, but the server could not capture the raw bytes to verify them.

Fix: Send a JSON body with Content-Type: application/json.

REPLAY_STORAGE_UNAVAILABLE (503)

Redis is configured on the platform but cannot be reached, so replay protection cannot run.

Fix: Retry with a new X-Request-Id after a short delay.

RATE_LIMIT_EXCEEDED (429)

You have exceeded the rate limit for this API key.

{
  "error": "RATE_LIMIT_EXCEEDED",
  "message": "API key rate limit exceeded"
}

Fix: Reduce your request rate. The default limit is 100 requests per minute per API key. Check the Retry-After header for when you can retry. Contact the platform admin to increase the limit if needed.

Security Notes

  • Never expose your API secret (sk_live_...) in client-side code. All HMAC-signed requests must originate from your backend server.
  • POST /auth/exchange is the only endpoint meant to be called from the user’s browser. It takes a one-time code, not your API credentials. Registration, the status check and the documentation pages are also public.
  • Timestamp validation prevents replay attacks — old requests cannot be reused.
  • Request ID deduplication prevents accidental double-processing and binds the nonce into the request signature.
  • Timing-safe comparison is used for signature verification to prevent timing attacks.

Next Steps

Try the live demo Pricing

Have questions about Poker Engine? We reply fast.