Paysafe API

Paysafe Gateway — API

Платёжный шлюз: партнёр создаёт платёж клиенту, мы выдаём ссылку на оплату, клиент платит, мы возвращаем статус.

Введение

КонтурБазовый URL
Sandbox (тесты)https://sandbox.lycode.one
Productionвыдаётся при подключении

Все запросы — поверх HTTPS, тела — JSON (Content-Type: application/json). Идентификатор партнёра и ключи выдаются после регистрации в Telegram-боте и одобрения модератором.

Как это работает

1. Партнёр  → POST /v1/payments        → pay_link + payment_id
2. Партнёр  → отправляет клиента на pay_link (наша страница оплаты)
3. Клиент   → выбирает банк/сумму, платит в приложении банка
4. Мы       → подтверждаем оплату у провайдера (сверка суммы)
5. Партнёр  → GET /v1/payments/{id}     → status: paid (или webhook)

amount и bank можно не передавать — тогда клиент выберет их сам на странице. Если передать — поля фиксируются.

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

При подключении вы генерируете пару ключей Ed25519 и передаёте нам публичный ключ (base64) через бота. Приватный храните у себя. Каждый запрос к /v1/* подписывается приватным ключом.

Заголовки запроса

ЗаголовокЗначение
X-Partner-Idваш UUID партнёра
X-Timestampunix-время в секундах (допуск ±300 c)
X-Nonceслучайная одноразовая строка (защита от повтора)
X-Signaturebase64( Ed25519_sign(message) )

Сообщение для подписи

message = "{timestamp}.{METHOD}.{path}.{sha256_hex(body)}"
  • METHOD — в верхнем регистре (POST, GET).
  • path — путь без query-строки (например /v1/payments).
  • body — точные байты тела. Для GET тело пустое → sha256_hex("").

Пример подписи

import base64, hashlib, json, time, uuid, httpx
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

PARTNER_ID = "0f90e5b5-171a-4df4-a793-7fc12b63a447"
priv = Ed25519PrivateKey.from_private_bytes(base64.b64decode("PRIVATE_KEY_b64"))
BASE = "https://sandbox.lycode.one"

def signed(method, path, payload=None):
    body = json.dumps(payload).encode() if payload is not None else b""
    ts = str(int(time.time()))
    body_hash = hashlib.sha256(body).hexdigest()
    msg = f"{ts}.{method.upper()}.{path}.{body_hash}".encode()
    headers = {
        "X-Partner-Id": PARTNER_ID,
        "X-Timestamp": ts,
        "X-Nonce": uuid.uuid4().hex,
        "X-Signature": base64.b64encode(priv.sign(msg)).decode(),
        "Content-Type": "application/json",
    }
    return httpx.request(method, BASE + path, headers=headers, content=body)

r = signed("POST", "/v1/payments", {
    "client": {"external_id": "user-42", "email": "user@example.com"},
    "amount": 100, "currency": "RUB",
})
print(r.json())
const crypto = require("crypto");

const PARTNER_ID = "0f90e5b5-171a-4df4-a793-7fc12b63a447";
const PRIV_B64 = "PRIVATE_KEY_b64";        // 32-байтный seed в base64
const BASE = "https://sandbox.lycode.one";

// Ed25519 ключ из 32-байтного seed (PKCS8)
const seed = Buffer.from(PRIV_B64, "base64");
const privKey = crypto.createPrivateKey({
  key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), seed]),
  format: "der", type: "pkcs8",
});

async function signed(method, path, payload) {
  const body = payload !== undefined ? JSON.stringify(payload) : "";
  const ts = Math.floor(Date.now() / 1000).toString();
  const bodyHash = crypto.createHash("sha256").update(body).digest("hex");
  const msg = `${ts}.${method.toUpperCase()}.${path}.${bodyHash}`;
  const sig = crypto.sign(null, Buffer.from(msg), privKey).toString("base64");
  const res = await fetch(BASE + path, {
    method,
    headers: {
      "X-Partner-Id": PARTNER_ID,
      "X-Timestamp": ts,
      "X-Nonce": crypto.randomUUID().replace(/-/g, ""),
      "X-Signature": sig,
      "Content-Type": "application/json",
    },
    body: body || undefined,
  });
  return res.json();
}

signed("POST", "/v1/payments", {
  client: { external_id: "user-42", email: "user@example.com" },
  amount: 100, currency: "RUB",
}).then(console.log);
<?php
$PARTNER_ID = "0f90e5b5-171a-4df4-a793-7fc12b63a447";
$PRIV = base64_decode("PRIVATE_KEY_b64");   // 32-байтный seed
$BASE = "https://sandbox.lycode.one";

$keypair = sodium_crypto_sign_seed_keypair($PRIV);
$secret  = sodium_crypto_sign_secretkey($keypair);

function signed($method, $path, $payload = null) {
    global $PARTNER_ID, $secret, $BASE;
    $body = $payload !== null ? json_encode($payload) : "";
    $ts   = (string) time();
    $hash = hash("sha256", $body);
    $msg  = "$ts." . strtoupper($method) . ".$path.$hash";
    $sig  = base64_encode(sodium_crypto_sign_detached($msg, $secret));

    $ch = curl_init($BASE . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_POSTFIELDS    => $body,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            "X-Partner-Id: $PARTNER_ID",
            "X-Timestamp: $ts",
            "X-Nonce: " . bin2hex(random_bytes(16)),
            "X-Signature: $sig",
            "Content-Type: application/json",
        ],
    ]);
    return curl_exec($ch);
}

echo signed("POST", "/v1/payments", [
    "client" => ["external_id" => "user-42", "email" => "user@example.com"],
    "amount" => 100, "currency" => "RUB",
]);
package main

import (
    "bytes"
    "crypto/ed25519"
    "crypto/sha256"
    "encoding/base64"
    "encoding/hex"
    "fmt"
    "io"
    "net/http"
    "time"

    "github.com/google/uuid"
)

const (
    partnerID = "0f90e5b5-171a-4df4-a793-7fc12b63a447"
    privB64   = "PRIVATE_KEY_b64" // 32-байтный seed
    base      = "https://sandbox.lycode.one"
)

func signed(method, path string, body []byte) (string, error) {
    seed, _ := base64.StdEncoding.DecodeString(privB64)
    priv := ed25519.NewKeyFromSeed(seed)
    ts := fmt.Sprintf("%d", time.Now().Unix())
    h := sha256.Sum256(body)
    msg := ts + "." + method + "." + path + "." + hex.EncodeToString(h[:])
    sig := base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(msg)))

    req, _ := http.NewRequest(method, base+path, bytes.NewReader(body))
    req.Header.Set("X-Partner-Id", partnerID)
    req.Header.Set("X-Timestamp", ts)
    req.Header.Set("X-Nonce", uuid.NewString())
    req.Header.Set("X-Signature", sig)
    req.Header.Set("Content-Type", "application/json")

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()
    b, _ := io.ReadAll(resp.Body)
    return string(b), nil
}

func main() {
    body := []byte(`{"client":{"external_id":"user-42"},"amount":100,"currency":"RUB"}`)
    out, _ := signed("POST", "/v1/payments", body)
    fmt.Println(out)
}
# Подпись (X-Signature) считается по формуле и подставляется заранее
# (см. примеры на Python/Node/PHP/Go выше).

curl -X POST https://sandbox.lycode.one/v1/payments \
  -H "X-Partner-Id: 0f90e5b5-171a-4df4-a793-7fc12b63a447" \
  -H "X-Timestamp: 1782000000" \
  -H "X-Nonce: 9f1c4a2b6d7e4f10b2c3d4e5f6a7b8c9" \
  -H "X-Signature: BASE64_ED25519_SIGNATURE" \
  -H "Content-Type: application/json" \
  -d '{"client":{"external_id":"user-42","email":"user@example.com"},"amount":100,"currency":"RUB"}'
Подпись покрывает тело запроса (его sha256). Метка времени и nonce защищают от повтора: окно ±300 секунд, повторный nonce отклоняется.

Создать платёж

POST /v1/payments

Запрос

{
  "client": {
    "external_id": "user-42",
    "email": "user@example.com",
    "phone": "+998901234567"
  },
  "amount": 100,
  "currency": "RUB",
  "bank": 4,
  "redirect_url": "https://shop.example.com/return",
  "idempotency_key": "order-12345",
  "metadata": { "order_id": "12345" }
}
ПолеТипОбяз.Описание
client.external_idstringдаID клиента в вашей системе
client.emailstringнетemail клиента (нужен провайдеру для лимитов)
amountnumberнетсумма; если не задать — клиент введёт на странице
currencystringнетпо умолчанию RUB
bankint 1–6нетесли не задать — клиент выберет
redirect_urlstringнеткуда вернуть клиента после оплаты (UX)
idempotency_keystringнетзащита от дублей
metadataobjectнетпроизвольные данные

Ответ 200

{
  "id": "7ecc0b50-a98e-4975-8f71-4ee88547e053",
  "status": "created",
  "amount": 100,
  "currency": "RUB",
  "bank": 4,
  "requisite": null,
  "deeplink": null,
  "pay_link": "https://sandbox.lycode.one/p/7ecc0b50-a98e-4975-8f71-4ee88547e053",
  "redirect_url": "https://shop.example.com/return",
  "code": null,
  "tx_id": null,
  "created_at": "2026-06-22T11:06:33.610500",
  "expires_at": null,
  "paid_at": null
}

Отправьте клиента на pay_link. Реквизиты и deeplink появляются после выбора банка на странице.

Примеры ошибок

401 — подпись не прошла проверку:

{ "detail": "signature verification failed" }

422 — отсутствует обязательное поле:

{
  "detail": [
    { "type": "missing", "loc": ["body", "client", "external_id"], "msg": "Field required" }
  ]
}

429 — превышен лимит активных платежей:

{ "detail": "too many active payments for partner" }

Статус платежа

GET /v1/payments/{id}

Запрос

GET без тела, подписан по тем же правилам (для пустого тела используется sha256_hex("")):

curl https://sandbox.lycode.one/v1/payments/7ecc0b50-a98e-4975-8f71-4ee88547e053 \
  -H "X-Partner-Id: 0f90e5b5-171a-4df4-a793-7fc12b63a447" \
  -H "X-Timestamp: 1782000000" \
  -H "X-Nonce: 9f1c4a2b6d7e4f10b2c3d4e5f6a7b8c9" \
  -H "X-Signature: BASE64_ED25519_SIGNATURE"

Ответ 200 (оплачен)

{
  "id": "7ecc0b50-a98e-4975-8f71-4ee88547e053",
  "status": "paid",
  "amount": 100,
  "currency": "RUB",
  "bank": 4,
  "requisite": {
    "owner": "IVAN PETROV",
    "card_number": "9860xxxxxxxx0001",
    "phone_number": "998xxxxxxxxx",
    "bank": "octobank",
    "country": "UZB"
  },
  "code": "F6XG9KUACSNHM",
  "tx_id": "1781077665795422401",
  "created_at": "2026-06-22T11:06:33.610500",
  "paid_at": "2026-06-22T11:10:02.000000"
}

Ошибка 404 — платёж не найден или не ваш

{ "detail": "payment not found" }

Список платежей

GET /v1/payments?status=paid&from=...&to=...&limit=50&offset=0
ПараметрОписание
statusфильтр по статусу
client_external_idфильтр по клиенту
from / toпериод по created_at (ISO)
limit / offsetпагинация (limit до 200)
{ "items": [ { "...PaymentOut..." } ], "total": 137, "limit": 50, "offset": 0 }

Статистика

GET /v1/stats?from=...&to=...
{
  "by_status": {
    "paid":    { "count": 120, "amount": 240500.0 },
    "pending": { "count": 5,   "amount": 0.0 }
  },
  "total_count": 137,
  "paid_amount": 240500.0
}

Отмена платежа

POST /v1/payments/{id}/cancel

Отменяет платёж и освобождает реквизиты у провайдера (если были выпущены). После отмены привязать оплату нельзя. Для уже оплаченного — возвращает платёж без изменений.

Ответ 200

{
  "id": "7ecc0b50-a98e-4975-8f71-4ee88547e053",
  "status": "cancelled",
  "amount": 100,
  "currency": "RUB",
  "paid_at": null
}

Ошибка 404

{ "detail": "payment not found" }

Идемпотентность

Передайте idempotency_key в POST /v1/payments. Повторный запрос с тем же ключом вернёт тот же платёж, а не создаст новый. Рекомендуется использовать ваш ID заказа.

Статусы и банки

СтатусЗначение
createdнамерение создано, клиент ещё не на странице
pendingреквизиты выданы, ждём оплату
paidоплата подтверждена (сумма сошлась)
manual_reviewоплата пришла, но сумма не совпала — разбираем вручную
expiredистёк срок
cancelledотменён
failedошибка

Банки (bank): 1 — VTB, 2 — Sberbank, 3 — Gazprombank, 4 — T-Bank, 5 — Solidarnost, 6 — Alfabank.

Достоверный признак оплаты — только paid из нашего API или вебхука.

Вебхуки

Если задан webhook_url, мы шлём POST при смене статуса (payment.paid, payment.manual_review, payment.expired, payment.cancelled) с ретраями.

Тело

{
  "event": "payment.paid",
  "payment_id": "7ecc0b50-a98e-4975-8f71-4ee88547e053",
  "status": "paid",
  "amount": 100,
  "currency": "RUB",
  "bank": 4,
  "paid_at": "2026-06-22T11:10:02.000000Z"
}

Заголовки

ЗаголовокЗначение
X-Paysafe-Eventтип события
X-Paysafe-Timestampunix-время отправки
X-Paysafe-Signaturebase64( Ed25519_sign("{ts}.{sha256_hex(body)}") ) нашим ключом

Наш публичный ключ для проверки: GET /pub/webhook-public-key. Отвечайте 2xx, иначе доставка повторится.

Redirect после оплаты

Если задан redirect_url, после оплаты мы вернём клиента туда с ?payment_id=...&status=paid.

Не доверяйте параметрам из URL как факту оплаты — их можно подделать, а при оплате диплинком клиент может не вернуться. Подтверждайте статус через GET /v1/payments/{id} или вебхук.

Ошибки

Все ошибки возвращаются с соответствующим HTTP-кодом и телом { "detail": ... } (кроме 422, где detail — массив).

КодКогда
400ошибка от провайдера (напр. сумма вне диапазона)
401проблема с подписью/аутентификацией
404платёж не найден
409платёж в финальном статусе / больше не активен
422ошибка валидации тела
429превышен лимит запросов или активных платежей

400 — ошибка провайдера

{ "detail": "Сумма вне допустимого диапазона сейчас — попробуйте другую сумму." }

401 — аутентификация

Возможные значения detail:

detailПричина
bad timestampX-Timestamp не число
timestamp expiredметка времени вне окна ±300 c
unknown or inactive partnerпартнёр не найден или не активен
bad signature encodingX-Signature не base64
signature verification failedподпись не сошлась с ключом
nonce already usedповтор nonce (replay)
{ "detail": "signature verification failed" }

404 — не найдено

{ "detail": "payment not found" }

409 — платёж в финальном статусе

{ "detail": "платёж больше не активен" }

422 — невалидное тело

{
  "detail": [
    {
      "type": "missing",
      "loc": ["body", "client", "external_id"],
      "msg": "Field required"
    }
  ]
}

429 — лимиты

detailПричина
too many requestsпревышен rate-limit API
too many active payments for partnerслишком много активных платежей у партнёра
слишком много одновременных оплат, завершите предыдущуюлимит активных оплат на клиента
{ "detail": "too many requests" }

Лимиты

  • Партнёрский API: ~120 запросов в минуту на партнёра.
  • Одновременных неоплаченных платежей на клиента — ограничено (по умолчанию 5).
  • При превышении — 429.

Тестирование в Sandbox

  1. Зарегистрируйтесь в Telegram-боте, дождитесь одобрения, добавьте публичный ключ.
  2. Создайте платёж (POST /v1/payments), откройте pay_link, выберите банк.
  3. На странице в sandbox есть кнопка «Симулировать оплату» — платёж перейдёт в paid без реальных денег.
  4. Проверьте GET /v1/payments/{id} и GET /v1/stats.