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-Timestamp | unix-время в секундах (допуск ±300 c) |
X-Nonce | случайная одноразовая строка (защита от повтора) |
X-Signature | base64( 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"}'
Создать платёж
Запрос
{
"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_id | string | да | ID клиента в вашей системе |
client.email | string | нет | email клиента (нужен провайдеру для лимитов) |
amount | number | нет | сумма; если не задать — клиент введёт на странице |
currency | string | нет | по умолчанию RUB |
bank | int 1–6 | нет | если не задать — клиент выберет |
redirect_url | string | нет | куда вернуть клиента после оплаты (UX) |
idempotency_key | string | нет | защита от дублей |
metadata | object | нет | произвольные данные |
Ответ 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 без тела, подписан по тем же правилам (для пустого тела используется 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" }Список платежей
| Параметр | Описание |
|---|---|
status | фильтр по статусу |
client_external_id | фильтр по клиенту |
from / to | период по created_at (ISO) |
limit / offset | пагинация (limit до 200) |
{ "items": [ { "...PaymentOut..." } ], "total": 137, "limit": 50, "offset": 0 }Статистика
{
"by_status": {
"paid": { "count": 120, "amount": 240500.0 },
"pending": { "count": 5, "amount": 0.0 }
},
"total_count": 137,
"paid_amount": 240500.0
}Отмена платежа
Отменяет платёж и освобождает реквизиты у провайдера (если были выпущены). После отмены привязать оплату нельзя. Для уже оплаченного — возвращает платёж без изменений.
Ответ 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-Timestamp | unix-время отправки |
X-Paysafe-Signature | base64( Ed25519_sign("{ts}.{sha256_hex(body)}") ) нашим ключом |
Наш публичный ключ для проверки: GET /pub/webhook-public-key. Отвечайте 2xx, иначе доставка повторится.
Redirect после оплаты
Если задан redirect_url, после оплаты мы вернём клиента туда с ?payment_id=...&status=paid.
GET /v1/payments/{id} или вебхук.Ошибки
Все ошибки возвращаются с соответствующим HTTP-кодом и телом { "detail": ... } (кроме 422, где detail — массив).
| Код | Когда |
|---|---|
| 400 | ошибка от провайдера (напр. сумма вне диапазона) |
| 401 | проблема с подписью/аутентификацией |
| 404 | платёж не найден |
| 409 | платёж в финальном статусе / больше не активен |
| 422 | ошибка валидации тела |
| 429 | превышен лимит запросов или активных платежей |
400 — ошибка провайдера
{ "detail": "Сумма вне допустимого диапазона сейчас — попробуйте другую сумму." }401 — аутентификация
Возможные значения detail:
| detail | Причина |
|---|---|
bad timestamp | X-Timestamp не число |
timestamp expired | метка времени вне окна ±300 c |
unknown or inactive partner | партнёр не найден или не активен |
bad signature encoding | X-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
- Зарегистрируйтесь в Telegram-боте, дождитесь одобрения, добавьте публичный ключ.
- Создайте платёж (
POST /v1/payments), откройтеpay_link, выберите банк. - На странице в sandbox есть кнопка «Симулировать оплату» — платёж перейдёт в
paidбез реальных денег. - Проверьте
GET /v1/payments/{id}иGET /v1/stats.