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

!!! tip "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 { #cabinet }

Open the sandbox cabinet at `{{cabinet_url}}`: the cabinet address and the API address
`{{base_url}}` come with your access (see [Environments](index.md#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 { #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.

```bash
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"
```

```json
{
  "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 { #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:

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

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.

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

## 4. Restrict source addresses (recommended) { #ip-allowlist }

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 { #callback }

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](callbacks.md#minimal-receiver)
and expose it to the internet with any tunnel.

## 6. Create an SBP payment { #first-payment }

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

=== "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"])
    	}
    }
    
    ```

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

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](sbp.md#payer-screen); what `merchant_information` means is in
[Settlement](sbp.md#merchant-information).

!!! failure "Got `401 signature_invalid`?"
    Walk through the [signature debugging checklist](signing.md#debug-checklist).
    Most often one body was signed and another was sent, or the server clock is off.

## 7. Receive the callback { #receive-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](sandbox.md). Check your handler
against the [test vector](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"
}
```

## Before going live { #go-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.