Перейти к содержимому
API-документация

Аутентификация и подпись

Ключи, 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-SignatureHMAC-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-6a0f2c3b7e514e35158c47e429f8a0f7913b3588ec70207ecba9107703020e91fdc58f26c306
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.

pp.mjs
// 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;
}
pp.py
# 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
pp.php
<?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;
}
pp.sh
# 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}/secret10 в минуту
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.