time2pay Developer Docs

Guide

Signing requests

One scheme for every endpoint, with a test vector to check your implementation against.

The four headers

Every merchant-facing endpoint uses the same key and the same scheme: deposits, wallet addresses, payouts, balance and hosted-page creation. Your merchant identity comes from the verified key, never from the request body. The same secret also signs the webhooks we send you, so one credential pair covers both directions.

HeaderValue
X-Api-KeyYour key id, issued in the admin panel.
X-TimestampUnix seconds. Rejected more than 5 minutes from our clock, so keep your servers on NTP.
X-NonceA fresh unguessable value per request — 16 random bytes as hex is ideal. Rejected if reused within the 5-minute window.
X-SignatureLowercase hex HMAC-SHA256 of the canonical string below.

Requests with a body also send Content-Type: application/json.

The canonical string

Five lines, joined by \n:

<timestamp>
<nonce>
<METHOD>
<path>
<hex(sha256(body))>

METHOD is upper-case. path is the URL path only, with no query string and no host. The body hash is taken over the exact bytes you send — hash the serialized string, not a re-serialization of your object, or a difference in key order or spacing will cost you an hour.

Per operation

The scheme never changes; only the method, path and body bytes do.

OperationMETHODpathbody
Open a deposit sessionPOST/v1/cashier/initrequest JSON
Advance / read a sessionPOST/v1/cashier/step, /v1/cashier/statusrequest JSON
Create a payment linkPOST/v1/hpp/sessionsrequest JSON
Wallet addressPOST/v1/wallet/addressrequest JSON
PayoutsPOST/v1/payouts, /v1/payouts/authorize, /v1/payouts/statusrequest JSON
Batch payoutsPOST/v1/payouts/batch, /v1/payouts/batch/authorize, /v1/payouts/batch/statusrequest JSON
Payout allowancePOST/v1/payouts/approval, /v1/payouts/approval/broadcast, /v1/payouts/approval/statusrequest JSON
BalanceGET/v1/balanceempty — the hash of zero bytes, e3b0c442…b855

Test vector

Fixed inputs with a known answer. Run your signing function against these before you send a single real request: a wrong signature, a wrong key and a wrong clock all come back as the same 401, and this is the only one of the three you can rule out on your own.

InputValue
secrettest-secret
timestamp1700000000
nonce0123456789abcdef0123456789abcdef
methodPOST
path/v1/hpp/sessions
body{"player_id":"player-123","external_order_id":"deposit-1001"}

Intermediate and final values:

hex(sha256(body)) = 073c8a88902f68c0e03e0063aff6c3532b333103211f2af2915be6c7286ff27c

canonical string  = "1700000000\n0123456789abcdef0123456789abcdef\nPOST\n/v1/hpp/sessions\n073c8a88902f68c0e03e0063aff6c3532b333103211f2af2915be6c7286ff27c"

X-Signature       = aec82eccaa5a1e4729606faf61e67486f78fe0a5b155374fd6672690cb3339ae

And the same vector for a GET with an empty body, which is what the balance endpoint signs:

method            = GET
path              = /v1/balance
hex(sha256(""))   = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

X-Signature       = 3a42715702b2d653aa4e5da2f5999ffc1628a2f45be0aea5c9b75088077a5ed7

Checking it with the client below:

sig := time2pay.Sign("test-secret", 1700000000, "0123456789abcdef0123456789abcdef", "POST", "/v1/hpp/sessions",
	[]byte(`{"player_id":"player-123","external_order_id":"deposit-1001"}`))
fmt.Println(sig == "aec82eccaa5a1e4729606faf61e67486f78fe0a5b155374fd6672690cb3339ae")
$sig = Time2payClient::sign('test-secret', 1700000000, '0123456789abcdef0123456789abcdef', 'POST', '/v1/hpp/sessions',
    '{"player_id":"player-123","external_order_id":"deposit-1001"}');
var_dump($sig === 'aec82eccaa5a1e4729606faf61e67486f78fe0a5b155374fd6672690cb3339ae');
const sig = await sign("test-secret", 1700000000, "0123456789abcdef0123456789abcdef", "POST", "/v1/hpp/sessions",
  '{"player_id":"player-123","external_order_id":"deposit-1001"}');
