ПРАВИЛА ИНТЕГРАЦИИ

Расчет подписи HS256 (JWS)

Для проверки запросов используется подпись, которая считается по алгоритму JSON Web Signature (JWS). Подробнее об алгоритме - https://tools.ietf.org/html/rfc7515

Алгоритм формирования

Подпись в API формируется по схеме JWS (HS256).

Формирование raw_string:<br> raw_string = BASE64URL( UTF8(header_json) ) + "." + BASE64URL( UTF8(payload_json) )

Header:<br> {"alg":"HS256"}<br> Используется компактный JSON (без пробелов и переносов).

Payload:<br> {"PATH":"<url_path>","GET":{...},"POST":{...}}

Правила подготовки payload:

  • GET и POST - словари, ключи сортируются по алфавиту.
  • Ключи signature и api_secret (если есть) исключаются из GET/POST.
  • Для API-запросов все значения приводятся к строкам.
  • Пустые объекты параметров GET и POST не включаются в payload.
  • Кириллица и любые не-ASCII символы в payload_json сериализуются как Unicode escape \uXXXX (ASCII JSON).
  • Для исходящих API-запросов порядок верхнего уровня: PATH, GET, POST.
  • Для подписи webhook используется режим подготовки данных, описанный ниже; его нельзя определять по HTTP Content-Type.

Base64URL:

  • удаляются = в конце;
  • + заменяется на -;
  • / заменяется на _.

Подпись: signature = BASE64URL( HMAC_SHA256(secret, raw_string) ), где secret используется как строка UTF-8.

Рекомендуемые библиотеки:

Язык программированияСсылка
Pythonhttps://pypi.org/project/jws/
PHPhttps://github.com/web-token/jwt-signature
JavaScripthttps://www.npmjs.com/package/jws

Строгий выбор реквизита

Необязательный target_requisite_id (UUID) поддерживается в /init, /p2p-selector/sale и /selector/v1/sale. Его можно задать прямо в sale без /init. ID сохраняется вместе с дополнительными данными инвойса. Сохранённое значение можно обновить через параметр запроса. Параметр можно передать на верхнем уровне либо внутри data. Запрос без параметра использует сохранённое значение, если оно есть.

Выдаётся только указанный реквизит с проверкой группы, типа, активности, лимитов использования и блокировок. Sticky-привязки не применяются и не изменяются; каскадирование отключено. Недоступность реквизита приводит к ошибке без замены. Терминал должен иметь supports_target_requisite_id: true; иначе возвращается TARGET_REQUISITE_UNSUPPORTED. Адресная выдача поддерживается через P2P selector; обычный card sale с целевым ID отклоняется.

Без нового поля и без ранее сохранённого ID все существующие сценарии работают как прежде; изменение интеграции клиента не требуется.

Готовые скрипты подписи

Python
python
#!/usr/bin/env python3
"""Calculate API or JSON-webhook signatures offline. Python 3.8+, no packages."""

import argparse
import base64
import hashlib
import hmac
import json
import sys


def number_error(webhook, fractional=False):
    if webhook:
        return ValueError(
            "Unsupported webhook number. Do not change received values; "
            "use a verifier supporting the original numeric representation."
        )
    if fractional:
        return ValueError('Use JSON strings for fractional numbers: "10.50".')
    return ValueError("Use JSON strings for integers outside the safe range.")


def validate_numbers(value, webhook):
    if isinstance(value, int) and not isinstance(value, bool):
        if abs(value) > 9007199254740991:
            raise number_error(webhook)
    elif isinstance(value, dict):
        for item in value.values():
            validate_numbers(item, webhook)
    elif isinstance(value, list):
        for item in value:
            validate_numbers(item, webhook)


def parse_data(source, webhook):
    def reject_fraction(value):
        raise number_error(webhook, fractional=True)

    data = json.loads(
        source, parse_float=reject_fraction, parse_constant=reject_fraction
    )
    if not isinstance(data, dict):
        raise ValueError("--get_data and --post_data must be JSON objects.")
    validate_numbers(data, webhook)
    return data


def signed_value(value):
    # The API accepts compact, recursively sorted JSON for object fields.
    if isinstance(value, dict):
        return json.dumps(
            value, sort_keys=True, ensure_ascii=False, separators=(",", ":")
        )
    if isinstance(value, list):
        raise ValueError("Top-level array fields are not supported by this helper.")
    return str(value)


def b64url(value):
    return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")


