Перейти к содержанию

Быстрый старт

За 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 — он должен совпасть с тем, что напечатал генератор.

export SIGNING_KEY_FILE=$PWD/private.pem
export SIGNING_KEY_ID=ed25519-...   # keyid из кабинета

4. Ограничьте адреса (рекомендуем)

Добавьте исходящие IP-адреса ваших серверов: отдельный адрес (203.0.113.5) или сеть (203.0.113.0/24). С первым правилом API пускает проект только с этих адресов, остальные получают 403 ip_not_allowed. Если список пуст, адрес не проверяется.

5. Настройте callback

  1. Укажите адрес callback-ов проекта: публичный https://…, без редиректов. Адрес можно передать и в каждой заявке полем callback_url.
  2. Нажмите «Выпустить секрет» в блоке «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-ов.