console.log(sig === "aec82eccaa5a1e4729606faf61e67486f78fe0a5b155374fd6672690cb3339ae");
sig = sign("test-secret", 1700000000, "0123456789abcdef0123456789abcdef", "POST", "/v1/hpp/sessions",
           b'{"player_id":"player-123","external_order_id":"deposit-1001"}')
print(sig == "aec82eccaa5a1e4729606faf61e67486f78fe0a5b155374fd6672690cb3339ae")
const sig = sign("test-secret", 1700000000, "0123456789abcdef0123456789abcdef", "POST", "/v1/hpp/sessions",
  '{"player_id":"player-123","external_order_id":"deposit-1001"}');
console.log(sig === "aec82eccaa5a1e4729606faf61e67486f78fe0a5b155374fd6672690cb3339ae");
These values are pinned by a test in the gateway itself, so they cannot drift away from what the server computes. If your output differs, the difference is on your side.

A complete client

One file per language, no SDK to install. It signs, sends, and turns an error response into an exception that carries the error code, the HTTP status and details. Every example in these docs calls it as client.

TabRuns onNeeds
GoGo 1.21+standard library only
PHPPHP 8.1+ext-curl, ext-json
JavaScriptDeno, Bun, Cloudflare Workers — anything with WebCrypto and fetchnothing
PythonPython 3.9+requests
Node.jsNode 18+nothing
package time2pay

import (
	"context"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strconv"
	"strings"
	"time"
)

type Client struct {
	BaseURL string
	KeyID   string
	Secret  string
	HTTP    *http.Client
}

type Error struct {
	Status  int               `json:"-"`
	Code    string            `json:"code"`
	Message string            `json:"message"`
	Details map[string]string `json:"details"`
}

func (e *Error) Error() string {
	return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message)
}

func Sign(secret string, ts int64, nonce, method, path string, body []byte) string {
	bodyHash := sha256.Sum256(body)
	canonical := strings.Join([]string{
		strconv.FormatInt(ts, 10),
		nonce,
		strings.ToUpper(method),
		path,
		hex.EncodeToString(bodyHash[:]),
	}, "\n")
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(canonical))
	return hex.EncodeToString(mac.Sum(nil))
}

func (c *Client) Call(ctx context.Context, method, path, body string) (map[string]any, error) {
	var n [16]byte
	if _, err := rand.Read(n[:]); err != nil {
		return nil, err
	}
	nonce := hex.EncodeToString(n[:])
	ts := time.Now().Unix()

	req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, strings.NewReader(body))
	if err != nil {
		return nil, err
	}
	if body != "" {
		req.Header.Set("Content-Type", "application/json")
	}
	req.Header.Set("X-Api-Key", c.KeyID)
	req.Header.Set("X-Timestamp", strconv.FormatInt(ts, 10))
	req.Header.Set("X-Nonce", nonce)
	req.Header.Set("X-Signature", Sign(c.Secret, ts, nonce, method, path, []byte(body)))

	httpClient := c.HTTP
	if httpClient == nil {
		httpClient = &http.Client{Timeout: 30 * time.Second}
	}
	resp, err := httpClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	raw, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}
	if resp.StatusCode >= 300 {
		var envelope struct {
			Error Error `json:"error"`
		}
		_ = json.Unmarshal(raw, &envelope)
		envelope.Error.Status = resp.StatusCode
		return nil, &envelope.Error
	}
	var out map[string]any
	if err := json.Unmarshal(raw, &out); err != nil {
		return nil, err
	}
	return out, nil
}
<?php

final class Time2payError extends RuntimeException
{
    public function __construct(public readonly int $status, public readonly array $error)
    {
        parent::__construct(sprintf('%d %s: %s', $status, $error['code'] ?? 'http_error', $error['message'] ?? ''));
    }

    public function errorCode(): string
    {
        return $this->error['code'] ?? '';
    }

    public function details(): array
    {
        return $this->error['details'] ?? [];
    }
}