def sign(secret, path, get_data, post_data, webhook):
    payload = {"PATH": path}
    for method, data in (("GET", get_data), ("POST", post_data)):
        if data:
            fields = {}
            for key in sorted(data):
                if key in ("signature", "api_secret"):
                    continue
                value = data[key]
                if webhook and method == "GET" and isinstance(value, (dict, list)):
                    raise ValueError(
                        "Webhook query parameters must have scalar values."
                    )
                fields[key] = (
                    value if webhook and method == "POST" else signed_value(value)
                )
            payload[method] = fields
    raw_string = (
        b64url(b'{"alg":"HS256"}')
        + "."
        + b64url(
            json.dumps(payload, separators=(",", ":"), sort_keys=webhook).encode(
                "utf-8"
            )
        )
    )
    signature = b64url(
        hmac.new(
            secret.encode("utf-8"), raw_string.encode("ascii"), hashlib.sha256
        ).digest()
    )
    return {"raw_string": raw_string, "signature": signature}


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--secret", required=True, help="API signing secret")
    parser.add_argument("--path", required=True, help="Exact request path, e.g. /init")
    parser.add_argument(
        "--get_data", default="{}", help="Query parameters as a JSON object"
    )
    parser.add_argument("--post_data", default="{}", help="POST body as a JSON object")
    parser.add_argument("--get", dest="get_data", help=argparse.SUPPRESS)
    parser.add_argument("--post", dest="post_data", help=argparse.SUPPRESS)
    parser.add_argument(
        "--webhook",
        choices=("0", "1"),
        default="0",
        help="1: JSON-webhook mode; 0: API request mode (default)",
    )
    args = parser.parse_args()
    try:
        webhook = args.webhook == "1"
        if not args.secret:
            raise ValueError("--secret must not be empty.")
        if not (args.path.startswith("/") or (webhook and args.path == "")) or any(
            c in args.path for c in ("?", "#", "://")
        ):
            raise ValueError(
                "--path must be a request path without a URL, query or fragment."
            )
        result = sign(
            args.secret,
            args.path,
            parse_data(args.get_data, webhook),
            parse_data(args.post_data, webhook),
            webhook,
        )
        print(json.dumps(result))
    except (ValueError, UnicodeError) as error:
        print("Error: " + str(error), file=sys.stderr)
        return 2
    return 0


if __name__ == "__main__":
    sys.exit(main())
JavaScript
javascript
#!/usr/bin/env node
'use strict';
// Calculate API or JSON-webhook signatures offline. Node.js 18+, no packages.
const crypto = require('node:crypto');

function parseArgs(argv) {
  const args = { get_data: '{}', post_data: '{}', webhook: '0' };
  const seen = new Set();
  for (let i = 0; i < argv.length; i++) {
    const match = /^--(secret|path|get_data|post_data|get|post|webhook)(?:=(.*))?$/s.exec(argv[i]);
    if (!match) throw new Error('Unknown argument.');
    const name = ['get', 'post'].includes(match[1]) ? match[1] + '_data' : match[1];
    if (seen.has(name)) throw new Error('Repeated argument.');
    const value = match[2] === undefined ? argv[++i] : match[2];
    if (value === undefined) throw new Error(`Missing value for --${match[1]}.`);
    args[name] = value;
    seen.add(name);
  }
  if (!args.secret) throw new Error('--secret must not be empty.');
  if (!['0', '1'].includes(args.webhook)) throw new Error('--webhook must be 0 or 1.');
  if (args.path === undefined ||
      !(args.path.startsWith('/') || (args.webhook === '1' && args.path === '')) ||
      /[?#]|:\/\//.test(args.path)) {
    throw new Error('--path must be a request path without a URL, query or fragment.');
  }
  return args;
}

function numberError(webhook, fractional = false) {
  if (webhook) return new Error('Unsupported webhook number. Do not change received values; use a verifier supporting the original numeric representation.');
  return new Error(fractional ? 'Use JSON strings for fractional numbers: "10.50".' :
    'Use JSON strings for integers outside the safe range.');
}

function validateNumbers(value, webhook) {
  if (typeof value === 'number' && !Number.isSafeInteger(value)) {
    throw numberError(webhook);
  }
  if (value && typeof value === 'object') Object.values(value).forEach(item => validateNumbers(item, webhook));
}

function parseData(source, webhook) {
  const data = JSON.parse(source);
  if (!data || typeof data !== 'object' || Array.isArray(data)) {
    throw new Error('--get_data and --post_data must be JSON objects.');
  }
  // JSON.parse loses the distinction between 1 and 1.0; reject such literals.
  const withoutStrings = source.replace(/"(?:[^"\\]|\\.)*"/gs, '""');
  if (/-?\d+(?:\.\d+|[eE][+-]?\d+)/.test(withoutStrings)) {
    throw numberError(webhook, true);
  }
  validateNumbers(data, webhook);
  return data;
}

