Auth and JWT

Two credentials open the same doors:

  1. HMAC headers on every request
  2. a Bearer JWT, obtained once with HMAC headers and reused until it expires

POST /auth is the only endpoint that accepts HMAC alone: a token cannot mint another token, so a leaked JWT can never be renewed without the secret.

Base URL:

https://cytrongift.com/api/v1

Step 1: sign a request

Headers:

X-Api-Key: {users.api_key}
X-Timestamp: {unix_time}
X-Signature: {signature}
Content-Type: application/json

Signed payload, four parts joined by a newline:

{METHOD}\n{PATH}\n{TIMESTAMP}\n{BODY}

For POST /auth with the body {"ttl":3600} the payload is literally:

POST
/auth
1788605189
{"ttl":3600}

Signature:

hash_hmac('sha256', payload, users.api_secret)

Rules that trip people up:

Failure modes: 3001 nothing sent, 3002 timestamp out of window, 3003 signature mismatch, 3006 unknown key. All four are 401. An account whose api flag is off (3004) or whose status is not active (3005) answers 403: the credential was accepted, the account was not.

Step 2: get a token

POST /api/v1/auth
Content-Type: application/json
X-Api-Key: your_api_key
X-Timestamp: 1788605189
X-Signature: your_signature

{"ttl": 3600}

ttl is optional, clamped to 300 - 86400, and defaults to settings.api_jwt_ttl.

{
  "ok": 1,
  "data": {
    "token_type": "Bearer",
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "expires_in": 3600,
    "expires_at": 1788608789,
    "merchant_id": 8269
  }
}

The token is HS256, signed with settings.api_jwt_secret, and carries:

There is no refresh token and no /auth/refresh. Renewing means calling POST /auth again with HMAC headers, which is a single signed request.

Step 3: use the token

GET /api/v1/account/balance
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

The token is checked in this order: header alg must be HS256 and typ must be JWT, then the signature, then exp (3008), nbf, iat (a minute of tolerance for a fast clock) and sub (3009 for all three). The account behind sub then goes through the same api flag and status checks as an HMAC caller.

The rate limit counts per X-Api-Key. A JWT request sends no key, so it is counted against the client address instead - send both headers if you want one bucket, or expect two.

PHP example

<?php

$base   = 'https://cytrongift.com/api/v1';
$key    = 'your_api_key';
$secret = 'your_api_secret';

$sign = static function (string $method, string $path, string $body) use ($key, $secret): array {
	$ts = (string) time();
	$signature = hash_hmac('sha256', $method."\n".$path."\n".$ts."\n".$body, $secret);
	return ['X-Api-Key: '.$key, 'X-Timestamp: '.$ts, 'X-Signature: '.$signature];
};

// serialise once, sign the same string that goes on the wire
$body = json_encode(['ttl' => 3600], JSON_UNESCAPED_SLASHES);

$ch = curl_init($base.'/auth');
curl_setopt_array($ch, [
	CURLOPT_POST           => true,
	CURLOPT_RETURNTRANSFER => true,
	CURLOPT_POSTFIELDS     => $body,
	CURLOPT_HTTPHEADER     => array_merge(['Content-Type: application/json'], $sign('POST', '/auth', $body)),
]);
$response = json_decode((string) curl_exec($ch), true);
curl_close($ch);

if (($response['ok'] ?? 0) !== 1) {
	exit('auth failed: '.($response['error']['id'] ?? '?').' '.($response['error']['desc'] ?? ''));
}

$token = $response['data']['access_token'];

$ch = curl_init($base.'/account/balance');
curl_setopt_array($ch, [
	CURLOPT_RETURNTRANSFER => true,
	CURLOPT_HTTPHEADER     => ['Authorization: Bearer '.$token],
]);
echo curl_exec($ch), PHP_EOL;
curl_close($ch);

Python example

import hmac, hashlib, json, time, requests

BASE, KEY, SECRET = "https://cytrongift.com/api/v1", "your_api_key", "your_api_secret"

def headers(method, path, body=""):
    ts = str(int(time.time()))
    payload = f"{method}\n{path}\n{ts}\n{body}"
    signature = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
    return {"X-Api-Key": KEY, "X-Timestamp": ts, "X-Signature": signature}

body = json.dumps({"ttl": 3600}, separators=(",", ":"))
h = headers("POST", "/auth", body)
h["Content-Type"] = "application/json"

token = requests.post(BASE + "/auth", data=body, headers=h).json()["data"]["access_token"]
print(requests.get(BASE + "/account/balance", headers={"Authorization": "Bearer " + token}).json())

Rotating credentials

users.api_key and users.api_secret are per account and are changed in the panel. Both take effect on the next request; there is no grace period, so rotate and deploy together. settings.api_jwt_secret is global: changing it invalidates every token already issued, which is the way to revoke them all at once.