Auth and JWT
Two credentials open the same doors:
- HMAC headers on every request
- 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:
PATHnever carries the/api/v1prefix. The request goes tohttps://cytrongift.com/api/v1/auth, the signature covers/authPATHcarries no query string.GET /catalog/cards?limit=50signs/catalog/cardsBODYis the exact bytes sent, not a re-encoded copy. Serialise once, sign that string, send that string- a
GEThas an empty body, so the payload ends with a newline multipart/form-datasigns an empty body, because the client builds the body with its own boundary and it is not reproducible- the digest is lowercase hex, compared in constant time
X-Timestampmust be within 300 seconds of the server clock.GET /configreturns the servertimestamp, which is the cheapest way to measure your drift
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:
subthe user id, as a decimal stringiatissued atnbfnot before, equal toiatexpexpiryjtia random id, for your own logging
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.