# Платёжный API / Payment API — полная документация / full documentation --- Source: / Language: ru

Платёжный API · v1

# Принимайте оплату по СБП и по карте через один API Вы создаёте заявку — API выдаёт реквизиты для перевода, следит за оплатой и присылает подписанный callback, когда деньги пришли. [Быстрый старт за 15 минут](quickstart.md){ .md-button .md-button--primary } [Подпись запросов](signing.md){ .md-button }
## Интеграция за пять шагов { #five-steps }
  1. Проект и токенСоздайте проект в кабинете и выпустите токен API.
  2. Ключ подписиСгенерируйте ключ Ed25519 и загрузите публичную часть в кабинет.
  3. CallbackУкажите адрес для уведомлений и выпустите секрет подписи.
  4. ЗаявкаСоздайте подписанный POST /api/v1/payments и покажите плательщику реквизиты.
  5. РезультатПримите callback, проверьте подпись и отметьте заказ оплаченным.
1. **Ваш сервер → API.** `POST /api/v1/payments`, подпись Ed25519 2. **API ⇢ Ваш сервер.** `201`: `status=processing`, реквизиты, получатель и банк 3. **Ваш сервер → Плательщик.** Показываете реквизиты, сумму и срок оплаты 4. **Плательщик.** Переводит по СБП или на карту в приложении своего банка 5. **API → Ваш сервер.** Callback `status=completed`, подпись HMAC-SHA256 6. **Ваш сервер ⇢ API.** Отвечаете `2xx`
## Что читать дальше { #next }
- :material-lightning-bolt:{ .lg } **[Приём по СБП](sbp.md)** --- Заявка, реквизиты для плательщика, статусы, опрос и крайние случаи. - :material-credit-card-outline:{ .lg } **[Приём по карте](card.md)** --- Перевод на карту: чем отличается от СБП и что показать плательщику. - :material-signature-freehand:{ .lg } **[Подпись запросов](signing.md)** --- RFC 9421 и Ed25519: готовые функции и проверочный пример. - :material-bell-ring-outline:{ .lg } **[Callback-и](callbacks.md)** --- Уведомления о результате и проверка HMAC-подписи. - :material-alert-circle-outline:{ .lg } **[Статусы и ошибки](statuses.md)** --- Все статусы, коды ошибок и когда повторять запрос. - :material-robot-outline:{ .lg } **[Для ИИ-агентов](ai-agents.md)** --- llms.txt, Markdown-версии страниц и MCP-сервер документации.
## Окружения { #environments } Адреса в документации записаны обозначениями — вместо них подставьте свои значения: - `{{base_url}}` — адрес API, который вам выдают при подключении. У песочницы и боевого окружения он свой. В примерах запросов `{{base_url}}` стоит перед путём: `POST {{base_url}}/api/v1/payments`. - `{{cabinet_url}}` — адрес кабинета мерчанта. Его сообщают вместе с доступом. | Окружение | Адрес API | Кабинет | Префикс токена | | --- | --- | --- | --- | | Песочница | `{{base_url}}` песочницы | `{{cabinet_url}}` песочницы | `nl_test_` | | Боевое | `{{base_url}}` боевого окружения | `{{cabinet_url}}` боевого окружения | `nl_live_` | Держите `{{base_url}}` в настройке приложения, а не в коде: при переходе в бой поменяется только она. Песочница работает так же, как боевое окружение: те же подпись, лимиты и ошибки. Отличается только то, что деньги не двигаются. Подробнее — в разделе [Песочница](sandbox.md). ## Общие правила API { #conventions } - **Формат.** Запросы и ответы — JSON в UTF-8. Сервер разбирает тело строго: неизвестное поле или лишний текст после объекта — `400 bad_request`. - **Доступ.** В каждом запросе заголовок `Authorization: Bearer <токен проекта>`. - **Подпись.** Каждый изменяющий запрос подписан ключом Ed25519 по [RFC 9421](signing.md). Без подписи заявку создать нельзя — ни в песочнице, ни в бою. - **Суммы.** `amount` — в основных единицах валюты: `3400` или `"3400.50"` рублей. Суммы в USDT в расчёте `merchant_information` — десятичные строки: `"34.0000"`; курс `course` и ставка `rate` — числа с двумя знаками: `100.00`. - **Время.** Все даты — RFC 3339 в UTC, например `2026-09-28T12:00:00Z`. - **Ошибки.** Тело ошибки — `{"error":"<код>"}`. Список кодов — в разделе [Статусы и ошибки](statuses.md). - **Трассировка.** Заголовок `X-Request-ID` из запроса возвращается в ответе. Сохраняйте его в логах: по нему поддержка найдёт ваш запрос. ## Методы API { #endpoints } | Метод | Что делает | Подпись | | --- | --- | --- | | `POST /api/v1/payments` | Создаёт заявку на приём | всегда | | `GET /api/v1/payments/{id}` | Возвращает заявку по `id` | если включена в политике проекта | | `GET /api/v1/project` | Показывает проект и права токена | если включена в политике проекта | Полное описание полей — в [API reference](api-reference.md). --- Source: /quickstart/ Language: ru # Быстрый старт За 15 минут вы настроите проект в кабинете, создадите первую подписанную заявку по СБП в песочнице и подготовите приём callback-ов. Нужны доступ к кабинету и компьютер с OpenSSL 3 или одним из языков: PHP 8.1+, Python 3.10+, Node.js 20+, Go 1.22+. !!! tip "Всё, что нужно, — в пяти значениях" В конце у вас будут: адрес API `{{base_url}}`, токен проекта, файл `private.pem`, `keyid` ключа и секрет callback-ов `whsec_…`. Храните токен, ключ и секрет в хранилище секретов, не в коде и не в репозитории. ## 1. Войдите в кабинет { #cabinet } Откройте кабинет песочницы `{{cabinet_url}}`: адрес кабинета и адрес API `{{base_url}}` сообщают вместе с доступом (см. [Окружения](index.md#environments)). Логин выдаёт ваш менеджер, при входе нужен код двухфакторной аутентификации. Всё, что ниже, делается в разделе **«Проекты и API»** → ваш проект → **«Доступ к внешнему API»**. Проект — это ваш магазин или сайт: у него свои токен, ключи, callback и лимиты. ## 2. Выпустите токен проекта { #token } Нажмите «Выпустить ключ», отметьте права `payments:create` и `payments:read`. Кабинет покажет токен **один раз** — сохраните его сразу. В песочнице токен начинается с `nl_test_`, в бою — с `nl_live_`. Проверьте токен: запрос возвращает ваш проект и права. ```bash export BASE_URL={{base_url}} # адрес API песочницы, выданный вам export API_TOKEN=nl_test_... # токен из кабинета curl -sS "$BASE_URL/api/v1/project" \ -H "Authorization: Bearer $API_TOKEN" ``` ```json { "project_id": "5d7e2c1a-0b9f-4e3d-8c2b-1a0f9e8d7c6b", "project_name": "Main site", "merchant_id": "8d0f1e2a-3b4c-4d5e-8f60-718293a4b5c6", "merchant_name": "Мой магазин", "token_prefix": "nl_test_abcdef", "scopes": ["payments:create", "payments:read"] } ``` Если пришло `401 token_invalid` — токен скопирован не целиком, отозван или проект не активен. ## 3. Создайте ключ подписи { #signing-key } Каждый запрос на создание заявки подписывается приватным ключом Ed25519. Приватный ключ остаётся у вас, в кабинет загружается только публичный. Украденный токен без ключа не создаст ни одной заявки. Сгенерируйте пару ключей любым способом: === "OpenSSL" ```bash #!/bin/sh # Creates an Ed25519 signing key: private.pem (keep it secret) and public.pem (for the cabinet). # Requires OpenSSL 3.0 or newer. set -eu . "$(dirname "$0")/sign.sh" [ -e private.pem ] && { echo 'private.pem already exists' >&2; exit 1; } umask 077 openssl genpkey -algorithm ed25519 -out private.pem openssl pkey -in private.pem -pubout -out public.pem echo "keyid: $(api_key_id public.pem)" # the cabinet shows the same value ``` === "PHP" ```php "-----BEGIN $label-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END $label-----\n"; $privatePem = $pem('PRIVATE KEY', hex2bin('302e020100300506032b657004220420') . $seed); $publicPem = $pem('PUBLIC KEY', hex2bin('302a300506032b6570032100') . $public); if (file_exists('private.pem')) { fwrite(STDERR, "private.pem already exists\n"); exit(1); } $old = umask(0077); file_put_contents('private.pem', $privatePem); umask($old); file_put_contents('public.pem', $publicPem); echo 'keyid: ', api_key_id($public), PHP_EOL; // the cabinet shows the same value ``` === "Python" ```python """Creates an Ed25519 signing key: private.pem (keep it secret) and public.pem (for the cabinet).""" import os from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from sign import key_id key = Ed25519PrivateKey.generate() private_pem = key.private_bytes( serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() ) public = key.public_key() public_pem = public.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo) fd = os.open("private.pem", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "wb") as f: f.write(private_pem) with open("public.pem", "wb") as f: f.write(public_pem) raw = public.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) print("keyid:", key_id(raw)) # the cabinet shows the same value ``` === "Node.js" ```javascript // Creates an Ed25519 signing key: private.pem (keep it secret) and public.pem (for the cabinet). import { createPublicKey, generateKeyPairSync } from 'node:crypto'; import { writeFileSync } from 'node:fs'; import { keyId } from './sign.mjs'; const { publicKey, privateKey } = generateKeyPairSync('ed25519', { publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); writeFileSync('private.pem', privateKey, { mode: 0o600, flag: 'wx' }); writeFileSync('public.pem', publicKey); // The last 32 bytes of SPKI are the raw public key. const raw = createPublicKey(publicKey).export({ type: 'spki', format: 'der' }).subarray(-32); console.log('keyid:', keyId(raw)); // the cabinet shows the same value ``` === "Go" ```go // Creates an Ed25519 signing key: private.pem (keep it secret) and public.pem (for the cabinet). package main import ( "crypto/ed25519" "crypto/rand" "crypto/x509" "encoding/pem" "fmt" "log" "os" "example.com/paymentapi" ) func main() { public, private, err := ed25519.GenerateKey(rand.Reader) if err != nil { log.Fatal(err) } privateDER, err := x509.MarshalPKCS8PrivateKey(private) if err != nil { log.Fatal(err) } publicDER, err := x509.MarshalPKIXPublicKey(public) if err != nil { log.Fatal(err) } f, err := os.OpenFile("private.pem", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { log.Fatal(err) } if err = pem.Encode(f, &pem.Block{Type: "PRIVATE KEY", Bytes: privateDER}); err != nil { log.Fatal(err) } if err = f.Close(); err != nil { log.Fatal(err) } if err = os.WriteFile("public.pem", pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicDER}), 0o644); err != nil { log.Fatal(err) } fmt.Println("keyid:", paymentapi.KeyID(public)) // the cabinet shows the same value } ``` В кабинете нажмите «Добавить ключ подписи» и вставьте содержимое `public.pem` целиком, вместе со строками `-----BEGIN PUBLIC KEY-----`. Подойдёт и 32-байтовый ключ в base64. Кабинет покажет `keyid` вида `ed25519-If4x36FUomFia_hUBG_SJw` — он должен совпасть с тем, что напечатал генератор. ```bash export SIGNING_KEY_FILE=$PWD/private.pem export SIGNING_KEY_ID=ed25519-... # keyid из кабинета ``` ## 4. Ограничьте адреса (рекомендуем) { #ip-allowlist } Добавьте исходящие IP-адреса ваших серверов: отдельный адрес (`203.0.113.5`) или сеть (`203.0.113.0/24`). С первым правилом API пускает проект **только** с этих адресов, остальные получают `403 ip_not_allowed`. Если список пуст, адрес не проверяется. ## 5. Настройте callback { #callback } 1. Укажите адрес callback-ов проекта: публичный `https://…`, без редиректов. Адрес можно передать и в каждой заявке полем `callback_url`. 2. Нажмите «Выпустить секрет» в блоке «Callback». Секрет вида `whsec_…` показывается **один раз**. Пока своего сервера нет, запустите минимальный приёмник из раздела [Callback-и](callbacks.md#minimal-receiver) и откройте к нему доступ из интернета любым туннелем. ## 6. Создайте заявку по СБП { #first-payment } Скрипт подписывает запрос, создаёт заявку и печатает реквизиты для плательщика. === "curl" ```bash #!/bin/sh # Creates an SBP pay-in. # # export BASE_URL={{base_url}} # the API address issued to you: sandbox or production # export API_TOKEN=nl_test_... # project token # export SIGNING_KEY_FILE=private.pem # Ed25519 private key # export SIGNING_KEY_ID=ed25519-... # keyid from the cabinet # ./create_sbp_payment.sh order-1001 5000.00 set -eu . "$(dirname "$0")/sign.sh" : "${BASE_URL:?BASE_URL is not set: export the API address issued to you (sandbox or production)}" url="${BASE_URL%/}/api/v1/payments" body=$(mktemp) trap 'rm -f "$body"' EXIT # The body goes to a file so the signed and the sent bytes are identical (--data-binary). printf '{"merchant_payment_id":"%s","amount":"%s","currency":"RUB","geo_code":"RU","payment_method":"sbp"}' \ "$1" "$2" > "$body" set -- while IFS= read -r header; do set -- "$@" -H "$header"; done < $orderId, // your order id, the idempotency key 'amount' => $amount, // a string in roubles: "5000.00" 'currency' => 'RUB', 'geo_code' => 'RU', 'payment_method' => 'sbp', ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); $secretKey = api_load_private_key(file_get_contents(getenv('SIGNING_KEY_FILE'))); $headers = api_sign_request('POST', $url, $body, $secretKey, getenv('SIGNING_KEY_ID')); $headers['Authorization'] = 'Bearer ' . getenv('API_TOKEN'); $headers['Content-Type'] = 'application/json'; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, // the same bytes that were signed CURLOPT_HTTPHEADER => array_map(fn ($k, $v) => "$k: $v", array_keys($headers), $headers), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, // requisites are issued synchronously: 15 seconds or more ]); $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); if ($response === false || $status >= 300) { fwrite(STDERR, "$status " . ($response ?: curl_error($ch)) . PHP_EOL); exit(1); } $payment = json_decode($response, true, flags: JSON_THROW_ON_ERROR); echo "Payment {$payment['id']} status {$payment['status']}", PHP_EOL; if (!empty($payment['requisite'])) { echo "Transfer {$payment['amount']} {$payment['currency']} to {$payment['requisite']}", ' recipient ', $payment['holder_name'] ?? '', PHP_EOL; } ``` === "Python" ```python """Creates an SBP pay-in and prints the requisites for the payer. export BASE_URL={{base_url}} # the API address issued to you: sandbox or production export API_TOKEN=nl_test_... # project token export SIGNING_KEY_FILE=private.pem # Ed25519 private key export SIGNING_KEY_ID=ed25519-... # keyid from the cabinet python create_sbp_payment.py order-1001 5000.00 """ import json import os import sys import urllib.error import urllib.request from sign import load_private_key, sign_request def main() -> None: order_id, amount = sys.argv[1], sys.argv[2] base_url = os.environ.get("BASE_URL", "") if not base_url: sys.exit("BASE_URL is not set: export the API address issued to you (sandbox or production)") url = base_url.rstrip("/") + "/api/v1/payments" payload = { "merchant_payment_id": order_id, # your order id, the idempotency key "amount": amount, # a string in roubles: "5000.00" "currency": "RUB", "geo_code": "RU", "payment_method": "sbp", } body = json.dumps(payload, separators=(",", ":")).encode() with open(os.environ["SIGNING_KEY_FILE"], "rb") as f: key = load_private_key(f.read()) headers = sign_request("POST", url, body, key, os.environ["SIGNING_KEY_ID"]) headers["Authorization"] = "Bearer " + os.environ["API_TOKEN"] headers["Content-Type"] = "application/json" request = urllib.request.Request(url, data=body, headers=headers, method="POST") try: # Requisites are issued synchronously: keep the timeout at 15 seconds or more. with urllib.request.urlopen(request, timeout=30) as response: payment = json.load(response) except urllib.error.HTTPError as error: print(error.code, error.read().decode(), file=sys.stderr) sys.exit(1) print("Payment", payment["id"], "status", payment["status"]) if payment.get("requisite"): print("Transfer", payment["amount"], payment["currency"], "to", payment["requisite"], "recipient", payment["holder_name"] or "") if __name__ == "__main__": main() ``` === "Node.js" ```javascript // Creates an SBP pay-in and prints the requisites for the payer. // // export BASE_URL={{base_url}} # the API address issued to you: sandbox or production // export API_TOKEN=nl_test_... # project token // export SIGNING_KEY_FILE=private.pem # Ed25519 private key // export SIGNING_KEY_ID=ed25519-... # keyid from the cabinet // node create-sbp-payment.mjs order-1001 5000.00 import { readFileSync } from 'node:fs'; import { loadPrivateKey, signRequest } from './sign.mjs'; const [orderId, amount] = process.argv.slice(2); const baseUrl = process.env.BASE_URL; if (!baseUrl) { console.error('BASE_URL is not set: export the API address issued to you (sandbox or production)'); process.exit(1); } const url = `${baseUrl.replace(/\/$/, '')}/api/v1/payments`; const body = JSON.stringify({ merchant_payment_id: orderId, // your order id, the idempotency key amount, // a string in roubles: "5000.00" currency: 'RUB', geo_code: 'RU', payment_method: 'sbp', }); const key = loadPrivateKey(readFileSync(process.env.SIGNING_KEY_FILE)); const headers = { ...signRequest('POST', url, body, key, process.env.SIGNING_KEY_ID), Authorization: `Bearer ${process.env.API_TOKEN}`, 'Content-Type': 'application/json', }; // Requisites are issued synchronously: keep the timeout at 15 seconds or more. const response = await fetch(url, { method: 'POST', headers, body, signal: AbortSignal.timeout(30_000) }); const payment = await response.json(); if (!response.ok) { console.error(response.status, payment); process.exit(1); } console.log('Payment', payment.id, 'status', payment.status); if (payment.requisite) { console.log('Transfer', payment.amount, payment.currency, 'to', payment.requisite, 'recipient', payment.holder_name ?? ''); } ``` === "Go" ```go // Creates an SBP pay-in and prints the requisites for the payer. // // export BASE_URL={{base_url}} # the API address issued to you: sandbox or production // export API_TOKEN=nl_test_... # project token // export SIGNING_KEY_FILE=private.pem # Ed25519 private key // export SIGNING_KEY_ID=ed25519-... # keyid from the cabinet // go run ./cmd/create-sbp-payment order-1001 5000.00 package main import ( "bytes" "encoding/json" "fmt" "log" "net/http" "os" "strings" "time" "example.com/paymentapi" ) func main() { if len(os.Args) != 3 { log.Fatal("usage: create-sbp-payment ") } baseURL := os.Getenv("BASE_URL") if baseURL == "" { log.Fatal("BASE_URL is not set: export the API address issued to you (sandbox or production)") } url := strings.TrimRight(baseURL, "/") + "/api/v1/payments" body, _ := json.Marshal(map[string]string{ "merchant_payment_id": os.Args[1], // your order id, the idempotency key "amount": os.Args[2], // a string in roubles: "5000.00" "currency": "RUB", "geo_code": "RU", "payment_method": "sbp", }) pemBytes, err := os.ReadFile(os.Getenv("SIGNING_KEY_FILE")) if err != nil { log.Fatal(err) } key, err := paymentapi.LoadPrivateKey(pemBytes) if err != nil { log.Fatal(err) } req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) if err = paymentapi.SignRequest(req, body, key, os.Getenv("SIGNING_KEY_ID"), paymentapi.SignOptions{}); err != nil { log.Fatal(err) } req.Header.Set("Authorization", "Bearer "+os.Getenv("API_TOKEN")) req.Header.Set("Content-Type", "application/json") // Requisites are issued synchronously: keep the timeout at 15 seconds or more. client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { log.Fatal(err) } defer resp.Body.Close() var payment map[string]any if err = json.NewDecoder(resp.Body).Decode(&payment); err != nil { log.Fatal(err) } if resp.StatusCode >= 300 { log.Fatalf("%d %v", resp.StatusCode, payment) } fmt.Println("Payment", payment["id"], "status", payment["status"]) // requisite and holder_name are always present; null until requisites are issued. if requisite, ok := payment["requisite"].(string); ok { fmt.Println("Transfer", payment["amount"], payment["currency"], "to", requisite, "recipient", payment["holder_name"]) } } ``` Ответ `201 Created`: ```json { "id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42", "merchant_payment_id": "order-1001", "flow": "runtime_live", "status": "processing", "amount": 5000, "initial_amount": 5000, "currency": "RUB", "geo_code": "RU", "payment_method": "sbp", "bank_code": null, "is_intrabank": false, "callback_url": null, "is_test": false, "merchant_information": { "course": 100.00, "rate": 11.00, "amount_usdt": "50.0000", "amount_rate": "4450.0000", "amount_usdt_rate": "44.5000" }, "requisite": "+79991234567", "holder_name": "Иван Петров", "created_at": "2026-09-29T09:30:00Z", "updated_at": "2026-09-29T09:30:01Z" } ``` Покажите плательщику `requisite`, `holder_name` и точную сумму `amount`. Заявка ждёт оплату столько, сколько задано в настройках проекта (по умолчанию 15 минут). Что именно выводить на экран — в разделе [Приём по СБП](sbp.md#payer-screen), что значит `merchant_information` — в разделе [Расчёт](sbp.md#merchant-information). !!! failure "Пришло `401 signature_invalid`?" Пройдите [чек-лист отладки подписи](signing.md#debug-checklist). Чаще всего подписано одно тело, а отправлено другое, или часы сервера отстают. ## 7. Получите callback { #receive-callback } Когда заявка станет `completed` (оплачена) или `canceled` (не оплачена), на адрес callback-а придёт `POST` с подписью `X-Callback-Signature`. Тело — событие: основные поля заявки, курс и ставка, `event_id` и `project_id` (без реквизитов — полную заявку читайте `GET`-ом). Проверьте подпись, ответьте `2xx` и обработайте событие. В песочнице исход заявки задаёте вы сами, а тестовые заявки callback автоматически не получают — см. [Песочница](sandbox.md). Обработчик проверьте на [проверочном примере](callbacks.md#test-vector). ```json { "id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42", "event_id": "5b0c8f5e-1d2a-4a8e-9f3c-7e6d5c4b3a21", "amount": 5000, "initial_amount": 5000, "status": "completed", "currency": "RUB", "payment_method": "sbp", "bank_code": null, "merchant_payment_id": "order-1001", "rate": 11.00, "course": 100.00, "is_intrabank": false, "is_test": false, "project_id": "5d7e2c1a-0b9f-4e3d-8c2b-1a0f9e8d7c6b" } ``` ## Перед запуском в бой { #go-live } - [ ] Токен, приватный ключ и секрет callback-ов лежат в хранилище секретов. - [ ] Базовый адрес API задан настройкой, а не в коде. - [ ] Часы серверов синхронизируются по NTP. - [ ] `merchant_payment_id` — ваш номер заказа, повтор запроса идёт с тем же номером. - [ ] Таймаут HTTP-клиента не меньше 15 секунд. - [ ] Обработчик callback-ов проверяет подпись, отвечает `2xx` сразу и не обрабатывает одно событие дважды. - [ ] Есть периодическая сверка: заявки без callback-а дольше срока заявки проекта читаются через `GET /api/v1/payments/{id}`. - [ ] Для боя выпущены отдельные токен, ключ подписи и секрет callback-ов. --- Source: /sandbox/ Language: ru # Песочница Песочница — отдельное окружение, где интеграцию можно проверить целиком, не двигая деньги. API, подпись, лимиты и ошибки там такие же, как в бою. | | Песочница | | --- | --- | | API | `{{base_url}}` песочницы — выдаётся вместе с доступом | | Кабинет | `{{cabinet_url}}` песочницы | | Токен | начинается с `nl_test_` | | Подпись запросов | обязательна, как в бою | !!! info "Раздел дописывается" Песочница сейчас доделывается. Ниже — как она будет работать. Места, которые ещё могут поменяться, отмечены **TBD**. Следите за [изменениями](changelog.md). ## Как устроена { #how-it-works } 1. **Тестовый проект.** В кабинете песочницы включите у проекта тестовый режим. Заявки такого проекта не проводят настоящих платежей. У тестовой заявки в ответе `is_test: true`. 2. **Тестовые реквизиты.** В ответ на создание заявки приходят тестовые реквизиты: тестовый номер телефона для СБП или тестовый номер карты. Переводить по ним ничего не нужно. 3. **Исход заявки задаёте вы.** Отметьте заявку оплаченной или отменённой — из кабинета или вызовом API (**TBD**). 4. **Результат читайте через API.** Тестовые заявки автоматически callback не получают (**TBD**: способ получить callback по тестовой заявке ещё проектируется). Итоговый статус читайте через `GET /api/v1/payments/{id}`; формат тела callback-а и его подпись проверяйте на [проверочном примере](callbacks.md#test-vector). ## Задать исход заявки { #outcome } !!! warning "TBD: API эмуляции исхода" Метод API, которым можно задать исход тестовой заявки, ещё проектируется. Пока используйте кнопки в карточке заявки в кабинете песочницы. Когда метод появится, здесь будут запрос, ответ и примеры на всех языках. | Что хотите проверить | Что сделать | | --- | --- | | Успешная оплата | Отметить заявку оплаченной → статус `completed` | | Отказ или отмена | Отменить заявку → статус `canceled` | | Истёкший срок | Создать заявку и не трогать её дольше срока заявки проекта (по умолчанию 15 минут) → `canceled` | | Идемпотентность | Отправить тот же запрос дважды → `201`, затем `200` с той же заявкой | | Конфликт номера | Тот же `merchant_payment_id` с другой суммой → `409 payment_idempotency_conflict` | | Повтор nonce | Отправить один и тот же подписанный запрос дважды → `409 request_replayed` | | Неверная подпись | Изменить тело после подписи → `401 signature_invalid` | ## Что отличается от боя { #differences } - Деньги не двигаются, реквизиты ненастоящие. - Тестовые заявки не получают callback автоматически и отмечены `is_test: true`. У песочницы свой `{{base_url}}` и токен `nl_test_`. - Токен, ключ подписи и секрет callback-ов у песочницы свои. Для боя выпустите новые. - Адрес API другой: держите его в настройке — см. [Окружения](index.md#environments). --- Source: /sbp/ Language: ru # Приём по СБП Плательщик переводит деньги по номеру телефона через Систему быстрых платежей из приложения своего банка. Вы создаёте заявку, API выдаёт номер телефона получателя и имя, вы показываете их плательщику и ждёте результат.
1. **Ваш сервер → API.** `POST /api/v1/payments` с `payment_method=sbp` 2. **API.** Подбирает реквизиты, обычно до 15 секунд 3. **API ⇢ Ваш сервер.** `201`: `status=processing`, `requisite=+7…`, `holder_name` 4. **Ваш сервер → Плательщик.** Номер телефона, получатель, сумма и срок 5. **Плательщик.** Переводит по СБП в приложении своего банка 6. **API → Ваш сервер.** Callback: `completed` или `canceled` 7. **Ваш сервер ⇢ API.** Отвечаете `2xx`
## Создать заявку { #create } `POST /api/v1/payments` — [подписанный](signing.md) запрос с JSON-телом. ```http POST {{base_url}}/api/v1/payments HTTP/1.1 Authorization: Bearer nl_test_... Content-Type: application/json Content-Digest: sha-256=:…: X-Request-ID: 0b6d2c4e-8f1a-4d3b-9e7c-5a2f1b3c4d5e X-Request-Nonce: 9f86d081884c7d659a2feaa0c55ad015 Signature-Input: sig1=(…);created=…;expires=…;keyid="ed25519-…";alg="ed25519" Signature: sig1=:…: {"merchant_payment_id":"order-42","amount":3400,"payment_method":"sbp","bank_code":"sber","callback_url":"https://merchant.example/callbacks"} ``` ### Поля запроса { #request-fields } | Поле | Тип | Обяз. | Правила | | --- | --- | --- | --- | | `merchant_payment_id` | строка | да | Ваш номер заказа. 1–64 символа `A-Za-z0-9._:-`. Уникален в проекте: это [ключ идемпотентности](idempotency.md) | | `amount` | число или строка | да | Сумма в основных единицах валюты: `3400` или `"3400.50"`. Больше нуля, не больше 6 знаков после точки. Строка надёжнее числа: без ошибок округления | | `payment_method` | строка | да | `sbp` | | `currency` | строка | нет | Код валюты. По умолчанию `RUB` | | `geo_code` | строка | нет | Страна, две буквы. По умолчанию `RU` | | `bank_code` | строка | нет | Банк плательщика — код из справочника в нижнем регистре: `sber`, `tinkoff`, `ozon`. Это не БИК. Пусто — любой банк | | `is_intrabank` | логическое | нет | `true` — перевод внутри одного банка. По умолчанию `false` | | `callback_url` | строка | нет | Куда слать callback по этой заявке. Абсолютный `https://`, до 2048 символов, без логина и пароля, публичный хост. Иначе — адрес проекта | Другие поля не принимаются: неизвестное поле — `400 bad_request`. Тестовый режим задаётся настройкой проекта, а не полем запроса. Запрос без `currency` и `geo_code` и тот же запрос с `"RUB"` и `"RU"` — один и тот же запрос. Срока оплаты в запросе нет: сколько живёт заявка, задаёт настройка проекта — по умолчанию 15 минут с момента создания. Изменить срок можно через вашего менеджера. ### Ответ { #response } `201 Created` — новая заявка. `200 OK` — повтор с тем же `merchant_payment_id` и тем же телом: вернулась уже созданная заявка, новой не появилось. ```json { "id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42", "merchant_payment_id": "order-42", "flow": "runtime_live", "status": "processing", "amount": 3400, "initial_amount": 3400, "currency": "RUB", "geo_code": "RU", "payment_method": "sbp", "bank_code": "sber", "is_intrabank": false, "callback_url": "https://merchant.example/callbacks", "is_test": false, "merchant_information": { "course": 100.00, "rate": 11.00, "amount_usdt": "34.0000", "amount_rate": "3026.0000", "amount_usdt_rate": "30.2600" }, "requisite": "+79991234567", "holder_name": "Иван Петров", "created_at": "2026-09-29T09:30:00Z", "updated_at": "2026-09-29T09:30:01Z" } ``` | Поле | Что значит | | --- | --- | | `id` | Идентификатор заявки. Сохраните его рядом с заказом | | `merchant_payment_id` | Ваш номер заказа | | `flow` | `runtime_live` — обычная заявка, `project_test` — тестовая заявка проекта | | `status` | Текущий статус, см. [ниже](#statuses) | | `amount` | Сумма к оплате, в основных единицах валюты. Может измениться после [проверки оплаты](#edge-cases) | | `initial_amount` | Сумма из запроса | | `currency`, `geo_code`, `payment_method`, `is_intrabank` | Как в запросе, с учётом значений по умолчанию | | `bank_code` | Банк плательщика из запроса или `null`. Это не банк реквизита | | `callback_url` | Адрес callback-а из запроса или `null` | | `is_test` | `true` у тестовой заявки проекта | | `merchant_information` | [Расчёт по заявке](#merchant-information) в USDT; `null`, пока реквизиты не выданы | | `requisite` | Номер телефона получателя для перевода по СБП; `null`, пока не выдан | | `holder_name` | Имя получателя, как его покажет банк плательщика, или `null` | | `qrcode_link`, `deeplink_url` | Только если они есть у реквизитов: можно показать QR-код или кнопку «Открыть банк» | | `error_code`, `error_comment` | Только у заявки в статусе `error`: почему реквизиты не выданы | | `created_at`, `updated_at` | Время создания и последнего изменения, UTC | Поля `bank_code`, `callback_url`, `merchant_information`, `requisite` и `holder_name` есть в ответе всегда, значение может быть `null`. Новые поля могут добавляться без смены версии API: неизвестные поля игнорируйте. ### Расчёт `merchant_information` { #merchant-information } Сколько вы получите по заявке. Курс и ставка фиксируются, когда выдаются реквизиты, и потом не меняются: по текущему курсу расчёт не пересчитывается. После проверки оплаты расчёт идёт по новой сумме с теми же курсом и ставкой. Это те же цифры, что зачисляются на баланс проекта. | Поле | Что значит | Тип, знаков после точки | | --- | --- | --- | | `course` | Курс: сколько единиц валюты заявки за 1 USDT | число, 2 | | `rate` | Ставка комиссии проекта, % | число, 2 | | `amount_usdt` | Сумма заявки в USDT | строка, 4 | | `amount_rate` | Сумма за вычетом комиссии, в валюте заявки | строка, 4 | | `amount_usdt_rate` | Сумма за вычетом комиссии в USDT — её зачислят на баланс проекта | строка, 4 | `course` и `rate` — числа ровно с двумя знаками (`100.00`), суммы — десятичные строки. Лишние знаки отбрасываются, а не округляются вверх; комиссия считается с округлением вниз. В примере выше: 3400 RUB по курсу 100 и ставке 11% — 34 USDT, комиссия 374 RUB (3,74 USDT), к зачислению 30,26 USDT. !!! tip "Читайте `course` и `rate` как decimal" Разбирайте их десятичным типом (`Decimal`, `BigDecimal`, `json.Number`), а не float — иначе можно потерять точность. !!! warning "Не пересчитывайте `amount_usdt` сами" Курс может храниться точнее двух знаков, поэтому `amount / course` иногда расходится с `amount_usdt` в последнем знаке. Верное значение — `amount_usdt`. ## Что показать плательщику { #payer-screen } Показывайте реквизиты, только когда статус `processing` и есть `requisite`. Плательщик переводит ровно `amount` на `requisite`. - **Номер телефона** из `requisite` — крупно, с кнопкой «Скопировать». - **Получатель** из `holder_name`: плательщик сверит имя в приложении банка перед переводом. - **Сумма** — ровно `amount`, до копейки. Предупредите: перевод другой суммы может не зачесться автоматически. - **Срок** — обратный отсчёт от `created_at` на срок заявки вашего проекта (по умолчанию 15 минут). После срока реквизиты использовать нельзя. - **Что дальше** — «После перевода вернитесь на эту страницу». Статус обновляйте по callback-у или опросом. !!! warning "Не кешируйте реквизиты" Реквизиты выдаются на одну заявку. Для нового заказа или повторной попытки создайте новую заявку с новым `merchant_payment_id`. ## Статусы { #statuses }
| Статус | Что значит | Что делать | | --- | --- | --- | | created | Заявка принята, реквизиты ещё не выданы | Ждать. Обычно ответ сразу приходит уже в `processing` | | processing | Реквизиты выданы, ждём перевод | Показать реквизиты плательщику | | completed | Оплата подтверждена | Выдать товар или услугу | | canceled | Не оплачено: срок заявки истёк, заявку отменили или оплату не удалось подтвердить | Предложить оплатить заново — новой заявкой | | error | Реквизиты не выданы, причина в `error_code` | Предложить оплатить заново — новой заявкой | | appeal | Оплату по закрытой заявке перепроверяют | Ждать решения: если итог — `completed` или `canceled`, придёт callback | `error` бывает только до выдачи реквизитов. Из `processing` заявка переходит только в `completed` или `canceled`: если после выдачи реквизитов оплату не удалось подтвердить — тоже `canceled`, без `error_code`. `completed`, `canceled` и `error` — финальные. Изменить их может только проверка оплаты или решение службы поддержки. Все коды ошибок — в разделе [Статусы и ошибки](statuses.md). ## Как узнать результат { #result } **Callback — основной способ.** Когда заявка становится `completed` или `canceled`, API отправляет `POST` на ваш адрес. Как проверить подпись и обработать — в разделе [Callback-и](callbacks.md). О статусе `error` callback не приходит: он бывает только до выдачи реквизитов и виден в ответе на создание и при чтении заявки. Если плательщик уже увидел реквизиты, результат придёт callback-ом. **Опрос — страховка.** Если callback не пришёл, прочитайте заявку: ```bash curl -sS "{{base_url}}/api/v1/payments/3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42" \ -H "Authorization: Bearer $API_TOKEN" ``` Ответ — та же форма, что при создании. Если в политике проекта включено `signature_required`, подпишите и `GET` (тело пустое). Рекомендуем так: - Страница оплаты у плательщика опрашивает **ваш** сервер, а не API. - Ваш сервер запрашивает заявку у API, только если callback не пришёл: например, раз в минуту для заявок в `processing`, у которых срок заявки уже прошёл. - Не опрашивайте чаще раза в 5–10 секунд на заявку: у проекта общий [лимит запросов](idempotency.md#rate-limits). ## Крайние случаи { #edge-cases } **Ответ сразу со статусом `error`.** `201` со `status: "error"` значит, что заявка сохранена, но реквизитов нет. Причина — в `error_code` (`provider_no_requisites`, `provider_result_ambiguous`). Callback-а об этой заявке не будет. Плательщику нечего показывать — предложите попробовать позже, новой заявкой. **`422` при создании.** Заявку нельзя провести: `payment_not_covered` (метод или сумма вне вашего договора), `payment_rate_unavailable` (нет курса), `payment_not_routable` (сейчас платёж этим способом принять нельзя). Если заявка успела сохраниться, в теле есть `payment_id`: заявка осталась в статусе `error`, номер занят, и повтор того же запроса вернёт её (`200`). Для новой попытки нужен новый номер. Без `payment_id` заявка не создана, и номер свободен. **Таймаут или обрыв связи.** Вы не знаете, создана ли заявка. Повторите **тот же** запрос с тем же `merchant_payment_id` и новыми nonce и подписью: если заявка есть, вернётся она (`200`), если нет — создастся. Двойной заявки не будет. **Плательщик перевёл другую сумму.** Оплату могут перепроверить и подтвердить фактическую сумму. Тогда придёт callback с `completed` и новой суммой: `amount` изменится, `initial_amount` останется прежним. Выдавайте заказ по сумме из callback-а. **Срок истёк.** Неоплаченная к концу срока заявка становится `canceled`, и приходит callback. Если плательщик перевёл деньги после срока, заявку разберут вручную: при подтверждённой оплате придёт новый callback `completed`. **Оплату не удалось подтвердить после выдачи реквизитов.** Тогда заявка становится `canceled` без `error_code`, и приходит callback. Если оплата всё же прошла, её разберут вручную: придёт новый callback `completed`. **Долгий ответ.** Реквизиты подбираются прямо во время запроса. Держите таймаут клиента не меньше 15 секунд. ## Смотрите также - [Приём по карте](card.md) — то же, но перевод на карту. - [Идемпотентность и лимиты](idempotency.md) — повторы запросов и лимиты. - [API reference](api-reference.md) — схема полей. --- Source: /card/ Language: ru # Приём по карте Плательщик переводит деньги на номер карты получателя из приложения своего банка. Заявка устроена так же, как [приём по СБП](sbp.md): тот же метод API, те же статусы, callback-и и правила повторов. Отличаются значение `payment_method` и то, что лежит в `requisite`. | | СБП | Карта | | --- | --- | --- | | `payment_method` | `sbp` | `card_transfer` | | `requisite` | номер телефона: `+79991234567` | номер карты: `2200123456789010` | | Как платит плательщик | «Перевод по СБП» по номеру телефона | «Перевод на карту» по номеру карты | ## Создать заявку { #create } `POST /api/v1/payments` с `payment_method: "card_transfer"`. Запрос [подписывается](signing.md) так же, как для СБП. ```json { "merchant_payment_id": "order-1002", "amount": "12500.00", "payment_method": "card_transfer", "bank_code": "sber" } ``` Все поля и правила — в разделе [Приём по СБП → Поля запроса](sbp.md#request-fields). `bank_code` — банк плательщика, код из справочника (не БИК): реквизиты подбираются под него. `is_intrabank: true` вместе с `bank_code` — перевод внутри этого банка: карта получателя будет в том же банке, что и у плательщика. Оба поля — редкие фильтры: без них реквизиты подбираются из всех банков. Ответ `201 Created`: ```json { "id": "7a2d4c1e-9b3f-4e8a-b1c2-3d4e5f6a7b8c", "merchant_payment_id": "order-1002", "flow": "runtime_live", "status": "processing", "amount": 12500, "initial_amount": 12500, "currency": "RUB", "geo_code": "RU", "payment_method": "card_transfer", "bank_code": "sber", "is_intrabank": false, "callback_url": null, "is_test": false, "merchant_information": { "course": 100.00, "rate": 11.00, "amount_usdt": "125.0000", "amount_rate": "11125.0000", "amount_usdt_rate": "111.2500" }, "requisite": "2200123456789010", "holder_name": "Мария П.", "created_at": "2026-09-29T09:30:00Z", "updated_at": "2026-09-29T09:30:01Z" } ``` Поля ответа и расчёт `merchant_information` — как у СБП: [Ответ](sbp.md#response) и [Расчёт `merchant_information`](sbp.md#merchant-information). ## Что показать плательщику { #payer-screen } - **Номер карты** из `requisite` — группами по четыре цифры (`2200 1234 5678 9010`), с кнопкой «Скопировать», которая копирует номер **без пробелов**. - **Получатель** из `holder_name` — банк покажет это имя перед переводом. - **Сумма** — ровно `amount`. Предупредите, что комиссию банка плательщик оплачивает сверх суммы: зачисляется то, что пришло на карту. - **Срок** — обратный отсчёт от `created_at` на срок заявки вашего проекта (по умолчанию 15 минут). !!! warning "Номер карты — только для этой заявки" Не сохраняйте и не показывайте номер карты повторно. Для новой оплаты создайте новую заявку. ## Статусы и результат { #result } Статусы, callback-и и опрос — такие же, как у СБП: [Статусы](sbp.md#statuses) и [Как узнать результат](sbp.md#result). Выданная карта ведёт только к `completed` или `canceled`, и о каждом из них приходит callback; `error` — только если карту не выдали, это видно в ответе на создание. ## Крайние случаи { #edge-cases } Всё из раздела [Приём по СБП → Крайние случаи](sbp.md#edge-cases) верно и для карты. Отдельно для карт: - **Перевод с комиссией.** Если банк плательщика удержал комиссию из суммы перевода, на карту придёт меньше. Фактическую сумму могут подтвердить при проверке оплаты — тогда в callback-е придёт `completed` с новым `amount`. - **Нет карт нужного банка.** С `bank_code` выбор реквизитов уже; если подходящих нет, заявка закроется ошибкой `provider_no_requisites` или ответ будет `422 payment_not_routable`. Попробуйте без `bank_code`. --- Source: /statuses/ Language: ru # Статусы и ошибки ## Статусы заявки { #statuses }
| Статус | Финальный | Что значит | | --- | --- | --- | | created | нет | Заявка принята, реквизиты ещё не выданы | | processing | нет | Реквизиты выданы, ждём перевод плательщика | | completed | да | Оплата подтверждена | | canceled | да | Не оплачено: истёк срок заявки, её отменили или оплату не удалось подтвердить после выдачи реквизитов | | error | да | Реквизиты не выданы, причина — в `error_code` | | appeal | нет | Оплату по закрытой заявке перепроверяют | Ответ на создание может сразу прийти в любом статусе, кроме `appeal`. Финальный статус меняется только после проверки оплаты или решения службы поддержки — см. [Смена статуса после закрытия](callbacks.md#status-changes). **`error` бывает только до выдачи реквизитов**: из `created`, обычно сразу в ответе на создание. Если реквизиты уже выданы (`processing`), заявка заканчивается только `completed` или `canceled`. Если после выдачи оплату не удалось подтвердить, заявка тоже становится `canceled`, без `error_code`. Срок заявки задаёт настройка проекта: по умолчанию 15 минут с `created_at`. Заявка, не оплаченная к концу срока, становится `canceled`. **Callback приходит только о `completed` и `canceled`** — при подтверждении оплаты, отмене, истечении срока, после проверки оплаты и после решения службы поддержки. О статусе `error` callback не приходит: его видно в ответе на создание и при чтении заявки. Подробнее — в разделе [Callback-и](callbacks.md#when). ## Коды `error_code` { #error-codes } `error_code` и `error_comment` есть только у заявки в статусе `error` — в ответе на создание и при чтении. Код объясняет, почему реквизиты не выданы. У `canceled` кода нет, в том числе когда оплату не удалось подтвердить. Для плательщика во всех случаях одно действие: оплатить заново, новой заявкой с новым `merchant_payment_id`. | Код | Что случилось | | --- | --- | | `provider_unavailable` | Платёж сейчас нельзя принять: способ оплаты временно недоступен | | `provider_no_requisites` | Не нашлось свободных реквизитов | | `rate_unavailable` | Нет курса для расчёта | | `provider_result_ambiguous` | Выдача реквизитов не подтвердилась, реквизиты не выданы | | `provider_result_unknown` | Результат выдачи реквизитов неизвестен, реквизиты не выданы | | `stale_dispatch` | Реквизиты не удалось получить вовремя | | `invalid_payment` | Заявка не прошла проверку полей | | `json_invalid` | Тело запроса не разобралось | Список может пополняться: обрабатывайте незнакомый код как общую ошибку. ## Ошибки HTTP { #http-errors } Тело ошибки — всегда JSON: ```json {"error": "signature_invalid"} ``` `Content-Type: application/json`, заголовок `X-Request-ID` возвращается как в запросе. | HTTP | `error` | Когда | Что делать | | --- | --- | --- | --- | | 400 | `bad_request` | Тело не JSON, неизвестное поле, лишний текст после объекта | Исправить запрос | | 400 | `invalid_payment` | Поле не прошло проверку: сумма, номер заказа, гео, метод, банк, `callback_url` и т. п. | Исправить запрос | | 401 | `token_invalid` | Нет токена, он неизвестен, отозван, истёк, или проект не активен | Проверить токен в кабинете | | 401 | `signature_invalid` | Подпись не прошла проверку | [Чек-лист подписи](signing.md#debug-checklist) | | 403 | `insufficient_scope` | У токена нет нужного права | Выпустить токен с `payments:create` / `payments:read` | | 403 | `ip_not_allowed` | Адрес не в allowlist проекта | Добавить адрес в кабинете | | 403 | `project_blocked` | API проекта закрыт сотрудником | Связаться с менеджером | | 403 | `merchant_blocked` | Мерчант заблокирован | Связаться с менеджером | | 403 | `merchant_archived` | Мерчант удалён | Связаться с менеджером | | 404 | `payment_not_found` | Заявки нет или она чужая | Проверить `id` | | 409 | `request_replayed` | Этот `X-Request-Nonce` уже был | Повторить с новым nonce и новой подписью | | 409 | `payment_idempotency_conflict` | `merchant_payment_id` уже занят заявкой с другими полями | Новый номер или те же поля, см. [Идемпотентность](idempotency.md) | | 413 | `request_too_large` | Тело больше лимита проекта (по умолчанию 64 КБ) | Уменьшить тело | | 422 | `payment_not_covered` | Метод, валюта или сумма вне вашего договора | Проверить условия с менеджером | | 422 | `payment_rate_unavailable` | Нет курса | Повторить позже новой заявкой | | 422 | `payment_not_routable` | Сейчас платёж этим способом принять нельзя | Повторить позже новой заявкой | | 429 | `rate_limited` | Превышен лимит запросов | Подождать `Retry-After` секунд | | 429 | `too_many_concurrent_requests` | Больше 20 одновременных запросов проекта | Подождать `Retry-After` (1 с) и повторить тот же запрос | | 500 | `internal_error` | Сбой на стороне API | Повторить с паузой | | 503 | `rate_limit_unavailable` | Временно недоступен учёт лимитов | Подождать `Retry-After` и повторить | | 503 | `replay_protection_unavailable` | Временно недоступна проверка nonce | Подождать `Retry-After` и повторить | Если заявка при `422` успела сохраниться (в статусе `error`), в теле есть её `id`: ```json {"error": "payment_not_routable", "payment_id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42"} ``` ## Когда повторять { #retries } | Ответ | Повторять? | Как | | --- | --- | --- | | Таймаут, обрыв связи | да | Тот же запрос и `merchant_payment_id`, **новые** nonce и подпись | | `429`, `503` | да | Через `Retry-After` секунд, затем с растущей паузой | | `500` | да | С растущей паузой: 1, 2, 4, 8 секунд… | | `409 request_replayed` | да | С новым nonce и новой подписью | | `401`, `403`, `400`, `404`, `413` | нет | Сначала исправить причину | | `422`, статус `error` | нет | Для новой попытки — новая заявка с новым `merchant_payment_id` | Повтор с тем же `merchant_payment_id` безопасен: второй заявки не будет. Подробнее — в разделе [Идемпотентность и лимиты](idempotency.md). ```python # Повтор с растущей паузой: тело и merchant_payment_id те же, подпись — новая. for attempt in range(5): headers = sign_request("POST", url, body, key, key_id) # новый nonce каждый раз response = send(url, body, headers) if response.status in (429, 503): time.sleep(int(response.headers.get("Retry-After", "1"))) continue if response.status >= 500 or response.status == 0: # 0 — таймаут или обрыв time.sleep(2 ** attempt) continue break ``` --- Source: /idempotency/ Language: ru # Идемпотентность и лимиты ## Идемпотентность { #idempotency } Ключ идемпотентности — ваш `merchant_payment_id`. Он уникален в пределах проекта. Отдельного заголовка `Idempotency-Key` нет. | Что пришло | Ответ | | --- | --- | | Новый `merchant_payment_id` | `201` — новая заявка | | Тот же `merchant_payment_id` и те же поля | `200` — уже созданная заявка, реквизиты повторно не запрашиваются | | Тот же `merchant_payment_id`, но другие поля | `409 payment_idempotency_conflict` | «Те же поля» — это `merchant_payment_id`, сумма, `currency`, `geo_code`, `payment_method`, `bank_code`, `is_intrabank` и `callback_url`. Значения по умолчанию подставляются до сравнения: запрос без `currency` и тот же запрос с `"currency": "RUB"` считаются одинаковыми. **Что это даёт.** Если ответ не дошёл — таймаут, обрыв, падение вашего сервера — просто повторите запрос с тем же номером. Двойной заявки и двойного списания не будет. **Что делать для новой попытки оплаты.** Если заявка закрылась (`canceled`, `error`) или получила `422`, для новой попытки нужен **новый** `merchant_payment_id`: повтор со старым вернёт прежний результат. Удобно добавлять номер попытки: `order-1001-2`. **Когда номер не занимается.** Отказы до приёма заявки не занимают `merchant_payment_id`: `429`, `503`, `401 signature_invalid`, `409 request_replayed`, `413 request_too_large`, `400 bad_request` (тело не разобралось), `400 invalid_payment` (поле не прошло проверку). Исправьте причину и отправьте запрос с тем же номером. Если исправленный запрос получает `409 payment_idempotency_conflict`, используйте новый номер. ### Nonce и повторы { #nonce } Идемпотентность заявки и одноразовость подписи — разные вещи. - `X-Request-Nonce` одноразовый: повтор того же подписанного запроса — `409 request_replayed`. - Поэтому при каждом повторе **заново подписывайте** запрос: новый nonce, новые `created`/`expires`, тот же `merchant_payment_id` и то же тело. - Исключение — `429 too_many_concurrent_requests`: nonce не тратится, и тот же подписанный запрос можно отправить ещё раз, пока не истёк `expires`. ## Лимиты { #rate-limits } | Лимит | По умолчанию | Ответ при превышении | | --- | --- | --- | | Запросов с одного IP-адреса | 600 в минуту | `429 rate_limited` | | Запросов проекта | 600 в минуту, всплеск до 60 | `429 rate_limited` | | Создание заявок проекта | 60 в минуту, всплеск до 6 | `429 rate_limited` | | Одновременных запросов проекта | 20 | `429 too_many_concurrent_requests` | | Размер тела запроса | 64 КБ | `413 request_too_large` | - Лимиты проекта видны в кабинете, изменить их можно через вашего менеджера: напишите ему, если ожидаете больше заявок. - Ответ `429` содержит `Retry-After` — сколько секунд подождать. - Лимиты считаются по проекту: все ваши серверы делят один счётчик. - Если учёт лимитов или nonce временно недоступен, API отвечает `503` с `Retry-After: 1` — это не ошибка вашего запроса, повторите его. ## Таймауты { #timeouts } Создание заявки синхронно подбирает реквизиты. Держите таймаут HTTP-клиента **не меньше 15 секунд**. Если таймаут всё же сработал, повторите запрос с тем же `merchant_payment_id` — см. [выше](#idempotency). --- Source: /signing/ Language: ru # Подпись запросов Каждый изменяющий запрос к API подписывается вашим приватным ключом Ed25519 по стандарту [RFC 9421 (HTTP Message Signatures)](https://www.rfc-editor.org/rfc/rfc9421). Сервер хранит только публичный ключ и проверяет подпись до того, как принять заявку. Так украденный токен без ключа не создаст ни одной заявки. Подпись нужна всегда: в песочнице и в бою. Для чтения (`GET`) — только если в политике проекта включено `signature_required`; подписанный `GET` принимается всегда. !!! tip "Не пишите подпись с нуля" Возьмите готовую функцию для своего языка [ниже](#ready-functions) и прогоните её на [проверочном примере](#test-vector). Если заголовки совпали байт в байт, подпись будет принята. ## Какие заголовки добавить { #headers } | Заголовок | Значение | | --- | --- | | `Content-Digest` | `sha-256=::`. Для `GET` — дайджест пустого тела | | `X-Request-ID` | Ваш идентификатор запроса: 1–128 символов `A-Za-z0-9._:-` | | `X-Request-Nonce` | Одноразовая строка: 16–128 символов без пробелов, табуляций и запятых | | `Signature-Input` | `sig1=(<компоненты>);created=…;expires=…;keyid="…";alg="ed25519"` | | `Signature` | `sig1=::` | И как обычно — `Authorization: Bearer <токен>` и `Content-Type: application/json`. Эти два заголовка не подписываются. ## Профиль подписи { #profile } Сервер принимает подпись, только если выполнено всё: - **Компоненты.** Подпись покрывает все шесть: `"@method"`, `"@path"`, `"@authority"`, `"content-digest"`, `"x-request-id"`, `"x-request-nonce"`. Порядок любой. Можно добавить другие заголовки; другие производные компоненты (`"@query"`, `"@target-uri"` и т. п.) не принимаются. - **Параметры.** Обязательны `created` и `expires` (целые, Unix-время в секундах) и `keyid` (строка из кабинета). `alg` необязателен, но если есть — только `"ed25519"`. `nonce` и `tag` допускаются и не влияют на проверку. Другие параметры — отказ. - **Одна подпись.** В `Signature-Input` ровно одна метка. Используйте `sig1`. - **Срок.** `expires - created` не больше 300 секунд. `created` не может быть в будущем больше чем на 30 секунд. Запрос должен дойти до `expires`. - **Nonce.** `X-Request-Nonce` уникален в проекте 5 минут 30 секунд. Повтор — `409 request_replayed`. - **Тело.** `Content-Digest` совпадает с SHA-256 **тех же байтов**, что пришли в запросе. Принимается только `sha-256`. - **Ключ.** `keyid` — рабочий ключ проекта или ключ на ротации. ## Как строится база подписи { #signature-base } База подписи — это текст, который вы подписываете. Одна строка на компонент в том же порядке, что в `Signature-Input`, затем строка `"@signature-params"`. Строки разделены `\n`, в конце перевода строки нет. ```text "@method": POST "@path": /api/v1/payments "@authority": api.example.com "content-digest": sha-256=:7vp0UJ9PjprEvF8nY/Eh9EQV284GkSkTMxsEL6T6IBo=: "x-request-id": 0b6d2c4e-8f1a-4d3b-9e7c-5a2f1b3c4d5e "x-request-nonce": 9f86d081884c7d659a2feaa0c55ad015 "@signature-params": ("@method" "@path" "@authority" "content-digest" "x-request-id" "x-request-nonce");created=1790596800;expires=1790597100;keyid="ed25519-If4x36FUomFia_hUBG_SJw";alg="ed25519" ``` | Компонент | Откуда значение | | --- | --- | | `@method` | Метод в верхнем регистре: `POST`, `GET` | | `@path` | Путь URL в том виде, как он уходит в запрос, **без query**: `/api/v1/payments` | | `@authority` | Хост из `{{base_url}}` в нижнем регистре, с портом, только если он нестандартный: `api.example.com` | | `content-digest` | Значение заголовка `Content-Digest` | | `x-request-id` | Значение заголовка `X-Request-ID` как есть | | `x-request-nonce` | Значение заголовка `X-Request-Nonce` как есть | | `@signature-params` | То, что стоит после `sig1=` в `Signature-Input` | Подпишите байты базы в UTF-8 ключом Ed25519, получите 64 байта, закодируйте в base64 и поставьте в `Signature: sig1=:…:`. ## Проверочный пример { #test-vector } Пример сгенерирован серверной реализацией проверки подписи и принят ею. Ed25519 детерминирован: ваша функция с теми же входными данными обязана выдать **ровно** эти заголовки. Все примеры кода на этой странице проверяются на нём автоматически. !!! danger "Это тестовый ключ" Ключ взят из RFC 8032 (раздел 7.1, TEST 1) и известен всем. Не загружайте его в кабинет и не используйте нигде, кроме тестов. **Вход** | Что | Значение | | --- | --- | | Приватный ключ (seed, hex) | `9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60` | | Приватный ключ (PEM) | `-----BEGIN PRIVATE KEY-----`
`MC4CAQAwBQYDK2VwBCIEIJ1hsZ3v/VpguoRK9JLsLMREScVpezJpGXA7rAMcrn9g`
`-----END PRIVATE KEY-----` | | Публичный ключ (base64) | `11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=` | | `keyid` | `ed25519-If4x36FUomFia_hUBG_SJw` | | Запрос | `POST https://api.example.com/api/v1/payments` — зарезервированный домен из RFC 2606. Хост входит в подпись, поэтому с вашим `{{base_url}}` подпись будет другой | | `created` / `expires` | `1790596800` / `1790597100` (2026-09-28 12:00:00 UTC + 300 с) | | `X-Request-Nonce` | `9f86d081884c7d659a2feaa0c55ad015` | | `X-Request-ID` | `0b6d2c4e-8f1a-4d3b-9e7c-5a2f1b3c4d5e` | Тело — ровно эти байты, без перевода строки в конце: ```json {"merchant_payment_id":"order-1001","amount":"5000.00","currency":"RUB","geo_code":"RU","payment_method":"sbp","bank_code":"sber"} ``` **Ожидаемый результат** ```http Content-Digest: sha-256=:7vp0UJ9PjprEvF8nY/Eh9EQV284GkSkTMxsEL6T6IBo=: Signature-Input: sig1=("@method" "@path" "@authority" "content-digest" "x-request-id" "x-request-nonce");created=1790596800;expires=1790597100;keyid="ed25519-If4x36FUomFia_hUBG_SJw";alg="ed25519" Signature: sig1=:BtY8/iQPBfIpIsuvra/cgfiKFMF+tL0yjt4Y37FCM5aUtbGvo1F0pH1SDKp6QSc8Wo6Jj/+rv12KE5VLuZecAg==: ``` Дайджест пустого тела (для `GET`): `sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:`. Пример в машиночитаемом виде — [`examples/test-vectors.json`](#test-vector-json). ## Готовые функции { #ready-functions } Функция принимает метод, URL, байты тела, приватный ключ и `keyid` и возвращает заголовки подписи. Время, nonce и request ID она ставит сама; в тестах их можно зафиксировать. === "curl" OpenSSL 3.0 или новее (в macOS: `brew install openssl@3`). Функция печатает заголовки по одному в строке — передайте их в `curl -H`, как в [скрипте создания заявки](quickstart.md#first-payment). ```bash # Payment API request signing: RFC 9421, Ed25519. # POSIX sh + OpenSSL 3.0 or newer (macOS: brew install openssl@3). Load with: . ./sign.sh # api_content_digest FILE — Content-Digest of the exact file bytes. api_content_digest() { printf 'sha-256=:%s:' "$(openssl dgst -sha256 -binary < "$1" | openssl base64 -A)" } # api_key_id PUBLIC_PEM — the keyid the cabinet shows for this public key: # "ed25519-" + base64url(sha256(32 key bytes)[:16]). api_key_id() { printf 'ed25519-%s' "$(openssl pkey -pubin -in "$1" -outform DER | tail -c 32 \ | openssl dgst -sha256 -binary | head -c 16 | openssl base64 -A | tr '+/' '-_' | tr -d '=')" } # api_sign_request METHOD URL BODY_FILE KEY_FILE KEY_ID [CREATED NONCE REQUEST_ID] # Prints the signature headers, one "Name: value" per line. # BODY_FILE holds the exact bytes that will be sent (for GET use an empty file or /dev/null). api_sign_request() { api_method=$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]') api_rest=${2#*://} # api.example.com/api/v1/payments?x=1 api_authority=$(printf '%s' "${api_rest%%/*}" | tr '[:upper:]' '[:lower:]') api_path=/${api_rest#*/}; [ "$api_rest" = "${api_rest#*/}" ] && api_path=/ api_path=${api_path%%\?*} # no query string api_created=${6:-$(date +%s)} api_nonce=${7:-$(openssl rand -hex 16)} api_request_id=${8:-$(openssl rand -hex 16)} api_digest=$(api_content_digest "$3") api_params="(\"@method\" \"@path\" \"@authority\" \"content-digest\" \"x-request-id\" \"x-request-nonce\")" api_params="$api_params;created=$api_created;expires=$((api_created + 300));keyid=\"$5\";alg=\"ed25519\"" api_base_file=$(mktemp) printf '"@method": %s\n"@path": %s\n"@authority": %s\n"content-digest": %s\n"x-request-id": %s\n"x-request-nonce": %s\n"@signature-params": %s' \ "$api_method" "$api_path" "$api_authority" "$api_digest" "$api_request_id" "$api_nonce" "$api_params" > "$api_base_file" api_signature=$(openssl pkeyutl -sign -rawin -inkey "$4" -in "$api_base_file" | openssl base64 -A) rm -f "$api_base_file" printf 'Content-Digest: %s\n' "$api_digest" printf 'X-Request-ID: %s\n' "$api_request_id" printf 'X-Request-Nonce: %s\n' "$api_nonce" printf 'Signature-Input: sig1=%s\n' "$api_params" printf 'Signature: sig1=:%s:\n' "$api_signature" } ``` === "PHP" PHP 8.1+, расширение `sodium` (есть в стандартной сборке PHP). ```php */ function api_sign_request( string $method, string $url, string $body, string $secretKey, string $keyId, array $options = [], ): array { $parts = parse_url($url); $authority = strtolower($parts['host'] . (isset($parts['port']) ? ':' . $parts['port'] : '')); $created = $options['created'] ?? time(); $lifetime = $options['lifetime'] ?? SIGNATURE_MAX_LIFETIME; $nonce = $options['nonce'] ?? bin2hex(random_bytes(16)); $requestId = $options['request_id'] ?? bin2hex(random_bytes(16)); $digest = api_content_digest($body); $values = [ '@method' => strtoupper($method), '@path' => $parts['path'] ?? '/', '@authority' => $authority, 'content-digest' => $digest, 'x-request-id' => $requestId, 'x-request-nonce' => $nonce, ]; $quoted = implode(' ', array_map(fn (string $name): string => "\"$name\"", SIGNATURE_COMPONENTS)); $params = sprintf('(%s);created=%d;expires=%d;keyid="%s";alg="ed25519"', $quoted, $created, $created + $lifetime, $keyId); $base = ''; foreach (SIGNATURE_COMPONENTS as $name) { $base .= "\"$name\": {$values[$name]}\n"; } $base .= "\"@signature-params\": $params"; $signature = base64_encode(sodium_crypto_sign_detached($base, $secretKey)); return [ 'Content-Digest' => $digest, 'X-Request-ID' => $requestId, 'X-Request-Nonce' => $nonce, 'Signature-Input' => "sig1=$params", 'Signature' => "sig1=:$signature:", ]; } ``` === "Python" Python 3.10+, `pip install cryptography`. ```python """Payment API request signing: RFC 9421, Ed25519. Python 3.10+, the cryptography package (pip install cryptography). """ import base64 import hashlib import secrets import time import uuid from urllib.parse import urlsplit from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.serialization import load_pem_private_key # Components the signature must cover. Any order, but the same as in Signature-Input. COMPONENTS = ("@method", "@path", "@authority", "content-digest", "x-request-id", "x-request-nonce") MAX_LIFETIME_SECONDS = 300 # expires - created must not exceed 5 minutes def content_digest(body: bytes) -> str: """RFC 9530 Content-Digest: sha-256 of the exact body bytes.""" return "sha-256=:" + base64.b64encode(hashlib.sha256(body).digest()).decode() + ":" def key_id(public_key: bytes) -> str: """The keyid the cabinet shows for a 32-byte public key.""" digest = hashlib.sha256(public_key).digest()[:16] return "ed25519-" + base64.urlsafe_b64encode(digest).decode().rstrip("=") def load_private_key(pem: bytes) -> Ed25519PrivateKey: """Loads a PEM private key (PKCS#8, as produced by openssl genpkey).""" key = load_pem_private_key(pem, password=None) if not isinstance(key, Ed25519PrivateKey): raise ValueError("expected an Ed25519 key") return key def sign_request( method: str, url: str, body: bytes, private_key: Ed25519PrivateKey, key_id: str, *, created: int | None = None, lifetime: int = MAX_LIFETIME_SECONDS, nonce: str | None = None, request_id: str | None = None, ) -> dict[str, str]: """Returns the signature headers. Add Authorization and Content-Type yourself. body must be the exact bytes that will be sent; b"" for GET. """ parts = urlsplit(url) created = int(time.time()) if created is None else created nonce = nonce or secrets.token_hex(16) request_id = request_id or str(uuid.uuid4()) digest = content_digest(body) values = { "@method": method.upper(), "@path": parts.path or "/", "@authority": parts.netloc.lower(), "content-digest": digest, "x-request-id": request_id, "x-request-nonce": nonce, } params = ( "(" + " ".join(f'"{name}"' for name in COMPONENTS) + ")" + f';created={created};expires={created + lifetime};keyid="{key_id}";alg="ed25519"' ) base = "".join(f'"{name}": {values[name]}\n' for name in COMPONENTS) base += f'"@signature-params": {params}' signature = base64.b64encode(private_key.sign(base.encode())).decode() return { "Content-Digest": digest, "X-Request-ID": request_id, "X-Request-Nonce": nonce, "Signature-Input": f"sig1={params}", "Signature": f"sig1=:{signature}:", } ``` === "Node.js" Node.js 20+, без зависимостей. ```javascript // Payment API request signing: RFC 9421, Ed25519. Node.js 20+, no dependencies. import { createHash, createPrivateKey, randomBytes, randomUUID, sign } from 'node:crypto'; // Components the signature must cover. Any order, but the same as in Signature-Input. const COMPONENTS = ['@method', '@path', '@authority', 'content-digest', 'x-request-id', 'x-request-nonce']; const MAX_LIFETIME_SECONDS = 300; // expires - created must not exceed 5 minutes /** RFC 9530 Content-Digest: sha-256 of the exact body bytes. */ export function contentDigest(body) { return `sha-256=:${createHash('sha256').update(body).digest('base64')}:`; } /** The keyid the cabinet shows for a 32-byte public key. */ export function keyId(publicKey) { return 'ed25519-' + createHash('sha256').update(publicKey).digest().subarray(0, 16).toString('base64url'); } /** Loads a PEM private key (PKCS#8, as produced by openssl genpkey). */ export function loadPrivateKey(pem) { const key = createPrivateKey(pem); if (key.asymmetricKeyType !== 'ed25519') throw new Error('expected an Ed25519 key'); return key; } /** * Returns the signature headers. Add Authorization and Content-Type yourself. * body must be the exact bytes (Buffer or string) that will be sent; '' for GET. */ export function signRequest(method, url, body, privateKey, keyIdValue, options = {}) { const { pathname, host } = new URL(url); const created = options.created ?? Math.floor(Date.now() / 1000); const lifetime = options.lifetime ?? MAX_LIFETIME_SECONDS; const nonce = options.nonce ?? randomBytes(16).toString('hex'); const requestId = options.requestId ?? randomUUID(); const digest = contentDigest(body); const values = { '@method': method.toUpperCase(), '@path': pathname || '/', '@authority': host.toLowerCase(), 'content-digest': digest, 'x-request-id': requestId, 'x-request-nonce': nonce, }; const params = `(${COMPONENTS.map((name) => `"${name}"`).join(' ')})` + `;created=${created};expires=${created + lifetime};keyid="${keyIdValue}";alg="ed25519"`; const base = COMPONENTS.map((name) => `"${name}": ${values[name]}\n`).join('') + `"@signature-params": ${params}`; const signature = sign(null, Buffer.from(base), privateKey).toString('base64'); return { 'Content-Digest': digest, 'X-Request-ID': requestId, 'X-Request-Nonce': nonce, 'Signature-Input': `sig1=${params}`, Signature: `sig1=:${signature}:`, }; } ``` === "Go" Go 1.22+, только стандартная библиотека. ```go // Package paymentapi is a payment API integration example using only the Go 1.22+ standard library. package paymentapi import ( "crypto/ed25519" "crypto/rand" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/hex" "encoding/pem" "errors" "fmt" "net/http" "strings" "time" ) // components the signature must cover. Any order, but the same as in Signature-Input. var components = []string{"@method", "@path", "@authority", "content-digest", "x-request-id", "x-request-nonce"} // MaxLifetime is the longest allowed window between created and expires. const MaxLifetime = 5 * time.Minute // SignOptions pins the time, nonce and request id. Empty fields are filled in automatically. type SignOptions struct { Created time.Time Lifetime time.Duration Nonce string RequestID string } // ContentDigest returns the RFC 9530 Content-Digest header: sha-256 of the exact body bytes. func ContentDigest(body []byte) string { sum := sha256.Sum256(body) return "sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":" } // KeyID returns the keyid the cabinet shows for this public key. func KeyID(publicKey ed25519.PublicKey) string { sum := sha256.Sum256(publicKey) return "ed25519-" + base64.RawURLEncoding.EncodeToString(sum[:16]) } // LoadPrivateKey reads a PEM private key (PKCS#8, as produced by openssl genpkey). func LoadPrivateKey(pemBytes []byte) (ed25519.PrivateKey, error) { block, _ := pem.Decode(pemBytes) if block == nil { return nil, errors.New("paymentapi: key is not PEM") } parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { return nil, fmt.Errorf("paymentapi: parse key: %w", err) } key, ok := parsed.(ed25519.PrivateKey) if !ok { return nil, errors.New("paymentapi: key is not Ed25519") } return key, nil } // SignRequest sets Content-Digest, X-Request-ID, X-Request-Nonce, Signature-Input and // Signature on the request. body must be the exact bytes that will be sent; nil for GET. // The caller sets Authorization and Content-Type. func SignRequest(r *http.Request, body []byte, key ed25519.PrivateKey, keyID string, opts SignOptions) error { if opts.Created.IsZero() { opts.Created = time.Now() } if opts.Lifetime == 0 { opts.Lifetime = MaxLifetime } if opts.Nonce == "" { opts.Nonce = randomHex(16) } if opts.RequestID == "" { opts.RequestID = randomHex(16) } path := r.URL.EscapedPath() if path == "" { path = "/" } authority := r.Host if authority == "" { authority = r.URL.Host } digest := ContentDigest(body) values := map[string]string{ "@method": strings.ToUpper(r.Method), "@path": path, "@authority": strings.ToLower(authority), "content-digest": digest, "x-request-id": opts.RequestID, "x-request-nonce": opts.Nonce, } quoted := make([]string, len(components)) var base strings.Builder for i, name := range components { quoted[i] = `"` + name + `"` base.WriteString(`"` + name + `": ` + values[name] + "\n") } created := opts.Created.Unix() params := fmt.Sprintf(`(%s);created=%d;expires=%d;keyid="%s";alg="ed25519"`, strings.Join(quoted, " "), created, created+int64(opts.Lifetime/time.Second), keyID) base.WriteString(`"@signature-params": ` + params) signature := ed25519.Sign(key, []byte(base.String())) r.Header.Set("Content-Digest", digest) r.Header.Set("X-Request-ID", opts.RequestID) r.Header.Set("X-Request-Nonce", opts.Nonce) r.Header.Set("Signature-Input", "sig1="+params) r.Header.Set("Signature", "sig1=:"+base64.StdEncoding.EncodeToString(signature)+":") return nil } func randomHex(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { panic(err) // crypto/rand does not fail on supported platforms } return hex.EncodeToString(b) } ``` !!! warning "Подписывайте и отправляйте одни и те же байты" Сериализуйте JSON один раз в строку или байты, подпишите их и отправьте **их же**. Не передавайте HTTP-клиенту объект, который он сериализует заново: порядок ключей, пробелы или экранирование могут измениться, и дайджест не совпадёт. ## Чек-лист: `401 signature_invalid` { #debug-checklist } Сервер не говорит, что именно не так: любая ошибка подписи выглядит одинаково. Проверьте по порядку. 1. **Проверочный пример.** Ваша функция выдаёт ровно те заголовки, что [выше](#test-vector)? Если нет — ошибка в коде подписи, а не в запросе. 2. **Тело.** Подписаны и отправлены одни и те же байты? Частая причина — клиент пересобирает JSON, добавляет перевод строки или меняет кодировку. 3. **Часы.** Время сервера точное? `created` в будущем больше чем на 30 секунд или запрос пришёл после `expires` — отказ. Включите NTP. 4. **Путь и хост.** `@path` без query и без домена, в том виде, как уходит в запрос. `@authority` — хост в нижнем регистре, без `https://`. Если запрос идёт через прокси, который меняет `Host`, подпись не совпадёт. 5. **Ключ.** `keyid` скопирован из кабинета и принадлежит этому проекту? Ключ не отозван? Приватный ключ — пара к загруженному публичному? Сравните `keyid` из генератора ключа с кабинетом. 6. **Компоненты.** Все шесть компонентов в `Signature-Input`, имена в нижнем регистре и в двойных кавычках? Порядок строк в базе такой же, как в `Signature-Input`? 7. **Формат.** `Signature: sig1=:…:` — двоеточия по краям, стандартный base64 с `=`, ровно 64 байта подписи. Метка в `Signature` та же, что в `Signature-Input`. 8. **Nonce.** 16–128 символов без пробелов и запятых. Повтор nonce даёт не 401, а `409 request_replayed`. 9. **Срок.** `expires - created` не больше 300. Если всё сходится, а ответ всё ещё `401`, пришлите в поддержку `X-Request-ID` запроса и время отправки. ## Ротация ключа { #key-rotation } У проекта один рабочий ключ и может быть один ключ на ротации. Пока идёт ротация, подписи принимаются обоими. 1. Сгенерируйте новую пару и загрузите публичный ключ в кабинет — он встанет «на ротацию». 2. Переключите серверы на новый приватный ключ и новый `keyid`. 3. Когда все серверы перешли, активируйте новый ключ в кабинете. Старый отзовётся. Если приватный ключ утёк, сразу отзовите его в кабинете и загрузите новый. ## Машиночитаемый пример { #test-vector-json } Файл `examples/test-vectors.json` из репозитория документации — общий для всех языков: ```json { "_comment": "Общие проверочные примеры. Подпись сгенерирована серверной реализацией platform/httpsig (Sign) и проверена ею же (Verify). Ключ — тестовый ключ RFC 8032 §7.1 TEST 1, публично известен: не используйте его нигде, кроме тестов.", "signing": { "private_key_pem_file": "testdata/test-key.pem", "public_key_pem_file": "testdata/test-key.pub.pem", "seed_hex": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", "public_key_b64": "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=", "key_id": "ed25519-If4x36FUomFia_hUBG_SJw", "method": "POST", "url": "https://api.example.com/api/v1/payments", "body": "{\"merchant_payment_id\":\"order-1001\",\"amount\":\"5000.00\",\"currency\":\"RUB\",\"geo_code\":\"RU\",\"payment_method\":\"sbp\",\"bank_code\":\"sber\"}", "created": 1790596800, "expires": 1790597100, "nonce": "9f86d081884c7d659a2feaa0c55ad015", "request_id": "0b6d2c4e-8f1a-4d3b-9e7c-5a2f1b3c4d5e", "expected": { "content_digest": "sha-256=:7vp0UJ9PjprEvF8nY/Eh9EQV284GkSkTMxsEL6T6IBo=:", "signature_base": "\"@method\": POST\n\"@path\": /api/v1/payments\n\"@authority\": api.example.com\n\"content-digest\": sha-256=:7vp0UJ9PjprEvF8nY/Eh9EQV284GkSkTMxsEL6T6IBo=:\n\"x-request-id\": 0b6d2c4e-8f1a-4d3b-9e7c-5a2f1b3c4d5e\n\"x-request-nonce\": 9f86d081884c7d659a2feaa0c55ad015\n\"@signature-params\": (\"@method\" \"@path\" \"@authority\" \"content-digest\" \"x-request-id\" \"x-request-nonce\");created=1790596800;expires=1790597100;keyid=\"ed25519-If4x36FUomFia_hUBG_SJw\";alg=\"ed25519\"", "signature_input": "sig1=(\"@method\" \"@path\" \"@authority\" \"content-digest\" \"x-request-id\" \"x-request-nonce\");created=1790596800;expires=1790597100;keyid=\"ed25519-If4x36FUomFia_hUBG_SJw\";alg=\"ed25519\"", "signature": "sig1=:BtY8/iQPBfIpIsuvra/cgfiKFMF+tL0yjt4Y37FCM5aUtbGvo1F0pH1SDKp6QSc8Wo6Jj/+rv12KE5VLuZecAg==:", "empty_body_digest": "sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:" } }, "callback": { "secret": "whsec_MfKQ9r2vXz", "previous_secret": "whsec_previous", "timestamp": "1700000000", "body": "{\"id\":\"3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42\",\"event_id\":\"5b0c8f5e-1d2a-4a8e-9f3c-7e6d5c4b3a21\",\"amount\":3400,\"initial_amount\":3400,\"status\":\"completed\",\"currency\":\"RUB\",\"payment_method\":\"sbp\",\"bank_code\":\"sber\",\"merchant_payment_id\":\"order-42\",\"rate\":11.00,\"course\":100.00,\"is_intrabank\":false,\"is_test\":false,\"project_id\":\"5d7e2c1a-0b9f-4e3d-8c2b-1a0f9e8d7c6b\"}", "expected_signature": "e3e688bac4039def6f680c16f14701866d8e5e2de66d9b37f9ecc8bccb90220f", "expected_previous_signature": "54b9a97acdb1adf27f81bc49bdc9d963b4fe4f3bf5114ea3303581084e99f3b4" } } ``` --- Source: /callbacks/ Language: ru # Callback-и Callback — это `POST` с JSON, который API отправляет на ваш сервер, когда заявка оплачена или отменена. По нему вы узнаёте результат без опроса. Каждый callback подписан секретом проекта: проверяйте подпись, прежде чем верить содержимому. ## Как это работает { #how-it-works }
1. **API.** Заявка закрылась — `completed` или `canceled`. Создаётся событие с новым `event_id` 2. **API → Ваш сервер.** `POST` на адрес callback-а с подписью `X-Callback-Signature` 3. **Ваш сервер.** Проверяет подпись и время, ищет `event_id` среди обработанных 4. **Ваш сервер ⇢ API.** Отвечает `2xx` сразу, до обработки заказа 5. **Ваш сервер.** В фоне обновляет заказ по `id` и `status` 6. **API → Ваш сервер.** Нет `2xx` — то же событие ещё раз через 30 и через 60 секунд
Главное: - Callback — **уведомление о событии**. Источник правды — заявка в API: `GET /api/v1/payments/{id}`. - Одно событие может прийти **несколько раз**, события по одной заявке — **не по порядку**. - По одной заявке может прийти **несколько разных событий**: например, `canceled`, а потом `completed` — см. [Смена статуса после закрытия](#status-changes). - Попыток мало — 3 за полторы минуты. Отвечайте `2xx` быстро и держите [сверку](#missing) на случай, если callback не дошёл. ## Когда приходит { #when } Callback приходит, когда боевая заявка переходит в один из двух статусов: | Статус | Что случилось | Что делать | | --- | --- | --- | | `completed` | Оплата подтверждена | Выдать заказ на сумму `amount` | | `canceled` | Заявка не оплачена: истёк её срок, её отменили или оплату не удалось подтвердить после выдачи реквизитов | Не выдавать заказ; предложить оплатить заново — новой заявкой | Причину отмены callback не передаёт: для вас все случаи одинаковы — реквизиты больше не действуют. Callback **не приходит**: - о заявке в статусе `error`. `error` бывает только до выдачи реквизитов, и вы узнаёте о нём из ответа на создание или из `GET /api/v1/payments/{id}`. Если реквизиты выданы, заявка заканчивается только `completed` или `canceled` — и о любом из них приходит callback; - о промежуточных статусах `created`, `processing` и `appeal` (оплату перепроверяют — callback придёт с результатом проверки); - о тестовых заявках (`is_test: true`) — см. [Тестовые заявки](#test-payments); - если адрес callback-а не задан ни в заявке, ни в проекте, ни у мерчанта — см. [Куда приходит](#url). ### Смена статуса после закрытия { #status-changes } `completed` и `canceled` — финальные статусы, но их может изменить проверка оплаты или решение службы поддержки. Каждое изменение — **новое событие с новым `event_id`**, и о нём приходит новый callback: | Было | Пришло | Когда бывает | | --- | --- | --- | | `canceled` | `completed` | Плательщик перевёл деньги после срока заявки, или оплату подтвердили позже | | `completed` | `completed` с другим `amount` | Плательщик перевёл другую сумму, и её подтвердили. `initial_amount` не меняется | | `completed` | `canceled` | Оплата не подтвердилась при проверке | | любой | тот же статус ещё раз | Проверка оставила статус прежним, или служба поддержки повторила callback | Обработчик должен уметь: - выдать заказ по отменённой заявке, если позже пришёл `completed`; - пересчитать заказ, если в новом `completed` другой `amount`; - отозвать или придержать заказ, если после `completed` пришёл `canceled`. !!! warning "События могут прийти не по порядку" Если callback меняет то, что вы уже записали по заявке, перечитайте её через `GET /api/v1/payments/{id}` и примените **статус и сумму из ответа API**. Так запоздавшее событие не откатит заказ. ## Куда приходит { #url } Адрес выбирается по первому заданному: 1. `callback_url` из запроса на создание заявки; 2. адрес callback-ов проекта (кабинет, «Проекты и API»); 3. общий адрес callback-ов мерчанта (кабинет, настройки мерчанта). Если адреса нет ни на одном уровне, callback не отправляется: результат узнавайте через `GET /api/v1/payments/{id}`. Требования к адресу: - абсолютный `https://`, без логина и пароля, до 2048 символов, не `localhost`; - хост разрешается только в публичные IP. Если среди адресов хоста есть внутренний (частные сети, loopback, link-local, CGNAT `100.64.0.0/10`), соединение не открывается; - **redirect не выполняется**: ответ `3xx` — неудача. Указывайте конечный адрес. ## Тело { #body } Тело — событие о переходе заявки в статус: основные поля заявки, курс и ставка, `event_id` и `project_id`. Реквизитов и сумм в USDT в нём нет — полную заявку отдаёт `GET /api/v1/payments/{id}` ([поля](sbp.md#response)). ```json { "id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42", "event_id": "5b0c8f5e-1d2a-4a8e-9f3c-7e6d5c4b3a21", "amount": 3400, "initial_amount": 3400, "status": "completed", "currency": "RUB", "payment_method": "sbp", "bank_code": "sber", "merchant_payment_id": "order-42", "rate": 11.00, "course": 100.00, "is_intrabank": false, "is_test": false, "project_id": "5d7e2c1a-0b9f-4e3d-8c2b-1a0f9e8d7c6b" } ``` | Поле | Что значит | | --- | --- | | `id` | `id` заявки из ответа на её создание | | `event_id` | Идентификатор события. Одинаковый во всех повторах одного события и равен заголовку `X-Callback-Event-Id` | | `status` | Статус события: `completed` или `canceled`. При ручном повторе — текущий закрытый статус заявки, в том числе `error` | | `amount` | Сумма, которую засчитали, в основных единицах валюты. После проверки оплаты может отличаться от `initial_amount` — выдавайте заказ по `amount` | | `initial_amount` | Сумма из запроса на создание, не меняется | | `merchant_payment_id` | Ваш номер заказа | | `rate`, `course` | Ваша ставка, % и курс — валюта заявки за 1 USDT. Числа с двумя знаками, те же, что в [`merchant_information`](sbp.md#merchant-information); `null`, если реквизиты не выдавались | | `is_test` | `true` у тестовой заявки проекта | | `project_id` | Проект, к которому относится заявка | | остальные поля | Как в [ответе на создание](sbp.md#response): `currency`, `payment_method`, `bank_code`, `is_intrabank` | - Кода ошибки в callback нет, даже при ручном повторе заявки в `error`: причину смотрите `GET`-ом. - `amount`, `initial_amount`, `rate` и `course` читайте как десятичные числа (decimal), а не float. - Новые поля могут добавляться: неизвестные поля игнорируйте. - Тело собирается один раз, когда событие ставится в очередь, и во всех повторах доставки одно и то же байт в байт. Если сомневаетесь в актуальности, перечитайте заявку `GET`-ом. ## Заголовки { #headers } | Заголовок | Значение | | --- | --- | | `Content-Type` | `application/json` | | `X-Callback-Event-Id` | `event_id` события | | `X-Callback-Timestamp` | Unix-время отправки этой попытки, секунды | | `X-Callback-Signature` | `v1=`; 24 часа после ротации секрета — `v1=,v1=` | ## Проверка подписи { #signature } ```text signed_payload = X-Callback-Timestamp + "." + сырое_тело_запроса signature = hex( HMAC-SHA256( key = секрет проекта целиком, message = signed_payload ) ) ``` - Секрет — строка `whsec_…`. Ключ HMAC — **вся строка** в UTF-8, вместе с `whsec_`. Декодировать ничего не нужно. - `hex` — 64 символа в нижнем регистре. - Считайте подпись по **сырым байтам тела** до разбора JSON. После разбора и повторной сериализации байты могут измениться. - Запрос подлинный, если хотя бы одно значение `v1=` совпало с вашей подписью. Сравнивайте за постоянное время. Значения с другой схемой (не `v1`) пропускайте. ### Защита от повтора { #replay } 1. Отклоняйте запрос, если `X-Callback-Timestamp` отличается от ваших часов больше чем на 5 минут. Держите часы синхронизированными по NTP. Timestamp входит в подпись, подменить его нельзя. 2. Храните обработанные `event_id` не меньше суток. Повтор с тем же `event_id` не обрабатывайте второй раз — просто ответьте `2xx`. `event_id` — идентификатор **события**, а не заявки и не попытки: | Что произошло | `event_id` | Тело | Timestamp и подпись | | --- | --- | --- | --- | | Повтор доставки после ошибки или ответа не `2xx` | тот же | то же байт в байт | новые | | Новый статус заявки | новый | новое | новые | | Ручной повтор службой поддержки | новый, даже если статус не изменился | новое | новые | Итог по заявке определяйте по `id` и `status`, а `event_id` используйте только чтобы не обработать одну доставку дважды. ### Функции проверки { #verify-functions } Функции проверяют подпись и свежесть и проверены на примере ниже. === "curl" Для отладки в терминале. В приложении используйте функцию своего языка: в shell нельзя сравнить строки за постоянное время. ```bash # Payment API callback signature check (HMAC-SHA256) for debugging in a terminal. # In your application verify with code that compares in constant time. # POSIX sh + openssl. Load with: . ./verify_callback.sh # api_callback_signature SECRET TIMESTAMP BODY_FILE — the expected signature (hex). api_callback_signature() { { printf '%s.' "$2"; cat "$3"; } | openssl dgst -sha256 -hmac "$1" -r | cut -d' ' -f1 } # api_verify_callback SECRET TIMESTAMP SIGNATURE_HEADER BODY_FILE [NOW] # Exit code 0: the signature matches and the timestamp is fresh (±300 seconds). api_verify_callback() { api_now=${5:-$(date +%s)} api_skew=$((api_now - $2)); [ "$api_skew" -lt 0 ] && api_skew=$((-api_skew)) [ "$api_skew" -le 300 ] || return 1 api_expected=$(api_callback_signature "$1" "$2" "$4") for api_part in $(printf '%s' "$3" | tr ',' ' '); do [ "$api_part" = "v1=$api_expected" ] && return 0 done return 1 } ``` === "PHP" ```php CALLBACK_MAX_SKEW) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret); foreach (explode(',', $signature) as $part) { [$scheme, $value] = array_pad(explode('=', trim($part), 2), 2, ''); if ($scheme === 'v1' && hash_equals($expected, $value)) { return true; } } return false; } ``` === "Python" ```python """Payment API callback signature check: HMAC-SHA256. Standard library only.""" import hashlib import hmac import time MAX_SKEW_SECONDS = 300 # reject callbacks more than 5 minutes old or ahead def verify_callback(secret: str, headers, body: bytes, now: float | None = None) -> bool: """True if the callback is authentic and fresh. secret — the whole project secret, including the whsec_ prefix; headers — request headers (any object with .get); body — raw body bytes, before JSON parsing. """ timestamp = headers.get("X-Callback-Timestamp", "") signature = headers.get("X-Callback-Signature", "") if not timestamp.isdigit(): return False now = time.time() if now is None else now if abs(now - int(timestamp)) > MAX_SKEW_SECONDS: return False signed = timestamp.encode() + b"." + body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() for part in signature.split(","): scheme, _, value = part.strip().partition("=") if scheme == "v1" and hmac.compare_digest(value, expected): return True return False ``` === "Node.js" ```javascript // Payment API callback signature check: HMAC-SHA256. Node.js 20+, no dependencies. import { createHmac, timingSafeEqual } from 'node:crypto'; const MAX_SKEW_SECONDS = 300; // reject callbacks more than 5 minutes old or ahead /** * true if the callback is authentic and fresh. * secret — the whole project secret, including the whsec_ prefix; * headers — request headers with lower-case names (as in node:http); * body — raw body bytes (Buffer), before JSON parsing. */ export function verifyCallback(secret, headers, body, now = Date.now() / 1000) { const timestamp = String(headers['x-callback-timestamp'] ?? ''); const signature = String(headers['x-callback-signature'] ?? ''); if (!/^\d+$/.test(timestamp)) return false; if (Math.abs(now - Number(timestamp)) > MAX_SKEW_SECONDS) return false; const expected = Buffer.from( createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest('hex'), ); return signature.split(',').some((part) => { const [scheme, value = ''] = part.trim().split('=', 2); const candidate = Buffer.from(value); return scheme === 'v1' && candidate.length === expected.length && timingSafeEqual(candidate, expected); }); } ``` === "Go" ```go package paymentapi import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "net/http" "strconv" "strings" "time" ) // maxSkew: callbacks more than 5 minutes old or ahead are rejected. const maxSkew = 5 * time.Minute // VerifyCallback checks the callback signature and freshness. secret is the whole project // secret including whsec_; body is the raw request body, before JSON parsing. func VerifyCallback(secret string, header http.Header, body []byte, now time.Time) bool { timestamp := header.Get("X-Callback-Timestamp") sent, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return false } if skew := now.Sub(time.Unix(sent, 0)); skew > maxSkew || skew < -maxSkew { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(timestamp + ".")) mac.Write(body) expected := []byte(hex.EncodeToString(mac.Sum(nil))) for _, part := range strings.Split(header.Get("X-Callback-Signature"), ",") { scheme, value, ok := strings.Cut(strings.TrimSpace(part), "=") if ok && scheme == "v1" && hmac.Equal([]byte(value), expected) { return true } } return false } ``` ### Проверочный пример { #test-vector } `body` — ровно одна строка без переводов строк и пробелов между полями (тело из примера выше в том виде, в каком его отправляет API), 362 байта: ```text secret = whsec_MfKQ9r2vXz timestamp = 1700000000 body = {"id":"3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42","event_id":"5b0c8f5e-1d2a-4a8e-9f3c-7e6d5c4b3a21","amount":3400,"initial_amount":3400,"status":"completed","currency":"RUB","payment_method":"sbp","bank_code":"sber","merchant_payment_id":"order-42","rate":11.00,"course":100.00,"is_intrabank":false,"is_test":false,"project_id":"5d7e2c1a-0b9f-4e3d-8c2b-1a0f9e8d7c6b"} signature = e3e688bac4039def6f680c16f14701866d8e5e2de66d9b37f9ecc8bccb90220f ``` С прошлым секретом `whsec_previous` то же тело даёт `54b9a97acdb1adf27f81bc49bdc9d963b4fe4f3bf5114ea3303581084e99f3b4`, и во время перекрытия заголовок выглядит так: `v1=e3e688ba…90220f,v1=54b9a97a…99f3b4`. ## Как обрабатывать { #handling } 1. Прочитайте **сырое тело** и заголовки. 2. Проверьте подпись и `X-Callback-Timestamp`. Не прошло — ответьте `401` и ничего не меняйте. 3. Запишите `event_id` в таблицу с уникальным индексом. Уже есть — ответьте `2xx` и выйдите. 4. Поставьте событие в свою очередь и сразу ответьте `2xx`. 5. В фоне найдите заказ по `merchant_payment_id` или сохранённому `id` и примените `status` и `amount` по правилам [смены статуса](#status-changes). Ответ `2xx` значит «событие принято», а не «заказ выдан». Если обработка упала уже после ответа, перечитайте заявку `GET`-ом: сам callback больше не придёт. ### Минимальный приёмник { #minimal-receiver } Проверяет подпись, отбрасывает повторы и сразу отвечает `2xx`. В бою храните `event_id` в базе с уникальным индексом, а обработку ставьте в очередь. === "PHP" ```php {$event['status']}"); http_response_code(204); // answer 2xx at once, process asynchronously ``` === "Python" ```python """Minimal callback receiver on the standard library. export CALLBACK_SECRET=whsec_... python callback_server.py # listens on :8080, any path """ import json import os from http.server import BaseHTTPRequestHandler, HTTPServer from callback import verify_callback SECRET = os.environ.get("CALLBACK_SECRET", "") seen_events: set[str] = set() # in production: a database table with a unique event_id class CallbackHandler(BaseHTTPRequestHandler): def do_POST(self) -> None: body = self.rfile.read(int(self.headers.get("Content-Length", 0))) if not verify_callback(SECRET, self.headers, body): self.send_response(401) self.end_headers() return event = json.loads(body) if event["event_id"] not in seen_events: seen_events.add(event["event_id"]) # Enqueue the work here: mark the order as paid, etc. print("payment", event["merchant_payment_id"], "->", event["status"]) self.send_response(204) # answer 2xx at once, process asynchronously self.end_headers() if __name__ == "__main__": HTTPServer(("", 8080), CallbackHandler).serve_forever() ``` === "Node.js" ```javascript // Minimal callback receiver on node:http. // // export CALLBACK_SECRET=whsec_... // node callback-server.mjs # listens on :8080, any path import { createServer } from 'node:http'; import { verifyCallback } from './callback.mjs'; const secret = process.env.CALLBACK_SECRET ?? ''; const seenEvents = new Set(); // in production: a database table with a unique event_id createServer((req, res) => { const chunks = []; req.on('data', (chunk) => chunks.push(chunk)); req.on('end', () => { const body = Buffer.concat(chunks); if (req.method !== 'POST' || !verifyCallback(secret, req.headers, body)) { res.writeHead(401).end(); return; } const event = JSON.parse(body); if (!seenEvents.has(event.event_id)) { seenEvents.add(event.event_id); // Enqueue the work here: mark the order as paid, etc. console.log('payment', event.merchant_payment_id, '->', event.status); } res.writeHead(204).end(); // answer 2xx at once, process asynchronously }); }).listen(8080); ``` === "Go" ```go // Minimal callback receiver on net/http. // // export CALLBACK_SECRET=whsec_... // go run ./cmd/callback-server # listens on :8080, any path package main import ( "encoding/json" "io" "log" "net/http" "os" "sync" "time" "example.com/paymentapi" ) func main() { secret := os.Getenv("CALLBACK_SECRET") var mu sync.Mutex seen := map[string]bool{} // in production: a database table with a unique event_id http.HandleFunc("POST /", func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) if err != nil || !paymentapi.VerifyCallback(secret, r.Header, body, time.Now()) { w.WriteHeader(http.StatusUnauthorized) return } var event struct { EventID string `json:"event_id"` MerchantPaymentID string `json:"merchant_payment_id"` Status string `json:"status"` } if err = json.Unmarshal(body, &event); err != nil { w.WriteHeader(http.StatusBadRequest) return } mu.Lock() first := !seen[event.EventID] seen[event.EventID] = true mu.Unlock() if first { // Enqueue the work here: mark the order as paid, etc. log.Printf("payment %s -> %s", event.MerchantPaymentID, event.Status) } w.WriteHeader(http.StatusNoContent) // answer 2xx at once, process asynchronously }) log.Fatal(http.ListenAndServe(":8080", nil)) } ``` ## Доставка и повторы { #delivery } - **Успех** — любой ответ `2xx`. Тело ответа не читается дальше 4 КБ. - **Неудача** — сетевая ошибка, таймаут или любой другой код: `3xx`, `4xx`, `5xx`. - **Таймаут** — 10 секунд на запрос. Отвечайте `2xx` сразу, обрабатывайте асинхронно. - **Повторы.** У события 3 попытки: сразу, через 30 секунд и ещё через 60 секунд — около полутора минут. Все попытки несут тот же `event_id` и то же тело. После третьей неудачи доставка этого события останавливается. - **Ручной повтор.** Служба поддержки может отправить callback по закрытой заявке ещё раз, в том числе по заявке в `error`. Это новое событие с новым `event_id` и одной попыткой, без автоматических повторов. - **Порядок не гарантирован.** Повтор старого события может прийти после нового. Обработчик должен быть идемпотентным. | Попытка | Когда | Если нет `2xx` | | --- | --- | --- | | 1 | сразу после события | попытка 2 через 30 секунд | | 2 | через 30 секунд | попытка 3 через 60 секунд | | 3 | через 90 секунд | доставка остановлена | !!! tip "Сомневаетесь — перечитайте заявку" Если callback противоречит тому, что вы знаете о заказе, прочитайте актуальное состояние через `GET /api/v1/payments/{id}`. Ответ API важнее порядка доставки. ## Если callback не пришёл { #missing } Попыток мало, поэтому не полагайтесь только на callback: - **Сверка.** Раз в несколько минут выбирайте свои заказы, которые ждут оплаты дольше срока заявки проекта (по умолчанию 15 минут), и читайте их через `GET /api/v1/payments/{id}`. Закрытую заявку обрабатывайте так же, как пришедший callback. Не опрашивайте чаще раза в 5–10 секунд на заявку: у проекта общий [лимит запросов](idempotency.md#rate-limits). - **Проверьте адрес.** Он задан хотя бы на одном уровне, открывается из интернета, отвечает `2xx` без redirect-а, сертификат действителен. - **Повторная отправка.** Если ваш сервер был недоступен, передайте в поддержку `id` заявок или их `merchant_payment_id` — callback-и отправят ещё раз. Каждый придёт с новым `event_id`. ### Частые проблемы { #troubleshooting } | Симптом | Причина | | --- | --- | | Подпись не сходится | Подпись считается по разобранному и заново собранному JSON, а не по сырому телу; ключ взят без `whsec_`; секрет от другого проекта или окружения | | Подпись верная, но запрос отклоняется по времени | Часы сервера расходятся больше чем на 5 минут | | Callback-и не приходят совсем | Адрес не задан; адрес отвечает `301` или `302`; хост разрешается во внутренний IP; заявки тестовые | | Заказ обработан дважды | Повторы не отбрасываются по `event_id`, или итог считается по `event_id`, а не по `id` и `status` | | Заказ «откатился» | Запоздавшее событие применено поверх нового. Перечитывайте заявку `GET`-ом, когда статус меняется | ## Тестовые заявки { #test-payments } Заявки с `is_test: true` — тестовые заявки проекта и тестовые платежи из кабинета — callback автоматически не получают. Их итог читайте через `GET /api/v1/payments/{id}`. Чтобы проверить свой обработчик: - прогоните функцию проверки на [проверочном примере](#test-vector); - попросите службу поддержки повторить callback по закрытой тестовой заявке — на ваш адрес придёт настоящий подписанный запрос с `is_test: true`. Подробнее — в разделе [Песочница](sandbox.md). ## Секрет и ротация { #secret } - Секрет выпускается в кабинете: «Проекты и API» → «Доступ к внешнему API» → «Callback». Значение показывается **один раз** — сохраните его в хранилище секретов. - Секрет у каждого проекта свой; у песочницы и боя — разные. - Если секрет ещё не выпускали, платформа создаёт его сама при первой доставке, но значение не показывает никому. Чтобы проверять подпись, выпустите новый. - **Ротация без потерь.** После выпуска нового секрета прошлый ещё 24 часа подписывает callback-и вторым значением `v1`. Порядок: выпустите новый → добавьте его в проверку рядом со старым (или сразу замените — подойдёт любое совпавшее значение) → в течение 24 часов уберите старый. Повторная ротация в эти 24 часа сразу отключает самый старый секрет: одновременно действуют не больше двух. - Если секрет утёк, выпустите новый и сразу уберите старый из проверки. ## Чек-лист { #checklist } - [ ] Адрес callback-а задан, открыт из интернета, `https://`, без redirect-а. - [ ] Подпись проверяется по сырому телу, сравнение за постоянное время. - [ ] Запросы старше 5 минут отклоняются, часы синхронизированы. - [ ] `event_id` хранится с уникальным индексом не меньше суток. - [ ] `2xx` уходит сразу, обработка — в фоне. - [ ] Заказ выдаётся по `amount`, а не по `initial_amount`. - [ ] Обработаны `canceled` → `completed`, `completed` → `canceled` и новый `amount`. - [ ] Есть сверка через `GET /api/v1/payments/{id}` для заказов без callback-а. --- Source: /api-reference/ Language: ru # Справочник API Все методы API для мерчанта: путь, права, поля запроса, ответы и примеры. Страница собрана из спецификации OpenAPI, поэтому совпадает с тем, что проверяет сервер. Как пройти путь целиком — в [быстром старте](quickstart.md), что значат статусы и коды — в разделе [Статусы и ошибки](statuses.md). Пути указаны после `{{base_url}}` — адреса API, выданного вам для песочницы или боевого окружения (см. [Окружения](index.md#environments)). Интерактивный справочник Скачать openapi.yaml !!! warning "Временная спецификация" Сейчас здесь основные методы из временной спецификации. Полная спецификация появится из репозитория бэкенда, и эта страница пересоберётся из неё сама. ## Создать заявку (СБП или карта) { #createPayment }
POST{{base_url}}/api/v1/payments
Создаёт заявку на приём и сразу подбирает реквизиты. Идемпотентна по `merchant_payment_id`: тот же номер с тем же запросом вернёт `200` и сохранённую заявку, тот же номер с другим запросом — `409 payment_idempotency_conflict`. `201` может прийти уже со `status: error` и `error_code`, если реквизиты не выданы; callback-а о ней не будет. Строгий JSON: неизвестное поле — `400 bad_request`. Срок заявки задаёт настройка проекта (по умолчанию 15 минут), в запросе его нет. Подпись обязательна всегда. Таймаут клиента — не меньше 15 секунд. **Доступ:** право `payments:create` · подпись обязательна Заголовки подписи `Signature-Input`, `Signature`, `Content-Digest`, `X-Request-ID`, `X-Request-Nonce` — см. [Подпись запросов](signing.md). ### Тело запроса | Поле | Тип | Обяз. | Описание | | --- | --- | --- | --- | | `merchant_payment_id` | string | да | Ваш номер заказа, уникален в проекте; ключ идемпотентности. 1–64 символа `A-Za-z0-9._:-`. | | `amount` | number \| string | да | Сумма в основных единицах валюты, больше нуля, до 6 знаков после точки: `3400` или `"3400.50"`. Строка надёжнее числа: без ошибок округления. | | `payment_method` | string: `sbp`, `card_transfer` | да | `sbp` — перевод по СБП, `card_transfer` — перевод на карту. | | `currency` | string | нет | Код валюты. По умолчанию `RUB`. | | `geo_code` | string | нет | Страна, 2 буквы. По умолчанию `RU`. | | `bank_code` | string | нет | Банк плательщика — код из справочника (`sber`, `tinkoff`, `ozon`), не БИК. Пусто — любой банк. | | `is_intrabank` | boolean | нет | Только перевод внутри одного банка. По умолчанию `false`. | | `callback_url` | string, uri | нет | HTTPS-адрес callback для этой заявки, публичный хост. Иначе используется адрес проекта. | **Пример запроса** ```json { "merchant_payment_id": "order-42", "amount": 3400, "payment_method": "sbp", "bank_code": "sber", "callback_url": "https://merchant.example/callbacks" } ``` ### Ответы | Код | Что значит | | --- | --- | | `201` | Заявка создана | | `200` | Повтор того же запроса: сохранённая заявка | | `400` | Неверный JSON или неизвестное поле (`bad_request`), неверные поля заявки (`invalid_payment`) | | `401` | Неверный токен или подпись | | `403` | Нет права или доступ закрыт | | `409` | Повтор nonce или другой запрос с тем же номером заказа | | `413` | Слишком большое тело запроса | | `422` | Заявку нельзя провести: нет договора, курса или способ оплаты сейчас недоступен | | `429` | Превышен лимит запросов | | `500` | Error | | `503` | Временно недоступна защита от повторов или лимиты — повторите позже | **Пример ответа** ```json { "id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42", "merchant_payment_id": "order-42", "flow": "runtime_live", "status": "processing", "amount": 3400, "initial_amount": 3400, "currency": "RUB", "geo_code": "RU", "payment_method": "sbp", "bank_code": "sber", "is_intrabank": false, "callback_url": "https://merchant.example/callbacks", "is_test": false, "merchant_information": { "course": 100.0, "rate": 11.0, "amount_usdt": "34.0000", "amount_rate": "3026.0000", "amount_usdt_rate": "30.2600" }, "requisite": "+79991234567", "holder_name": "Иван Петров", "created_at": "2026-09-29T09:30:00Z", "updated_at": "2026-09-29T09:30:01Z" } ``` ## Получить заявку { #getPayment }
GET{{base_url}}/api/v1/payments/{id}
Возвращает заявку в том же виде, что и ответ на создание. Подпись нужна, только если её требует политика проекта. **Доступ:** право `payments:read` · подпись — если её требует политика проекта ### Параметры пути | Параметр | Тип | Обяз. | Описание | | --- | --- | --- | --- | | `id` | string, uuid | да | Payment `id` returned on create. | ### Ответы | Код | Что значит | | --- | --- | | `200` | Заявка | | `401` | Неверный токен или подпись | | `403` | Нет права или доступ закрыт | | `404` | Заявка не найдена, чужая или `id` не UUID | | `429` | Превышен лимит запросов | **Пример ответа** ```json { "id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42", "merchant_payment_id": "order-42", "flow": "runtime_live", "status": "processing", "amount": 3400, "initial_amount": 3400, "currency": "RUB", "geo_code": "RU", "payment_method": "sbp", "bank_code": "sber", "is_intrabank": false, "callback_url": "https://merchant.example/callbacks", "is_test": false, "merchant_information": { "course": 100.0, "rate": 11.0, "amount_usdt": "34.0000", "amount_rate": "3026.0000", "amount_usdt_rate": "30.2600" }, "requisite": "+79991234567", "holder_name": "Иван Петров", "created_at": "2026-09-29T09:30:00Z", "updated_at": "2026-09-29T09:30:01Z" } ``` ## Кто я (проверка токена) { #getProject }
GET{{base_url}}/api/v1/project
Показывает проект и мерчанта, к которым относится токен, и его права. Удобно для первой проверки ключа. **Доступ:** подпись — если её требует политика проекта ### Ответы | Код | Что значит | | --- | --- | | `200` | Проект токена | | `401` | Неверный токен или подпись | --- Source: /changelog/ Language: ru # Изменения Новые поля в ответах и callback-ах добавляются без смены версии API — игнорируйте незнакомые поля. О несовместимых изменениях предупредим заранее. ## 2026-10-01 { #2026-10-01 } **Callback-и** — раздел [Callback-и](callbacks.md) расширен. - Повторы доставки: 3 попытки — сразу, через 30 и через 60 секунд (было 10 попыток за ~2,5 минуты). Ручной повтор службой поддержки — одна попытка. - Новые разделы: как работает доставка, смена статуса после закрытия (`canceled` → `completed`, `completed` → `canceled`, новый `amount`), куда приходит callback, как обрабатывать, что делать без callback-а, частые проблемы, тестовые заявки, чек-лист. ## 2026-09-30 { #2026-09-30 } **API v1** — формат заявки и callback-а приведён к итоговой спецификации (до первых боевых интеграций). - Расчёт `settlement` переименован в `merchant_information`: `course` (был `exchange_rate`) и `rate` (был `fee_percent`) теперь числа с двумя знаками, `amount_rate` и `amount_usdt_rate` — прежние `net_amount` и `net_amount_usdt`. - В ответе появились `flow`, `is_test` и `updated_at`; поля `bank` больше нет. - Callback — короткое событие: `id`, `event_id`, суммы, `status`, `currency`, `payment_method`, `bank_code`, `merchant_payment_id`, `rate`, `course`, `is_intrabank`, `is_test`, `project_id`. Реквизиты и суммы в USDT — в `GET /api/v1/payments/{id}`. - Проверочный пример подписи callback-а обновлён под новое тело. ## 2026-09-29 { #2026-09-29 } **Документация** - Первая версия документации для мерчантов на русском и английском. - Примеры подписи запросов и проверки callback-ов на curl, PHP, Python, Node.js и Go, проверенные на общих проверочных примерах. - Markdown-версии страниц, `llms.txt` и MCP-сервер документации для ИИ-агентов. **API v1** - `POST /api/v1/payments` — создание заявки, `GET /api/v1/payments/{id}` — чтение. - Запрос: `merchant_payment_id` (1–64 символа `A-Za-z0-9._:-`), `amount` в основных единицах валюты, `payment_method`; необязательные `currency` (`RUB`), `geo_code` (`RU`), `bank_code`, `is_intrabank`, `callback_url`. - Срок заявки задаётся настройкой проекта, по умолчанию 15 минут. Не оплаченная к сроку заявка становится `canceled`, и приходит callback. - Ответ: банк реквизита `bank` и расчёт `settlement` — курс, ставка и суммы в USDT. - Callback приходит о `completed` и `canceled`; тело — та же заявка плюс `event_id` и `project_id`. - Отказы до приёма заявки (`429`, `503`, подпись, повтор nonce, размер тела, неверное тело или поля) не занимают `merchant_payment_id`. - Ответ `422` содержит `payment_id`, если заявка успела сохраниться. Мерчант на паузе получает `422 payment_not_routable`. --- Source: /ai-agents/ Language: ru # Для ИИ-агентов Документация устроена так, чтобы ИИ-ассистент — Claude, ChatGPT, Cursor, Codex — мог прочитать её целиком и написать интеграцию без догадок. Есть три способа. ## 1. Markdown-версия любой страницы { #markdown } Добавьте `.md` к адресу страницы — получите её исходный Markdown с примерами кода: | HTML | Markdown | | --- | --- | | `/quickstart/` | `/quickstart.md` | | `/en/signing/` | `/en/signing.md` | | `/` | `/index.md` | В шапке каждой страницы есть кнопка **«Скопировать как Markdown»** и меню «Спросить в ChatGPT / Claude» — ассистент откроется со ссылкой на страницу. ## 2. llms.txt { #llms-txt } - /llms.txt — список всех страниц на двух языках с однострочным описанием, по [стандарту llms.txt](https://llmstxt.org/). - /llms-full.txt — вся документация одним файлом. Удобно вставить в контекст целиком. ## 3. MCP-сервер документации { #mcp } MCP-сервер даёт ассистенту инструменты поиска и чтения документации. Только чтение, без авторизации. | Инструмент | Что делает | | --- | --- | | `search_docs(query, lang)` | Поиск по документации, возвращает разделы с цитатами | | `get_page(path, lang)` | Страница целиком в Markdown | | `list_pages(lang)` | Все страницы с описаниями | | `get_api_operation(operation_id)` | Метод API из спецификации OpenAPI: `createPayment`, `getPayment`, `getProject` | | `get_code_example(language, topic)` | Проверенный пример: `curl`, `php`, `python`, `node`, `go` × `sign_request`, `verify_callback`, `create_sbp_payment`, `generate_key`, `callback_receiver` | Страницы доступны и как ресурсы MCP: `docs://ru/quickstart`, `docs://en/signing`. Адрес сервера: `/mcp` — на том же хосте, что и документация (транспорт Streamable HTTP). === "Claude Code" ```bash claude mcp add --transport http payment-api-docs /mcp ``` === "Cursor" Файл `.cursor/mcp.json` в проекте или `~/.cursor/mcp.json`: ```json { "mcpServers": { "payment-api-docs": { "url": "/mcp" } } } ``` === "Codex" Файл `~/.codex/config.toml`: ```toml [mcp_servers.payment-api-docs] url = "/mcp" ``` === "ChatGPT" В ChatGPT: **Settings → Apps & Connectors → Advanced → Developer mode**, затем **Create** и укажите адрес сервера, авторизация — **No authentication**. ChatGPT подключается только к публичному `https://`-адресу: для локальной копии документации используйте туннель. ## Подсказка для ассистента { #prompt } Скопируйте в начало разговора: ```text Ты помогаешь интегрировать платёжный API. Источник правды — документация: /mcp (MCP) или llms.txt. Правила: - Каждый POST подписывается по RFC 9421 Ed25519: возьми готовую функцию get_code_example(<язык>, "sign_request") и не пиши подпись с нуля. - Проверь функцию на проверочном примере со страницы signing. - merchant_payment_id — ключ идемпотентности: при повторе тот же номер, новая подпись. - Callback проверяй HMAC-SHA256 по сырому телу: get_code_example(<язык>, "verify_callback"). - Адрес API {{base_url}} (свой у песочницы и боя) держи в настройке, не в коде. ``` --- Source: /en/ Language: en

Payment API · v1

# Accept SBP and card payments through one API You create a payment. The API issues the transfer requisites, watches for the money and sends you a signed callback when it arrives. [Quickstart in 15 minutes](quickstart.md){ .md-button .md-button--primary } [Request signing](signing.md){ .md-button }
## Integration in five steps { #five-steps }
  1. Project and tokenCreate a project in the cabinet and issue an API token.
  2. Signing keyGenerate an Ed25519 key and upload the public part to the cabinet.
  3. CallbackSet a notification URL and issue a callback signing secret.
  4. PaymentSend a signed POST /api/v1/payments and show the requisites to the payer.
  5. ResultReceive the callback, verify its signature and mark the order as paid.
1. **Your server → API.** `POST /api/v1/payments`, Ed25519 signature 2. **API ⇢ Your server.** `201`: `status=processing`, requisites, recipient and bank 3. **Your server → Payer.** You show the requisites, amount and deadline 4. **Payer.** Transfers via SBP or to the card in their bank app 5. **API → Your server.** Callback `status=completed`, HMAC-SHA256 signature 6. **Your server ⇢ API.** You reply `2xx`
## Where to go next { #next }
- :material-lightning-bolt:{ .lg } **[SBP payments](sbp.md)** --- Payment request, what to show the payer, statuses, polling and edge cases. - :material-credit-card-outline:{ .lg } **[Card payments](card.md)** --- Transfer to a card: how it differs from SBP and what to show the payer. - :material-signature-freehand:{ .lg } **[Request signing](signing.md)** --- RFC 9421 and Ed25519: ready-made functions and a shared test vector. - :material-bell-ring-outline:{ .lg } **[Callbacks](callbacks.md)** --- Result notifications and HMAC signature verification. - :material-alert-circle-outline:{ .lg } **[Statuses and errors](statuses.md)** --- Every status, error code and when to retry. - :material-robot-outline:{ .lg } **[For AI agents](ai-agents.md)** --- llms.txt, Markdown pages and the docs MCP server.
## Environments { #environments } Addresses in these docs are written as placeholders; substitute your own values: - `{{base_url}}` is the API address issued to you when you are connected. The sandbox and production each have their own. Request examples put `{{base_url}}` before the path: `POST {{base_url}}/api/v1/payments`. - `{{cabinet_url}}` is the merchant cabinet address. You receive it together with your access. | Environment | API address | Cabinet | Token prefix | | --- | --- | --- | --- | | Sandbox | sandbox `{{base_url}}` | sandbox `{{cabinet_url}}` | `nl_test_` | | Production | production `{{base_url}}` | production `{{cabinet_url}}` | `nl_live_` | Keep `{{base_url}}` in your app configuration, not in code: going live then changes one setting. The sandbox behaves like production: same signing, limits and errors. The only difference is that no money moves. See [Sandbox](sandbox.md). ## General API conventions { #conventions } - **Format.** Requests and responses are UTF-8 JSON. Parsing is strict: an unknown field or trailing data after the object returns `400 bad_request`. - **Access.** Every request carries `Authorization: Bearer `. - **Signature.** Every write request is signed with an Ed25519 key per [RFC 9421](signing.md). Payments cannot be created without a signature, neither in the sandbox nor in production. - **Amounts.** `amount` is in major currency units: `3400` or `"3400.50"` roubles. USDT amounts in `merchant_information` are decimal strings: `"34.0000"`; the rate `course` and fee `rate` are numbers with two decimals: `100.00`. - **Time.** All timestamps are RFC 3339 in UTC, for example `2026-09-28T12:00:00Z`. - **Errors.** The error body is `{"error":""}`. See [Statuses and errors](statuses.md) for the list. - **Tracing.** The `X-Request-ID` request header is echoed in the response. Log it: support finds your request by it. ## API methods { #endpoints } | Method | What it does | Signature | | --- | --- | --- | | `POST /api/v1/payments` | Creates a pay-in | always | | `GET /api/v1/payments/{id}` | Returns a payment by `id` | if the project policy requires it | | `GET /api/v1/project` | Shows the project and token scopes | if the project policy requires it | Field-level details are in the [API reference](api-reference.md). --- Source: /en/quickstart/ Language: en # Quickstart In 15 minutes you will set up a project in the cabinet, create your first signed SBP payment in the sandbox and get ready to receive callbacks. You need cabinet access and a machine with OpenSSL 3 or one of: PHP 8.1+, Python 3.10+, Node.js 20+, Go 1.22+. !!! tip "Everything you need fits in five values" At the end you will have the API base URL `{{base_url}}`, a project token, a `private.pem` file, the key's `keyid` and a callback secret `whsec_…`. Keep the token, key and secret in a secret store, not in code or in the repository. ## 1. Sign in to the cabinet { #cabinet } Open the sandbox cabinet at `{{cabinet_url}}`: the cabinet address and the API address `{{base_url}}` come with your access (see [Environments](index.md#environments)). Your manager gives you the login; sign-in asks for a two-factor code. Everything below happens in **Projects and API** («Проекты и API») → your project → **External API access** («Доступ к внешнему API»). The cabinet may show Russian labels. A project is your shop or site: it has its own token, keys, callback and limits. ## 2. Issue a project token { #token } Click "Issue key" and tick the scopes `payments:create` and `payments:read`. The cabinet shows the token **once** — save it right away. Sandbox tokens start with `nl_test_`, production tokens with `nl_live_`. Check the token: the call returns your project and scopes. ```bash export BASE_URL={{base_url}} # the sandbox API address issued to you export API_TOKEN=nl_test_... # token from the cabinet curl -sS "$BASE_URL/api/v1/project" \ -H "Authorization: Bearer $API_TOKEN" ``` ```json { "project_id": "5d7e2c1a-0b9f-4e3d-8c2b-1a0f9e8d7c6b", "project_name": "Main site", "merchant_id": "8d0f1e2a-3b4c-4d5e-8f60-718293a4b5c6", "merchant_name": "My shop", "token_prefix": "nl_test_abcdef", "scopes": ["payments:create", "payments:read"] } ``` `401 token_invalid` means the token was copied partially, was revoked, or the project is not active. ## 3. Create a signing key { #signing-key } Every payment request is signed with an Ed25519 private key. The private key stays with you; only the public key goes to the cabinet. A stolen token without the key cannot create a single payment. Generate a key pair in any of these ways: === "OpenSSL" ```bash #!/bin/sh # Creates an Ed25519 signing key: private.pem (keep it secret) and public.pem (for the cabinet). # Requires OpenSSL 3.0 or newer. set -eu . "$(dirname "$0")/sign.sh" [ -e private.pem ] && { echo 'private.pem already exists' >&2; exit 1; } umask 077 openssl genpkey -algorithm ed25519 -out private.pem openssl pkey -in private.pem -pubout -out public.pem echo "keyid: $(api_key_id public.pem)" # the cabinet shows the same value ``` === "PHP" ```php "-----BEGIN $label-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END $label-----\n"; $privatePem = $pem('PRIVATE KEY', hex2bin('302e020100300506032b657004220420') . $seed); $publicPem = $pem('PUBLIC KEY', hex2bin('302a300506032b6570032100') . $public); if (file_exists('private.pem')) { fwrite(STDERR, "private.pem already exists\n"); exit(1); } $old = umask(0077); file_put_contents('private.pem', $privatePem); umask($old); file_put_contents('public.pem', $publicPem); echo 'keyid: ', api_key_id($public), PHP_EOL; // the cabinet shows the same value ``` === "Python" ```python """Creates an Ed25519 signing key: private.pem (keep it secret) and public.pem (for the cabinet).""" import os from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from sign import key_id key = Ed25519PrivateKey.generate() private_pem = key.private_bytes( serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() ) public = key.public_key() public_pem = public.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo) fd = os.open("private.pem", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "wb") as f: f.write(private_pem) with open("public.pem", "wb") as f: f.write(public_pem) raw = public.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) print("keyid:", key_id(raw)) # the cabinet shows the same value ``` === "Node.js" ```javascript // Creates an Ed25519 signing key: private.pem (keep it secret) and public.pem (for the cabinet). import { createPublicKey, generateKeyPairSync } from 'node:crypto'; import { writeFileSync } from 'node:fs'; import { keyId } from './sign.mjs'; const { publicKey, privateKey } = generateKeyPairSync('ed25519', { publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); writeFileSync('private.pem', privateKey, { mode: 0o600, flag: 'wx' }); writeFileSync('public.pem', publicKey); // The last 32 bytes of SPKI are the raw public key. const raw = createPublicKey(publicKey).export({ type: 'spki', format: 'der' }).subarray(-32); console.log('keyid:', keyId(raw)); // the cabinet shows the same value ``` === "Go" ```go // Creates an Ed25519 signing key: private.pem (keep it secret) and public.pem (for the cabinet). package main import ( "crypto/ed25519" "crypto/rand" "crypto/x509" "encoding/pem" "fmt" "log" "os" "example.com/paymentapi" ) func main() { public, private, err := ed25519.GenerateKey(rand.Reader) if err != nil { log.Fatal(err) } privateDER, err := x509.MarshalPKCS8PrivateKey(private) if err != nil { log.Fatal(err) } publicDER, err := x509.MarshalPKIXPublicKey(public) if err != nil { log.Fatal(err) } f, err := os.OpenFile("private.pem", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { log.Fatal(err) } if err = pem.Encode(f, &pem.Block{Type: "PRIVATE KEY", Bytes: privateDER}); err != nil { log.Fatal(err) } if err = f.Close(); err != nil { log.Fatal(err) } if err = os.WriteFile("public.pem", pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicDER}), 0o644); err != nil { log.Fatal(err) } fmt.Println("keyid:", paymentapi.KeyID(public)) // the cabinet shows the same value } ``` In the cabinet click "Add signing key" and paste the whole `public.pem`, including the `-----BEGIN PUBLIC KEY-----` lines. A 32-byte key in base64 works too. The cabinet shows a `keyid` such as `ed25519-If4x36FUomFia_hUBG_SJw`; it must match what the generator printed. ```bash export SIGNING_KEY_FILE=$PWD/private.pem export SIGNING_KEY_ID=ed25519-... # keyid from the cabinet ``` ## 4. Restrict source addresses (recommended) { #ip-allowlist } Add the outgoing IP addresses of your servers: a single address (`203.0.113.5`) or a network (`203.0.113.0/24`). Once the first rule exists, the API accepts the project **only** from these addresses; others get `403 ip_not_allowed`. An empty list means no address check. ## 5. Set up callbacks { #callback } 1. Set the project callback URL: a public `https://…` address without redirects. You can also pass `callback_url` in each payment. 2. Click "Issue secret" in the Callback block. The `whsec_…` secret is shown **once**. If you have no server yet, run the minimal receiver from [Callbacks](callbacks.md#minimal-receiver) and expose it to the internet with any tunnel. ## 6. Create an SBP payment { #first-payment } The script signs the request, creates a payment and prints the requisites for the payer. === "curl" ```bash #!/bin/sh # Creates an SBP pay-in. # # export BASE_URL={{base_url}} # the API address issued to you: sandbox or production # export API_TOKEN=nl_test_... # project token # export SIGNING_KEY_FILE=private.pem # Ed25519 private key # export SIGNING_KEY_ID=ed25519-... # keyid from the cabinet # ./create_sbp_payment.sh order-1001 5000.00 set -eu . "$(dirname "$0")/sign.sh" : "${BASE_URL:?BASE_URL is not set: export the API address issued to you (sandbox or production)}" url="${BASE_URL%/}/api/v1/payments" body=$(mktemp) trap 'rm -f "$body"' EXIT # The body goes to a file so the signed and the sent bytes are identical (--data-binary). printf '{"merchant_payment_id":"%s","amount":"%s","currency":"RUB","geo_code":"RU","payment_method":"sbp"}' \ "$1" "$2" > "$body" set -- while IFS= read -r header; do set -- "$@" -H "$header"; done < $orderId, // your order id, the idempotency key 'amount' => $amount, // a string in roubles: "5000.00" 'currency' => 'RUB', 'geo_code' => 'RU', 'payment_method' => 'sbp', ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); $secretKey = api_load_private_key(file_get_contents(getenv('SIGNING_KEY_FILE'))); $headers = api_sign_request('POST', $url, $body, $secretKey, getenv('SIGNING_KEY_ID')); $headers['Authorization'] = 'Bearer ' . getenv('API_TOKEN'); $headers['Content-Type'] = 'application/json'; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, // the same bytes that were signed CURLOPT_HTTPHEADER => array_map(fn ($k, $v) => "$k: $v", array_keys($headers), $headers), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, // requisites are issued synchronously: 15 seconds or more ]); $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); if ($response === false || $status >= 300) { fwrite(STDERR, "$status " . ($response ?: curl_error($ch)) . PHP_EOL); exit(1); } $payment = json_decode($response, true, flags: JSON_THROW_ON_ERROR); echo "Payment {$payment['id']} status {$payment['status']}", PHP_EOL; if (!empty($payment['requisite'])) { echo "Transfer {$payment['amount']} {$payment['currency']} to {$payment['requisite']}", ' recipient ', $payment['holder_name'] ?? '', PHP_EOL; } ``` === "Python" ```python """Creates an SBP pay-in and prints the requisites for the payer. export BASE_URL={{base_url}} # the API address issued to you: sandbox or production export API_TOKEN=nl_test_... # project token export SIGNING_KEY_FILE=private.pem # Ed25519 private key export SIGNING_KEY_ID=ed25519-... # keyid from the cabinet python create_sbp_payment.py order-1001 5000.00 """ import json import os import sys import urllib.error import urllib.request from sign import load_private_key, sign_request def main() -> None: order_id, amount = sys.argv[1], sys.argv[2] base_url = os.environ.get("BASE_URL", "") if not base_url: sys.exit("BASE_URL is not set: export the API address issued to you (sandbox or production)") url = base_url.rstrip("/") + "/api/v1/payments" payload = { "merchant_payment_id": order_id, # your order id, the idempotency key "amount": amount, # a string in roubles: "5000.00" "currency": "RUB", "geo_code": "RU", "payment_method": "sbp", } body = json.dumps(payload, separators=(",", ":")).encode() with open(os.environ["SIGNING_KEY_FILE"], "rb") as f: key = load_private_key(f.read()) headers = sign_request("POST", url, body, key, os.environ["SIGNING_KEY_ID"]) headers["Authorization"] = "Bearer " + os.environ["API_TOKEN"] headers["Content-Type"] = "application/json" request = urllib.request.Request(url, data=body, headers=headers, method="POST") try: # Requisites are issued synchronously: keep the timeout at 15 seconds or more. with urllib.request.urlopen(request, timeout=30) as response: payment = json.load(response) except urllib.error.HTTPError as error: print(error.code, error.read().decode(), file=sys.stderr) sys.exit(1) print("Payment", payment["id"], "status", payment["status"]) if payment.get("requisite"): print("Transfer", payment["amount"], payment["currency"], "to", payment["requisite"], "recipient", payment["holder_name"] or "") if __name__ == "__main__": main() ``` === "Node.js" ```javascript // Creates an SBP pay-in and prints the requisites for the payer. // // export BASE_URL={{base_url}} # the API address issued to you: sandbox or production // export API_TOKEN=nl_test_... # project token // export SIGNING_KEY_FILE=private.pem # Ed25519 private key // export SIGNING_KEY_ID=ed25519-... # keyid from the cabinet // node create-sbp-payment.mjs order-1001 5000.00 import { readFileSync } from 'node:fs'; import { loadPrivateKey, signRequest } from './sign.mjs'; const [orderId, amount] = process.argv.slice(2); const baseUrl = process.env.BASE_URL; if (!baseUrl) { console.error('BASE_URL is not set: export the API address issued to you (sandbox or production)'); process.exit(1); } const url = `${baseUrl.replace(/\/$/, '')}/api/v1/payments`; const body = JSON.stringify({ merchant_payment_id: orderId, // your order id, the idempotency key amount, // a string in roubles: "5000.00" currency: 'RUB', geo_code: 'RU', payment_method: 'sbp', }); const key = loadPrivateKey(readFileSync(process.env.SIGNING_KEY_FILE)); const headers = { ...signRequest('POST', url, body, key, process.env.SIGNING_KEY_ID), Authorization: `Bearer ${process.env.API_TOKEN}`, 'Content-Type': 'application/json', }; // Requisites are issued synchronously: keep the timeout at 15 seconds or more. const response = await fetch(url, { method: 'POST', headers, body, signal: AbortSignal.timeout(30_000) }); const payment = await response.json(); if (!response.ok) { console.error(response.status, payment); process.exit(1); } console.log('Payment', payment.id, 'status', payment.status); if (payment.requisite) { console.log('Transfer', payment.amount, payment.currency, 'to', payment.requisite, 'recipient', payment.holder_name ?? ''); } ``` === "Go" ```go // Creates an SBP pay-in and prints the requisites for the payer. // // export BASE_URL={{base_url}} # the API address issued to you: sandbox or production // export API_TOKEN=nl_test_... # project token // export SIGNING_KEY_FILE=private.pem # Ed25519 private key // export SIGNING_KEY_ID=ed25519-... # keyid from the cabinet // go run ./cmd/create-sbp-payment order-1001 5000.00 package main import ( "bytes" "encoding/json" "fmt" "log" "net/http" "os" "strings" "time" "example.com/paymentapi" ) func main() { if len(os.Args) != 3 { log.Fatal("usage: create-sbp-payment ") } baseURL := os.Getenv("BASE_URL") if baseURL == "" { log.Fatal("BASE_URL is not set: export the API address issued to you (sandbox or production)") } url := strings.TrimRight(baseURL, "/") + "/api/v1/payments" body, _ := json.Marshal(map[string]string{ "merchant_payment_id": os.Args[1], // your order id, the idempotency key "amount": os.Args[2], // a string in roubles: "5000.00" "currency": "RUB", "geo_code": "RU", "payment_method": "sbp", }) pemBytes, err := os.ReadFile(os.Getenv("SIGNING_KEY_FILE")) if err != nil { log.Fatal(err) } key, err := paymentapi.LoadPrivateKey(pemBytes) if err != nil { log.Fatal(err) } req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) if err = paymentapi.SignRequest(req, body, key, os.Getenv("SIGNING_KEY_ID"), paymentapi.SignOptions{}); err != nil { log.Fatal(err) } req.Header.Set("Authorization", "Bearer "+os.Getenv("API_TOKEN")) req.Header.Set("Content-Type", "application/json") // Requisites are issued synchronously: keep the timeout at 15 seconds or more. client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { log.Fatal(err) } defer resp.Body.Close() var payment map[string]any if err = json.NewDecoder(resp.Body).Decode(&payment); err != nil { log.Fatal(err) } if resp.StatusCode >= 300 { log.Fatalf("%d %v", resp.StatusCode, payment) } fmt.Println("Payment", payment["id"], "status", payment["status"]) // requisite and holder_name are always present; null until requisites are issued. if requisite, ok := payment["requisite"].(string); ok { fmt.Println("Transfer", payment["amount"], payment["currency"], "to", requisite, "recipient", payment["holder_name"]) } } ``` Response `201 Created`: ```json { "id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42", "merchant_payment_id": "order-1001", "flow": "runtime_live", "status": "processing", "amount": 5000, "initial_amount": 5000, "currency": "RUB", "geo_code": "RU", "payment_method": "sbp", "bank_code": null, "is_intrabank": false, "callback_url": null, "is_test": false, "merchant_information": { "course": 100.00, "rate": 11.00, "amount_usdt": "50.0000", "amount_rate": "4450.0000", "amount_usdt_rate": "44.5000" }, "requisite": "+79991234567", "holder_name": "Иван Петров", "created_at": "2026-09-29T09:30:00Z", "updated_at": "2026-09-29T09:30:01Z" } ``` Show the payer `requisite`, `holder_name` and the exact `amount`. The payment waits for the transfer as long as the project setting says (15 minutes by default). What exactly to display is in [SBP payments](sbp.md#payer-screen); what `merchant_information` means is in [Settlement](sbp.md#merchant-information). !!! failure "Got `401 signature_invalid`?" Walk through the [signature debugging checklist](signing.md#debug-checklist). Most often one body was signed and another was sent, or the server clock is off. ## 7. Receive the callback { #receive-callback } When the payment becomes `completed` (paid) or `canceled` (not paid), a `POST` with an `X-Callback-Signature` header arrives at your callback URL. The body is an event: the main payment fields, the rate and fee, `event_id` and `project_id` (no requisites — read the full payment with `GET`). Verify the signature, answer `2xx` and process the event. In the sandbox you choose the payment outcome yourself, and test payments get no automatic callback — see [Sandbox](sandbox.md). Check your handler against the [test vector](callbacks.md#test-vector). ```json { "id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42", "event_id": "5b0c8f5e-1d2a-4a8e-9f3c-7e6d5c4b3a21", "amount": 5000, "initial_amount": 5000, "status": "completed", "currency": "RUB", "payment_method": "sbp", "bank_code": null, "merchant_payment_id": "order-1001", "rate": 11.00, "course": 100.00, "is_intrabank": false, "is_test": false, "project_id": "5d7e2c1a-0b9f-4e3d-8c2b-1a0f9e8d7c6b" } ``` ## Before going live { #go-live } - [ ] The token, private key and callback secret live in a secret store. - [ ] The API base URL is a setting, not hard-coded. - [ ] Server clocks are synced with NTP. - [ ] `merchant_payment_id` is your order id; retries reuse the same id. - [ ] The HTTP client timeout is at least 15 seconds. - [ ] The callback handler verifies the signature, answers `2xx` at once and never processes the same event twice. - [ ] A periodic reconciliation reads payments without a callback past the project's payment lifetime via `GET /api/v1/payments/{id}`. - [ ] Production has its own token, signing key and callback secret. --- Source: /en/sandbox/ Language: en # Sandbox The sandbox is a separate environment for testing the whole integration without moving money. The API, signing, limits and errors work as in production. | | Sandbox | | --- | --- | | API | the sandbox `{{base_url}}`, issued with your access | | Cabinet | the sandbox `{{cabinet_url}}` | | Token | starts with `nl_test_` | | Request signing | required, as in production | !!! info "This section is being finished" The sandbox is still being built. Below is how it will work. Places that may still change are marked **TBD**. Watch the [changelog](changelog.md). ## How it works { #how-it-works } 1. **Test project.** Turn on test mode for a project in the sandbox cabinet. Its payments never make real payments. A test payment has `is_test: true` in the response. 2. **Test requisites.** A create response carries test requisites: a test phone number for SBP or a test card number. Nothing needs to be transferred. 3. **You choose the outcome.** Mark the payment as paid or canceled — in the cabinet or via an API call (**TBD**). 4. **Read the result through the API.** Test payments get no automatic callback (**TBD**: a way to receive a callback for a test payment is still being designed). Read the final status with `GET /api/v1/payments/{id}`; check the callback body format and signature against the [test vector](callbacks.md#test-vector). ## Set a payment outcome { #outcome } !!! warning "TBD: outcome emulation API" The API method to set a test payment's outcome is still being designed. For now use the buttons on the payment card in the sandbox cabinet. When the method ships, the request, response and samples in every language will appear here. | What to test | What to do | | --- | --- | | Successful payment | Mark the payment paid → status `completed` | | Decline or cancellation | Cancel the payment → status `canceled` | | Expired deadline | Create a payment and leave it past the project's payment lifetime (15 minutes by default) → `canceled` | | Idempotency | Send the same request twice → `201`, then `200` with the same payment | | Id conflict | The same `merchant_payment_id` with another amount → `409 payment_idempotency_conflict` | | Nonce replay | Send one signed request twice → `409 request_replayed` | | Bad signature | Change the body after signing → `401 signature_invalid` | ## Differences from production { #differences } - No money moves; requisites are not real. - Test payments get no automatic callback and are marked `is_test: true`. The sandbox has its own `{{base_url}}` and an `nl_test_` token. - The sandbox has its own token, signing key and callback secret. Issue new ones for production. - The API address differs: keep it in configuration — see [Environments](index.md#environments). --- Source: /en/sbp/ Language: en # SBP payments The payer sends money by phone number through the Faster Payments System (SBP) in their bank app. You create a payment, the API issues the recipient's phone number and name, you show them to the payer and wait for the result.
1. **Your server → API.** `POST /api/v1/payments` with `payment_method=sbp` 2. **API.** Picks requisites, usually within 15 seconds 3. **API ⇢ Your server.** `201`: `status=processing`, `requisite=+7…`, `holder_name` 4. **Your server → Payer.** Phone number, recipient, amount and deadline 5. **Payer.** Transfers via SBP in their bank app 6. **API → Your server.** Callback: `completed` or `canceled` 7. **Your server ⇢ API.** You reply `2xx`
## Create a payment { #create } `POST /api/v1/payments` — a [signed](signing.md) request with a JSON body. ```http POST {{base_url}}/api/v1/payments HTTP/1.1 Authorization: Bearer nl_test_... Content-Type: application/json Content-Digest: sha-256=:…: X-Request-ID: 0b6d2c4e-8f1a-4d3b-9e7c-5a2f1b3c4d5e X-Request-Nonce: 9f86d081884c7d659a2feaa0c55ad015 Signature-Input: sig1=(…);created=…;expires=…;keyid="ed25519-…";alg="ed25519" Signature: sig1=:…: {"merchant_payment_id":"order-42","amount":3400,"payment_method":"sbp","bank_code":"sber","callback_url":"https://merchant.example/callbacks"} ``` ### Request fields { #request-fields } | Field | Type | Req. | Rules | | --- | --- | --- | --- | | `merchant_payment_id` | string | yes | Your order id. 1–64 characters `A-Za-z0-9._:-`. Unique per project: it is the [idempotency key](idempotency.md) | | `amount` | number or string | yes | Amount in major currency units: `3400` or `"3400.50"`. Positive, at most 6 decimals. A string is safer than a number: no rounding errors | | `payment_method` | string | yes | `sbp` | | `currency` | string | no | Currency code. Default `RUB` | | `geo_code` | string | no | Country, two letters. Default `RU` | | `bank_code` | string | no | The payer's bank — a lower-case code from our bank catalog: `sber`, `tinkoff`, `ozon`. Not a BIC. Empty means any bank | | `is_intrabank` | boolean | no | `true` for a transfer within one bank. Default `false` | | `callback_url` | string | no | Where to send this payment's callback. Absolute `https://`, up to 2048 characters, no credentials, public host. Otherwise the project URL is used | No other fields are accepted: an unknown field returns `400 bad_request`. Test mode is a project setting, not a request field. A request without `currency` and `geo_code` and the same request with `"RUB"` and `"RU"` are the same request. There is no deadline in the request: how long a payment lives is a project setting, 15 minutes from creation by default. Your manager can change it. ### Response { #response } `201 Created` for a new payment. `200 OK` for a repeat with the same `merchant_payment_id` and the same body: the existing payment is returned, no new one is created. ```json { "id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42", "merchant_payment_id": "order-42", "flow": "runtime_live", "status": "processing", "amount": 3400, "initial_amount": 3400, "currency": "RUB", "geo_code": "RU", "payment_method": "sbp", "bank_code": "sber", "is_intrabank": false, "callback_url": "https://merchant.example/callbacks", "is_test": false, "merchant_information": { "course": 100.00, "rate": 11.00, "amount_usdt": "34.0000", "amount_rate": "3026.0000", "amount_usdt_rate": "30.2600" }, "requisite": "+79991234567", "holder_name": "Иван Петров", "created_at": "2026-09-29T09:30:00Z", "updated_at": "2026-09-29T09:30:01Z" } ``` | Field | Meaning | | --- | --- | | `id` | Payment id. Store it next to the order | | `merchant_payment_id` | Your order id | | `flow` | `runtime_live` — a regular payment, `project_test` — a project test payment | | `status` | Current status, see [below](#statuses) | | `amount` | Amount to pay, in major currency units. May change after a [payment re-check](#edge-cases) | | `initial_amount` | Amount from the request | | `currency`, `geo_code`, `payment_method`, `is_intrabank` | As in the request, with defaults applied | | `bank_code` | The payer's bank from the request, or `null`. Not the requisite's bank | | `callback_url` | Callback URL from the request, or `null` | | `is_test` | `true` for a project test payment | | `merchant_information` | [Settlement](#merchant-information) in USDT; `null` until requisites are issued | | `requisite` | Recipient phone number for the SBP transfer; `null` until issued | | `holder_name` | Recipient name as the payer's bank will show it, or `null` | | `qrcode_link`, `deeplink_url` | Only when the requisites have them: you can show a QR code or an "Open bank app" button | | `error_code`, `error_comment` | Only for a payment in `error`: why no requisites were issued | | `created_at`, `updated_at` | Creation and last change time, UTC | `bank_code`, `callback_url`, `merchant_information`, `requisite` and `holder_name` are always present; the value may be `null`. New fields may be added without a new API version: ignore unknown fields. ### Settlement `merchant_information` { #merchant-information } What you receive for the payment. The exchange rate and fee are fixed when requisites are issued and do not change afterwards: settlement is not recalculated at current rates. After a payment re-check, settlement uses the new amount with the same rate and fee. These are the same figures that are credited to the project balance. | Field | Meaning | Type, decimals | | --- | --- | --- | | `course` | Rate: units of the payment currency per 1 USDT | number, 2 | | `rate` | Project fee, % | number, 2 | | `amount_usdt` | Payment amount in USDT | string, 4 | | `amount_rate` | Amount after the fee, in the payment currency | string, 4 | | `amount_usdt_rate` | Amount after the fee in USDT — credited to the project balance | string, 4 | `course` and `rate` are numbers with exactly two decimals (`100.00`); amounts are decimal strings. Extra digits are truncated, never rounded up; the fee is rounded down. In the example above: 3400 RUB at a rate of 100 and an 11% fee is 34 USDT, a fee of 374 RUB (3.74 USDT), 30.26 USDT credited. !!! tip "Parse `course` and `rate` as decimals" Use a decimal type (`Decimal`, `BigDecimal`, `json.Number`), not a float, to keep the precision. !!! warning "Do not recompute `amount_usdt`" The rate may be stored with more than two decimals, so `amount / course` can differ from `amount_usdt` in the last digit. `amount_usdt` is authoritative. ## What to show the payer { #payer-screen } Show the requisites only when the status is `processing` and `requisite` is present. The payer transfers exactly `amount` to `requisite`. - **Phone number** from `requisite` — large, with a "Copy" button. - **Recipient** from `holder_name`: the payer checks the name in the bank app before sending. - **Amount** — exactly `amount`, to the kopeck. Warn that a different amount may not be credited automatically. - **Deadline** — a countdown from `created_at` over your project's payment lifetime (15 minutes by default). After it, the requisites must not be used. - **Next step** — "Come back to this page after the transfer". Update the status from the callback or by polling. !!! warning "Do not cache requisites" Requisites are issued for one payment. For a new order or another attempt, create a new payment with a new `merchant_payment_id`. ## Statuses { #statuses }
| Status | Meaning | What to do | | --- | --- | --- | | created | Accepted, no requisites yet | Wait. The response usually already says `processing` | | processing | Requisites issued, waiting for the transfer | Show the requisites to the payer | | completed | Payment confirmed | Deliver the goods or service | | canceled | Not paid: the lifetime ran out, it was canceled, or the payment could not be confirmed | Offer to pay again with a new payment | | error | No requisites were issued, reason in `error_code` | Offer to pay again with a new payment | | appeal | A closed payment is being re-checked | Wait for the resolution: a callback follows if it ends in `completed` or `canceled` | `error` happens only before requisites are issued. From `processing` a payment goes only to `completed` or `canceled`: if the payment cannot be confirmed after requisites were issued, it is also `canceled`, with no `error_code`. `completed`, `canceled` and `error` are final. Only a payment re-check or a support decision can change them. All error codes are in [Statuses and errors](statuses.md). ## Getting the result { #result } **Callbacks are the primary channel.** When a payment becomes `completed` or `canceled`, the API sends a `POST` to your URL. Verification and handling are in [Callbacks](callbacks.md). There is no callback for `error`: it happens only before requisites are issued and is visible in the create response and when you read the payment. Once the payer has seen requisites, the result arrives as a callback. **Polling is the safety net.** If no callback arrived, read the payment: ```bash curl -sS "{{base_url}}/api/v1/payments/3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42" \ -H "Authorization: Bearer $API_TOKEN" ``` The response has the same shape as on create. If the project policy has `signature_required`, sign the `GET` too (empty body). Recommended: - The payer's checkout page polls **your** server, not the API. - Your server reads the payment from the API only when a callback is missing, for example once a minute for `processing` payments past the payment lifetime. - Do not poll a payment more often than every 5–10 seconds: the project shares one [rate limit](idempotency.md#rate-limits). ## Edge cases { #edge-cases } **Response with status `error` right away.** `201` with `status: "error"` means the payment was saved but no requisites were issued. The reason is in `error_code` (`provider_no_requisites`, `provider_result_ambiguous`). No callback follows for this payment. There is nothing to show the payer — offer to try later with a new payment. **`422` on create.** The payment cannot be processed: `payment_not_covered` (method or amount is outside your contract), `payment_rate_unavailable` (no exchange rate), `payment_not_routable` (the payment cannot be accepted with this method right now). If the payment was saved, the body carries `payment_id`: the payment stays in `error`, the id is taken, and repeating the same request returns it (`200`). A new attempt needs a new id. Without `payment_id` no payment was created and the id is free. **Timeout or dropped connection.** You do not know whether the payment exists. Repeat **the same** request with the same `merchant_payment_id` and a fresh nonce and signature: if the payment exists you get it back (`200`), otherwise it is created. No duplicate is possible. **The payer sent a different amount.** The payment may be re-checked and the actual amount confirmed. You then receive a `completed` callback with the new amount: `amount` changes, `initial_amount` stays. Fulfil the order by the callback amount. **Deadline passed.** A payment not paid within its lifetime becomes `canceled`, and a callback is sent. If the payer paid after the deadline, the payment is reviewed manually: a confirmed payment brings a new `completed` callback. **The payment could not be confirmed after requisites were issued.** The payment then becomes `canceled` with no `error_code`, and a callback is sent. If the money did arrive, the payment is reviewed manually and a new `completed` callback follows. **Slow response.** Requisites are picked during your request. Keep the client timeout at 15 seconds or more. ## See also - [Card payments](card.md) — the same, with a card transfer. - [Idempotency and limits](idempotency.md) — retries and rate limits. - [API reference](api-reference.md) — field schema. --- Source: /en/card/ Language: en # Card payments The payer transfers money to the recipient's card number from their bank app. The payment works like an [SBP payment](sbp.md): the same API method, statuses, callbacks and retry rules. What differs is `payment_method` and what `requisite` holds. | | SBP | Card | | --- | --- | --- | | `payment_method` | `sbp` | `card_transfer` | | `requisite` | phone number: `+79991234567` | card number: `2200123456789010` | | How the payer pays | "SBP transfer" by phone number | "Card transfer" by card number | ## Create a payment { #create } `POST /api/v1/payments` with `payment_method: "card_transfer"`. The request is [signed](signing.md) the same way as for SBP. ```json { "merchant_payment_id": "order-1002", "amount": "12500.00", "payment_method": "card_transfer", "bank_code": "sber" } ``` All fields and rules are in [SBP payments → Request fields](sbp.md#request-fields). `bank_code` is the payer's bank, a code from our bank catalog (not a BIC): requisites are picked for it. `is_intrabank: true` together with `bank_code` means a transfer within that bank: the recipient card is at the same bank as the payer's. Both are rare filters: without them, requisites come from any bank. Response `201 Created`: ```json { "id": "7a2d4c1e-9b3f-4e8a-b1c2-3d4e5f6a7b8c", "merchant_payment_id": "order-1002", "flow": "runtime_live", "status": "processing", "amount": 12500, "initial_amount": 12500, "currency": "RUB", "geo_code": "RU", "payment_method": "card_transfer", "bank_code": "sber", "is_intrabank": false, "callback_url": null, "is_test": false, "merchant_information": { "course": 100.00, "rate": 11.00, "amount_usdt": "125.0000", "amount_rate": "11125.0000", "amount_usdt_rate": "111.2500" }, "requisite": "2200123456789010", "holder_name": "Мария П.", "created_at": "2026-09-29T09:30:00Z", "updated_at": "2026-09-29T09:30:01Z" } ``` Response fields and `merchant_information` are the same as for SBP: [Response](sbp.md#response) and [Settlement](sbp.md#merchant-information). ## What to show the payer { #payer-screen } - **Card number** from `requisite` — in groups of four (`2200 1234 5678 9010`), with a "Copy" button that copies the number **without spaces**. - **Recipient** from `holder_name` — the bank shows this name before the transfer. - **Amount** — exactly `amount`. Warn that bank fees are paid on top: what arrives on the card is what gets credited. - **Deadline** — a countdown from `created_at` over your project's payment lifetime (15 minutes by default). !!! warning "The card number is for this payment only" Do not store the card number or show it again. Create a new payment for a new transfer. ## Statuses and result { #result } Statuses, callbacks and polling are the same as for SBP: [Statuses](sbp.md#statuses) and [Getting the result](sbp.md#result). Once a card is issued, the payment ends only as `completed` or `canceled`, each with a callback; `error` means no card was issued and shows in the create response. ## Edge cases { #edge-cases } Everything in [SBP payments → Edge cases](sbp.md#edge-cases) applies to cards too. Card-specific: - **Transfer with a fee.** If the payer's bank deducts a fee from the transfer, less arrives on the card. The actual amount may be confirmed on a payment re-check; the callback then says `completed` with a new `amount`. - **No cards of the requested bank.** With `bank_code` the choice is narrower; if nothing fits, the payment closes with `provider_no_requisites` or the answer is `422 payment_not_routable`. Try without `bank_code`. --- Source: /en/statuses/ Language: en # Statuses and errors ## Payment statuses { #statuses }
| Status | Final | Meaning | | --- | --- | --- | | created | no | Accepted, no requisites yet | | processing | no | Requisites issued, waiting for the payer's transfer | | completed | yes | Payment confirmed | | canceled | yes | Not paid: the payment lifetime ran out, it was canceled, or the payment could not be confirmed after requisites were issued | | error | yes | No requisites were issued, reason in `error_code` | | appeal | no | A closed payment is being re-checked | A create response may already carry any status except `appeal`. A final status changes only after a payment re-check or a support decision — see [Status changes after closing](callbacks.md#status-changes). **`error` happens only before requisites are issued**: from `created`, usually right in the create response. Once requisites are issued (`processing`), a payment ends only as `completed` or `canceled`. If the payment cannot be confirmed after that, it also becomes `canceled`, with no `error_code`. The payment lifetime is a project setting: 15 minutes from `created_at` by default. A payment not paid within it becomes `canceled`. **Callbacks are sent only for `completed` and `canceled`** — when the payment is confirmed or canceled, when the lifetime runs out, after a payment re-check and after a support decision. There is no callback for `error`: you see it in the create response and when you read the payment. More in [Callbacks](callbacks.md#when). ## `error_code` values { #error-codes } `error_code` and `error_comment` are present only for a payment in `error` — in the create response and when you read it. The code explains why no requisites were issued. A `canceled` payment has no code, including when the payment could not be confirmed. For the payer the action is always the same: pay again with a new payment and a new `merchant_payment_id`. | Code | What happened | | --- | --- | | `provider_unavailable` | The payment cannot be accepted now: the payment method is temporarily unavailable | | `provider_no_requisites` | No free requisites were found | | `rate_unavailable` | No exchange rate available | | `provider_result_ambiguous` | Issuing requisites was not confirmed, no requisites issued | | `provider_result_unknown` | The outcome of issuing requisites is unknown, no requisites issued | | `stale_dispatch` | Requisites could not be obtained in time | | `invalid_payment` | The payment failed field validation | | `json_invalid` | The request body could not be parsed | The list may grow: treat an unknown code as a generic failure. ## HTTP errors { #http-errors } The error body is always JSON: ```json {"error": "signature_invalid"} ``` `Content-Type: application/json`; the `X-Request-ID` header is echoed from the request. | HTTP | `error` | When | What to do | | --- | --- | --- | --- | | 400 | `bad_request` | Not JSON, unknown field, trailing data after the object | Fix the request | | 400 | `invalid_payment` | A field failed validation: amount, order id, geo, method, bank, `callback_url`, … | Fix the request | | 401 | `token_invalid` | No token, unknown, revoked or expired token, or the project is not active | Check the token in the cabinet | | 401 | `signature_invalid` | The signature did not verify | [Signing checklist](signing.md#debug-checklist) | | 403 | `insufficient_scope` | The token lacks the scope | Issue a token with `payments:create` / `payments:read` | | 403 | `ip_not_allowed` | The address is not in the project allowlist | Add it in the cabinet | | 403 | `project_blocked` | Project API closed by staff | Contact your manager | | 403 | `merchant_blocked` | Merchant is blocked | Contact your manager | | 403 | `merchant_archived` | Merchant is deleted | Contact your manager | | 404 | `payment_not_found` | No such payment, or it belongs to someone else | Check the `id` | | 409 | `request_replayed` | This `X-Request-Nonce` was already used | Retry with a new nonce and signature | | 409 | `payment_idempotency_conflict` | `merchant_payment_id` is taken by a payment with other fields | New id, or the same fields, see [Idempotency](idempotency.md) | | 413 | `request_too_large` | Body over the project limit (64 KB by default) | Shrink the body | | 422 | `payment_not_covered` | Method, currency or amount outside your contract | Check terms with your manager | | 422 | `payment_rate_unavailable` | No exchange rate | Retry later with a new payment | | 422 | `payment_not_routable` | The payment cannot be accepted with this method right now | Retry later with a new payment | | 429 | `rate_limited` | Rate limit exceeded | Wait `Retry-After` seconds | | 429 | `too_many_concurrent_requests` | More than 20 concurrent project requests | Wait `Retry-After` (1 s) and resend the same request | | 500 | `internal_error` | Server-side failure | Retry with backoff | | 503 | `rate_limit_unavailable` | Rate accounting temporarily unavailable | Wait `Retry-After` and retry | | 503 | `replay_protection_unavailable` | Nonce check temporarily unavailable | Wait `Retry-After` and retry | If the payment was saved (in `error`) before a `422`, the body carries its id: ```json {"error": "payment_not_routable", "payment_id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42"} ``` ## When to retry { #retries } | Answer | Retry? | How | | --- | --- | --- | | Timeout, dropped connection | yes | Same request and `merchant_payment_id`, **new** nonce and signature | | `429`, `503` | yes | After `Retry-After` seconds, then with growing delays | | `500` | yes | With growing delays: 1, 2, 4, 8 seconds… | | `409 request_replayed` | yes | With a new nonce and signature | | `401`, `403`, `400`, `404`, `413` | no | Fix the cause first | | `422`, status `error` | no | A new attempt is a new payment with a new `merchant_payment_id` | Retrying with the same `merchant_payment_id` is safe: no second payment is created. See [Idempotency and limits](idempotency.md). ```python # Retry with backoff: same body and merchant_payment_id, fresh signature each time. for attempt in range(5): headers = sign_request("POST", url, body, key, key_id) # new nonce every time response = send(url, body, headers) if response.status in (429, 503): time.sleep(int(response.headers.get("Retry-After", "1"))) continue if response.status >= 500 or response.status == 0: # 0: timeout or dropped connection time.sleep(2 ** attempt) continue break ``` --- Source: /en/idempotency/ Language: en # Idempotency and limits ## Idempotency { #idempotency } The idempotency key is your `merchant_payment_id`, unique within the project. There is no `Idempotency-Key` header. | What you send | Answer | | --- | --- | | A new `merchant_payment_id` | `201` — a new payment | | The same `merchant_payment_id` and the same fields | `200` — the existing payment; requisites are not requested again | | The same `merchant_payment_id` with other fields | `409 payment_idempotency_conflict` | "The same fields" are `merchant_payment_id`, the amount, `currency`, `geo_code`, `payment_method`, `bank_code`, `is_intrabank` and `callback_url`. Defaults are applied before the comparison: a request without `currency` and the same request with `"currency": "RUB"` are the same. **What you get.** If the answer was lost — timeout, dropped connection, your server crashed — just repeat the request with the same id. No duplicate payment, no double charge. **A new payment attempt.** If a payment closed (`canceled`, `error`) or got `422`, a new attempt needs a **new** `merchant_payment_id`: repeating the old one returns the old result. Adding an attempt number works well: `order-1001-2`. **When the id is not taken.** Rejections before the payment is accepted do not take the `merchant_payment_id`: `429`, `503`, `401 signature_invalid`, `409 request_replayed`, `413 request_too_large`, `400 bad_request` (unparsable body), `400 invalid_payment` (a field failed validation). Fix the cause and send the request with the same id. If the corrected request gets `409 payment_idempotency_conflict`, use a new id. ### Nonces and retries { #nonce } Payment idempotency and one-time signatures are different things. - `X-Request-Nonce` is single-use: resending the same signed request returns `409 request_replayed`. - So **re-sign** on every retry: new nonce, new `created`/`expires`, the same `merchant_payment_id` and the same body. - Exception: `429 too_many_concurrent_requests` does not burn the nonce, so the same signed request may be sent again before `expires`. ## Rate limits { #rate-limits } | Limit | Default | When exceeded | | --- | --- | --- | | Requests per source IP | 600 per minute | `429 rate_limited` | | Project requests | 600 per minute, burst 60 | `429 rate_limited` | | Project payment creates | 60 per minute, burst 6 | `429 rate_limited` | | Concurrent project requests | 20 | `429 too_many_concurrent_requests` | | Request body size | 64 KB | `413 request_too_large` | - Project limits are visible in the cabinet; your manager can change them — ask them manager if you expect more payments. - A `429` carries `Retry-After`: how many seconds to wait. - Limits are per project: all your servers share one counter. - If rate or nonce accounting is temporarily unavailable, the API answers `503` with `Retry-After: 1` — not a problem with your request, retry it. ## Timeouts { #timeouts } Creating a payment picks requisites synchronously. Keep the HTTP client timeout at **15 seconds or more**. If a timeout still happens, repeat the request with the same `merchant_payment_id` — see [above](#idempotency). --- Source: /en/signing/ Language: en # Request signing Every write request to the API is signed with your Ed25519 private key per [RFC 9421 (HTTP Message Signatures)](https://www.rfc-editor.org/rfc/rfc9421). The server stores only your public key and checks the signature before it accepts a payment. A stolen token without the key cannot create a single payment. A signature is always required, in the sandbox and in production. Reads (`GET`) need it only when the project policy has `signature_required`; a signed `GET` is always accepted. !!! tip "Do not write signing from scratch" Take the ready-made function for your language [below](#ready-functions) and run it on the [test vector](#test-vector). If the headers match byte for byte, the server will accept your signatures. ## Headers to add { #headers } | Header | Value | | --- | --- | | `Content-Digest` | `sha-256=::`. For `GET`, the digest of an empty body | | `X-Request-ID` | Your request id: 1–128 characters `A-Za-z0-9._:-` | | `X-Request-Nonce` | A one-time string: 16–128 characters, no spaces, tabs or commas | | `Signature-Input` | `sig1=();created=…;expires=…;keyid="…";alg="ed25519"` | | `Signature` | `sig1=::` | Plus, as usual, `Authorization: Bearer ` and `Content-Type: application/json`. These two are not signed. ## Signature profile { #profile } The server accepts a signature only if all of this holds: - **Components.** The signature covers all six: `"@method"`, `"@path"`, `"@authority"`, `"content-digest"`, `"x-request-id"`, `"x-request-nonce"`. Any order. Extra header components are allowed; other derived components (`"@query"`, `"@target-uri"`, …) are not. - **Parameters.** `created` and `expires` (integers, Unix seconds) and `keyid` (the string from the cabinet) are required. `alg` is optional, but if present it must be `"ed25519"`. `nonce` and `tag` are accepted and ignored. Any other parameter is rejected. - **One signature.** Exactly one label in `Signature-Input`. Use `sig1`. - **Lifetime.** `expires - created` is at most 300 seconds. `created` may be at most 30 seconds in the future. The request must arrive before `expires`. - **Nonce.** `X-Request-Nonce` is unique per project for 5 minutes 30 seconds. A replay returns `409 request_replayed`. - **Body.** `Content-Digest` matches the SHA-256 of **the same bytes** that arrived in the request. Only `sha-256` is accepted. - **Key.** `keyid` is the project's active key or the key in rotation. ## How the signature base is built { #signature-base } The signature base is the text you sign: one line per component, in the same order as in `Signature-Input`, then the `"@signature-params"` line. Lines are separated by `\n`; there is no trailing newline. ```text "@method": POST "@path": /api/v1/payments "@authority": api.example.com "content-digest": sha-256=:7vp0UJ9PjprEvF8nY/Eh9EQV284GkSkTMxsEL6T6IBo=: "x-request-id": 0b6d2c4e-8f1a-4d3b-9e7c-5a2f1b3c4d5e "x-request-nonce": 9f86d081884c7d659a2feaa0c55ad015 "@signature-params": ("@method" "@path" "@authority" "content-digest" "x-request-id" "x-request-nonce");created=1790596800;expires=1790597100;keyid="ed25519-If4x36FUomFia_hUBG_SJw";alg="ed25519" ``` | Component | Value | | --- | --- | | `@method` | Upper-case method: `POST`, `GET` | | `@path` | The URL path exactly as sent, **without the query**: `/api/v1/payments` | | `@authority` | Lower-case host of `{{base_url}}`, with a port only if it is non-default: `api.example.com` | | `content-digest` | The `Content-Digest` header value | | `x-request-id` | The `X-Request-ID` header value as is | | `x-request-nonce` | The `X-Request-Nonce` header value as is | | `@signature-params` | Everything after `sig1=` in `Signature-Input` | Sign the UTF-8 bytes of the base with Ed25519, take the 64 bytes, encode them in base64 and put them in `Signature: sig1=:…:`. ## Test vector { #test-vector } This vector was produced by the server's own signature code and accepted by it. Ed25519 is deterministic: given the same input, your function must produce **exactly** these headers. Every code sample on this page is checked against it automatically. !!! danger "This is a test key" The key comes from RFC 8032 (section 7.1, TEST 1) and is public. Never upload it to the cabinet or use it anywhere except tests. **Input** | What | Value | | --- | --- | | Private key (seed, hex) | `9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60` | | Private key (PEM) | `-----BEGIN PRIVATE KEY-----`
`MC4CAQAwBQYDK2VwBCIEIJ1hsZ3v/VpguoRK9JLsLMREScVpezJpGXA7rAMcrn9g`
`-----END PRIVATE KEY-----` | | Public key (base64) | `11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=` | | `keyid` | `ed25519-If4x36FUomFia_hUBG_SJw` | | Request | `POST https://api.example.com/api/v1/payments`, a domain reserved by RFC 2606. The host is signed, so with your `{{base_url}}` the signature differs | | `created` / `expires` | `1790596800` / `1790597100` (2026-09-28 12:00:00 UTC + 300 s) | | `X-Request-Nonce` | `9f86d081884c7d659a2feaa0c55ad015` | | `X-Request-ID` | `0b6d2c4e-8f1a-4d3b-9e7c-5a2f1b3c4d5e` | The body is exactly these bytes, with no trailing newline: ```json {"merchant_payment_id":"order-1001","amount":"5000.00","currency":"RUB","geo_code":"RU","payment_method":"sbp","bank_code":"sber"} ``` **Expected output** ```http Content-Digest: sha-256=:7vp0UJ9PjprEvF8nY/Eh9EQV284GkSkTMxsEL6T6IBo=: Signature-Input: sig1=("@method" "@path" "@authority" "content-digest" "x-request-id" "x-request-nonce");created=1790596800;expires=1790597100;keyid="ed25519-If4x36FUomFia_hUBG_SJw";alg="ed25519" Signature: sig1=:BtY8/iQPBfIpIsuvra/cgfiKFMF+tL0yjt4Y37FCM5aUtbGvo1F0pH1SDKp6QSc8Wo6Jj/+rv12KE5VLuZecAg==: ``` Empty-body digest (for `GET`): `sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:`. The machine-readable vector is [`examples/test-vectors.json`](#test-vector-json). ## Ready-made functions { #ready-functions } The function takes the method, URL, body bytes, private key and `keyid` and returns the signature headers. It fills in the time, nonce and request id itself; tests can pin them. === "curl" OpenSSL 3.0 or newer (macOS: `brew install openssl@3`). The function prints one header per line — pass them to `curl -H` as the [payment script](quickstart.md#first-payment) does. ```bash # Payment API request signing: RFC 9421, Ed25519. # POSIX sh + OpenSSL 3.0 or newer (macOS: brew install openssl@3). Load with: . ./sign.sh # api_content_digest FILE — Content-Digest of the exact file bytes. api_content_digest() { printf 'sha-256=:%s:' "$(openssl dgst -sha256 -binary < "$1" | openssl base64 -A)" } # api_key_id PUBLIC_PEM — the keyid the cabinet shows for this public key: # "ed25519-" + base64url(sha256(32 key bytes)[:16]). api_key_id() { printf 'ed25519-%s' "$(openssl pkey -pubin -in "$1" -outform DER | tail -c 32 \ | openssl dgst -sha256 -binary | head -c 16 | openssl base64 -A | tr '+/' '-_' | tr -d '=')" } # api_sign_request METHOD URL BODY_FILE KEY_FILE KEY_ID [CREATED NONCE REQUEST_ID] # Prints the signature headers, one "Name: value" per line. # BODY_FILE holds the exact bytes that will be sent (for GET use an empty file or /dev/null). api_sign_request() { api_method=$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]') api_rest=${2#*://} # api.example.com/api/v1/payments?x=1 api_authority=$(printf '%s' "${api_rest%%/*}" | tr '[:upper:]' '[:lower:]') api_path=/${api_rest#*/}; [ "$api_rest" = "${api_rest#*/}" ] && api_path=/ api_path=${api_path%%\?*} # no query string api_created=${6:-$(date +%s)} api_nonce=${7:-$(openssl rand -hex 16)} api_request_id=${8:-$(openssl rand -hex 16)} api_digest=$(api_content_digest "$3") api_params="(\"@method\" \"@path\" \"@authority\" \"content-digest\" \"x-request-id\" \"x-request-nonce\")" api_params="$api_params;created=$api_created;expires=$((api_created + 300));keyid=\"$5\";alg=\"ed25519\"" api_base_file=$(mktemp) printf '"@method": %s\n"@path": %s\n"@authority": %s\n"content-digest": %s\n"x-request-id": %s\n"x-request-nonce": %s\n"@signature-params": %s' \ "$api_method" "$api_path" "$api_authority" "$api_digest" "$api_request_id" "$api_nonce" "$api_params" > "$api_base_file" api_signature=$(openssl pkeyutl -sign -rawin -inkey "$4" -in "$api_base_file" | openssl base64 -A) rm -f "$api_base_file" printf 'Content-Digest: %s\n' "$api_digest" printf 'X-Request-ID: %s\n' "$api_request_id" printf 'X-Request-Nonce: %s\n' "$api_nonce" printf 'Signature-Input: sig1=%s\n' "$api_params" printf 'Signature: sig1=:%s:\n' "$api_signature" } ``` === "PHP" PHP 8.1+, the `sodium` extension (bundled with standard PHP builds). ```php */ function api_sign_request( string $method, string $url, string $body, string $secretKey, string $keyId, array $options = [], ): array { $parts = parse_url($url); $authority = strtolower($parts['host'] . (isset($parts['port']) ? ':' . $parts['port'] : '')); $created = $options['created'] ?? time(); $lifetime = $options['lifetime'] ?? SIGNATURE_MAX_LIFETIME; $nonce = $options['nonce'] ?? bin2hex(random_bytes(16)); $requestId = $options['request_id'] ?? bin2hex(random_bytes(16)); $digest = api_content_digest($body); $values = [ '@method' => strtoupper($method), '@path' => $parts['path'] ?? '/', '@authority' => $authority, 'content-digest' => $digest, 'x-request-id' => $requestId, 'x-request-nonce' => $nonce, ]; $quoted = implode(' ', array_map(fn (string $name): string => "\"$name\"", SIGNATURE_COMPONENTS)); $params = sprintf('(%s);created=%d;expires=%d;keyid="%s";alg="ed25519"', $quoted, $created, $created + $lifetime, $keyId); $base = ''; foreach (SIGNATURE_COMPONENTS as $name) { $base .= "\"$name\": {$values[$name]}\n"; } $base .= "\"@signature-params\": $params"; $signature = base64_encode(sodium_crypto_sign_detached($base, $secretKey)); return [ 'Content-Digest' => $digest, 'X-Request-ID' => $requestId, 'X-Request-Nonce' => $nonce, 'Signature-Input' => "sig1=$params", 'Signature' => "sig1=:$signature:", ]; } ``` === "Python" Python 3.10+, `pip install cryptography`. ```python """Payment API request signing: RFC 9421, Ed25519. Python 3.10+, the cryptography package (pip install cryptography). """ import base64 import hashlib import secrets import time import uuid from urllib.parse import urlsplit from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.serialization import load_pem_private_key # Components the signature must cover. Any order, but the same as in Signature-Input. COMPONENTS = ("@method", "@path", "@authority", "content-digest", "x-request-id", "x-request-nonce") MAX_LIFETIME_SECONDS = 300 # expires - created must not exceed 5 minutes def content_digest(body: bytes) -> str: """RFC 9530 Content-Digest: sha-256 of the exact body bytes.""" return "sha-256=:" + base64.b64encode(hashlib.sha256(body).digest()).decode() + ":" def key_id(public_key: bytes) -> str: """The keyid the cabinet shows for a 32-byte public key.""" digest = hashlib.sha256(public_key).digest()[:16] return "ed25519-" + base64.urlsafe_b64encode(digest).decode().rstrip("=") def load_private_key(pem: bytes) -> Ed25519PrivateKey: """Loads a PEM private key (PKCS#8, as produced by openssl genpkey).""" key = load_pem_private_key(pem, password=None) if not isinstance(key, Ed25519PrivateKey): raise ValueError("expected an Ed25519 key") return key def sign_request( method: str, url: str, body: bytes, private_key: Ed25519PrivateKey, key_id: str, *, created: int | None = None, lifetime: int = MAX_LIFETIME_SECONDS, nonce: str | None = None, request_id: str | None = None, ) -> dict[str, str]: """Returns the signature headers. Add Authorization and Content-Type yourself. body must be the exact bytes that will be sent; b"" for GET. """ parts = urlsplit(url) created = int(time.time()) if created is None else created nonce = nonce or secrets.token_hex(16) request_id = request_id or str(uuid.uuid4()) digest = content_digest(body) values = { "@method": method.upper(), "@path": parts.path or "/", "@authority": parts.netloc.lower(), "content-digest": digest, "x-request-id": request_id, "x-request-nonce": nonce, } params = ( "(" + " ".join(f'"{name}"' for name in COMPONENTS) + ")" + f';created={created};expires={created + lifetime};keyid="{key_id}";alg="ed25519"' ) base = "".join(f'"{name}": {values[name]}\n' for name in COMPONENTS) base += f'"@signature-params": {params}' signature = base64.b64encode(private_key.sign(base.encode())).decode() return { "Content-Digest": digest, "X-Request-ID": request_id, "X-Request-Nonce": nonce, "Signature-Input": f"sig1={params}", "Signature": f"sig1=:{signature}:", } ``` === "Node.js" Node.js 20+, no dependencies. ```javascript // Payment API request signing: RFC 9421, Ed25519. Node.js 20+, no dependencies. import { createHash, createPrivateKey, randomBytes, randomUUID, sign } from 'node:crypto'; // Components the signature must cover. Any order, but the same as in Signature-Input. const COMPONENTS = ['@method', '@path', '@authority', 'content-digest', 'x-request-id', 'x-request-nonce']; const MAX_LIFETIME_SECONDS = 300; // expires - created must not exceed 5 minutes /** RFC 9530 Content-Digest: sha-256 of the exact body bytes. */ export function contentDigest(body) { return `sha-256=:${createHash('sha256').update(body).digest('base64')}:`; } /** The keyid the cabinet shows for a 32-byte public key. */ export function keyId(publicKey) { return 'ed25519-' + createHash('sha256').update(publicKey).digest().subarray(0, 16).toString('base64url'); } /** Loads a PEM private key (PKCS#8, as produced by openssl genpkey). */ export function loadPrivateKey(pem) { const key = createPrivateKey(pem); if (key.asymmetricKeyType !== 'ed25519') throw new Error('expected an Ed25519 key'); return key; } /** * Returns the signature headers. Add Authorization and Content-Type yourself. * body must be the exact bytes (Buffer or string) that will be sent; '' for GET. */ export function signRequest(method, url, body, privateKey, keyIdValue, options = {}) { const { pathname, host } = new URL(url); const created = options.created ?? Math.floor(Date.now() / 1000); const lifetime = options.lifetime ?? MAX_LIFETIME_SECONDS; const nonce = options.nonce ?? randomBytes(16).toString('hex'); const requestId = options.requestId ?? randomUUID(); const digest = contentDigest(body); const values = { '@method': method.toUpperCase(), '@path': pathname || '/', '@authority': host.toLowerCase(), 'content-digest': digest, 'x-request-id': requestId, 'x-request-nonce': nonce, }; const params = `(${COMPONENTS.map((name) => `"${name}"`).join(' ')})` + `;created=${created};expires=${created + lifetime};keyid="${keyIdValue}";alg="ed25519"`; const base = COMPONENTS.map((name) => `"${name}": ${values[name]}\n`).join('') + `"@signature-params": ${params}`; const signature = sign(null, Buffer.from(base), privateKey).toString('base64'); return { 'Content-Digest': digest, 'X-Request-ID': requestId, 'X-Request-Nonce': nonce, 'Signature-Input': `sig1=${params}`, Signature: `sig1=:${signature}:`, }; } ``` === "Go" Go 1.22+, standard library only. ```go // Package paymentapi is a payment API integration example using only the Go 1.22+ standard library. package paymentapi import ( "crypto/ed25519" "crypto/rand" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/hex" "encoding/pem" "errors" "fmt" "net/http" "strings" "time" ) // components the signature must cover. Any order, but the same as in Signature-Input. var components = []string{"@method", "@path", "@authority", "content-digest", "x-request-id", "x-request-nonce"} // MaxLifetime is the longest allowed window between created and expires. const MaxLifetime = 5 * time.Minute // SignOptions pins the time, nonce and request id. Empty fields are filled in automatically. type SignOptions struct { Created time.Time Lifetime time.Duration Nonce string RequestID string } // ContentDigest returns the RFC 9530 Content-Digest header: sha-256 of the exact body bytes. func ContentDigest(body []byte) string { sum := sha256.Sum256(body) return "sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":" } // KeyID returns the keyid the cabinet shows for this public key. func KeyID(publicKey ed25519.PublicKey) string { sum := sha256.Sum256(publicKey) return "ed25519-" + base64.RawURLEncoding.EncodeToString(sum[:16]) } // LoadPrivateKey reads a PEM private key (PKCS#8, as produced by openssl genpkey). func LoadPrivateKey(pemBytes []byte) (ed25519.PrivateKey, error) { block, _ := pem.Decode(pemBytes) if block == nil { return nil, errors.New("paymentapi: key is not PEM") } parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { return nil, fmt.Errorf("paymentapi: parse key: %w", err) } key, ok := parsed.(ed25519.PrivateKey) if !ok { return nil, errors.New("paymentapi: key is not Ed25519") } return key, nil } // SignRequest sets Content-Digest, X-Request-ID, X-Request-Nonce, Signature-Input and // Signature on the request. body must be the exact bytes that will be sent; nil for GET. // The caller sets Authorization and Content-Type. func SignRequest(r *http.Request, body []byte, key ed25519.PrivateKey, keyID string, opts SignOptions) error { if opts.Created.IsZero() { opts.Created = time.Now() } if opts.Lifetime == 0 { opts.Lifetime = MaxLifetime } if opts.Nonce == "" { opts.Nonce = randomHex(16) } if opts.RequestID == "" { opts.RequestID = randomHex(16) } path := r.URL.EscapedPath() if path == "" { path = "/" } authority := r.Host if authority == "" { authority = r.URL.Host } digest := ContentDigest(body) values := map[string]string{ "@method": strings.ToUpper(r.Method), "@path": path, "@authority": strings.ToLower(authority), "content-digest": digest, "x-request-id": opts.RequestID, "x-request-nonce": opts.Nonce, } quoted := make([]string, len(components)) var base strings.Builder for i, name := range components { quoted[i] = `"` + name + `"` base.WriteString(`"` + name + `": ` + values[name] + "\n") } created := opts.Created.Unix() params := fmt.Sprintf(`(%s);created=%d;expires=%d;keyid="%s";alg="ed25519"`, strings.Join(quoted, " "), created, created+int64(opts.Lifetime/time.Second), keyID) base.WriteString(`"@signature-params": ` + params) signature := ed25519.Sign(key, []byte(base.String())) r.Header.Set("Content-Digest", digest) r.Header.Set("X-Request-ID", opts.RequestID) r.Header.Set("X-Request-Nonce", opts.Nonce) r.Header.Set("Signature-Input", "sig1="+params) r.Header.Set("Signature", "sig1=:"+base64.StdEncoding.EncodeToString(signature)+":") return nil } func randomHex(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { panic(err) // crypto/rand does not fail on supported platforms } return hex.EncodeToString(b) } ``` !!! warning "Sign and send the same bytes" Serialize the JSON once into a string or bytes, sign them and send **them**. Do not hand the HTTP client an object that it serializes again: key order, whitespace or escaping may change and the digest will no longer match. ## Checklist: `401 signature_invalid` { #debug-checklist } The server does not say what exactly is wrong: every signature failure looks the same. Check in this order. 1. **Test vector.** Does your function produce exactly the headers [above](#test-vector)? If not, the bug is in your signing code, not in the request. 2. **Body.** Are the signed and the sent bytes the same? Common causes: the client re-serializes JSON, adds a newline or changes the encoding. 3. **Clock.** Is the server time correct? `created` more than 30 seconds in the future, or a request arriving after `expires`, is rejected. Enable NTP. 4. **Path and host.** `@path` has no query and no domain, exactly as sent. `@authority` is the lower-case host without `https://`. A proxy that rewrites `Host` breaks the signature. 5. **Key.** Is `keyid` copied from the cabinet and does it belong to this project? Is the key not revoked? Is the private key the pair of the uploaded public key? Compare the `keyid` printed by the key generator with the cabinet. 6. **Components.** Are all six components in `Signature-Input`, lower-case and double-quoted? Are the base lines in the same order as in `Signature-Input`? 7. **Format.** `Signature: sig1=:…:` — colons on both sides, standard base64 with `=`, exactly 64 signature bytes. The label in `Signature` matches `Signature-Input`. 8. **Nonce.** 16–128 characters without spaces or commas. A reused nonce gives `409 request_replayed`, not 401. 9. **Lifetime.** `expires - created` is at most 300. If everything matches and you still get `401`, send support the request's `X-Request-ID` and the time it was sent. ## Key rotation { #key-rotation } A project has one active key and may have one key in rotation. During rotation, both verify. 1. Generate a new pair and upload the public key in the cabinet; it becomes "rotating". 2. Switch your servers to the new private key and the new `keyid`. 3. Once all servers use it, activate the new key in the cabinet. The old one is revoked. If a private key leaks, revoke it in the cabinet immediately and upload a new one. ## Machine-readable vector { #test-vector-json } `examples/test-vectors.json` from the docs repository, shared by all languages: ```json { "_comment": "Общие проверочные примеры. Подпись сгенерирована серверной реализацией platform/httpsig (Sign) и проверена ею же (Verify). Ключ — тестовый ключ RFC 8032 §7.1 TEST 1, публично известен: не используйте его нигде, кроме тестов.", "signing": { "private_key_pem_file": "testdata/test-key.pem", "public_key_pem_file": "testdata/test-key.pub.pem", "seed_hex": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", "public_key_b64": "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=", "key_id": "ed25519-If4x36FUomFia_hUBG_SJw", "method": "POST", "url": "https://api.example.com/api/v1/payments", "body": "{\"merchant_payment_id\":\"order-1001\",\"amount\":\"5000.00\",\"currency\":\"RUB\",\"geo_code\":\"RU\",\"payment_method\":\"sbp\",\"bank_code\":\"sber\"}", "created": 1790596800, "expires": 1790597100, "nonce": "9f86d081884c7d659a2feaa0c55ad015", "request_id": "0b6d2c4e-8f1a-4d3b-9e7c-5a2f1b3c4d5e", "expected": { "content_digest": "sha-256=:7vp0UJ9PjprEvF8nY/Eh9EQV284GkSkTMxsEL6T6IBo=:", "signature_base": "\"@method\": POST\n\"@path\": /api/v1/payments\n\"@authority\": api.example.com\n\"content-digest\": sha-256=:7vp0UJ9PjprEvF8nY/Eh9EQV284GkSkTMxsEL6T6IBo=:\n\"x-request-id\": 0b6d2c4e-8f1a-4d3b-9e7c-5a2f1b3c4d5e\n\"x-request-nonce\": 9f86d081884c7d659a2feaa0c55ad015\n\"@signature-params\": (\"@method\" \"@path\" \"@authority\" \"content-digest\" \"x-request-id\" \"x-request-nonce\");created=1790596800;expires=1790597100;keyid=\"ed25519-If4x36FUomFia_hUBG_SJw\";alg=\"ed25519\"", "signature_input": "sig1=(\"@method\" \"@path\" \"@authority\" \"content-digest\" \"x-request-id\" \"x-request-nonce\");created=1790596800;expires=1790597100;keyid=\"ed25519-If4x36FUomFia_hUBG_SJw\";alg=\"ed25519\"", "signature": "sig1=:BtY8/iQPBfIpIsuvra/cgfiKFMF+tL0yjt4Y37FCM5aUtbGvo1F0pH1SDKp6QSc8Wo6Jj/+rv12KE5VLuZecAg==:", "empty_body_digest": "sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:" } }, "callback": { "secret": "whsec_MfKQ9r2vXz", "previous_secret": "whsec_previous", "timestamp": "1700000000", "body": "{\"id\":\"3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42\",\"event_id\":\"5b0c8f5e-1d2a-4a8e-9f3c-7e6d5c4b3a21\",\"amount\":3400,\"initial_amount\":3400,\"status\":\"completed\",\"currency\":\"RUB\",\"payment_method\":\"sbp\",\"bank_code\":\"sber\",\"merchant_payment_id\":\"order-42\",\"rate\":11.00,\"course\":100.00,\"is_intrabank\":false,\"is_test\":false,\"project_id\":\"5d7e2c1a-0b9f-4e3d-8c2b-1a0f9e8d7c6b\"}", "expected_signature": "e3e688bac4039def6f680c16f14701866d8e5e2de66d9b37f9ecc8bccb90220f", "expected_previous_signature": "54b9a97acdb1adf27f81bc49bdc9d963b4fe4f3bf5114ea3303581084e99f3b4" } } ``` --- Source: /en/callbacks/ Language: en # Callbacks A callback is a JSON `POST` that the API sends to your server when a payment is paid or canceled. It tells you the result without polling. Every callback is signed with the project secret: verify the signature before trusting the content. ## How it works { #how-it-works }
1. **API.** The payment is closed — `completed` or `canceled`. An event with a new `event_id` is created 2. **API → Your server.** `POST` to the callback URL, signed in `X-Callback-Signature` 3. **Your server.** Verifies the signature and time, looks the `event_id` up among processed ones 4. **Your server ⇢ API.** Answers `2xx` at once, before handling the order 5. **Your server.** Updates the order by `id` and `status` in the background 6. **API → Your server.** No `2xx` — the same event again after 30 and after 60 seconds
Key points: - A callback is an **event notification**. The source of truth is the payment in the API: `GET /api/v1/payments/{id}`. - One event may arrive **more than once**; events of one payment may arrive **out of order**. - One payment may get **several different events**: for example `canceled` and then `completed` — see [Status changes after closing](#status-changes). - There are few attempts — 3 within a minute and a half. Answer `2xx` quickly and keep a [reconciliation](#missing) in case a callback does not get through. ## When it arrives { #when } A callback arrives when a live payment moves to one of two statuses: | Status | What happened | What to do | | --- | --- | --- | | `completed` | Payment confirmed | Fulfil the order for `amount` | | `canceled` | Not paid: its lifetime ran out, it was canceled, or the payment could not be confirmed after requisites were issued | Do not fulfil; offer to pay again — with a new payment | The callback does not carry the cancellation reason: for you every case is the same — the requisites are no longer valid. A callback **does not arrive**: - for a payment in `error`. `error` happens only before requisites are issued, and you learn about it from the create response or from `GET /api/v1/payments/{id}`. Once requisites are issued, a payment ends only as `completed` or `canceled`, and either one brings a callback; - for intermediate statuses `created`, `processing` and `appeal` (the payment is being re-checked — the callback comes with the outcome); - for test payments (`is_test: true`) — see [Test payments](#test-payments); - if no callback URL is set on the payment, the project or the merchant — see [Where it goes](#url). ### Status changes after closing { #status-changes } `completed` and `canceled` are final, but a payment re-check or a support decision can change them. Every change is a **new event with a new `event_id`** and brings a new callback: | Was | Received | When | | --- | --- | --- | | `canceled` | `completed` | The payer paid after the payment lifetime, or the payment was confirmed later | | `completed` | `completed` with another `amount` | The payer sent a different amount and it was confirmed. `initial_amount` does not change | | `completed` | `canceled` | The payment was not confirmed on re-check | | any | the same status again | The re-check kept the status, or support resent the callback | Your handler must be able to: - fulfil an order of a canceled payment if `completed` arrives later; - recalculate the order if a new `completed` has another `amount`; - revoke or hold the order if `canceled` arrives after `completed`. !!! warning "Events may arrive out of order" If a callback changes what you have already stored for the payment, re-read it with `GET /api/v1/payments/{id}` and apply **the status and amount from the API response**. This way a late event cannot roll the order back. ## Where it goes { #url } The first URL that is set wins: 1. `callback_url` from the create request; 2. the project callback URL (cabinet, Projects and API); 3. the merchant's common callback URL (cabinet, merchant settings). If no URL is set at any level, no callback is sent: read the result with `GET /api/v1/payments/{id}`. URL requirements: - absolute `https://`, no credentials, up to 2048 characters, not `localhost`; - the host resolves to public IPs only. If any of its addresses is internal (private networks, loopback, link-local, CGNAT `100.64.0.0/10`), no connection is made; - **redirects are not followed**: a `3xx` response is a failure. Use the final URL. ## Body { #body } The body is an event about the payment's transition to a status: the main payment fields, the rate and fee, `event_id` and `project_id`. It has no requisites or USDT amounts — the full payment is returned by `GET /api/v1/payments/{id}` ([fields](sbp.md#response)). ```json { "id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42", "event_id": "5b0c8f5e-1d2a-4a8e-9f3c-7e6d5c4b3a21", "amount": 3400, "initial_amount": 3400, "status": "completed", "currency": "RUB", "payment_method": "sbp", "bank_code": "sber", "merchant_payment_id": "order-42", "rate": 11.00, "course": 100.00, "is_intrabank": false, "is_test": false, "project_id": "5d7e2c1a-0b9f-4e3d-8c2b-1a0f9e8d7c6b" } ``` | Field | Meaning | | --- | --- | | `id` | Payment `id` from the create response | | `event_id` | Event id. The same in every retry of one event and equal to the `X-Callback-Event-Id` header | | `status` | Event status: `completed` or `canceled`. On a manual resend, the payment's current closed status, `error` included | | `amount` | Credited amount in major currency units. After a payment re-check it may differ from `initial_amount` — fulfil the order by `amount` | | `initial_amount` | Amount from the create request, never changes | | `merchant_payment_id` | Your order id | | `rate`, `course` | Your fee, %, and rate — payment currency per 1 USDT. Numbers with two decimals, the same as in [`merchant_information`](sbp.md#merchant-information); `null` if no requisites were issued | | `is_test` | `true` for a project test payment | | `project_id` | Project the payment belongs to | | other fields | As in the [create response](sbp.md#response): `currency`, `payment_method`, `bank_code`, `is_intrabank` | - There is no error code in a callback, even on a manual resend of a payment in `error`: read the reason with `GET`. - Parse `amount`, `initial_amount`, `rate` and `course` as decimals, not floats. - New fields may be added: ignore unknown fields. - The body is built once, when the event is queued, and is byte-for-byte the same in every delivery retry. When in doubt about freshness, re-read the payment with `GET`. ## Headers { #headers } | Header | Value | | --- | --- | | `Content-Type` | `application/json` | | `X-Callback-Event-Id` | The event's `event_id` | | `X-Callback-Timestamp` | Unix time of this attempt, seconds | | `X-Callback-Signature` | `v1=`; for 24 hours after a secret rotation, `v1=,v1=` | ## Signature verification { #signature } ```text signed_payload = X-Callback-Timestamp + "." + raw_request_body signature = hex( HMAC-SHA256( key = the whole project secret, message = signed_payload ) ) ``` - The secret is a `whsec_…` string. The HMAC key is **the whole string** as UTF-8, `whsec_` included. Nothing needs decoding. - `hex` is 64 lower-case characters. - Compute over the **raw body bytes** before parsing JSON. Parsing and re-serializing may change the bytes. - The request is authentic if at least one `v1=` value matches your signature. Compare in constant time. Skip values with another scheme (not `v1`). ### Replay protection { #replay } 1. Reject the request if `X-Callback-Timestamp` differs from your clock by more than 5 minutes. Keep your clock synced with NTP. The timestamp is part of the signature and cannot be forged. 2. Keep processed `event_id` values for at least a day. Do not process a repeated `event_id` again — just answer `2xx`. `event_id` identifies an **event**, not a payment and not an attempt: | What happened | `event_id` | Body | Timestamp and signature | | --- | --- | --- | --- | | Delivery retry after an error or a non-`2xx` answer | same | byte-for-byte same | new | | New payment status | new | new | new | | Manual resend by support | new, even if the status did not change | new | new | Decide the payment outcome by `id` and `status`; use `event_id` only to avoid processing one delivery twice. ### Verification functions { #verify-functions } The functions check the signature and freshness and are tested on the vector below. === "curl" For debugging in a terminal. In your application use your language's function: shell cannot compare strings in constant time. ```bash # Payment API callback signature check (HMAC-SHA256) for debugging in a terminal. # In your application verify with code that compares in constant time. # POSIX sh + openssl. Load with: . ./verify_callback.sh # api_callback_signature SECRET TIMESTAMP BODY_FILE — the expected signature (hex). api_callback_signature() { { printf '%s.' "$2"; cat "$3"; } | openssl dgst -sha256 -hmac "$1" -r | cut -d' ' -f1 } # api_verify_callback SECRET TIMESTAMP SIGNATURE_HEADER BODY_FILE [NOW] # Exit code 0: the signature matches and the timestamp is fresh (±300 seconds). api_verify_callback() { api_now=${5:-$(date +%s)} api_skew=$((api_now - $2)); [ "$api_skew" -lt 0 ] && api_skew=$((-api_skew)) [ "$api_skew" -le 300 ] || return 1 api_expected=$(api_callback_signature "$1" "$2" "$4") for api_part in $(printf '%s' "$3" | tr ',' ' '); do [ "$api_part" = "v1=$api_expected" ] && return 0 done return 1 } ``` === "PHP" ```php CALLBACK_MAX_SKEW) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret); foreach (explode(',', $signature) as $part) { [$scheme, $value] = array_pad(explode('=', trim($part), 2), 2, ''); if ($scheme === 'v1' && hash_equals($expected, $value)) { return true; } } return false; } ``` === "Python" ```python """Payment API callback signature check: HMAC-SHA256. Standard library only.""" import hashlib import hmac import time MAX_SKEW_SECONDS = 300 # reject callbacks more than 5 minutes old or ahead def verify_callback(secret: str, headers, body: bytes, now: float | None = None) -> bool: """True if the callback is authentic and fresh. secret — the whole project secret, including the whsec_ prefix; headers — request headers (any object with .get); body — raw body bytes, before JSON parsing. """ timestamp = headers.get("X-Callback-Timestamp", "") signature = headers.get("X-Callback-Signature", "") if not timestamp.isdigit(): return False now = time.time() if now is None else now if abs(now - int(timestamp)) > MAX_SKEW_SECONDS: return False signed = timestamp.encode() + b"." + body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() for part in signature.split(","): scheme, _, value = part.strip().partition("=") if scheme == "v1" and hmac.compare_digest(value, expected): return True return False ``` === "Node.js" ```javascript // Payment API callback signature check: HMAC-SHA256. Node.js 20+, no dependencies. import { createHmac, timingSafeEqual } from 'node:crypto'; const MAX_SKEW_SECONDS = 300; // reject callbacks more than 5 minutes old or ahead /** * true if the callback is authentic and fresh. * secret — the whole project secret, including the whsec_ prefix; * headers — request headers with lower-case names (as in node:http); * body — raw body bytes (Buffer), before JSON parsing. */ export function verifyCallback(secret, headers, body, now = Date.now() / 1000) { const timestamp = String(headers['x-callback-timestamp'] ?? ''); const signature = String(headers['x-callback-signature'] ?? ''); if (!/^\d+$/.test(timestamp)) return false; if (Math.abs(now - Number(timestamp)) > MAX_SKEW_SECONDS) return false; const expected = Buffer.from( createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest('hex'), ); return signature.split(',').some((part) => { const [scheme, value = ''] = part.trim().split('=', 2); const candidate = Buffer.from(value); return scheme === 'v1' && candidate.length === expected.length && timingSafeEqual(candidate, expected); }); } ``` === "Go" ```go package paymentapi import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "net/http" "strconv" "strings" "time" ) // maxSkew: callbacks more than 5 minutes old or ahead are rejected. const maxSkew = 5 * time.Minute // VerifyCallback checks the callback signature and freshness. secret is the whole project // secret including whsec_; body is the raw request body, before JSON parsing. func VerifyCallback(secret string, header http.Header, body []byte, now time.Time) bool { timestamp := header.Get("X-Callback-Timestamp") sent, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return false } if skew := now.Sub(time.Unix(sent, 0)); skew > maxSkew || skew < -maxSkew { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(timestamp + ".")) mac.Write(body) expected := []byte(hex.EncodeToString(mac.Sum(nil))) for _, part := range strings.Split(header.Get("X-Callback-Signature"), ",") { scheme, value, ok := strings.Cut(strings.TrimSpace(part), "=") if ok && scheme == "v1" && hmac.Equal([]byte(value), expected) { return true } } return false } ``` ### Test vector { #test-vector } `body` is exactly one line with no line breaks and no spaces between fields (the body from the example above as the API sends it), 362 bytes: ```text secret = whsec_MfKQ9r2vXz timestamp = 1700000000 body = {"id":"3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42","event_id":"5b0c8f5e-1d2a-4a8e-9f3c-7e6d5c4b3a21","amount":3400,"initial_amount":3400,"status":"completed","currency":"RUB","payment_method":"sbp","bank_code":"sber","merchant_payment_id":"order-42","rate":11.00,"course":100.00,"is_intrabank":false,"is_test":false,"project_id":"5d7e2c1a-0b9f-4e3d-8c2b-1a0f9e8d7c6b"} signature = e3e688bac4039def6f680c16f14701866d8e5e2de66d9b37f9ecc8bccb90220f ``` With the previous secret `whsec_previous` the same body gives `54b9a97acdb1adf27f81bc49bdc9d963b4fe4f3bf5114ea3303581084e99f3b4`, and during the overlap the header looks like `v1=e3e688ba…90220f,v1=54b9a97a…99f3b4`. ## How to handle it { #handling } 1. Read the **raw body** and headers. 2. Verify the signature and `X-Callback-Timestamp`. If they fail, answer `401` and change nothing. 3. Insert `event_id` into a table with a unique index. Already there — answer `2xx` and stop. 4. Queue the event in your own queue and answer `2xx` at once. 5. In the background find the order by `merchant_payment_id` or the stored `id` and apply `status` and `amount` following the [status change rules](#status-changes). `2xx` means "event accepted", not "order fulfilled". If your processing fails after the answer, re-read the payment with `GET`: the callback will not come again by itself. ### Minimal receiver { #minimal-receiver } Verifies the signature, drops duplicates and answers `2xx` at once. In production store `event_id` in a database with a unique index and queue the processing. === "PHP" ```php {$event['status']}"); http_response_code(204); // answer 2xx at once, process asynchronously ``` === "Python" ```python """Minimal callback receiver on the standard library. export CALLBACK_SECRET=whsec_... python callback_server.py # listens on :8080, any path """ import json import os from http.server import BaseHTTPRequestHandler, HTTPServer from callback import verify_callback SECRET = os.environ.get("CALLBACK_SECRET", "") seen_events: set[str] = set() # in production: a database table with a unique event_id class CallbackHandler(BaseHTTPRequestHandler): def do_POST(self) -> None: body = self.rfile.read(int(self.headers.get("Content-Length", 0))) if not verify_callback(SECRET, self.headers, body): self.send_response(401) self.end_headers() return event = json.loads(body) if event["event_id"] not in seen_events: seen_events.add(event["event_id"]) # Enqueue the work here: mark the order as paid, etc. print("payment", event["merchant_payment_id"], "->", event["status"]) self.send_response(204) # answer 2xx at once, process asynchronously self.end_headers() if __name__ == "__main__": HTTPServer(("", 8080), CallbackHandler).serve_forever() ``` === "Node.js" ```javascript // Minimal callback receiver on node:http. // // export CALLBACK_SECRET=whsec_... // node callback-server.mjs # listens on :8080, any path import { createServer } from 'node:http'; import { verifyCallback } from './callback.mjs'; const secret = process.env.CALLBACK_SECRET ?? ''; const seenEvents = new Set(); // in production: a database table with a unique event_id createServer((req, res) => { const chunks = []; req.on('data', (chunk) => chunks.push(chunk)); req.on('end', () => { const body = Buffer.concat(chunks); if (req.method !== 'POST' || !verifyCallback(secret, req.headers, body)) { res.writeHead(401).end(); return; } const event = JSON.parse(body); if (!seenEvents.has(event.event_id)) { seenEvents.add(event.event_id); // Enqueue the work here: mark the order as paid, etc. console.log('payment', event.merchant_payment_id, '->', event.status); } res.writeHead(204).end(); // answer 2xx at once, process asynchronously }); }).listen(8080); ``` === "Go" ```go // Minimal callback receiver on net/http. // // export CALLBACK_SECRET=whsec_... // go run ./cmd/callback-server # listens on :8080, any path package main import ( "encoding/json" "io" "log" "net/http" "os" "sync" "time" "example.com/paymentapi" ) func main() { secret := os.Getenv("CALLBACK_SECRET") var mu sync.Mutex seen := map[string]bool{} // in production: a database table with a unique event_id http.HandleFunc("POST /", func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) if err != nil || !paymentapi.VerifyCallback(secret, r.Header, body, time.Now()) { w.WriteHeader(http.StatusUnauthorized) return } var event struct { EventID string `json:"event_id"` MerchantPaymentID string `json:"merchant_payment_id"` Status string `json:"status"` } if err = json.Unmarshal(body, &event); err != nil { w.WriteHeader(http.StatusBadRequest) return } mu.Lock() first := !seen[event.EventID] seen[event.EventID] = true mu.Unlock() if first { // Enqueue the work here: mark the order as paid, etc. log.Printf("payment %s -> %s", event.MerchantPaymentID, event.Status) } w.WriteHeader(http.StatusNoContent) // answer 2xx at once, process asynchronously }) log.Fatal(http.ListenAndServe(":8080", nil)) } ``` ## Delivery and retries { #delivery } - **Success** is any `2xx`. The response body is not read beyond 4 KB. - **Failure** is a network error, a timeout or any other code: `3xx`, `4xx`, `5xx`. - **Timeout** — 10 seconds per request. Answer `2xx` at once and process asynchronously. - **Retries.** An event gets 3 attempts: at once, after 30 seconds and after another 60 seconds — about a minute and a half. All attempts carry the same `event_id` and body. After the third failure delivery of this event stops. - **Manual resend.** Support can send a callback for a closed payment again, including a payment in `error`. It is a new event with a new `event_id` and a single attempt, without automatic retries. - **No ordering guarantee.** A retry of an old event may arrive after a new one. Make the handler idempotent. | Attempt | When | If no `2xx` | | --- | --- | --- | | 1 | right after the event | attempt 2 in 30 seconds | | 2 | after 30 seconds | attempt 3 in 60 seconds | | 3 | after 90 seconds | delivery stops | !!! tip "When in doubt, re-read the payment" If a callback contradicts what you know about the order, read the current state with `GET /api/v1/payments/{id}`. The API answer beats delivery order. ## If a callback did not arrive { #missing } There are few attempts, so do not rely on callbacks alone: - **Reconciliation.** Every few minutes select your orders that have waited for payment longer than the project payment lifetime (15 minutes by default) and read them with `GET /api/v1/payments/{id}`. Handle a closed payment the same way as a received callback. Do not poll more often than once per 5–10 seconds per payment: the project shares one [rate limit](idempotency.md#rate-limits). - **Check the URL.** It is set on at least one level, reachable from the internet, answers `2xx` without a redirect, and its certificate is valid. - **Resend.** If your server was down, send support the payment `id` values or their `merchant_payment_id` — the callbacks will be sent again. Each comes with a new `event_id`. ### Common problems { #troubleshooting } | Symptom | Cause | | --- | --- | | Signature does not match | Signed over parsed and re-serialized JSON instead of the raw body; key taken without `whsec_`; secret from another project or environment | | Signature is right, but the request is rejected by time | Server clock is off by more than 5 minutes | | No callbacks at all | URL not set; URL answers `301` or `302`; host resolves to an internal IP; payments are test ones | | Order processed twice | Retries are not dropped by `event_id`, or the outcome is decided by `event_id` instead of `id` and `status` | | Order "rolled back" | A late event was applied over a newer one. Re-read the payment with `GET` when the status changes | ## Test payments { #test-payments } Payments with `is_test: true` — project test payments and test payments from the cabinet — do not get callbacks automatically. Read their outcome with `GET /api/v1/payments/{id}`. To test your handler: - run your verification function on the [test vector](#test-vector); - ask support to resend the callback of a closed test payment — a real signed request with `is_test: true` will reach your URL. More in [Sandbox](sandbox.md). ## Secret and rotation { #secret } - The secret is issued in the cabinet: Projects and API → External API access → Callback. It is shown **once** — keep it in a secret store. - Each project has its own secret; sandbox and production secrets differ. - If no secret was issued, the platform creates one on first delivery but shows it to nobody. To verify signatures, issue a new one. - **Lossless rotation.** After a new secret is issued, the previous one keeps signing for 24 hours as a second `v1` value. Steps: issue the new secret → add it next to the old one (or replace at once — any matching value is fine) → remove the old one within 24 hours. Rotating again within those 24 hours drops the oldest secret immediately: at most two are valid at a time. - If the secret leaks, issue a new one and remove the old one from your check right away. ## Checklist { #checklist } - [ ] The callback URL is set, reachable from the internet, `https://`, no redirect. - [ ] The signature is checked over the raw body with a constant-time comparison. - [ ] Requests older than 5 minutes are rejected; the clock is synced. - [ ] `event_id` is stored with a unique index for at least a day. - [ ] `2xx` goes out at once; processing runs in the background. - [ ] The order is fulfilled by `amount`, not `initial_amount`. - [ ] `canceled` → `completed`, `completed` → `canceled` and a new `amount` are handled. - [ ] Orders without a callback are reconciled with `GET /api/v1/payments/{id}`. --- Source: /en/api-reference/ Language: en # API reference Every merchant API method: path, access, request fields, responses and examples. The page is generated from the OpenAPI spec, so it matches what the server checks. For the end-to-end flow see the [quickstart](quickstart.md); for statuses and codes see [Statuses and errors](statuses.md). Paths follow `{{base_url}}`, the API address issued to you for the sandbox or production (see [Environments](index.md#environments)). Interactive reference Download openapi.yaml !!! warning "Temporary spec" These are the main methods from a temporary spec. The full spec will come from the backend repository, and this page will rebuild from it automatically. ## Create a payment (SBP or card) { #createPayment }
POST{{base_url}}/api/v1/payments
Creates a pay-in and synchronously picks requisites. Idempotent by `merchant_payment_id`: the same id with the same request returns `200` and the stored payment; the same id with a different request returns `409 payment_idempotency_conflict`. A `201` may already carry `status: error` with `error_code` when no requisites were issued; no callback follows for it. Strict JSON: an unknown field returns `400 bad_request`. The payment lifetime is a project setting (15 minutes by default) and is not part of the request. Signature is always required. Recommended client timeout: at least 15 seconds. **Access:** scope `payments:create` · signature required Signing headers `Signature-Input`, `Signature`, `Content-Digest`, `X-Request-ID`, `X-Request-Nonce` — see [Request signing](signing.md). ### Request body | Field | Type | Req. | Description | | --- | --- | --- | --- | | `merchant_payment_id` | string | yes | Your order id, unique per project; the idempotency key. 1–64 chars `A-Za-z0-9._:-`. | | `amount` | number \| string | yes | Major currency units, > 0, up to 6 decimals: `3400` or `"3400.50"`. A string avoids rounding errors. | | `payment_method` | string: `sbp`, `card_transfer` | yes | `sbp` — SBP transfer, `card_transfer` — transfer to a card. | | `currency` | string | no | Currency code. Default `RUB`. | | `geo_code` | string | no | Country, 2 letters. Default `RU`. | | `bank_code` | string | no | The payer's bank, a code from the bank catalog (`sber`, `tinkoff`, `ozon`); not a BIC. Empty — any bank. | | `is_intrabank` | boolean | no | Transfer within one bank only. Default `false`. | | `callback_url` | string, uri | no | HTTPS address for this payment's callback, public host. Otherwise the project address is used. | **Example request** ```json { "merchant_payment_id": "order-42", "amount": 3400, "payment_method": "sbp", "bank_code": "sber", "callback_url": "https://merchant.example/callbacks" } ``` ### Responses | Code | Meaning | | --- | --- | | `201` | Payment created | | `200` | Idempotent repeat, the stored payment is returned | | `400` | Invalid JSON or unknown field (`bad_request`), invalid payment fields (`invalid_payment`) | | `401` | Error | | `403` | Error | | `409` | Error | | `413` | Error | | `422` | Payment not accepted (not covered, no rate, not routable) | | `429` | Error | | `500` | Error | | `503` | Error | **Example response** ```json { "id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42", "merchant_payment_id": "order-42", "flow": "runtime_live", "status": "processing", "amount": 3400, "initial_amount": 3400, "currency": "RUB", "geo_code": "RU", "payment_method": "sbp", "bank_code": "sber", "is_intrabank": false, "callback_url": "https://merchant.example/callbacks", "is_test": false, "merchant_information": { "course": 100.0, "rate": 11.0, "amount_usdt": "34.0000", "amount_rate": "3026.0000", "amount_usdt_rate": "30.2600" }, "requisite": "+79991234567", "holder_name": "Иван Петров", "created_at": "2026-09-29T09:30:00Z", "updated_at": "2026-09-29T09:30:01Z" } ``` ## Read a payment { #getPayment }
GET{{base_url}}/api/v1/payments/{id}
Returns the payment in the same shape as the create response. Signature is required only when the project policy has `signature_required`. **Access:** scope `payments:read` · signature if the project policy requires it ### Path parameters | Parameter | Type | Req. | Description | | --- | --- | --- | --- | | `id` | string, uuid | yes | Payment `id` returned on create. | ### Responses | Code | Meaning | | --- | --- | | `200` | Payment | | `401` | Error | | `403` | Error | | `404` | Error | | `429` | Error | **Example response** ```json { "id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42", "merchant_payment_id": "order-42", "flow": "runtime_live", "status": "processing", "amount": 3400, "initial_amount": 3400, "currency": "RUB", "geo_code": "RU", "payment_method": "sbp", "bank_code": "sber", "is_intrabank": false, "callback_url": "https://merchant.example/callbacks", "is_test": false, "merchant_information": { "course": 100.0, "rate": 11.0, "amount_usdt": "34.0000", "amount_rate": "3026.0000", "amount_usdt_rate": "30.2600" }, "requisite": "+79991234567", "holder_name": "Иван Петров", "created_at": "2026-09-29T09:30:00Z", "updated_at": "2026-09-29T09:30:01Z" } ``` ## Who am I (token check) { #getProject }
GET{{base_url}}/api/v1/project
**Access:** signature if the project policy requires it ### Responses | Code | Meaning | | --- | --- | | `200` | Project of the token | | `401` | Error | --- Source: /en/changelog/ Language: en # Changelog New fields in responses and callbacks are added without a new API version — ignore unknown fields. Breaking changes are announced in advance. ## 2026-10-01 { #2026-10-01 } **Callbacks** — the [Callbacks](callbacks.md) section is expanded. - Delivery retries: 3 attempts — at once, after 30 and after 60 seconds (was 10 attempts over ~2.5 minutes). A manual resend by support is a single attempt. - New sections: how delivery works, status changes after closing (`canceled` → `completed`, `completed` → `canceled`, a new `amount`), where a callback goes, how to handle it, what to do without a callback, common problems, test payments, checklist. ## 2026-09-30 { #2026-09-30 } **API v1** — the payment and callback format now follows the final specification (before any production integrations). - `settlement` is renamed to `merchant_information`: `course` (was `exchange_rate`) and `rate` (was `fee_percent`) are now numbers with two decimals; `amount_rate` and `amount_usdt_rate` are the former `net_amount` and `net_amount_usdt`. - The response gains `flow`, `is_test` and `updated_at`; the `bank` field is gone. - A callback is a short event: `id`, `event_id`, amounts, `status`, `currency`, `payment_method`, `bank_code`, `merchant_payment_id`, `rate`, `course`, `is_intrabank`, `is_test`, `project_id`. Requisites and USDT amounts are in `GET /api/v1/payments/{id}`. - The callback signature test vector is updated for the new body. ## 2026-09-29 { #2026-09-29 } **Documentation** - First version of the merchant documentation in Russian and English. - Request signing and callback verification samples in curl, PHP, Python, Node.js and Go, checked against shared test vectors. - Markdown pages, `llms.txt` and a docs MCP server for AI agents. **API v1** - `POST /api/v1/payments` creates a payment, `GET /api/v1/payments/{id}` reads it. - Request: `merchant_payment_id` (1–64 characters `A-Za-z0-9._:-`), `amount` in major currency units, `payment_method`; optional `currency` (`RUB`), `geo_code` (`RU`), `bank_code`, `is_intrabank`, `callback_url`. - The payment lifetime is a project setting, 15 minutes by default. A payment not paid within it becomes `canceled`, and a callback is sent. - Response: the requisite's bank `bank` and the `settlement` — rate, fee and USDT amounts. - Callbacks are sent for `completed` and `canceled`; the body is the same payment plus `event_id` and `project_id`. - Rejections before the payment is accepted (`429`, `503`, signature, nonce replay, body size, invalid body or fields) do not take the `merchant_payment_id`. - A `422` response carries `payment_id` when the payment was saved. A paused merchant gets `422 payment_not_routable`. --- Source: /en/ai-agents/ Language: en # For AI agents The docs are built so that an AI assistant — Claude, ChatGPT, Cursor, Codex — can read them in full and write the integration without guessing. There are three ways. ## 1. Markdown version of any page { #markdown } Append `.md` to a page address to get its source Markdown with code samples: | HTML | Markdown | | --- | --- | | `/en/quickstart/` | `/en/quickstart.md` | | `/signing/` (Russian) | `/signing.md` | | `/en/` | `/en/index.md` | Every page header has a **Copy as Markdown** button and an "Open in ChatGPT / Claude" menu that opens the assistant with a link to the page. ## 2. llms.txt { #llms-txt } - /llms.txt — every page in both languages with a one-line description, per the [llms.txt standard](https://llmstxt.org/). - /llms-full.txt — all the docs in one file, handy to paste into context. ## 3. Docs MCP server { #mcp } The MCP server gives the assistant search and read tools over the docs. Read-only, no authentication. | Tool | What it does | | --- | --- | | `search_docs(query, lang)` | Full-text search, returns sections with excerpts | | `get_page(path, lang)` | A whole page in Markdown | | `list_pages(lang)` | All pages with descriptions | | `get_api_operation(operation_id)` | An API method from the OpenAPI spec: `createPayment`, `getPayment`, `getProject` | | `get_code_example(language, topic)` | A tested sample: `curl`, `php`, `python`, `node`, `go` × `sign_request`, `verify_callback`, `create_sbp_payment`, `generate_key`, `callback_receiver` | Pages are also MCP resources: `docs://ru/quickstart`, `docs://en/signing`. Server address: `/mcp`, on the same host as these docs (Streamable HTTP transport). === "Claude Code" ```bash claude mcp add --transport http payment-api-docs /mcp ``` === "Cursor" `.cursor/mcp.json` in the project or `~/.cursor/mcp.json`: ```json { "mcpServers": { "payment-api-docs": { "url": "/mcp" } } } ``` === "Codex" `~/.codex/config.toml`: ```toml [mcp_servers.payment-api-docs] url = "/mcp" ``` === "ChatGPT" In ChatGPT: **Settings → Apps & Connectors → Advanced → Developer mode**, then **Create**, enter the server URL and choose **No authentication**. ChatGPT connects only to a public `https://` address: for a local copy of the docs, use a tunnel. ## Prompt for your assistant { #prompt } Paste at the start of a conversation: ```text You are helping integrate the payments API. The source of truth is the docs: /mcp (MCP) or llms.txt. Rules: - Every POST is signed per RFC 9421 with Ed25519: use the ready-made function from get_code_example(, "sign_request"); do not write signing from scratch. - Check the function against the test vector on the signing page. - merchant_payment_id is the idempotency key: on retry keep the id, re-sign the request. - Verify callbacks with HMAC-SHA256 over the raw body: get_code_example(, "verify_callback"). - Keep the API base URL {{base_url}} (sandbox and production differ) in configuration, not in code. ```