function compareKeys(a, b) {
  const x = Array.from(a, ch => ch.codePointAt(0));
  const y = Array.from(b, ch => ch.codePointAt(0));
  for (let i = 0; i < Math.min(x.length, y.length); i++) {
    if (x[i] !== y[i]) return x[i] - y[i];
  }
  return x.length - y.length;
}

function compactSortedJson(value) {
  if (Array.isArray(value)) return '[' + value.map(compactSortedJson).join(',') + ']';
  if (value && typeof value === 'object') {
    return '{' + Object.keys(value).sort(compareKeys).map(key =>
      JSON.stringify(key) + ':' + compactSortedJson(value[key])
    ).join(',') + '}';
  }
  return JSON.stringify(value);
}

function signedValue(value) {
  if (Array.isArray(value)) throw new Error('Top-level array fields are not supported by this helper.');
  if (value && typeof value === 'object') return compactSortedJson(value);
  if (value === null) return 'None';
  if (typeof value === 'boolean') return value ? 'True' : 'False';
  return String(value);
}

function asciiJson(value) {
  return escapeAscii(JSON.stringify(value));
}

function escapeAscii(source) {
  return source.replace(/[\u007f-\uffff]/g,
    ch => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
}

function sign(args) {
  const webhook = args.webhook === '1';
  // Construct object JSON explicitly to preserve alphabetical numeric field names.
  const parts = new Map([['PATH', asciiJson(args.path)]]);
  for (const method of ['GET', 'POST']) {
    const data = parseData(args[method.toLowerCase() + '_data'], webhook);
    if (Object.keys(data).length) {
      const fields = Object.keys(data).sort(compareKeys)
        .filter(key => !['signature', 'api_secret'].includes(key))
        .map(key => {
          const value = data[key];
          if (webhook && method === 'GET' && value && typeof value === 'object') {
            throw new Error('Webhook query parameters must have scalar values.');
          }
          const encoded = webhook && method === 'POST' ?
            escapeAscii(compactSortedJson(value)) : asciiJson(signedValue(value));
          return asciiJson(key) + ':' + encoded;
        });
      parts.set(method, '{' + fields.join(',') + '}');
    }
  }
  const order = webhook ? ['GET', 'PATH', 'POST'] : ['PATH', 'GET', 'POST'];
  const payload = '{' + order.filter(key => parts.has(key))
    .map(key => '"' + key + '":' + parts.get(key)).join(',') + '}';
  const raw_string = Buffer.from('{"alg":"HS256"}').toString('base64url') + '.' +
    Buffer.from(payload, 'utf8').toString('base64url');
  const signature = crypto.createHmac('sha256', args.secret).update(raw_string).digest('base64url');
  return { raw_string, signature };
}

try {
  console.log(JSON.stringify(sign(parseArgs(process.argv.slice(2)))));
} catch (error) {
  console.error('Error: ' + error.message);
  process.exitCode = 2;
}
PHP
php
#!/usr/bin/env php
<?php
// Calculate API or JSON-webhook signatures offline. PHP 8.0+, no packages.

function parseArgs(array $argv): array {
    $args = ['get_data' => '{}', 'post_data' => '{}', 'webhook' => '0'];
    $seen = [];
    for ($i = 1; $i < count($argv); $i++) {
        if (!preg_match('/^--(secret|path|get_data|post_data|get|post|webhook)(?:=(.*))?$/s', $argv[$i], $match)) {
            throw new InvalidArgumentException('Unknown argument.');
        }
        $name = in_array($match[1], ['get', 'post'], true) ? $match[1] . '_data' : $match[1];
        if (isset($seen[$name])) throw new InvalidArgumentException('Repeated argument.');
        $value = array_key_exists(2, $match) ? $match[2] : ($argv[++$i] ?? null);
        if ($value === null) throw new InvalidArgumentException('Missing argument value.');
        $args[$name] = $value;
        $seen[$name] = true;
    }
    if (!isset($args['secret']) || $args['secret'] === '') {
        throw new InvalidArgumentException('--secret must not be empty.');
    }
    if (!in_array($args['webhook'], ['0', '1'], true)) {
        throw new InvalidArgumentException('--webhook must be 0 or 1.');
    }
    if (!isset($args['path']) || !(str_starts_with($args['path'], '/')
        || ($args['webhook'] === '1' && $args['path'] === ''))
        || preg_match('/[?#]|:\/\//', $args['path'])) {
        throw new InvalidArgumentException('--path must be a request path without a URL, query or fragment.');
    }
    return $args;
}

function numberError(bool $webhook, bool $fractional = false): InvalidArgumentException {
    if ($webhook) {
        return new InvalidArgumentException('Unsupported webhook number. Do not change received values; use a verifier supporting the original numeric representation.');
    }
    return new InvalidArgumentException($fractional
        ? 'Use JSON strings for fractional numbers: "10.50".'
        : 'Use JSON strings for integers outside the safe range.');
}

function validateNumbers($value, bool $webhook): void {
    if (is_float($value)) {
        throw numberError($webhook, true);
    }
    if (is_int($value) && abs($value) > 9007199254740991) {
        throw numberError($webhook);
    }
    if (is_object($value) || is_array($value)) {
        foreach ($value as $item) validateNumbers($item, $webhook);
    }
}

function parseData(string $source, bool $webhook): stdClass {
    $data = json_decode($source, false, 512, JSON_THROW_ON_ERROR);
    if (!$data instanceof stdClass) {
        throw new InvalidArgumentException('--get_data and --post_data must be JSON objects.');
    }
    validateNumbers($data, $webhook);
    return $data;
}

function compactSortedJson($value): string {
    if (is_array($value)) {
        return '[' . implode(',', array_map('compactSortedJson', $value)) . ']';
    }
    if ($value instanceof stdClass) {
        $fields = get_object_vars($value);
        ksort($fields, SORT_STRING);
        $parts = [];
        foreach ($fields as $key => $item) {
            $parts[] = compactSortedJson((string)$key) . ':' . compactSortedJson($item);
        }
        return '{' . implode(',', $parts) . '}';
    }
    return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
        | JSON_UNESCAPED_LINE_TERMINATORS | JSON_THROW_ON_ERROR);
}

