Authentication
Every request to /gw/v1 is signed with the secret of the cash desk it acts for. There are no
sessions, no bearer tokens and no IP allowlists: each request proves itself, and the shop it
acts for is taken from the key, never from the body.
The four headers
X-Gate-Key: shp_01M1P932FS0FQPNM3T63KM2XAT
X-Gate-Ts: 1757001234
X-Gate-Nonce: 9b1cf4a7e02d5183
X-Gate-Sign: a3f1… (64 hex characters)
| Header | Meaning |
|---|---|
X-Gate-Key | The cash desk key: shp_ and 26 characters. It tells us which secret to verify with; on its own it grants nothing. |
X-Gate-Ts | Unix time in seconds when the request was made. We accept ±300 seconds of skew. |
X-Gate-Nonce | A random string of 8 to 128 characters. Must not repeat for the same cash desk within 6 minutes. |
X-Gate-Sign | The signature, described below. Hex, either case, exactly 64 characters. |
What is signed
{ts}.{nonce}.{METHOD}.{path}.{sha256hex(body)}
tsandnonceare exactly the header values.METHODis upper case:POST,GET.pathis the path without host and without the query string:/gw/v1/orders,/gw/v1/rates. ForGET /gw/v1/rates?currency=RUBthe path is still/gw/v1/rates.bodyis the request body byte for byte as you send it. ForGETit is the empty string, whose SHA-256 ise3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
The signature is hex(hmac_sha256(secret, signed_string)), where the secret is the cash desk
secret as shown once at creation, as UTF-8 bytes.
The method and the path are part of the signature so that a captured signature cannot be aimed at another endpoint. The body hash is there so that a proxy cannot change the amount on the way. The timestamp and the nonce make a captured request unusable after a few minutes and unusable twice within them.
In what order we check
- All four headers present, else
401 AUTH_HEADERS_MISSING. X-Gate-Tsis an integer, else401 AUTH_TIMESTAMP_INVALID.- The timestamp is within 300 seconds of our clock, else
401 AUTH_TIMESTAMP_SKEW. - The nonce is 8 to 128 characters, else
401 AUTH_NONCE_INVALID. - The key exists and the signature matches, else
401 AUTH_FAILED. The response is the same for an unknown key and a wrong signature, deliberately: the endpoint must not reveal which keys exist. - The nonce has not been seen for this cash desk in the last 360 seconds, else
409 REPLAY_DETECTED.
Only then does the request reach the endpoint.
Signing in your language
Each snippet signs POST /gw/v1/orders with the body in body and returns the four headers.
import { createHash, createHmac, randomBytes } from 'node:crypto';
export function gateHeaders(key, secret, method, path, body = '') {
const ts = Math.floor(Date.now() / 1000).toString();
const nonce = randomBytes(8).toString('hex');
const bodyHash = createHash('sha256').update(body, 'utf8').digest('hex');
const sign = createHmac('sha256', secret)
.update(`${ts}.${nonce}.${method.toUpperCase()}.${path}.${bodyHash}`, 'utf8')
.digest('hex');
return { 'X-Gate-Key': key, 'X-Gate-Ts': ts, 'X-Gate-Nonce': nonce, 'X-Gate-Sign': sign };
}
const body = JSON.stringify({ amount: 5000.0, currency: 'RUB', rail: 'Sbp', orderRef: 'A-17' });
const res = await fetch('https://api.peso.fast/gw/v1/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...gateHeaders(KEY, SECRET, 'POST', '/gw/v1/orders', body) },
body, // send exactly the string you hashed
});
import hashlib, hmac, json, secrets, time, requests
def gate_headers(key, secret, method, path, body=""):
ts = str(int(time.time()))
nonce = secrets.token_hex(8)
body_hash = hashlib.sha256(body.encode()).hexdigest()
payload = f"{ts}.{nonce}.{method.upper()}.{path}.{body_hash}"
sign = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
return {"X-Gate-Key": key, "X-Gate-Ts": ts, "X-Gate-Nonce": nonce, "X-Gate-Sign": sign}
body = json.dumps({"amount": 5000.00, "currency": "RUB", "rail": "Sbp", "orderRef": "A-17"})
r = requests.post("https://api.peso.fast/gw/v1/orders", data=body,
headers={"Content-Type": "application/json", **gate_headers(KEY, SECRET, "POST", "/gw/v1/orders", body)})
function gateHeaders(string $key, string $secret, string $method, string $path, string $body = ''): array {
$ts = (string) time();
$nonce = bin2hex(random_bytes(8));
$sign = hash_hmac('sha256', "$ts.$nonce." . strtoupper($method) . ".$path." . hash('sha256', $body), $secret);
return ["X-Gate-Key: $key", "X-Gate-Ts: $ts", "X-Gate-Nonce: $nonce", "X-Gate-Sign: $sign"];
}
$body = json_encode(['amount' => 5000.00, 'currency' => 'RUB', 'rail' => 'Sbp', 'orderRef' => 'A-17']);
$ch = curl_init('https://api.peso.fast/gw/v1/orders');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => array_merge(['Content-Type: application/json'], gateHeaders($key, $secret, 'POST', '/gw/v1/orders', $body)),
CURLOPT_RETURNTRANSFER => true,
]);
using System.Security.Cryptography;
using System.Text;
static Dictionary<string, string> GateHeaders(string key, string secret, string method, string path, string body = "")
{
var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant();
var bodyHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(body))).ToLowerInvariant();
var payload = $"{ts}.{nonce}.{method.ToUpperInvariant()}.{path}.{bodyHash}";
var sign = Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(payload))).ToLowerInvariant();
return new() { ["X-Gate-Key"] = key, ["X-Gate-Ts"] = ts, ["X-Gate-Nonce"] = nonce, ["X-Gate-Sign"] = sign };
}
func gateHeaders(key, secret, method, path, body string) map[string]string {
ts := strconv.FormatInt(time.Now().Unix(), 10)
b := make([]byte, 8); rand.Read(b)
nonce := hex.EncodeToString(b)
sum := sha256.Sum256([]byte(body))
payload := ts + "." + nonce + "." + strings.ToUpper(method) + "." + path + "." + hex.EncodeToString(sum[:])
mac := hmac.New(sha256.New, []byte(secret)); mac.Write([]byte(payload))
return map[string]string{"X-Gate-Key": key, "X-Gate-Ts": ts, "X-Gate-Nonce": nonce, "X-Gate-Sign": hex.EncodeToString(mac.Sum(nil))}
}
When it fails
| HTTP | Code | Cause |
|---|---|---|
| 401 | AUTH_HEADERS_MISSING | One of the four headers is absent. |
| 401 | AUTH_TIMESTAMP_INVALID | X-Gate-Ts is not an integer. Send seconds, not milliseconds and not ISO text. |
| 401 | AUTH_TIMESTAMP_SKEW | More than 300 seconds from our clock. Sync your server time with NTP. |
| 401 | AUTH_NONCE_INVALID | The nonce is shorter than 8 or longer than 128 characters. |
| 401 | AUTH_FAILED | The key is unknown or the signature does not match. |
| 409 | REPLAY_DETECTED | This nonce was already used for this cash desk within the window. Generate a new one per request. |
Debugging AUTH_FAILED
Sign the string exactly as shown above and compare byte for byte. The usual culprits, in the order we see them in support:
- The body you hashed is not the body you sent: a trailing newline, re-serialisation by the HTTP library, or pretty-printing. Hash the exact string and send that same string.
- The method is lower case, or the path includes the host or the query string.
- The secret was pasted with a trailing space, or the key of another cash desk was used.
- The signature was computed over the body hash with a different encoding. Everything is UTF-8;
hex digests are lower case, and we accept upper case only in
X-Gate-Signitself. - The secret was rotated in the cabinet and the old one is still in your configuration.
GET /gw/v1/methods is the safest place to test: it changes nothing and needs no body.