Аутентификация и подпись
Ключи, scope, подпись HMAC-SHA256, защита от повтора и лимиты запросов.
Ключи
Ключ состоит из двух частей:
| Часть | Пример | Где живёт |
|---|---|---|
| Идентификатор | pp_live_3c9a71f0 | Заголовок X-Key-Id каждого запроса. Не секрет. |
| Секрет | 43 символа base64url | Только на вашем сервере. Им подписываются запросы, по сети он не передаётся. |
Ключи выдаёт ваш менеджер. Секрет показывают один раз — сохраните его сразу. Префикс говорит о режиме: pp_live_ — боевой аккаунт, pp_test_ — тестовый (карты не настоящие, баланс виртуальный).
Ротация. У аккаунта может быть два активных ключа одновременно. Чтобы сменить ключ без простоя: получите новый, переключите сервис, попросите отозвать старый.
Scope
| Scope | Даёт доступ |
|---|---|
| — (любой ключ) | GET /balance, GET /tariffs |
cards:read | Чтение карт, держателей, операций, выписки, кодов подтверждения, событий и вебхуков; POST /quote; подтверждение событий |
cards:write | Регистрация держателей, выпуск, пополнение, закрытие, управление подписками на вебхуки |
cards:secret | Полные реквизиты карты — только вместе с открытым аккаунту доступом, см. Реквизиты карты и PCI |
Запрос с ключом без нужного scope получает 403 с кодом forbidden.
Подпись запроса
Каждый запрос несёт три заголовка:
| Заголовок | Значение |
|---|---|
X-Key-Id | Идентификатор ключа |
X-Timestamp | Текущее время Unix в секундах |
X-Signature | HMAC-SHA256 строки подписи на секрете ключа, hex в нижнем регистре |
Строка подписи
Части соединяются символом перевода строки \n в таком порядке:
<X-Timestamp>
<МЕТОД>
<путь с query-строкой>
<сырое тело запроса>
<Idempotency-Key> ← только если заголовок отправляется- Метод — в верхнем регистре:
GET,POST,PATCH,DELETE. - Путь — всё после хоста, включая query-строку:
/api/partner/v1/events?limit=10. Ровно в том виде, в каком уходит в запрос. - Тело — те же байты, что вы отправляете. Сериализуйте JSON один раз и подпишите и отправьте одну и ту же строку. У запроса без тела — пустая строка (строка подписи тогда заканчивается на
\n). - Idempotency-Key — если вы отправляете этот заголовок, его значение — пятая часть строки.
Подписывайте Idempotency-Key, если отправляете его
Сервер принимает и четырёхчастную строку, но у неё есть ловушка. Время — в секундах, поэтому два разных пополнения на одну сумму в одну секунду дадут одинаковую четырёхчастную подпись, и второе будет отклонено как повтор (partner_replay). С ключом идемпотентности в строке подписи разные операции всегда подписаны по-разному.
Проверочные значения
Сверьте свою реализацию. Секрет test-secret, время 1750000000:
| Запрос | Подпись |
|---|---|
GET /api/partner/v1/balance, без тела | 1f6d8e1db570e2c69551209fe5538d69ef5a36cd28ea69727db9f512f9f31c3f |
POST /api/partner/v1/cards, тело {"tariff_id":"abc","amount":"50.00"} | 603ec3bd882776562e597994ba5b0e2e2b7e1c3599d3a5242443194a23360c54 |
То же + Idempotency-Key: 1f0b5f4e-2a9c-4a1a-9d6e-6a0f2c3b7e51 | 4e35158c47e429f8a0f7913b3588ec70207ecba9107703020e91fdc58f26c306 |
printf '%s\n%s\n%s\n%s\n%s' 1750000000 POST /api/partner/v1/cards \
'{"tariff_id":"abc","amount":"50.00"}' 1f0b5f4e-2a9c-4a1a-9d6e-6a0f2c3b7e51 \
| openssl dgst -sha256 -hmac test-secret -hex | awk '{print $NF}'
# 4e35158c47e429f8a0f7913b3588ec70207ecba9107703020e91fdc58f26c306Готовый клиент
Функция pp() подписывает запрос, отправляет его и превращает ошибку в исключение с кодом. Все примеры в документации используют её. Путь передаётся относительно /api/partner/v1.
// Node.js 18+: встроенный fetch.
import { createHmac } from "node:crypto";
const BASE = "https://api.plativputi.com";
const PREFIX = "/api/partner/v1";
const KEY_ID = process.env.PP_KEY_ID;
const SECRET = process.env.PP_SECRET;
export class PPError extends Error {
constructor(status, code, body) {
super(`${status} ${code}`);
Object.assign(this, { status, code, body });
}
}
export async function pp(method, path, body, { idempotencyKey } = {}) {
const fullPath = PREFIX + path;
const raw = body === undefined ? "" : JSON.stringify(body);
const ts = Math.floor(Date.now() / 1000).toString();
const parts = [ts, method, fullPath, raw];
if (idempotencyKey) parts.push(idempotencyKey);
const signature = createHmac("sha256", SECRET).update(parts.join("\n")).digest("hex");
const headers = { "X-Key-Id": KEY_ID, "X-Timestamp": ts, "X-Signature": signature };
if (raw) headers["Content-Type"] = "application/json";
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
const res = await fetch(BASE + fullPath, { method, headers, body: raw || undefined });
const data = await res.json().catch(() => null);
if (!res.ok) {
// Два формата ошибок — см. раздел «Ошибки».
const code = data?.error ?? data?.errors?.[0]?.value ?? `http_${res.status}`;
throw new PPError(res.status, code, data);
}
return data;
}# Python 3.9+, пакет requests.
import hashlib
import hmac
import json
import os
import time
import requests
BASE = "https://api.plativputi.com"
PREFIX = "/api/partner/v1"
KEY_ID = os.environ["PP_KEY_ID"]
SECRET = os.environ["PP_SECRET"].encode()
class PPError(Exception):
def __init__(self, status, code, body):
super().__init__(f"{status} {code}")
self.status, self.code, self.body = status, code, body
def pp(method, path, body=None, idempotency_key=None):
full_path = PREFIX + path
raw = "" if body is None else json.dumps(body, separators=(",", ":"), ensure_ascii=False)
ts = str(int(time.time()))
parts = [ts, method, full_path, raw]
if idempotency_key:
parts.append(idempotency_key)
signature = hmac.new(SECRET, "\n".join(parts).encode(), hashlib.sha256).hexdigest()
headers = {"X-Key-Id": KEY_ID, "X-Timestamp": ts, "X-Signature": signature}
if raw:
headers["Content-Type"] = "application/json"
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
r = requests.request(method, BASE + full_path, headers=headers,
data=raw.encode() if raw else None, timeout=30)
data = r.json() if r.content else None
if not r.ok:
# Два формата ошибок — см. раздел «Ошибки».
errors = (data or {}).get("errors") or [{}]
code = (data or {}).get("error") or errors[0].get("value") or f"http_{r.status_code}"
raise PPError(r.status_code, code, data)
return data<?php
// PHP 8.0+, расширение curl.
const PP_BASE = 'https://api.plativputi.com';
const PP_PREFIX = '/api/partner/v1';
function uuid4(): string
{
$b = random_bytes(16);
$b[6] = chr(ord($b[6]) & 0x0f | 0x40);
$b[8] = chr(ord($b[8]) & 0x3f | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4));
}
function pp(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): array
{
$fullPath = PP_PREFIX . $path;
$raw = $body === null ? '' : json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$ts = (string) time();
$parts = [$ts, $method, $fullPath, $raw];
if ($idempotencyKey !== null) {
$parts[] = $idempotencyKey;
}
$signature = hash_hmac('sha256', implode("\n", $parts), getenv('PP_SECRET'));
$headers = ['X-Key-Id: ' . getenv('PP_KEY_ID'), "X-Timestamp: $ts", "X-Signature: $signature"];
if ($raw !== '') {
$headers[] = 'Content-Type: application/json';
}
if ($idempotencyKey !== null) {
$headers[] = "Idempotency-Key: $idempotencyKey";
}
$ch = curl_init(PP_BASE . $fullPath);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
if ($raw !== '') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $raw);
}
$resp = curl_exec($ch);
if ($resp === false) {
throw new RuntimeException(curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data = json_decode($resp, true) ?? [];
if ($status >= 400) {
// Два формата ошибок — см. раздел «Ошибки».
$code = $data['error'] ?? ($data['errors'][0]['value'] ?? "http_$status");
throw new RuntimeException("$status $code", $status);
}
return $data;
}# bash и zsh. Использование: pp METHOD PATH [BODY] [IDEMPOTENCY_KEY]
# pp GET /balance
# pp POST /cards '{"tariff_id":"…","holder_external_id":"cust-42","amount":"50"}' "$(uuidgen)"
pp() {
local method=$1 req_path="/api/partner/v1$2" body=${3:-} idem=${4:-}
local ts sig
ts=$(date +%s)
if [ -n "$idem" ]; then
sig=$(printf '%s\n%s\n%s\n%s\n%s' "$ts" "$method" "$req_path" "$body" "$idem" \
| openssl dgst -sha256 -hmac "$PP_SECRET" -hex | awk '{print $NF}')
else
sig=$(printf '%s\n%s\n%s\n%s' "$ts" "$method" "$req_path" "$body" \
| openssl dgst -sha256 -hmac "$PP_SECRET" -hex | awk '{print $NF}')
fi
local -a extra=()
[ -n "$idem" ] && extra+=(-H "Idempotency-Key: $idem")
[ -n "$body" ] && extra+=(-H "Content-Type: application/json" --data-binary "$body")
curl -sS -X "$method" "https://api.plativputi.com$req_path" \
-H "X-Key-Id: $PP_KEY_ID" -H "X-Timestamp: $ts" -H "X-Signature: $sig" "${extra[@]}"
}awk '{print $NF}' берёт последнее поле вывода openssl: старые версии печатают SHA2-256(stdin)= <hex>, новые — только hex.
Проверка клиента
Первый вызов после подключения клиента — баланс: он не меняет данных и сразу показывает, верны ли ключ и подпись.
import { pp } from "./pp.mjs";
console.log(await pp("GET", "/balance"));from pp import pp
print(pp("GET", "/balance"))require __DIR__ . '/pp.php';
print_r(pp('GET', '/balance'));source pp.sh
pp GET /balance{ "currency": "USD", "balance": "1250.5", "hold": "0", "available": "1250.5" }Ответ 401 — см. частые ошибки ниже.
Защита от повтора
- Окно времени.
X-Timestampдолжен отличаться от наших часов не больше чем на 5 минут. Держите на сервере NTP. - Подпись одноразовая. Повтор того же запроса с той же подписью отклоняется с
401 partner_replay. Чтобы повторить запрос (например, после таймаута), подпишите его заново — с новым временем. Клиенты выше делают это на каждом вызове.
Защита от повтора не заменяет идемпотентность: для денежных запросов повторяйте с тем же Idempotency-Key, см. Идемпотентность.
Лимиты запросов
Лимиты считаются на ключ, окно — минута.
| Что | По умолчанию |
|---|---|
| Все запросы | 120 в минуту |
GET /cards/{id}/secret | 10 в минуту |
GET /cards/{id}/otp | Отдельный лимит, рассчитанный на опрос раз в несколько секунд |
Превышение — 429 с кодом rate_limited. Если вашей нагрузке нужен больший лимит, напишите менеджеру.
Частые ошибки
| Симптом | Причина |
|---|---|
401, сообщение про ±5 minute window | Часы сервера ушли. Включите NTP. |
401 unauthorized: signature mismatch | Строка подписи не совпала. Чаще всего: подписан путь без /api/partner/v1 или без query-строки; подписано одно тело, а отправлено другое (переформатированный JSON); метод в нижнем регистре; секрет с лишним переводом строки из файла. |
401 partner_replay | Повтор с той же подписью. Подпишите запрос заново. Если это два разных запроса в одну секунду — добавьте Idempotency-Key в строку подписи. |
401 unauthorized без подробностей | Неизвестный или отозванный ключ. |
403 partner_suspended | Аккаунт приостановлен — ключ ни при чём, свяжитесь с менеджером. |
403 forbidden | У ключа нет нужного scope. |