final class Time2payClient
{
    public function __construct(
        private readonly string $baseUrl,
        private readonly string $keyId,
        private readonly string $secret,
    ) {
    }

    public static function sign(string $secret, int $ts, string $nonce, string $method, string $path, string $body): string
    {
        $canonical = implode("\n", [$ts, $nonce, strtoupper($method), $path, hash('sha256', $body)]);
        return hash_hmac('sha256', $canonical, $secret);
    }

    public function call(string $method, string $path, ?array $payload = null): array
    {
        $body = $payload === null ? '' : json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
        $ts = time();
        $nonce = bin2hex(random_bytes(16));
        $headers = [
            'X-Api-Key: ' . $this->keyId,
            'X-Timestamp: ' . $ts,
            'X-Nonce: ' . $nonce,
            'X-Signature: ' . self::sign($this->secret, $ts, $nonce, $method, $path, $body),
        ];
        if ($body !== '') {
            $headers[] = 'Content-Type: application/json';
        }

        $ch = curl_init($this->baseUrl . $path);
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST => strtoupper($method),
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_TIMEOUT => 30,
        ]);
        if ($body !== '') {
            curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
        }
        $response = curl_exec($ch);
        if ($response === false) {
            throw new RuntimeException(curl_error($ch));
        }
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $data = json_decode($response, true) ?? [];
        if ($status >= 300) {
            throw new Time2payError($status, $data['error'] ?? []);
        }
        return $data;
    }
}
const encoder = new TextEncoder();

function toHex(bytes) {
  return Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, "0")).join("");
}

export class Time2payError extends Error {
  constructor(status, error = {}) {
    super(`${status} ${error.code ?? "http_error"}: ${error.message ?? ""}`);
    this.status = status;
    this.code = error.code;
    this.details = error.details ?? {};
  }
}

export async function sign(secret, ts, nonce, method, path, body) {
  const bodyHash = toHex(await crypto.subtle.digest("SHA-256", encoder.encode(body)));
  const canonical = [ts, nonce, method.toUpperCase(), path, bodyHash].join("\n");
  const key = await crypto.subtle.importKey(
    "raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
  );
  return toHex(await crypto.subtle.sign("HMAC", key, encoder.encode(canonical)));
}

export function createClient({ baseUrl, keyId, secret }) {
  return {
    async call(method, path, payload) {
      const body = payload === undefined ? "" : JSON.stringify(payload);
      const ts = Math.floor(Date.now() / 1000);
      const nonce = toHex(crypto.getRandomValues(new Uint8Array(16)));
      const headers = {
        "X-Api-Key": keyId,
        "X-Timestamp": String(ts),
        "X-Nonce": nonce,
        "X-Signature": await sign(secret, ts, nonce, method, path, body),
      };
      if (body) headers["Content-Type"] = "application/json";

      const res = await fetch(baseUrl + path, { method, headers, body: body || undefined });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Time2payError(res.status, data.error);
      return data;
    },
  };
}
import hashlib
import hmac
import json
import secrets
import time

import requests


class Time2payError(Exception):
    def __init__(self, status, error):
        super().__init__(f"{status} {error.get('code')}: {error.get('message')}")
        self.status = status
        self.code = error.get("code")
        self.details = error.get("details") or {}


def sign(secret, ts, nonce, method, path, body):
    body_hash = hashlib.sha256(body).hexdigest()
    canonical = "\n".join([str(ts), nonce, method.upper(), path, body_hash])
    return hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()


class Time2payClient:
    def __init__(self, base_url, key_id, secret, timeout=30):
        self.base_url = base_url
        self.key_id = key_id
        self.secret = secret
        self.timeout = timeout

    def call(self, method, path, payload=None):
        body = b"" if payload is None else json.dumps(payload, separators=(",", ":")).encode()
        ts = int(time.time())
        nonce = secrets.token_hex(16)
        headers = {
            "X-Api-Key": self.key_id,
            "X-Timestamp": str(ts),
            "X-Nonce": nonce,
            "X-Signature": sign(self.secret, ts, nonce, method, path, body),
        }
        if body:
            headers["Content-Type"] = "application/json"
        resp = requests.request(method, self.base_url + path, data=body or None,
                                headers=headers, timeout=self.timeout)
        data = resp.json() if resp.content else {}
        if resp.status_code >= 300:
            raise Time2payError(resp.status_code, data.get("error", {}))
        return data
