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.
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¶
- Set the project callback URL: a public
https://…address without redirects. You can also passcallback_urlin each payment. - 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_idis your order id; retries reuse the same id. - The HTTP client timeout is at least 15 seconds.
- The callback handler verifies the signature, answers
2xxat 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.