Быстрый старт¶
За 15 минут вы настроите проект в кабинете, создадите первую подписанную заявку по СБП в песочнице и подготовите приём callback-ов. Нужны доступ к кабинету и компьютер с OpenSSL 3 или одним из языков: PHP 8.1+, Python 3.10+, Node.js 20+, Go 1.22+.
Всё, что нужно, — в пяти значениях
В конце у вас будут: адрес API {{base_url}}, токен проекта, файл private.pem, keyid ключа
и секрет callback-ов whsec_…. Храните токен, ключ и секрет в хранилище секретов,
не в коде и не в репозитории.
1. Войдите в кабинет¶
Откройте кабинет песочницы {{cabinet_url}}: адрес кабинета и адрес API {{base_url}}
сообщают вместе с доступом (см. Окружения). Логин выдаёт ваш
менеджер, при входе нужен код двухфакторной аутентификации.
Всё, что ниже, делается в разделе «Проекты и API» → ваш проект → «Доступ к внешнему API». Проект — это ваш магазин или сайт: у него свои токен, ключи, callback и лимиты.
2. Выпустите токен проекта¶
Нажмите «Выпустить ключ», отметьте права payments:create и payments:read.
Кабинет покажет токен один раз — сохраните его сразу. В песочнице токен начинается
с nl_test_, в бою — с nl_live_.
Проверьте токен: запрос возвращает ваш проект и права.
export BASE_URL={{base_url}} # адрес API песочницы, выданный вам
export API_TOKEN=nl_test_... # токен из кабинета
curl -sS "$BASE_URL/api/v1/project" \
-H "Authorization: Bearer $API_TOKEN"
{
"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. Создайте ключ подписи¶
Каждый запрос на создание заявки подписывается приватным ключом Ed25519. Приватный ключ остаётся у вас, в кабинет загружается только публичный. Украденный токен без ключа не создаст ни одной заявки.
Сгенерируйте пару ключей любым способом:
#!/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
// Creates an Ed25519 signing key: private.pem (keep it secret) and public.pem (for the cabinet).
declare(strict_types=1);
require __DIR__ . '/sign.php';
$keypair = sodium_crypto_sign_keypair();
$seed = substr(sodium_crypto_sign_secretkey($keypair), 0, 32);
$public = sodium_crypto_sign_publickey($keypair);
// Ed25519 PKCS#8 and SPKI are a fixed header followed by the 32 key bytes.
$pem = fn (string $label, string $der): string =>
"-----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
"""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
// 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
// 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 — он должен совпасть
с тем, что напечатал генератор.
4. Ограничьте адреса (рекомендуем)¶
Добавьте исходящие IP-адреса ваших серверов: отдельный адрес (203.0.113.5) или сеть
(203.0.113.0/24). С первым правилом API пускает проект только с этих адресов,
остальные получают 403 ip_not_allowed. Если список пуст, адрес не проверяется.
5. Настройте callback¶
- Укажите адрес callback-ов проекта: публичный
https://…, без редиректов. Адрес можно передать и в каждой заявке полемcallback_url. - Нажмите «Выпустить секрет» в блоке «Callback». Секрет вида
whsec_…показывается один раз.
Пока своего сервера нет, запустите минимальный приёмник из раздела Callback-и и откройте к нему доступ из интернета любым туннелем.
6. Создайте заявку по СБП¶
Скрипт подписывает запрос, создаёт заявку и печатает реквизиты для плательщика.
#!/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 <<EOT
$(api_sign_request POST "$url" "$body" "$SIGNING_KEY_FILE" "$SIGNING_KEY_ID")
EOT
curl -sS --max-time 30 "$url" \
-H "Authorization: Bearer $API_TOKEN" \
-H 'Content-Type: application/json' \
"$@" \
--data-binary @"$body"
echo
<?php
// 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
// php create_sbp_payment.php order-1001 5000.00
declare(strict_types=1);
require __DIR__ . '/sign.php';
[, $orderId, $amount] = $argv;
$baseUrl = getenv('BASE_URL') ?: '';
if ($baseUrl === '') {
fwrite(STDERR, 'BASE_URL is not set: export the API address issued to you (sandbox or production)' . PHP_EOL);
exit(1);
}
$url = rtrim($baseUrl, '/') . '/api/v1/payments';
$body = json_encode([
'merchant_payment_id' => $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;
}
"""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()
// 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 ?? '');
}
// 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 <order-id> <amount>")
}
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:
{
"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 минут). Что именно
выводить на экран — в разделе Приём по СБП, что значит
merchant_information — в разделе Расчёт.
Пришло 401 signature_invalid?
Пройдите чек-лист отладки подписи. Чаще всего подписано одно тело, а отправлено другое, или часы сервера отстают.
7. Получите callback¶
Когда заявка станет completed (оплачена) или canceled (не оплачена), на адрес
callback-а придёт POST с подписью X-Callback-Signature. Тело — событие: основные поля
заявки, курс и ставка, event_id и project_id (без реквизитов — полную заявку читайте
GET-ом). Проверьте подпись, ответьте 2xx и
обработайте событие. В песочнице исход заявки задаёте вы сами, а тестовые заявки
callback автоматически не получают — см. Песочница. Обработчик проверьте на
проверочном примере.
{
"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"
}
Перед запуском в бой¶
- Токен, приватный ключ и секрет callback-ов лежат в хранилище секретов.
- Базовый адрес API задан настройкой, а не в коде.
- Часы серверов синхронизируются по NTP.
-
merchant_payment_id— ваш номер заказа, повтор запроса идёт с тем же номером. - Таймаут HTTP-клиента не меньше 15 секунд.
- Обработчик callback-ов проверяет подпись, отвечает
2xxсразу и не обрабатывает одно событие дважды. - Есть периодическая сверка: заявки без callback-а дольше срока заявки проекта
читаются через
GET /api/v1/payments/{id}. - Для боя выпущены отдельные токен, ключ подписи и секрет callback-ов.