Skip to content

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+.

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

Open the sandbox cabinet at {{cabinet_url}}: the cabinet address and the API address {{base_url}} come with your access (see 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

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.

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"
{
  "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

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:

#!/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
}

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.

export SIGNING_KEY_FILE=$PWD/private.pem
export SIGNING_KEY_ID=ed25519-...   # keyid from the cabinet

4. Restrict source addresses (recommended)

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

  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 and expose it to the internet with any tunnel.

6. Create an SBP payment

The script signs the request, creates a payment and prints the requisites for the payer.

#!/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"])
    }
}

Response 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"
}

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; what merchant_information means is in Settlement.

Got 401 signature_invalid?

Walk through the signature debugging checklist. Most often one body was signed and another was sent, or the server clock is off.

7. Receive the 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. Check your handler against the test vector.

{
  "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

  • 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.