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.
| Header | Value |
|---|---|
X-Api-Key | Your key id, issued in the admin panel. |
X-Timestamp | Unix seconds. Rejected more than 5 minutes from our clock, so keep your servers on NTP. |
X-Nonce | A fresh unguessable value per request — 16 random bytes as hex is ideal. Rejected if reused within the 5-minute window. |
X-Signature | Lowercase 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.
| Operation | METHOD | path | body |
|---|---|---|---|
| Open a deposit session | POST | /v1/cashier/init | request JSON |
| Advance / read a session | POST | /v1/cashier/step, /v1/cashier/status | request JSON |
| Create a payment link | POST | /v1/hpp/sessions | request JSON |
| Wallet address | POST | /v1/wallet/address | request JSON |
| Payouts | POST | /v1/payouts, /v1/payouts/authorize, /v1/payouts/status | request JSON |
| Batch payouts | POST | /v1/payouts/batch, /v1/payouts/batch/authorize, /v1/payouts/batch/status | request JSON |
| Payout allowance | POST | /v1/payouts/approval, /v1/payouts/approval/broadcast, /v1/payouts/approval/status | request JSON |
| Balance | GET | /v1/balance | empty — 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.
| Input | Value |
|---|---|
| secret | test-secret |
| timestamp | 1700000000 |
| nonce | 0123456789abcdef0123456789abcdef |
| method | POST |
| 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");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.
| Tab | Runs on | Needs |
|---|---|---|
| Go | Go 1.21+ | standard library only |
| PHP | PHP 8.1+ | ext-curl, ext-json |
| JavaScript | Deno, Bun, Cloudflare Workers — anything with WebCrypto and fetch | nothing |
| Python | Python 3.9+ | requests |
| Node.js | Node 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 dataimport 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,
});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.
| Nonce | external_order_id / external_id | |
|---|---|---|
| Job | Stops a captured request being replayed | Stops one order becoming two |
| On a retry | Must change | Must stay the same |
| Reusing it | 401 unauthorized | Returns 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:
| Status | Retry? |
|---|---|
503 unavailable | Yes, with backoff. Something on our side is degraded and refused rather than answered wrongly. |
5xx, timeout, connection reset | Yes, with backoff and the same order reference. |
401, 403, 400, 409 | No. Retrying an identical request changes nothing; fix the request. Some 400s clear once you act — approve more, top up. |
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.