import crypto from "node:crypto";

export class Time2payError extends Error {
  constructor(status, error = {}) {
    super(`${status} ${error.code ?? "http_error"}: ${error.message ?? ""}`);
    this.status = status;
    this.code = error.code;
    this.details = error.details ?? {};
  }
}

export function sign(secret, ts, nonce, method, path, body) {
  const bodyHash = crypto.createHash("sha256").update(body).digest("hex");
  const canonical = [ts, nonce, method.toUpperCase(), path, bodyHash].join("\n");
  return crypto.createHmac("sha256", secret).update(canonical).digest("hex");
}

export class Time2payClient {
  constructor({ baseUrl, keyId, secret }) {
    this.baseUrl = baseUrl;
    this.keyId = keyId;
    this.secret = secret;
  }

  async call(method, path, payload) {
    const body = payload === undefined ? "" : JSON.stringify(payload);
    const ts = Math.floor(Date.now() / 1000);
    const nonce = crypto.randomBytes(16).toString("hex");
    const headers = {
      "X-Api-Key": this.keyId,
      "X-Timestamp": String(ts),
      "X-Nonce": nonce,
      "X-Signature": sign(this.secret, ts, nonce, method, path, body),
    };
    if (body) headers["Content-Type"] = "application/json";

    const res = await fetch(this.baseUrl + path, {
      method,
      headers,
      body: body || undefined,
      signal: AbortSignal.timeout(30_000),
    });
    const data = await res.json().catch(() => ({}));
    if (!res.ok) throw new Time2payError(res.status, data.error);
    return data;
  }
}

Create it once and reuse it:

client := &time2pay.Client{
	BaseURL: os.Getenv("BASE_URL"),
	KeyID:   os.Getenv("API_KEY_ID"),
	Secret:  os.Getenv("API_KEY_SECRET"),
}
ctx := context.Background()
$client = new Time2payClient(getenv('BASE_URL'), getenv('API_KEY_ID'), getenv('API_KEY_SECRET'));
const client = createClient({ baseUrl: env.BASE_URL, keyId: env.API_KEY_ID, secret: env.API_KEY_SECRET });
client = Time2payClient(os.environ["BASE_URL"], os.environ["API_KEY_ID"], os.environ["API_KEY_SECRET"])
const client = new Time2payClient({
  baseUrl: process.env.BASE_URL,
  keyId: process.env.API_KEY_ID,
  secret: process.env.API_KEY_SECRET,
});
The JavaScript tab is server-side code for edge and Deno-style runtimes. It works in a browser too, which is exactly why it must never run in one: the secret would ship to every player. Where these docs show browser code, it talks to your backend, never to us.

Retries, nonces and idempotency

These three are easy to conflate and they do different jobs. Getting the split right is the difference between a safe retry and a double deposit.

Nonceexternal_order_id / external_id
JobStops a captured request being replayedStops one order becoming two
On a retryMust changeMust stay the same
Reusing it401 unauthorizedReturns the original order

So a retry is a brand new signed request carrying the same business reference. The client above already does the first half — every call makes a fresh timestamp, nonce and signature — so retrying is calling it again with the same payload.

What to do per status:

StatusRetry?
503 unavailableYes, with backoff. Something on our side is degraded and refused rather than answered wrongly.
5xx, timeout, connection resetYes, with backoff and the same order reference.
401, 403, 400, 409No. Retrying an identical request changes nothing; fix the request. Some 400s clear once you act — approve more, top up.
Do not use a timestamp or a counter as your nonce. Two requests in the same second collide and the second is rejected as a replay — and you will only find out under load, when polling is busiest.

IP allowlist

Optionally, your merchant profile can carry a list of source networks. When it does, a correctly signed request from anywhere else is refused with 403 forbidden. Empty list means no restriction, so this is purely additive — you cannot lock yourself out by not configuring it. Give the operator the egress addresses of every host that calls us, including workers and cron jobs, not just the web servers.