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

За 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
    <?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
    
    ```

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

    ```php
    <?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;
    }
    
    ```

=== "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 <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`:

```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-ов.