function signedValue($value): string {
    if ($value instanceof stdClass) return compactSortedJson($value);
    if (is_array($value)) {
        throw new InvalidArgumentException('Top-level array fields are not supported by this helper.');
    }
    if ($value === null) return 'None';
    if (is_bool($value)) return $value ? 'True' : 'False';
    return (string)$value;
}

function asciiJson($value): string {
    return str_replace("\x7f", '\u007f', json_encode($value,
        JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
}

function sortedAsciiJson($value): string {
    if (is_array($value)) return '[' . implode(',', array_map('sortedAsciiJson', $value)) . ']';
    if ($value instanceof stdClass) {
        $fields = get_object_vars($value);
        ksort($fields, SORT_STRING);
        $parts = [];
        foreach ($fields as $key => $item) {
            $parts[] = asciiJson((string)$key) . ':' . sortedAsciiJson($item);
        }
        return '{' . implode(',', $parts) . '}';
    }
    return asciiJson($value);
}

function b64url(string $value): string {
    return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}

function signRequest(array $args): array {
    $webhook = $args['webhook'] === '1';
    $parts = ['PATH' => asciiJson($args['path'])];
    foreach (['GET', 'POST'] as $method) {
        $fields = get_object_vars(parseData($args[strtolower($method) . '_data'], $webhook));
        if (count($fields)) {
            ksort($fields, SORT_STRING);
            $values = [];
            foreach ($fields as $key => $value) {
                if ($key === 'signature' || $key === 'api_secret') continue;
                if ($webhook && $method === 'GET' && (is_object($value) || is_array($value))) {
                    throw new InvalidArgumentException('Webhook query parameters must have scalar values.');
                }
                $encoded = $webhook && $method === 'POST'
                    ? sortedAsciiJson($value) : asciiJson(signedValue($value));
                $values[] = asciiJson((string)$key) . ':' . $encoded;
            }
            $parts[$method] = '{' . implode(',', $values) . '}';
        }
    }
    if ($webhook) ksort($parts, SORT_STRING);
    $encodedParts = [];
    foreach ($parts as $key => $value) $encodedParts[] = '"' . $key . '":' . $value;
    $raw = b64url('{"alg":"HS256"}') . '.' . b64url('{' . implode(',', $encodedParts) . '}');
    return ['raw_string' => $raw, 'signature' => b64url(hash_hmac('sha256', $raw, $args['secret'], true))];
}

try {
    echo json_encode(signRequest(parseArgs($argv)), JSON_THROW_ON_ERROR) . PHP_EOL;
} catch (Throwable $error) {
    fwrite(STDERR, 'Error: ' . $error->getMessage() . PHP_EOL);
    exit(2);
}