# Платёжный API / Payment API — полная документация / full documentation --- Source: / Language: ru
Платёжный API · v1
# Принимайте оплату по СБП и по карте через один API Вы создаёте заявку — API выдаёт реквизиты для перевода, следит за оплатой и присылает подписанный callback, когда деньги пришли. [Быстрый старт за 15 минут](quickstart.md){ .md-button .md-button--primary } [Подпись запросов](signing.md){ .md-button }POST /api/v1/payments и покажите плательщику реквизиты.{{base_url}}/api/v1/payments{{base_url}}/api/v1/payments/{id}{{base_url}}/api/v1/project/llms.txt — список всех страниц на двух языках с однострочным
описанием, по [стандарту llms.txt](https://llmstxt.org/).
- /llms-full.txt — вся документация одним файлом.
Удобно вставить в контекст целиком.
## 3. MCP-сервер документации { #mcp }
MCP-сервер даёт ассистенту инструменты поиска и чтения документации. Только чтение,
без авторизации.
| Инструмент | Что делает |
| --- | --- |
| `search_docs(query, lang)` | Поиск по документации, возвращает разделы с цитатами |
| `get_page(path, lang)` | Страница целиком в Markdown |
| `list_pages(lang)` | Все страницы с описаниями |
| `get_api_operation(operation_id)` | Метод API из спецификации OpenAPI: `createPayment`, `getPayment`, `getProject` |
| `get_code_example(language, topic)` | Проверенный пример: `curl`, `php`, `python`, `node`, `go` × `sign_request`, `verify_callback`, `create_sbp_payment`, `generate_key`, `callback_receiver` |
Страницы доступны и как ресурсы MCP: `docs://ru/quickstart`, `docs://en/signing`.
Адрес сервера: `/mcp` — на том же хосте, что и документация (транспорт Streamable HTTP).
=== "Claude Code"
```bash
claude mcp add --transport http payment-api-docs /mcp
```
=== "Cursor"
Файл `.cursor/mcp.json` в проекте или `~/.cursor/mcp.json`:
```json
{
"mcpServers": {
"payment-api-docs": { "url": "/mcp" }
}
}
```
=== "Codex"
Файл `~/.codex/config.toml`:
```toml
[mcp_servers.payment-api-docs]
url = "/mcp"
```
=== "ChatGPT"
В ChatGPT: **Settings → Apps & Connectors → Advanced → Developer mode**, затем
**Create** и укажите адрес сервера, авторизация — **No authentication**. ChatGPT
подключается только к публичному `https://`-адресу: для локальной копии
документации используйте туннель.
## Подсказка для ассистента { #prompt }
Скопируйте в начало разговора:
```text
Ты помогаешь интегрировать платёжный API. Источник правды — документация:
/mcp (MCP) или llms.txt. Правила:
- Каждый POST подписывается по RFC 9421 Ed25519: возьми готовую функцию
get_code_example(<язык>, "sign_request") и не пиши подпись с нуля.
- Проверь функцию на проверочном примере со страницы signing.
- merchant_payment_id — ключ идемпотентности: при повторе тот же номер, новая подпись.
- Callback проверяй HMAC-SHA256 по сырому телу: get_code_example(<язык>, "verify_callback").
- Адрес API {{base_url}} (свой у песочницы и боя) держи в настройке, не в коде.
```
---
Source: /en/
Language: en
Payment API · v1
# Accept SBP and card payments through one API You create a payment. The API issues the transfer requisites, watches for the money and sends you a signed callback when it arrives. [Quickstart in 15 minutes](quickstart.md){ .md-button .md-button--primary } [Request signing](signing.md){ .md-button }POST /api/v1/payments and show the requisites to the payer."}`. See
[Statuses and errors](statuses.md) for the list.
- **Tracing.** The `X-Request-ID` request header is echoed in the response. Log it:
support finds your request by it.
## API methods { #endpoints }
| Method | What it does | Signature |
| --- | --- | --- |
| `POST /api/v1/payments` | Creates a pay-in | always |
| `GET /api/v1/payments/{id}` | Returns a payment by `id` | if the project policy requires it |
| `GET /api/v1/project` | Shows the project and token scopes | if the project policy requires it |
Field-level details are in the [API reference](api-reference.md).
---
Source: /en/quickstart/
Language: en
# 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
"-----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 < $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 ")
}
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.
---
Source: /en/sandbox/
Language: en
# Sandbox
The sandbox is a separate environment for testing the whole integration without moving
money. The API, signing, limits and errors work as in production.
| | Sandbox |
| --- | --- |
| API | the sandbox `{{base_url}}`, issued with your access |
| Cabinet | the sandbox `{{cabinet_url}}` |
| Token | starts with `nl_test_` |
| Request signing | required, as in production |
!!! info "This section is being finished"
The sandbox is still being built. Below is how it will work. Places that may still
change are marked **TBD**. Watch the [changelog](changelog.md).
## How it works { #how-it-works }
1. **Test project.** Turn on test mode for a project in the sandbox cabinet. Its payments
never make real payments. A test payment has `is_test: true` in the response.
2. **Test requisites.** A create response carries test requisites: a test phone number for
SBP or a test card number. Nothing needs to be transferred.
3. **You choose the outcome.** Mark the payment as paid or canceled — in the cabinet or via
an API call (**TBD**).
4. **Read the result through the API.** Test payments get no automatic callback (**TBD**:
a way to receive a callback for a test payment is still being designed). Read the final
status with `GET /api/v1/payments/{id}`; check the callback body format and signature
against the [test vector](callbacks.md#test-vector).
## Set a payment outcome { #outcome }
!!! warning "TBD: outcome emulation API"
The API method to set a test payment's outcome is still being designed. For now use
the buttons on the payment card in the sandbox cabinet. When the method ships, the
request, response and samples in every language will appear here.
| What to test | What to do |
| --- | --- |
| Successful payment | Mark the payment paid → status `completed` |
| Decline or cancellation | Cancel the payment → status `canceled` |
| Expired deadline | Create a payment and leave it past the project's payment lifetime (15 minutes by default) → `canceled` |
| Idempotency | Send the same request twice → `201`, then `200` with the same payment |
| Id conflict | The same `merchant_payment_id` with another amount → `409 payment_idempotency_conflict` |
| Nonce replay | Send one signed request twice → `409 request_replayed` |
| Bad signature | Change the body after signing → `401 signature_invalid` |
## Differences from production { #differences }
- No money moves; requisites are not real.
- Test payments get no automatic callback and are marked `is_test: true`.
The sandbox has its own `{{base_url}}` and an `nl_test_` token.
- The sandbox has its own token, signing key and callback secret. Issue new ones for
production.
- The API address differs: keep it in configuration — see [Environments](index.md#environments).
---
Source: /en/sbp/
Language: en
# SBP payments
The payer sends money by phone number through the Faster Payments System (SBP) in their
bank app. You create a payment, the API issues the recipient's phone number and name,
you show them to the payer and wait for the result.
1. **Your server → API.** `POST /api/v1/payments` with `payment_method=sbp`
2. **API.** Picks requisites, usually within 15 seconds
3. **API ⇢ Your server.** `201`: `status=processing`, `requisite=+7…`, `holder_name`
4. **Your server → Payer.** Phone number, recipient, amount and deadline
5. **Payer.** Transfers via SBP in their bank app
6. **API → Your server.** Callback: `completed` or `canceled`
7. **Your server ⇢ API.** You reply `2xx`
## Create a payment { #create }
`POST /api/v1/payments` — a [signed](signing.md) request with a JSON body.
```http
POST {{base_url}}/api/v1/payments HTTP/1.1
Authorization: Bearer nl_test_...
Content-Type: application/json
Content-Digest: sha-256=:…:
X-Request-ID: 0b6d2c4e-8f1a-4d3b-9e7c-5a2f1b3c4d5e
X-Request-Nonce: 9f86d081884c7d659a2feaa0c55ad015
Signature-Input: sig1=(…);created=…;expires=…;keyid="ed25519-…";alg="ed25519"
Signature: sig1=:…:
{"merchant_payment_id":"order-42","amount":3400,"payment_method":"sbp","bank_code":"sber","callback_url":"https://merchant.example/callbacks"}
```
### Request fields { #request-fields }
| Field | Type | Req. | Rules |
| --- | --- | --- | --- |
| `merchant_payment_id` | string | yes | Your order id. 1–64 characters `A-Za-z0-9._:-`. Unique per project: it is the [idempotency key](idempotency.md) |
| `amount` | number or string | yes | Amount in major currency units: `3400` or `"3400.50"`. Positive, at most 6 decimals. A string is safer than a number: no rounding errors |
| `payment_method` | string | yes | `sbp` |
| `currency` | string | no | Currency code. Default `RUB` |
| `geo_code` | string | no | Country, two letters. Default `RU` |
| `bank_code` | string | no | The payer's bank — a lower-case code from our bank catalog: `sber`, `tinkoff`, `ozon`. Not a BIC. Empty means any bank |
| `is_intrabank` | boolean | no | `true` for a transfer within one bank. Default `false` |
| `callback_url` | string | no | Where to send this payment's callback. Absolute `https://`, up to 2048 characters, no credentials, public host. Otherwise the project URL is used |
No other fields are accepted: an unknown field returns `400 bad_request`. Test mode is a
project setting, not a request field. A request without `currency` and `geo_code` and the
same request with `"RUB"` and `"RU"` are the same request.
There is no deadline in the request: how long a payment lives is a project setting,
15 minutes from creation by default. Your manager can change it.
### Response { #response }
`201 Created` for a new payment. `200 OK` for a repeat with the same
`merchant_payment_id` and the same body: the existing payment is returned, no new one is
created.
```json
{
"id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42",
"merchant_payment_id": "order-42",
"flow": "runtime_live",
"status": "processing",
"amount": 3400,
"initial_amount": 3400,
"currency": "RUB",
"geo_code": "RU",
"payment_method": "sbp",
"bank_code": "sber",
"is_intrabank": false,
"callback_url": "https://merchant.example/callbacks",
"is_test": false,
"merchant_information": {
"course": 100.00,
"rate": 11.00,
"amount_usdt": "34.0000",
"amount_rate": "3026.0000",
"amount_usdt_rate": "30.2600"
},
"requisite": "+79991234567",
"holder_name": "Иван Петров",
"created_at": "2026-09-29T09:30:00Z",
"updated_at": "2026-09-29T09:30:01Z"
}
```
| Field | Meaning |
| --- | --- |
| `id` | Payment id. Store it next to the order |
| `merchant_payment_id` | Your order id |
| `flow` | `runtime_live` — a regular payment, `project_test` — a project test payment |
| `status` | Current status, see [below](#statuses) |
| `amount` | Amount to pay, in major currency units. May change after a [payment re-check](#edge-cases) |
| `initial_amount` | Amount from the request |
| `currency`, `geo_code`, `payment_method`, `is_intrabank` | As in the request, with defaults applied |
| `bank_code` | The payer's bank from the request, or `null`. Not the requisite's bank |
| `callback_url` | Callback URL from the request, or `null` |
| `is_test` | `true` for a project test payment |
| `merchant_information` | [Settlement](#merchant-information) in USDT; `null` until requisites are issued |
| `requisite` | Recipient phone number for the SBP transfer; `null` until issued |
| `holder_name` | Recipient name as the payer's bank will show it, or `null` |
| `qrcode_link`, `deeplink_url` | Only when the requisites have them: you can show a QR code or an "Open bank app" button |
| `error_code`, `error_comment` | Only for a payment in `error`: why no requisites were issued |
| `created_at`, `updated_at` | Creation and last change time, UTC |
`bank_code`, `callback_url`, `merchant_information`, `requisite` and `holder_name` are always
present; the value may be `null`. New fields may be added without a new API version:
ignore unknown fields.
### Settlement `merchant_information` { #merchant-information }
What you receive for the payment. The exchange rate and fee are fixed when requisites are
issued and do not change afterwards: settlement is not recalculated at current rates.
After a payment re-check, settlement uses the new amount with the same rate and fee. These are the
same figures that are credited to the project balance.
| Field | Meaning | Type, decimals |
| --- | --- | --- |
| `course` | Rate: units of the payment currency per 1 USDT | number, 2 |
| `rate` | Project fee, % | number, 2 |
| `amount_usdt` | Payment amount in USDT | string, 4 |
| `amount_rate` | Amount after the fee, in the payment currency | string, 4 |
| `amount_usdt_rate` | Amount after the fee in USDT — credited to the project balance | string, 4 |
`course` and `rate` are numbers with exactly two decimals (`100.00`); amounts are decimal
strings. Extra digits are truncated, never rounded up; the fee is rounded down. In the example above: 3400 RUB at a
rate of 100 and an 11% fee is 34 USDT, a fee of 374 RUB (3.74 USDT), 30.26 USDT credited.
!!! tip "Parse `course` and `rate` as decimals"
Use a decimal type (`Decimal`, `BigDecimal`, `json.Number`), not a float, to keep
the precision.
!!! warning "Do not recompute `amount_usdt`"
The rate may be stored with more than two decimals, so `amount / course` can
differ from `amount_usdt` in the last digit. `amount_usdt` is authoritative.
## What to show the payer { #payer-screen }
Show the requisites only when the status is `processing` and `requisite` is present. The
payer transfers exactly `amount` to `requisite`.
- **Phone number** from `requisite` — large, with a "Copy" button.
- **Recipient** from `holder_name`: the payer checks the name in the bank app before
sending.
- **Amount** — exactly `amount`, to the kopeck. Warn that a different amount may not be
credited automatically.
- **Deadline** — a countdown from `created_at` over your project's payment lifetime
(15 minutes by default). After it, the requisites must not be used.
- **Next step** — "Come back to this page after the transfer". Update the status from the
callback or by polling.
!!! warning "Do not cache requisites"
Requisites are issued for one payment. For a new order or another attempt, create a
new payment with a new `merchant_payment_id`.
## Statuses { #statuses }
| Status | Meaning | What to do |
| --- | --- | --- |
| created | Accepted, no requisites yet | Wait. The response usually already says `processing` |
| processing | Requisites issued, waiting for the transfer | Show the requisites to the payer |
| completed | Payment confirmed | Deliver the goods or service |
| canceled | Not paid: the lifetime ran out, it was canceled, or the payment could not be confirmed | Offer to pay again with a new payment |
| error | No requisites were issued, reason in `error_code` | Offer to pay again with a new payment |
| appeal | A closed payment is being re-checked | Wait for the resolution: a callback follows if it ends in `completed` or `canceled` |
`error` happens only before requisites are issued. From `processing` a payment goes only
to `completed` or `canceled`: if the payment cannot be confirmed after requisites were
issued, it is also `canceled`, with no `error_code`. `completed`, `canceled` and `error` are
final. Only a payment re-check or a support decision can change them.
All error codes are in
[Statuses and errors](statuses.md).
## Getting the result { #result }
**Callbacks are the primary channel.** When a payment becomes `completed` or `canceled`,
the API sends a `POST` to your URL. Verification and handling are in
[Callbacks](callbacks.md). There is no callback for `error`: it happens only before
requisites are issued and is visible in the create response and when you read the payment.
Once the payer has seen requisites, the result arrives as a callback.
**Polling is the safety net.** If no callback arrived, read the payment:
```bash
curl -sS "{{base_url}}/api/v1/payments/3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42" \
-H "Authorization: Bearer $API_TOKEN"
```
The response has the same shape as on create. If the project policy has
`signature_required`, sign the `GET` too (empty body).
Recommended:
- The payer's checkout page polls **your** server, not the API.
- Your server reads the payment from the API only when a callback is missing, for
example once a minute for `processing` payments past the payment lifetime.
- Do not poll a payment more often than every 5–10 seconds: the project shares one
[rate limit](idempotency.md#rate-limits).
## Edge cases { #edge-cases }
**Response with status `error` right away.** `201` with `status: "error"` means the
payment was saved but no requisites were issued. The reason is in `error_code`
(`provider_no_requisites`, `provider_result_ambiguous`). No callback follows for this
payment. There is nothing to show the payer — offer to try later with a new payment.
**`422` on create.** The payment cannot be processed: `payment_not_covered` (method or
amount is outside your contract), `payment_rate_unavailable` (no exchange rate),
`payment_not_routable` (the payment cannot be accepted with this method right now). If the payment was saved, the
body carries `payment_id`: the payment stays in `error`, the id is taken, and repeating the
same request returns it (`200`). A new attempt needs a new id. Without `payment_id` no
payment was created and the id is free.
**Timeout or dropped connection.** You do not know whether the payment exists. Repeat
**the same** request with the same `merchant_payment_id` and a fresh nonce and signature:
if the payment exists you get it back (`200`), otherwise it is created. No duplicate is
possible.
**The payer sent a different amount.** The payment may be re-checked and the actual
amount confirmed. You then receive a `completed` callback with the new amount: `amount`
changes, `initial_amount` stays. Fulfil the order by the callback amount.
**Deadline passed.** A payment not paid within its lifetime becomes `canceled`, and a
callback is sent. If the payer paid after the deadline, the payment is reviewed manually:
a confirmed payment brings a new `completed` callback.
**The payment could not be confirmed after requisites were issued.** The payment then
becomes `canceled` with no `error_code`, and a callback is sent. If the money did arrive, the payment is reviewed
manually and a new `completed` callback follows.
**Slow response.** Requisites are picked during your request. Keep
the client timeout at 15 seconds or more.
## See also
- [Card payments](card.md) — the same, with a card transfer.
- [Idempotency and limits](idempotency.md) — retries and rate limits.
- [API reference](api-reference.md) — field schema.
---
Source: /en/card/
Language: en
# Card payments
The payer transfers money to the recipient's card number from their bank app.
The payment works like an [SBP payment](sbp.md): the same API method, statuses,
callbacks and retry rules. What differs is `payment_method` and what `requisite` holds.
| | SBP | Card |
| --- | --- | --- |
| `payment_method` | `sbp` | `card_transfer` |
| `requisite` | phone number: `+79991234567` | card number: `2200123456789010` |
| How the payer pays | "SBP transfer" by phone number | "Card transfer" by card number |
## Create a payment { #create }
`POST /api/v1/payments` with `payment_method: "card_transfer"`. The request is
[signed](signing.md) the same way as for SBP.
```json
{
"merchant_payment_id": "order-1002",
"amount": "12500.00",
"payment_method": "card_transfer",
"bank_code": "sber"
}
```
All fields and rules are in [SBP payments → Request fields](sbp.md#request-fields).
`bank_code` is the payer's bank, a code from our bank catalog (not a BIC): requisites are
picked for it. `is_intrabank: true` together with `bank_code` means a transfer within that
bank: the recipient card is at the same bank as the payer's. Both are rare filters: without
them, requisites come from any bank.
Response `201 Created`:
```json
{
"id": "7a2d4c1e-9b3f-4e8a-b1c2-3d4e5f6a7b8c",
"merchant_payment_id": "order-1002",
"flow": "runtime_live",
"status": "processing",
"amount": 12500,
"initial_amount": 12500,
"currency": "RUB",
"geo_code": "RU",
"payment_method": "card_transfer",
"bank_code": "sber",
"is_intrabank": false,
"callback_url": null,
"is_test": false,
"merchant_information": {
"course": 100.00,
"rate": 11.00,
"amount_usdt": "125.0000",
"amount_rate": "11125.0000",
"amount_usdt_rate": "111.2500"
},
"requisite": "2200123456789010",
"holder_name": "Мария П.",
"created_at": "2026-09-29T09:30:00Z",
"updated_at": "2026-09-29T09:30:01Z"
}
```
Response fields and `merchant_information` are the same as for SBP:
[Response](sbp.md#response) and [Settlement](sbp.md#merchant-information).
## What to show the payer { #payer-screen }
- **Card number** from `requisite` — in groups of four (`2200 1234 5678 9010`), with a
"Copy" button that copies the number **without spaces**.
- **Recipient** from `holder_name` — the bank shows this name before the transfer.
- **Amount** — exactly `amount`. Warn that bank fees are paid on top: what arrives on the
card is what gets credited.
- **Deadline** — a countdown from `created_at` over your project's payment lifetime
(15 minutes by default).
!!! warning "The card number is for this payment only"
Do not store the card number or show it again. Create a new payment for a new
transfer.
## Statuses and result { #result }
Statuses, callbacks and polling are the same as for SBP:
[Statuses](sbp.md#statuses) and [Getting the result](sbp.md#result). Once a card is issued,
the payment ends only as `completed` or `canceled`, each with a callback; `error` means no
card was issued and shows in the create response.
## Edge cases { #edge-cases }
Everything in [SBP payments → Edge cases](sbp.md#edge-cases) applies to cards too.
Card-specific:
- **Transfer with a fee.** If the payer's bank deducts a fee from the transfer, less
arrives on the card. The actual amount may be confirmed on a payment re-check; the
callback then says `completed` with a new `amount`.
- **No cards of the requested bank.** With `bank_code` the choice is narrower; if nothing
fits, the payment closes with `provider_no_requisites` or the answer is
`422 payment_not_routable`. Try without `bank_code`.
---
Source: /en/statuses/
Language: en
# Statuses and errors
## Payment statuses { #statuses }
| Status | Final | Meaning |
| --- | --- | --- |
| created | no | Accepted, no requisites yet |
| processing | no | Requisites issued, waiting for the payer's transfer |
| completed | yes | Payment confirmed |
| canceled | yes | Not paid: the payment lifetime ran out, it was canceled, or the payment could not be confirmed after requisites were issued |
| error | yes | No requisites were issued, reason in `error_code` |
| appeal | no | A closed payment is being re-checked |
A create response may already carry any status except `appeal`. A final status changes
only after a payment re-check or a support decision — see
[Status changes after closing](callbacks.md#status-changes).
**`error` happens only before requisites are issued**: from `created`, usually right in the
create response. Once requisites are issued (`processing`), a payment ends only as
`completed` or `canceled`. If the payment cannot be confirmed after that, it also becomes
`canceled`, with no `error_code`.
The payment lifetime is a project setting: 15 minutes from `created_at` by default. A
payment not paid within it becomes `canceled`.
**Callbacks are sent only for `completed` and `canceled`** — when the payment is confirmed
or canceled, when the lifetime runs out, after a payment re-check and after a support
decision. There is no callback for `error`: you see it in the create response
and when you read the payment. More in
[Callbacks](callbacks.md#when).
## `error_code` values { #error-codes }
`error_code` and `error_comment` are present only for a payment in `error` — in the create
response and when you read it. The code explains why no requisites were issued. A
`canceled` payment has no code, including when the payment could not be confirmed. For the payer the
action is always the same: pay again with a new payment and a new `merchant_payment_id`.
| Code | What happened |
| --- | --- |
| `provider_unavailable` | The payment cannot be accepted now: the payment method is temporarily unavailable |
| `provider_no_requisites` | No free requisites were found |
| `rate_unavailable` | No exchange rate available |
| `provider_result_ambiguous` | Issuing requisites was not confirmed, no requisites issued |
| `provider_result_unknown` | The outcome of issuing requisites is unknown, no requisites issued |
| `stale_dispatch` | Requisites could not be obtained in time |
| `invalid_payment` | The payment failed field validation |
| `json_invalid` | The request body could not be parsed |
The list may grow: treat an unknown code as a generic failure.
## HTTP errors { #http-errors }
The error body is always JSON:
```json
{"error": "signature_invalid"}
```
`Content-Type: application/json`; the `X-Request-ID` header is echoed from the request.
| HTTP | `error` | When | What to do |
| --- | --- | --- | --- |
| 400 | `bad_request` | Not JSON, unknown field, trailing data after the object | Fix the request |
| 400 | `invalid_payment` | A field failed validation: amount, order id, geo, method, bank, `callback_url`, … | Fix the request |
| 401 | `token_invalid` | No token, unknown, revoked or expired token, or the project is not active | Check the token in the cabinet |
| 401 | `signature_invalid` | The signature did not verify | [Signing checklist](signing.md#debug-checklist) |
| 403 | `insufficient_scope` | The token lacks the scope | Issue a token with `payments:create` / `payments:read` |
| 403 | `ip_not_allowed` | The address is not in the project allowlist | Add it in the cabinet |
| 403 | `project_blocked` | Project API closed by staff | Contact your manager |
| 403 | `merchant_blocked` | Merchant is blocked | Contact your manager |
| 403 | `merchant_archived` | Merchant is deleted | Contact your manager |
| 404 | `payment_not_found` | No such payment, or it belongs to someone else | Check the `id` |
| 409 | `request_replayed` | This `X-Request-Nonce` was already used | Retry with a new nonce and signature |
| 409 | `payment_idempotency_conflict` | `merchant_payment_id` is taken by a payment with other fields | New id, or the same fields, see [Idempotency](idempotency.md) |
| 413 | `request_too_large` | Body over the project limit (64 KB by default) | Shrink the body |
| 422 | `payment_not_covered` | Method, currency or amount outside your contract | Check terms with your manager |
| 422 | `payment_rate_unavailable` | No exchange rate | Retry later with a new payment |
| 422 | `payment_not_routable` | The payment cannot be accepted with this method right now | Retry later with a new payment |
| 429 | `rate_limited` | Rate limit exceeded | Wait `Retry-After` seconds |
| 429 | `too_many_concurrent_requests` | More than 20 concurrent project requests | Wait `Retry-After` (1 s) and resend the same request |
| 500 | `internal_error` | Server-side failure | Retry with backoff |
| 503 | `rate_limit_unavailable` | Rate accounting temporarily unavailable | Wait `Retry-After` and retry |
| 503 | `replay_protection_unavailable` | Nonce check temporarily unavailable | Wait `Retry-After` and retry |
If the payment was saved (in `error`) before a `422`, the body carries its id:
```json
{"error": "payment_not_routable", "payment_id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42"}
```
## When to retry { #retries }
| Answer | Retry? | How |
| --- | --- | --- |
| Timeout, dropped connection | yes | Same request and `merchant_payment_id`, **new** nonce and signature |
| `429`, `503` | yes | After `Retry-After` seconds, then with growing delays |
| `500` | yes | With growing delays: 1, 2, 4, 8 seconds… |
| `409 request_replayed` | yes | With a new nonce and signature |
| `401`, `403`, `400`, `404`, `413` | no | Fix the cause first |
| `422`, status `error` | no | A new attempt is a new payment with a new `merchant_payment_id` |
Retrying with the same `merchant_payment_id` is safe: no second payment is created. See
[Idempotency and limits](idempotency.md).
```python
# Retry with backoff: same body and merchant_payment_id, fresh signature each time.
for attempt in range(5):
headers = sign_request("POST", url, body, key, key_id) # new nonce every time
response = send(url, body, headers)
if response.status in (429, 503):
time.sleep(int(response.headers.get("Retry-After", "1")))
continue
if response.status >= 500 or response.status == 0: # 0: timeout or dropped connection
time.sleep(2 ** attempt)
continue
break
```
---
Source: /en/idempotency/
Language: en
# Idempotency and limits
## Idempotency { #idempotency }
The idempotency key is your `merchant_payment_id`, unique within the project.
There is no `Idempotency-Key` header.
| What you send | Answer |
| --- | --- |
| A new `merchant_payment_id` | `201` — a new payment |
| The same `merchant_payment_id` and the same fields | `200` — the existing payment; requisites are not requested again |
| The same `merchant_payment_id` with other fields | `409 payment_idempotency_conflict` |
"The same fields" are `merchant_payment_id`, the amount, `currency`, `geo_code`,
`payment_method`, `bank_code`, `is_intrabank` and `callback_url`. Defaults are applied
before the comparison: a request without `currency` and the same request with
`"currency": "RUB"` are the same.
**What you get.** If the answer was lost — timeout, dropped connection, your server
crashed — just repeat the request with the same id. No duplicate payment, no double charge.
**A new payment attempt.** If a payment closed (`canceled`, `error`) or got `422`, a new
attempt needs a **new** `merchant_payment_id`: repeating the old one returns the old
result. Adding an attempt number works well: `order-1001-2`.
**When the id is not taken.** Rejections before the payment is accepted do not take the
`merchant_payment_id`: `429`, `503`, `401 signature_invalid`, `409 request_replayed`,
`413 request_too_large`, `400 bad_request` (unparsable body), `400 invalid_payment` (a
field failed validation). Fix the cause and send the request with the same id. If the corrected request gets `409 payment_idempotency_conflict`,
use a new id.
### Nonces and retries { #nonce }
Payment idempotency and one-time signatures are different things.
- `X-Request-Nonce` is single-use: resending the same signed request returns
`409 request_replayed`.
- So **re-sign** on every retry: new nonce, new `created`/`expires`, the same
`merchant_payment_id` and the same body.
- Exception: `429 too_many_concurrent_requests` does not burn the nonce, so the same signed
request may be sent again before `expires`.
## Rate limits { #rate-limits }
| Limit | Default | When exceeded |
| --- | --- | --- |
| Requests per source IP | 600 per minute | `429 rate_limited` |
| Project requests | 600 per minute, burst 60 | `429 rate_limited` |
| Project payment creates | 60 per minute, burst 6 | `429 rate_limited` |
| Concurrent project requests | 20 | `429 too_many_concurrent_requests` |
| Request body size | 64 KB | `413 request_too_large` |
- Project limits are visible in the cabinet; your manager can change them — ask them
manager if you expect more payments.
- A `429` carries `Retry-After`: how many seconds to wait.
- Limits are per project: all your servers share one counter.
- If rate or nonce accounting is temporarily unavailable, the API answers `503` with
`Retry-After: 1` — not a problem with your request, retry it.
## Timeouts { #timeouts }
Creating a payment picks requisites synchronously. Keep the HTTP client
timeout at **15 seconds or more**. If a timeout still happens, repeat the request with the
same `merchant_payment_id` — see [above](#idempotency).
---
Source: /en/signing/
Language: en
# Request signing
Every write request to the API is signed with your Ed25519 private key per
[RFC 9421 (HTTP Message Signatures)](https://www.rfc-editor.org/rfc/rfc9421).
The server stores only your public key and checks the signature before it accepts a
payment. A stolen token without the key cannot create a single payment.
A signature is always required, in the sandbox and in production. Reads (`GET`) need it
only when the project policy has `signature_required`; a signed `GET` is always accepted.
!!! tip "Do not write signing from scratch"
Take the ready-made function for your language [below](#ready-functions) and run it
on the [test vector](#test-vector). If the headers match byte for byte, the server will
accept your signatures.
## Headers to add { #headers }
| Header | Value |
| --- | --- |
| `Content-Digest` | `sha-256=::`. For `GET`, the digest of an empty body |
| `X-Request-ID` | Your request id: 1–128 characters `A-Za-z0-9._:-` |
| `X-Request-Nonce` | A one-time string: 16–128 characters, no spaces, tabs or commas |
| `Signature-Input` | `sig1=();created=…;expires=…;keyid="…";alg="ed25519"` |
| `Signature` | `sig1=::` |
Plus, as usual, `Authorization: Bearer ` and `Content-Type: application/json`.
These two are not signed.
## Signature profile { #profile }
The server accepts a signature only if all of this holds:
- **Components.** The signature covers all six: `"@method"`, `"@path"`, `"@authority"`,
`"content-digest"`, `"x-request-id"`, `"x-request-nonce"`. Any order. Extra header
components are allowed; other derived components (`"@query"`, `"@target-uri"`, …) are not.
- **Parameters.** `created` and `expires` (integers, Unix seconds) and `keyid` (the string
from the cabinet) are required. `alg` is optional, but if present it must be `"ed25519"`.
`nonce` and `tag` are accepted and ignored. Any other parameter is rejected.
- **One signature.** Exactly one label in `Signature-Input`. Use `sig1`.
- **Lifetime.** `expires - created` is at most 300 seconds. `created` may be at most
30 seconds in the future. The request must arrive before `expires`.
- **Nonce.** `X-Request-Nonce` is unique per project for 5 minutes 30 seconds. A replay
returns `409 request_replayed`.
- **Body.** `Content-Digest` matches the SHA-256 of **the same bytes** that arrived in the
request. Only `sha-256` is accepted.
- **Key.** `keyid` is the project's active key or the key in rotation.
## How the signature base is built { #signature-base }
The signature base is the text you sign: one line per component, in the same order as in
`Signature-Input`, then the `"@signature-params"` line. Lines are separated by `\n`;
there is no trailing newline.
```text
"@method": POST
"@path": /api/v1/payments
"@authority": api.example.com
"content-digest": sha-256=:7vp0UJ9PjprEvF8nY/Eh9EQV284GkSkTMxsEL6T6IBo=:
"x-request-id": 0b6d2c4e-8f1a-4d3b-9e7c-5a2f1b3c4d5e
"x-request-nonce": 9f86d081884c7d659a2feaa0c55ad015
"@signature-params": ("@method" "@path" "@authority" "content-digest" "x-request-id" "x-request-nonce");created=1790596800;expires=1790597100;keyid="ed25519-If4x36FUomFia_hUBG_SJw";alg="ed25519"
```
| Component | Value |
| --- | --- |
| `@method` | Upper-case method: `POST`, `GET` |
| `@path` | The URL path exactly as sent, **without the query**: `/api/v1/payments` |
| `@authority` | Lower-case host of `{{base_url}}`, with a port only if it is non-default: `api.example.com` |
| `content-digest` | The `Content-Digest` header value |
| `x-request-id` | The `X-Request-ID` header value as is |
| `x-request-nonce` | The `X-Request-Nonce` header value as is |
| `@signature-params` | Everything after `sig1=` in `Signature-Input` |
Sign the UTF-8 bytes of the base with Ed25519, take the 64 bytes, encode them in base64
and put them in `Signature: sig1=:…:`.
## Test vector { #test-vector }
This vector was produced by the server's own signature code and accepted by it.
Ed25519 is deterministic: given the same input, your function must produce **exactly**
these headers. Every code sample on this page is checked against it automatically.
!!! danger "This is a test key"
The key comes from RFC 8032 (section 7.1, TEST 1) and is public. Never upload it to the
cabinet or use it anywhere except tests.
**Input**
| What | Value |
| --- | --- |
| Private key (seed, hex) | `9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60` |
| Private key (PEM) | `-----BEGIN PRIVATE KEY-----`
`MC4CAQAwBQYDK2VwBCIEIJ1hsZ3v/VpguoRK9JLsLMREScVpezJpGXA7rAMcrn9g`
`-----END PRIVATE KEY-----` |
| Public key (base64) | `11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=` |
| `keyid` | `ed25519-If4x36FUomFia_hUBG_SJw` |
| Request | `POST https://api.example.com/api/v1/payments`, a domain reserved by RFC 2606. The host is signed, so with your `{{base_url}}` the signature differs |
| `created` / `expires` | `1790596800` / `1790597100` (2026-09-28 12:00:00 UTC + 300 s) |
| `X-Request-Nonce` | `9f86d081884c7d659a2feaa0c55ad015` |
| `X-Request-ID` | `0b6d2c4e-8f1a-4d3b-9e7c-5a2f1b3c4d5e` |
The body is exactly these bytes, with no trailing newline:
```json
{"merchant_payment_id":"order-1001","amount":"5000.00","currency":"RUB","geo_code":"RU","payment_method":"sbp","bank_code":"sber"}
```
**Expected output**
```http
Content-Digest: sha-256=:7vp0UJ9PjprEvF8nY/Eh9EQV284GkSkTMxsEL6T6IBo=:
Signature-Input: sig1=("@method" "@path" "@authority" "content-digest" "x-request-id" "x-request-nonce");created=1790596800;expires=1790597100;keyid="ed25519-If4x36FUomFia_hUBG_SJw";alg="ed25519"
Signature: sig1=:BtY8/iQPBfIpIsuvra/cgfiKFMF+tL0yjt4Y37FCM5aUtbGvo1F0pH1SDKp6QSc8Wo6Jj/+rv12KE5VLuZecAg==:
```
Empty-body digest (for `GET`): `sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:`.
The machine-readable vector is [`examples/test-vectors.json`](#test-vector-json).
## Ready-made functions { #ready-functions }
The function takes the method, URL, body bytes, private key and `keyid` and returns the
signature headers. It fills in the time, nonce and request id itself; tests can pin them.
=== "curl"
OpenSSL 3.0 or newer (macOS: `brew install openssl@3`). The function prints one header
per line — pass them to `curl -H` as the [payment script](quickstart.md#first-payment)
does.
```bash
# Payment API request signing: RFC 9421, Ed25519.
# POSIX sh + OpenSSL 3.0 or newer (macOS: brew install openssl@3). Load with: . ./sign.sh
# api_content_digest FILE — Content-Digest of the exact file bytes.
api_content_digest() {
printf 'sha-256=:%s:' "$(openssl dgst -sha256 -binary < "$1" | openssl base64 -A)"
}
# api_key_id PUBLIC_PEM — the keyid the cabinet shows for this public key:
# "ed25519-" + base64url(sha256(32 key bytes)[:16]).
api_key_id() {
printf 'ed25519-%s' "$(openssl pkey -pubin -in "$1" -outform DER | tail -c 32 \
| openssl dgst -sha256 -binary | head -c 16 | openssl base64 -A | tr '+/' '-_' | tr -d '=')"
}
# api_sign_request METHOD URL BODY_FILE KEY_FILE KEY_ID [CREATED NONCE REQUEST_ID]
# Prints the signature headers, one "Name: value" per line.
# BODY_FILE holds the exact bytes that will be sent (for GET use an empty file or /dev/null).
api_sign_request() {
api_method=$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')
api_rest=${2#*://} # api.example.com/api/v1/payments?x=1
api_authority=$(printf '%s' "${api_rest%%/*}" | tr '[:upper:]' '[:lower:]')
api_path=/${api_rest#*/}; [ "$api_rest" = "${api_rest#*/}" ] && api_path=/
api_path=${api_path%%\?*} # no query string
api_created=${6:-$(date +%s)}
api_nonce=${7:-$(openssl rand -hex 16)}
api_request_id=${8:-$(openssl rand -hex 16)}
api_digest=$(api_content_digest "$3")
api_params="(\"@method\" \"@path\" \"@authority\" \"content-digest\" \"x-request-id\" \"x-request-nonce\")"
api_params="$api_params;created=$api_created;expires=$((api_created + 300));keyid=\"$5\";alg=\"ed25519\""
api_base_file=$(mktemp)
printf '"@method": %s\n"@path": %s\n"@authority": %s\n"content-digest": %s\n"x-request-id": %s\n"x-request-nonce": %s\n"@signature-params": %s' \
"$api_method" "$api_path" "$api_authority" "$api_digest" "$api_request_id" "$api_nonce" "$api_params" > "$api_base_file"
api_signature=$(openssl pkeyutl -sign -rawin -inkey "$4" -in "$api_base_file" | openssl base64 -A)
rm -f "$api_base_file"
printf 'Content-Digest: %s\n' "$api_digest"
printf 'X-Request-ID: %s\n' "$api_request_id"
printf 'X-Request-Nonce: %s\n' "$api_nonce"
printf 'Signature-Input: sig1=%s\n' "$api_params"
printf 'Signature: sig1=:%s:\n' "$api_signature"
}
```
=== "PHP"
PHP 8.1+, the `sodium` extension (bundled with standard PHP builds).
```php
*/
function api_sign_request(
string $method,
string $url,
string $body,
string $secretKey,
string $keyId,
array $options = [],
): array {
$parts = parse_url($url);
$authority = strtolower($parts['host'] . (isset($parts['port']) ? ':' . $parts['port'] : ''));
$created = $options['created'] ?? time();
$lifetime = $options['lifetime'] ?? SIGNATURE_MAX_LIFETIME;
$nonce = $options['nonce'] ?? bin2hex(random_bytes(16));
$requestId = $options['request_id'] ?? bin2hex(random_bytes(16));
$digest = api_content_digest($body);
$values = [
'@method' => strtoupper($method),
'@path' => $parts['path'] ?? '/',
'@authority' => $authority,
'content-digest' => $digest,
'x-request-id' => $requestId,
'x-request-nonce' => $nonce,
];
$quoted = implode(' ', array_map(fn (string $name): string => "\"$name\"", SIGNATURE_COMPONENTS));
$params = sprintf('(%s);created=%d;expires=%d;keyid="%s";alg="ed25519"', $quoted, $created, $created + $lifetime, $keyId);
$base = '';
foreach (SIGNATURE_COMPONENTS as $name) {
$base .= "\"$name\": {$values[$name]}\n";
}
$base .= "\"@signature-params\": $params";
$signature = base64_encode(sodium_crypto_sign_detached($base, $secretKey));
return [
'Content-Digest' => $digest,
'X-Request-ID' => $requestId,
'X-Request-Nonce' => $nonce,
'Signature-Input' => "sig1=$params",
'Signature' => "sig1=:$signature:",
];
}
```
=== "Python"
Python 3.10+, `pip install cryptography`.
```python
"""Payment API request signing: RFC 9421, Ed25519.
Python 3.10+, the cryptography package (pip install cryptography).
"""
import base64
import hashlib
import secrets
import time
import uuid
from urllib.parse import urlsplit
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import load_pem_private_key
# Components the signature must cover. Any order, but the same as in Signature-Input.
COMPONENTS = ("@method", "@path", "@authority", "content-digest", "x-request-id", "x-request-nonce")
MAX_LIFETIME_SECONDS = 300 # expires - created must not exceed 5 minutes
def content_digest(body: bytes) -> str:
"""RFC 9530 Content-Digest: sha-256 of the exact body bytes."""
return "sha-256=:" + base64.b64encode(hashlib.sha256(body).digest()).decode() + ":"
def key_id(public_key: bytes) -> str:
"""The keyid the cabinet shows for a 32-byte public key."""
digest = hashlib.sha256(public_key).digest()[:16]
return "ed25519-" + base64.urlsafe_b64encode(digest).decode().rstrip("=")
def load_private_key(pem: bytes) -> Ed25519PrivateKey:
"""Loads a PEM private key (PKCS#8, as produced by openssl genpkey)."""
key = load_pem_private_key(pem, password=None)
if not isinstance(key, Ed25519PrivateKey):
raise ValueError("expected an Ed25519 key")
return key
def sign_request(
method: str,
url: str,
body: bytes,
private_key: Ed25519PrivateKey,
key_id: str,
*,
created: int | None = None,
lifetime: int = MAX_LIFETIME_SECONDS,
nonce: str | None = None,
request_id: str | None = None,
) -> dict[str, str]:
"""Returns the signature headers. Add Authorization and Content-Type yourself.
body must be the exact bytes that will be sent; b"" for GET.
"""
parts = urlsplit(url)
created = int(time.time()) if created is None else created
nonce = nonce or secrets.token_hex(16)
request_id = request_id or str(uuid.uuid4())
digest = content_digest(body)
values = {
"@method": method.upper(),
"@path": parts.path or "/",
"@authority": parts.netloc.lower(),
"content-digest": digest,
"x-request-id": request_id,
"x-request-nonce": nonce,
}
params = (
"(" + " ".join(f'"{name}"' for name in COMPONENTS) + ")"
+ f';created={created};expires={created + lifetime};keyid="{key_id}";alg="ed25519"'
)
base = "".join(f'"{name}": {values[name]}\n' for name in COMPONENTS)
base += f'"@signature-params": {params}'
signature = base64.b64encode(private_key.sign(base.encode())).decode()
return {
"Content-Digest": digest,
"X-Request-ID": request_id,
"X-Request-Nonce": nonce,
"Signature-Input": f"sig1={params}",
"Signature": f"sig1=:{signature}:",
}
```
=== "Node.js"
Node.js 20+, no dependencies.
```javascript
// Payment API request signing: RFC 9421, Ed25519. Node.js 20+, no dependencies.
import { createHash, createPrivateKey, randomBytes, randomUUID, sign } from 'node:crypto';
// Components the signature must cover. Any order, but the same as in Signature-Input.
const COMPONENTS = ['@method', '@path', '@authority', 'content-digest', 'x-request-id', 'x-request-nonce'];
const MAX_LIFETIME_SECONDS = 300; // expires - created must not exceed 5 minutes
/** RFC 9530 Content-Digest: sha-256 of the exact body bytes. */
export function contentDigest(body) {
return `sha-256=:${createHash('sha256').update(body).digest('base64')}:`;
}
/** The keyid the cabinet shows for a 32-byte public key. */
export function keyId(publicKey) {
return 'ed25519-' + createHash('sha256').update(publicKey).digest().subarray(0, 16).toString('base64url');
}
/** Loads a PEM private key (PKCS#8, as produced by openssl genpkey). */
export function loadPrivateKey(pem) {
const key = createPrivateKey(pem);
if (key.asymmetricKeyType !== 'ed25519') throw new Error('expected an Ed25519 key');
return key;
}
/**
* Returns the signature headers. Add Authorization and Content-Type yourself.
* body must be the exact bytes (Buffer or string) that will be sent; '' for GET.
*/
export function signRequest(method, url, body, privateKey, keyIdValue, options = {}) {
const { pathname, host } = new URL(url);
const created = options.created ?? Math.floor(Date.now() / 1000);
const lifetime = options.lifetime ?? MAX_LIFETIME_SECONDS;
const nonce = options.nonce ?? randomBytes(16).toString('hex');
const requestId = options.requestId ?? randomUUID();
const digest = contentDigest(body);
const values = {
'@method': method.toUpperCase(),
'@path': pathname || '/',
'@authority': host.toLowerCase(),
'content-digest': digest,
'x-request-id': requestId,
'x-request-nonce': nonce,
};
const params =
`(${COMPONENTS.map((name) => `"${name}"`).join(' ')})` +
`;created=${created};expires=${created + lifetime};keyid="${keyIdValue}";alg="ed25519"`;
const base =
COMPONENTS.map((name) => `"${name}": ${values[name]}\n`).join('') + `"@signature-params": ${params}`;
const signature = sign(null, Buffer.from(base), privateKey).toString('base64');
return {
'Content-Digest': digest,
'X-Request-ID': requestId,
'X-Request-Nonce': nonce,
'Signature-Input': `sig1=${params}`,
Signature: `sig1=:${signature}:`,
};
}
```
=== "Go"
Go 1.22+, standard library only.
```go
// Package paymentapi is a payment API integration example using only the Go 1.22+ standard library.
package paymentapi
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
// components the signature must cover. Any order, but the same as in Signature-Input.
var components = []string{"@method", "@path", "@authority", "content-digest", "x-request-id", "x-request-nonce"}
// MaxLifetime is the longest allowed window between created and expires.
const MaxLifetime = 5 * time.Minute
// SignOptions pins the time, nonce and request id. Empty fields are filled in automatically.
type SignOptions struct {
Created time.Time
Lifetime time.Duration
Nonce string
RequestID string
}
// ContentDigest returns the RFC 9530 Content-Digest header: sha-256 of the exact body bytes.
func ContentDigest(body []byte) string {
sum := sha256.Sum256(body)
return "sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":"
}
// KeyID returns the keyid the cabinet shows for this public key.
func KeyID(publicKey ed25519.PublicKey) string {
sum := sha256.Sum256(publicKey)
return "ed25519-" + base64.RawURLEncoding.EncodeToString(sum[:16])
}
// LoadPrivateKey reads a PEM private key (PKCS#8, as produced by openssl genpkey).
func LoadPrivateKey(pemBytes []byte) (ed25519.PrivateKey, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, errors.New("paymentapi: key is not PEM")
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("paymentapi: parse key: %w", err)
}
key, ok := parsed.(ed25519.PrivateKey)
if !ok {
return nil, errors.New("paymentapi: key is not Ed25519")
}
return key, nil
}
// SignRequest sets Content-Digest, X-Request-ID, X-Request-Nonce, Signature-Input and
// Signature on the request. body must be the exact bytes that will be sent; nil for GET.
// The caller sets Authorization and Content-Type.
func SignRequest(r *http.Request, body []byte, key ed25519.PrivateKey, keyID string, opts SignOptions) error {
if opts.Created.IsZero() {
opts.Created = time.Now()
}
if opts.Lifetime == 0 {
opts.Lifetime = MaxLifetime
}
if opts.Nonce == "" {
opts.Nonce = randomHex(16)
}
if opts.RequestID == "" {
opts.RequestID = randomHex(16)
}
path := r.URL.EscapedPath()
if path == "" {
path = "/"
}
authority := r.Host
if authority == "" {
authority = r.URL.Host
}
digest := ContentDigest(body)
values := map[string]string{
"@method": strings.ToUpper(r.Method),
"@path": path,
"@authority": strings.ToLower(authority),
"content-digest": digest,
"x-request-id": opts.RequestID,
"x-request-nonce": opts.Nonce,
}
quoted := make([]string, len(components))
var base strings.Builder
for i, name := range components {
quoted[i] = `"` + name + `"`
base.WriteString(`"` + name + `": ` + values[name] + "\n")
}
created := opts.Created.Unix()
params := fmt.Sprintf(`(%s);created=%d;expires=%d;keyid="%s";alg="ed25519"`,
strings.Join(quoted, " "), created, created+int64(opts.Lifetime/time.Second), keyID)
base.WriteString(`"@signature-params": ` + params)
signature := ed25519.Sign(key, []byte(base.String()))
r.Header.Set("Content-Digest", digest)
r.Header.Set("X-Request-ID", opts.RequestID)
r.Header.Set("X-Request-Nonce", opts.Nonce)
r.Header.Set("Signature-Input", "sig1="+params)
r.Header.Set("Signature", "sig1=:"+base64.StdEncoding.EncodeToString(signature)+":")
return nil
}
func randomHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err) // crypto/rand does not fail on supported platforms
}
return hex.EncodeToString(b)
}
```
!!! warning "Sign and send the same bytes"
Serialize the JSON once into a string or bytes, sign them and send **them**. Do not
hand the HTTP client an object that it serializes again: key order, whitespace or
escaping may change and the digest will no longer match.
## Checklist: `401 signature_invalid` { #debug-checklist }
The server does not say what exactly is wrong: every signature failure looks the same.
Check in this order.
1. **Test vector.** Does your function produce exactly the headers [above](#test-vector)?
If not, the bug is in your signing code, not in the request.
2. **Body.** Are the signed and the sent bytes the same? Common causes: the client
re-serializes JSON, adds a newline or changes the encoding.
3. **Clock.** Is the server time correct? `created` more than 30 seconds in the future,
or a request arriving after `expires`, is rejected. Enable NTP.
4. **Path and host.** `@path` has no query and no domain, exactly as sent. `@authority`
is the lower-case host without `https://`. A proxy that rewrites `Host` breaks the
signature.
5. **Key.** Is `keyid` copied from the cabinet and does it belong to this project? Is the
key not revoked? Is the private key the pair of the uploaded public key? Compare the
`keyid` printed by the key generator with the cabinet.
6. **Components.** Are all six components in `Signature-Input`, lower-case and
double-quoted? Are the base lines in the same order as in `Signature-Input`?
7. **Format.** `Signature: sig1=:…:` — colons on both sides, standard base64 with `=`,
exactly 64 signature bytes. The label in `Signature` matches `Signature-Input`.
8. **Nonce.** 16–128 characters without spaces or commas. A reused nonce gives
`409 request_replayed`, not 401.
9. **Lifetime.** `expires - created` is at most 300.
If everything matches and you still get `401`, send support the request's `X-Request-ID`
and the time it was sent.
## Key rotation { #key-rotation }
A project has one active key and may have one key in rotation. During rotation, both
verify.
1. Generate a new pair and upload the public key in the cabinet; it becomes "rotating".
2. Switch your servers to the new private key and the new `keyid`.
3. Once all servers use it, activate the new key in the cabinet. The old one is revoked.
If a private key leaks, revoke it in the cabinet immediately and upload a new one.
## Machine-readable vector { #test-vector-json }
`examples/test-vectors.json` from the docs repository, shared by all languages:
```json
{
"_comment": "Общие проверочные примеры. Подпись сгенерирована серверной реализацией platform/httpsig (Sign) и проверена ею же (Verify). Ключ — тестовый ключ RFC 8032 §7.1 TEST 1, публично известен: не используйте его нигде, кроме тестов.",
"signing": {
"private_key_pem_file": "testdata/test-key.pem",
"public_key_pem_file": "testdata/test-key.pub.pem",
"seed_hex": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
"public_key_b64": "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=",
"key_id": "ed25519-If4x36FUomFia_hUBG_SJw",
"method": "POST",
"url": "https://api.example.com/api/v1/payments",
"body": "{\"merchant_payment_id\":\"order-1001\",\"amount\":\"5000.00\",\"currency\":\"RUB\",\"geo_code\":\"RU\",\"payment_method\":\"sbp\",\"bank_code\":\"sber\"}",
"created": 1790596800,
"expires": 1790597100,
"nonce": "9f86d081884c7d659a2feaa0c55ad015",
"request_id": "0b6d2c4e-8f1a-4d3b-9e7c-5a2f1b3c4d5e",
"expected": {
"content_digest": "sha-256=:7vp0UJ9PjprEvF8nY/Eh9EQV284GkSkTMxsEL6T6IBo=:",
"signature_base": "\"@method\": POST\n\"@path\": /api/v1/payments\n\"@authority\": api.example.com\n\"content-digest\": sha-256=:7vp0UJ9PjprEvF8nY/Eh9EQV284GkSkTMxsEL6T6IBo=:\n\"x-request-id\": 0b6d2c4e-8f1a-4d3b-9e7c-5a2f1b3c4d5e\n\"x-request-nonce\": 9f86d081884c7d659a2feaa0c55ad015\n\"@signature-params\": (\"@method\" \"@path\" \"@authority\" \"content-digest\" \"x-request-id\" \"x-request-nonce\");created=1790596800;expires=1790597100;keyid=\"ed25519-If4x36FUomFia_hUBG_SJw\";alg=\"ed25519\"",
"signature_input": "sig1=(\"@method\" \"@path\" \"@authority\" \"content-digest\" \"x-request-id\" \"x-request-nonce\");created=1790596800;expires=1790597100;keyid=\"ed25519-If4x36FUomFia_hUBG_SJw\";alg=\"ed25519\"",
"signature": "sig1=:BtY8/iQPBfIpIsuvra/cgfiKFMF+tL0yjt4Y37FCM5aUtbGvo1F0pH1SDKp6QSc8Wo6Jj/+rv12KE5VLuZecAg==:",
"empty_body_digest": "sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:"
}
},
"callback": {
"secret": "whsec_MfKQ9r2vXz",
"previous_secret": "whsec_previous",
"timestamp": "1700000000",
"body": "{\"id\":\"3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42\",\"event_id\":\"5b0c8f5e-1d2a-4a8e-9f3c-7e6d5c4b3a21\",\"amount\":3400,\"initial_amount\":3400,\"status\":\"completed\",\"currency\":\"RUB\",\"payment_method\":\"sbp\",\"bank_code\":\"sber\",\"merchant_payment_id\":\"order-42\",\"rate\":11.00,\"course\":100.00,\"is_intrabank\":false,\"is_test\":false,\"project_id\":\"5d7e2c1a-0b9f-4e3d-8c2b-1a0f9e8d7c6b\"}",
"expected_signature": "e3e688bac4039def6f680c16f14701866d8e5e2de66d9b37f9ecc8bccb90220f",
"expected_previous_signature": "54b9a97acdb1adf27f81bc49bdc9d963b4fe4f3bf5114ea3303581084e99f3b4"
}
}
```
---
Source: /en/callbacks/
Language: en
# Callbacks
A callback is a JSON `POST` that the API sends to your server when a payment is paid or
canceled. It tells you the result without polling. Every callback is signed with the
project secret: verify the signature before trusting the content.
## How it works { #how-it-works }
1. **API.** The payment is closed — `completed` or `canceled`. An event with a new `event_id` is created
2. **API → Your server.** `POST` to the callback URL, signed in `X-Callback-Signature`
3. **Your server.** Verifies the signature and time, looks the `event_id` up among processed ones
4. **Your server ⇢ API.** Answers `2xx` at once, before handling the order
5. **Your server.** Updates the order by `id` and `status` in the background
6. **API → Your server.** No `2xx` — the same event again after 30 and after 60 seconds
Key points:
- A callback is an **event notification**. The source of truth is the payment in the API:
`GET /api/v1/payments/{id}`.
- One event may arrive **more than once**; events of one payment may arrive **out of order**.
- One payment may get **several different events**: for example `canceled` and then
`completed` — see [Status changes after closing](#status-changes).
- There are few attempts — 3 within a minute and a half. Answer `2xx` quickly and keep a
[reconciliation](#missing) in case a callback does not get through.
## When it arrives { #when }
A callback arrives when a live payment moves to one of two statuses:
| Status | What happened | What to do |
| --- | --- | --- |
| `completed` | Payment confirmed | Fulfil the order for `amount` |
| `canceled` | Not paid: its lifetime ran out, it was canceled, or the payment could not be confirmed after requisites were issued | Do not fulfil; offer to pay again — with a new payment |
The callback does not carry the cancellation reason: for you every case is the same — the
requisites are no longer valid.
A callback **does not arrive**:
- for a payment in `error`. `error` happens only before requisites are issued, and you learn
about it from the create response or from `GET /api/v1/payments/{id}`. Once requisites are
issued, a payment ends only as `completed` or `canceled`, and either one brings a callback;
- for intermediate statuses `created`, `processing` and `appeal` (the payment is being
re-checked — the callback comes with the outcome);
- for test payments (`is_test: true`) — see [Test payments](#test-payments);
- if no callback URL is set on the payment, the project or the merchant — see
[Where it goes](#url).
### Status changes after closing { #status-changes }
`completed` and `canceled` are final, but a payment re-check or a support decision can
change them. Every change is a **new event with a new `event_id`** and brings a new
callback:
| Was | Received | When |
| --- | --- | --- |
| `canceled` | `completed` | The payer paid after the payment lifetime, or the payment was confirmed later |
| `completed` | `completed` with another `amount` | The payer sent a different amount and it was confirmed. `initial_amount` does not change |
| `completed` | `canceled` | The payment was not confirmed on re-check |
| any | the same status again | The re-check kept the status, or support resent the callback |
Your handler must be able to:
- fulfil an order of a canceled payment if `completed` arrives later;
- recalculate the order if a new `completed` has another `amount`;
- revoke or hold the order if `canceled` arrives after `completed`.
!!! warning "Events may arrive out of order"
If a callback changes what you have already stored for the payment, re-read it with
`GET /api/v1/payments/{id}` and apply **the status and amount from the API response**.
This way a late event cannot roll the order back.
## Where it goes { #url }
The first URL that is set wins:
1. `callback_url` from the create request;
2. the project callback URL (cabinet, Projects and API);
3. the merchant's common callback URL (cabinet, merchant settings).
If no URL is set at any level, no callback is sent: read the result with
`GET /api/v1/payments/{id}`.
URL requirements:
- absolute `https://`, no credentials, up to 2048 characters, not `localhost`;
- the host resolves to public IPs only. If any of its addresses is internal (private
networks, loopback, link-local, CGNAT `100.64.0.0/10`), no connection is made;
- **redirects are not followed**: a `3xx` response is a failure. Use the final URL.
## Body { #body }
The body is an event about the payment's transition to a status: the main payment fields,
the rate and fee, `event_id` and `project_id`. It has no requisites or USDT amounts — the
full payment is returned by `GET /api/v1/payments/{id}` ([fields](sbp.md#response)).
```json
{
"id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42",
"event_id": "5b0c8f5e-1d2a-4a8e-9f3c-7e6d5c4b3a21",
"amount": 3400,
"initial_amount": 3400,
"status": "completed",
"currency": "RUB",
"payment_method": "sbp",
"bank_code": "sber",
"merchant_payment_id": "order-42",
"rate": 11.00,
"course": 100.00,
"is_intrabank": false,
"is_test": false,
"project_id": "5d7e2c1a-0b9f-4e3d-8c2b-1a0f9e8d7c6b"
}
```
| Field | Meaning |
| --- | --- |
| `id` | Payment `id` from the create response |
| `event_id` | Event id. The same in every retry of one event and equal to the `X-Callback-Event-Id` header |
| `status` | Event status: `completed` or `canceled`. On a manual resend, the payment's current closed status, `error` included |
| `amount` | Credited amount in major currency units. After a payment re-check it may differ from `initial_amount` — fulfil the order by `amount` |
| `initial_amount` | Amount from the create request, never changes |
| `merchant_payment_id` | Your order id |
| `rate`, `course` | Your fee, %, and rate — payment currency per 1 USDT. Numbers with two decimals, the same as in [`merchant_information`](sbp.md#merchant-information); `null` if no requisites were issued |
| `is_test` | `true` for a project test payment |
| `project_id` | Project the payment belongs to |
| other fields | As in the [create response](sbp.md#response): `currency`, `payment_method`, `bank_code`, `is_intrabank` |
- There is no error code in a callback, even on a manual resend of a payment in `error`:
read the reason with `GET`.
- Parse `amount`, `initial_amount`, `rate` and `course` as decimals, not floats.
- New fields may be added: ignore unknown fields.
- The body is built once, when the event is queued, and is byte-for-byte the same in every
delivery retry. When in doubt about freshness, re-read the payment with `GET`.
## Headers { #headers }
| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `X-Callback-Event-Id` | The event's `event_id` |
| `X-Callback-Timestamp` | Unix time of this attempt, seconds |
| `X-Callback-Signature` | `v1=`; for 24 hours after a secret rotation, `v1=,v1=` |
## Signature verification { #signature }
```text
signed_payload = X-Callback-Timestamp + "." + raw_request_body
signature = hex( HMAC-SHA256( key = the whole project secret, message = signed_payload ) )
```
- The secret is a `whsec_…` string. The HMAC key is **the whole string** as UTF-8,
`whsec_` included. Nothing needs decoding.
- `hex` is 64 lower-case characters.
- Compute over the **raw body bytes** before parsing JSON. Parsing and re-serializing may
change the bytes.
- The request is authentic if at least one `v1=` value matches your signature. Compare in
constant time. Skip values with another scheme (not `v1`).
### Replay protection { #replay }
1. Reject the request if `X-Callback-Timestamp` differs from your clock by more than
5 minutes. Keep your clock synced with NTP. The timestamp is part of the signature and
cannot be forged.
2. Keep processed `event_id` values for at least a day. Do not process a repeated
`event_id` again — just answer `2xx`.
`event_id` identifies an **event**, not a payment and not an attempt:
| What happened | `event_id` | Body | Timestamp and signature |
| --- | --- | --- | --- |
| Delivery retry after an error or a non-`2xx` answer | same | byte-for-byte same | new |
| New payment status | new | new | new |
| Manual resend by support | new, even if the status did not change | new | new |
Decide the payment outcome by `id` and `status`; use `event_id` only to avoid processing
one delivery twice.
### Verification functions { #verify-functions }
The functions check the signature and freshness and are tested on the vector below.
=== "curl"
For debugging in a terminal. In your application use your language's function:
shell cannot compare strings in constant time.
```bash
# Payment API callback signature check (HMAC-SHA256) for debugging in a terminal.
# In your application verify with code that compares in constant time.
# POSIX sh + openssl. Load with: . ./verify_callback.sh
# api_callback_signature SECRET TIMESTAMP BODY_FILE — the expected signature (hex).
api_callback_signature() {
{ printf '%s.' "$2"; cat "$3"; } | openssl dgst -sha256 -hmac "$1" -r | cut -d' ' -f1
}
# api_verify_callback SECRET TIMESTAMP SIGNATURE_HEADER BODY_FILE [NOW]
# Exit code 0: the signature matches and the timestamp is fresh (±300 seconds).
api_verify_callback() {
api_now=${5:-$(date +%s)}
api_skew=$((api_now - $2)); [ "$api_skew" -lt 0 ] && api_skew=$((-api_skew))
[ "$api_skew" -le 300 ] || return 1
api_expected=$(api_callback_signature "$1" "$2" "$4")
for api_part in $(printf '%s' "$3" | tr ',' ' '); do
[ "$api_part" = "v1=$api_expected" ] && return 0
done
return 1
}
```
=== "PHP"
```php
CALLBACK_MAX_SKEW) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret);
foreach (explode(',', $signature) as $part) {
[$scheme, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($scheme === 'v1' && hash_equals($expected, $value)) {
return true;
}
}
return false;
}
```
=== "Python"
```python
"""Payment API callback signature check: HMAC-SHA256. Standard library only."""
import hashlib
import hmac
import time
MAX_SKEW_SECONDS = 300 # reject callbacks more than 5 minutes old or ahead
def verify_callback(secret: str, headers, body: bytes, now: float | None = None) -> bool:
"""True if the callback is authentic and fresh.
secret — the whole project secret, including the whsec_ prefix;
headers — request headers (any object with .get);
body — raw body bytes, before JSON parsing.
"""
timestamp = headers.get("X-Callback-Timestamp", "")
signature = headers.get("X-Callback-Signature", "")
if not timestamp.isdigit():
return False
now = time.time() if now is None else now
if abs(now - int(timestamp)) > MAX_SKEW_SECONDS:
return False
signed = timestamp.encode() + b"." + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
for part in signature.split(","):
scheme, _, value = part.strip().partition("=")
if scheme == "v1" and hmac.compare_digest(value, expected):
return True
return False
```
=== "Node.js"
```javascript
// Payment API callback signature check: HMAC-SHA256. Node.js 20+, no dependencies.
import { createHmac, timingSafeEqual } from 'node:crypto';
const MAX_SKEW_SECONDS = 300; // reject callbacks more than 5 minutes old or ahead
/**
* true if the callback is authentic and fresh.
* secret — the whole project secret, including the whsec_ prefix;
* headers — request headers with lower-case names (as in node:http);
* body — raw body bytes (Buffer), before JSON parsing.
*/
export function verifyCallback(secret, headers, body, now = Date.now() / 1000) {
const timestamp = String(headers['x-callback-timestamp'] ?? '');
const signature = String(headers['x-callback-signature'] ?? '');
if (!/^\d+$/.test(timestamp)) return false;
if (Math.abs(now - Number(timestamp)) > MAX_SKEW_SECONDS) return false;
const expected = Buffer.from(
createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest('hex'),
);
return signature.split(',').some((part) => {
const [scheme, value = ''] = part.trim().split('=', 2);
const candidate = Buffer.from(value);
return scheme === 'v1' && candidate.length === expected.length && timingSafeEqual(candidate, expected);
});
}
```
=== "Go"
```go
package paymentapi
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"strings"
"time"
)
// maxSkew: callbacks more than 5 minutes old or ahead are rejected.
const maxSkew = 5 * time.Minute
// VerifyCallback checks the callback signature and freshness. secret is the whole project
// secret including whsec_; body is the raw request body, before JSON parsing.
func VerifyCallback(secret string, header http.Header, body []byte, now time.Time) bool {
timestamp := header.Get("X-Callback-Timestamp")
sent, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if skew := now.Sub(time.Unix(sent, 0)); skew > maxSkew || skew < -maxSkew {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(body)
expected := []byte(hex.EncodeToString(mac.Sum(nil)))
for _, part := range strings.Split(header.Get("X-Callback-Signature"), ",") {
scheme, value, ok := strings.Cut(strings.TrimSpace(part), "=")
if ok && scheme == "v1" && hmac.Equal([]byte(value), expected) {
return true
}
}
return false
}
```
### Test vector { #test-vector }
`body` is exactly one line with no line breaks and no spaces between fields (the body
from the example above as the API sends it), 362 bytes:
```text
secret = whsec_MfKQ9r2vXz
timestamp = 1700000000
body = {"id":"3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42","event_id":"5b0c8f5e-1d2a-4a8e-9f3c-7e6d5c4b3a21","amount":3400,"initial_amount":3400,"status":"completed","currency":"RUB","payment_method":"sbp","bank_code":"sber","merchant_payment_id":"order-42","rate":11.00,"course":100.00,"is_intrabank":false,"is_test":false,"project_id":"5d7e2c1a-0b9f-4e3d-8c2b-1a0f9e8d7c6b"}
signature = e3e688bac4039def6f680c16f14701866d8e5e2de66d9b37f9ecc8bccb90220f
```
With the previous secret `whsec_previous` the same body gives
`54b9a97acdb1adf27f81bc49bdc9d963b4fe4f3bf5114ea3303581084e99f3b4`, and during the
overlap the header looks like
`v1=e3e688ba…90220f,v1=54b9a97a…99f3b4`.
## How to handle it { #handling }
1. Read the **raw body** and headers.
2. Verify the signature and `X-Callback-Timestamp`. If they fail, answer `401` and change
nothing.
3. Insert `event_id` into a table with a unique index. Already there — answer `2xx` and stop.
4. Queue the event in your own queue and answer `2xx` at once.
5. In the background find the order by `merchant_payment_id` or the stored `id` and apply
`status` and `amount` following the [status change rules](#status-changes).
`2xx` means "event accepted", not "order fulfilled". If your processing fails after the
answer, re-read the payment with `GET`: the callback will not come again by itself.
### Minimal receiver { #minimal-receiver }
Verifies the signature, drops duplicates and answers `2xx` at once. In production store
`event_id` in a database with a unique index and queue the processing.
=== "PHP"
```php
{$event['status']}");
http_response_code(204); // answer 2xx at once, process asynchronously
```
=== "Python"
```python
"""Minimal callback receiver on the standard library.
export CALLBACK_SECRET=whsec_...
python callback_server.py # listens on :8080, any path
"""
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
from callback import verify_callback
SECRET = os.environ.get("CALLBACK_SECRET", "")
seen_events: set[str] = set() # in production: a database table with a unique event_id
class CallbackHandler(BaseHTTPRequestHandler):
def do_POST(self) -> None:
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
if not verify_callback(SECRET, self.headers, body):
self.send_response(401)
self.end_headers()
return
event = json.loads(body)
if event["event_id"] not in seen_events:
seen_events.add(event["event_id"])
# Enqueue the work here: mark the order as paid, etc.
print("payment", event["merchant_payment_id"], "->", event["status"])
self.send_response(204) # answer 2xx at once, process asynchronously
self.end_headers()
if __name__ == "__main__":
HTTPServer(("", 8080), CallbackHandler).serve_forever()
```
=== "Node.js"
```javascript
// Minimal callback receiver on node:http.
//
// export CALLBACK_SECRET=whsec_...
// node callback-server.mjs # listens on :8080, any path
import { createServer } from 'node:http';
import { verifyCallback } from './callback.mjs';
const secret = process.env.CALLBACK_SECRET ?? '';
const seenEvents = new Set(); // in production: a database table with a unique event_id
createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const body = Buffer.concat(chunks);
if (req.method !== 'POST' || !verifyCallback(secret, req.headers, body)) {
res.writeHead(401).end();
return;
}
const event = JSON.parse(body);
if (!seenEvents.has(event.event_id)) {
seenEvents.add(event.event_id);
// Enqueue the work here: mark the order as paid, etc.
console.log('payment', event.merchant_payment_id, '->', event.status);
}
res.writeHead(204).end(); // answer 2xx at once, process asynchronously
});
}).listen(8080);
```
=== "Go"
```go
// Minimal callback receiver on net/http.
//
// export CALLBACK_SECRET=whsec_...
// go run ./cmd/callback-server # listens on :8080, any path
package main
import (
"encoding/json"
"io"
"log"
"net/http"
"os"
"sync"
"time"
"example.com/paymentapi"
)
func main() {
secret := os.Getenv("CALLBACK_SECRET")
var mu sync.Mutex
seen := map[string]bool{} // in production: a database table with a unique event_id
http.HandleFunc("POST /", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil || !paymentapi.VerifyCallback(secret, r.Header, body, time.Now()) {
w.WriteHeader(http.StatusUnauthorized)
return
}
var event struct {
EventID string `json:"event_id"`
MerchantPaymentID string `json:"merchant_payment_id"`
Status string `json:"status"`
}
if err = json.Unmarshal(body, &event); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
mu.Lock()
first := !seen[event.EventID]
seen[event.EventID] = true
mu.Unlock()
if first {
// Enqueue the work here: mark the order as paid, etc.
log.Printf("payment %s -> %s", event.MerchantPaymentID, event.Status)
}
w.WriteHeader(http.StatusNoContent) // answer 2xx at once, process asynchronously
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
```
## Delivery and retries { #delivery }
- **Success** is any `2xx`. The response body is not read beyond 4 KB.
- **Failure** is a network error, a timeout or any other code: `3xx`, `4xx`, `5xx`.
- **Timeout** — 10 seconds per request. Answer `2xx` at once and process asynchronously.
- **Retries.** An event gets 3 attempts: at once, after 30 seconds and after another
60 seconds — about a minute and a half. All attempts carry the same `event_id` and body.
After the third failure delivery of this event stops.
- **Manual resend.** Support can send a callback for a closed payment again, including a
payment in `error`. It is a new event with a new `event_id` and a single attempt, without
automatic retries.
- **No ordering guarantee.** A retry of an old event may arrive after a new one. Make the
handler idempotent.
| Attempt | When | If no `2xx` |
| --- | --- | --- |
| 1 | right after the event | attempt 2 in 30 seconds |
| 2 | after 30 seconds | attempt 3 in 60 seconds |
| 3 | after 90 seconds | delivery stops |
!!! tip "When in doubt, re-read the payment"
If a callback contradicts what you know about the order, read the current state with
`GET /api/v1/payments/{id}`. The API answer beats delivery order.
## If a callback did not arrive { #missing }
There are few attempts, so do not rely on callbacks alone:
- **Reconciliation.** Every few minutes select your orders that have waited for payment
longer than the project payment lifetime (15 minutes by default) and read them with
`GET /api/v1/payments/{id}`. Handle a closed payment the same way as a received callback.
Do not poll more often than once per 5–10 seconds per payment: the project shares one
[rate limit](idempotency.md#rate-limits).
- **Check the URL.** It is set on at least one level, reachable from the internet, answers
`2xx` without a redirect, and its certificate is valid.
- **Resend.** If your server was down, send support the payment `id` values or their
`merchant_payment_id` — the callbacks will be sent again. Each comes with a new
`event_id`.
### Common problems { #troubleshooting }
| Symptom | Cause |
| --- | --- |
| Signature does not match | Signed over parsed and re-serialized JSON instead of the raw body; key taken without `whsec_`; secret from another project or environment |
| Signature is right, but the request is rejected by time | Server clock is off by more than 5 minutes |
| No callbacks at all | URL not set; URL answers `301` or `302`; host resolves to an internal IP; payments are test ones |
| Order processed twice | Retries are not dropped by `event_id`, or the outcome is decided by `event_id` instead of `id` and `status` |
| Order "rolled back" | A late event was applied over a newer one. Re-read the payment with `GET` when the status changes |
## Test payments { #test-payments }
Payments with `is_test: true` — project test payments and test payments from the cabinet —
do not get callbacks automatically. Read their outcome with `GET /api/v1/payments/{id}`.
To test your handler:
- run your verification function on the [test vector](#test-vector);
- ask support to resend the callback of a closed test payment — a real signed request with
`is_test: true` will reach your URL.
More in [Sandbox](sandbox.md).
## Secret and rotation { #secret }
- The secret is issued in the cabinet: Projects and API → External API access → Callback.
It is shown **once** — keep it in a secret store.
- Each project has its own secret; sandbox and production secrets differ.
- If no secret was issued, the platform creates one on first delivery but shows it to
nobody. To verify signatures, issue a new one.
- **Lossless rotation.** After a new secret is issued, the previous one keeps signing for
24 hours as a second `v1` value. Steps: issue the new secret → add it next to the old one
(or replace at once — any matching value is fine) → remove the old one within 24 hours.
Rotating again within those 24 hours drops the oldest secret immediately: at most two are
valid at a time.
- If the secret leaks, issue a new one and remove the old one from your check right away.
## Checklist { #checklist }
- [ ] The callback URL is set, reachable from the internet, `https://`, no redirect.
- [ ] The signature is checked over the raw body with a constant-time comparison.
- [ ] Requests older than 5 minutes are rejected; the clock is synced.
- [ ] `event_id` is stored with a unique index for at least a day.
- [ ] `2xx` goes out at once; processing runs in the background.
- [ ] The order is fulfilled by `amount`, not `initial_amount`.
- [ ] `canceled` → `completed`, `completed` → `canceled` and a new `amount` are handled.
- [ ] Orders without a callback are reconciled with `GET /api/v1/payments/{id}`.
---
Source: /en/api-reference/
Language: en
# API reference
Every merchant API method: path, access, request fields, responses and examples. The page is
generated from the OpenAPI spec, so it matches what the server checks. For the end-to-end
flow see the [quickstart](quickstart.md); for statuses and codes see
[Statuses and errors](statuses.md).
Paths follow `{{base_url}}`, the API address issued to you for the sandbox or production
(see [Environments](index.md#environments)).
Interactive reference
Download openapi.yaml
!!! warning "Temporary spec"
These are the main methods from a temporary spec. The full spec will come from the backend
repository, and this page will rebuild from it automatically.
## Create a payment (SBP or card) { #createPayment }
POST{{base_url}}/api/v1/payments
Creates a pay-in and synchronously picks requisites. Idempotent by
`merchant_payment_id`: the same id with the same request returns `200` and the stored
payment; the same id with a different request returns `409 payment_idempotency_conflict`.
A `201` may already carry `status: error` with `error_code` when no requisites were issued; no callback follows
for it. Strict JSON: an unknown field returns `400 bad_request`. The payment lifetime is a project
setting (15 minutes by default) and is not part of the request. Signature is always
required. Recommended client timeout: at least 15 seconds.
**Access:** scope `payments:create` · signature required
Signing headers `Signature-Input`, `Signature`, `Content-Digest`, `X-Request-ID`, `X-Request-Nonce` — see [Request signing](signing.md).
### Request body
| Field | Type | Req. | Description |
| --- | --- | --- | --- |
| `merchant_payment_id` | string | yes | Your order id, unique per project; the idempotency key. 1–64 chars `A-Za-z0-9._:-`. |
| `amount` | number \| string | yes | Major currency units, > 0, up to 6 decimals: `3400` or `"3400.50"`. A string avoids rounding errors. |
| `payment_method` | string: `sbp`, `card_transfer` | yes | `sbp` — SBP transfer, `card_transfer` — transfer to a card. |
| `currency` | string | no | Currency code. Default `RUB`. |
| `geo_code` | string | no | Country, 2 letters. Default `RU`. |
| `bank_code` | string | no | The payer's bank, a code from the bank catalog (`sber`, `tinkoff`, `ozon`); not a BIC. Empty — any bank. |
| `is_intrabank` | boolean | no | Transfer within one bank only. Default `false`. |
| `callback_url` | string, uri | no | HTTPS address for this payment's callback, public host. Otherwise the project address is used. |
**Example request**
```json
{
"merchant_payment_id": "order-42",
"amount": 3400,
"payment_method": "sbp",
"bank_code": "sber",
"callback_url": "https://merchant.example/callbacks"
}
```
### Responses
| Code | Meaning |
| --- | --- |
| `201` | Payment created |
| `200` | Idempotent repeat, the stored payment is returned |
| `400` | Invalid JSON or unknown field (`bad_request`), invalid payment fields (`invalid_payment`) |
| `401` | Error |
| `403` | Error |
| `409` | Error |
| `413` | Error |
| `422` | Payment not accepted (not covered, no rate, not routable) |
| `429` | Error |
| `500` | Error |
| `503` | Error |
**Example response**
```json
{
"id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42",
"merchant_payment_id": "order-42",
"flow": "runtime_live",
"status": "processing",
"amount": 3400,
"initial_amount": 3400,
"currency": "RUB",
"geo_code": "RU",
"payment_method": "sbp",
"bank_code": "sber",
"is_intrabank": false,
"callback_url": "https://merchant.example/callbacks",
"is_test": false,
"merchant_information": {
"course": 100.0,
"rate": 11.0,
"amount_usdt": "34.0000",
"amount_rate": "3026.0000",
"amount_usdt_rate": "30.2600"
},
"requisite": "+79991234567",
"holder_name": "Иван Петров",
"created_at": "2026-09-29T09:30:00Z",
"updated_at": "2026-09-29T09:30:01Z"
}
```
## Read a payment { #getPayment }
GET{{base_url}}/api/v1/payments/{id}
Returns the payment in the same shape as the create response. Signature is required only when the project policy has `signature_required`.
**Access:** scope `payments:read` · signature if the project policy requires it
### Path parameters
| Parameter | Type | Req. | Description |
| --- | --- | --- | --- |
| `id` | string, uuid | yes | Payment `id` returned on create. |
### Responses
| Code | Meaning |
| --- | --- |
| `200` | Payment |
| `401` | Error |
| `403` | Error |
| `404` | Error |
| `429` | Error |
**Example response**
```json
{
"id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42",
"merchant_payment_id": "order-42",
"flow": "runtime_live",
"status": "processing",
"amount": 3400,
"initial_amount": 3400,
"currency": "RUB",
"geo_code": "RU",
"payment_method": "sbp",
"bank_code": "sber",
"is_intrabank": false,
"callback_url": "https://merchant.example/callbacks",
"is_test": false,
"merchant_information": {
"course": 100.0,
"rate": 11.0,
"amount_usdt": "34.0000",
"amount_rate": "3026.0000",
"amount_usdt_rate": "30.2600"
},
"requisite": "+79991234567",
"holder_name": "Иван Петров",
"created_at": "2026-09-29T09:30:00Z",
"updated_at": "2026-09-29T09:30:01Z"
}
```
## Who am I (token check) { #getProject }
GET{{base_url}}/api/v1/project
**Access:** signature if the project policy requires it
### Responses
| Code | Meaning |
| --- | --- |
| `200` | Project of the token |
| `401` | Error |
---
Source: /en/changelog/
Language: en
# Changelog
New fields in responses and callbacks are added without a new API version — ignore unknown
fields. Breaking changes are announced in advance.
## 2026-10-01 { #2026-10-01 }
**Callbacks** — the [Callbacks](callbacks.md) section is expanded.
- Delivery retries: 3 attempts — at once, after 30 and after 60 seconds (was 10 attempts
over ~2.5 minutes). A manual resend by support is a single attempt.
- New sections: how delivery works, status changes after closing (`canceled` →
`completed`, `completed` → `canceled`, a new `amount`), where a callback goes, how to
handle it, what to do without a callback, common problems, test payments, checklist.
## 2026-09-30 { #2026-09-30 }
**API v1** — the payment and callback format now follows the final specification (before
any production integrations).
- `settlement` is renamed to `merchant_information`: `course` (was `exchange_rate`) and
`rate` (was `fee_percent`) are now numbers with two decimals; `amount_rate` and
`amount_usdt_rate` are the former `net_amount` and `net_amount_usdt`.
- The response gains `flow`, `is_test` and `updated_at`; the `bank` field is gone.
- A callback is a short event: `id`, `event_id`, amounts, `status`, `currency`,
`payment_method`, `bank_code`, `merchant_payment_id`, `rate`, `course`, `is_intrabank`,
`is_test`, `project_id`. Requisites and USDT amounts are in `GET /api/v1/payments/{id}`.
- The callback signature test vector is updated for the new body.
## 2026-09-29 { #2026-09-29 }
**Documentation**
- First version of the merchant documentation in Russian and English.
- Request signing and callback verification samples in curl, PHP, Python, Node.js and Go,
checked against shared test vectors.
- Markdown pages, `llms.txt` and a docs MCP server for AI agents.
**API v1**
- `POST /api/v1/payments` creates a payment, `GET /api/v1/payments/{id}` reads it.
- Request: `merchant_payment_id` (1–64 characters `A-Za-z0-9._:-`), `amount` in major
currency units, `payment_method`; optional `currency` (`RUB`), `geo_code` (`RU`),
`bank_code`, `is_intrabank`, `callback_url`.
- The payment lifetime is a project setting, 15 minutes by default. A payment not paid
within it becomes `canceled`, and a callback is sent.
- Response: the requisite's bank `bank` and the `settlement` — rate, fee and USDT amounts.
- Callbacks are sent for `completed` and `canceled`; the body is the same payment plus
`event_id` and `project_id`.
- Rejections before the payment is accepted (`429`, `503`, signature, nonce replay, body
size, invalid body or fields) do not take the `merchant_payment_id`.
- A `422` response carries `payment_id` when the payment was saved. A paused merchant gets
`422 payment_not_routable`.
---
Source: /en/ai-agents/
Language: en
# For AI agents
The docs are built so that an AI assistant — Claude, ChatGPT, Cursor, Codex — can read them
in full and write the integration without guessing. There are three ways.
## 1. Markdown version of any page { #markdown }
Append `.md` to a page address to get its source Markdown with code samples:
| HTML | Markdown |
| --- | --- |
| `/en/quickstart/` | `/en/quickstart.md` |
| `/signing/` (Russian) | `/signing.md` |
| `/en/` | `/en/index.md` |
Every page header has a **Copy as Markdown** button and an "Open in ChatGPT / Claude"
menu that opens the assistant with a link to the page.
## 2. llms.txt { #llms-txt }
- /llms.txt — every page in both languages with a one-line
description, per the [llms.txt standard](https://llmstxt.org/).
- /llms-full.txt — all the docs in one file, handy to
paste into context.
## 3. Docs MCP server { #mcp }
The MCP server gives the assistant search and read tools over the docs. Read-only, no
authentication.
| Tool | What it does |
| --- | --- |
| `search_docs(query, lang)` | Full-text search, returns sections with excerpts |
| `get_page(path, lang)` | A whole page in Markdown |
| `list_pages(lang)` | All pages with descriptions |
| `get_api_operation(operation_id)` | An API method from the OpenAPI spec: `createPayment`, `getPayment`, `getProject` |
| `get_code_example(language, topic)` | A tested sample: `curl`, `php`, `python`, `node`, `go` × `sign_request`, `verify_callback`, `create_sbp_payment`, `generate_key`, `callback_receiver` |
Pages are also MCP resources: `docs://ru/quickstart`, `docs://en/signing`.
Server address: `/mcp`, on the same host as these docs (Streamable HTTP transport).
=== "Claude Code"
```bash
claude mcp add --transport http payment-api-docs /mcp
```
=== "Cursor"
`.cursor/mcp.json` in the project or `~/.cursor/mcp.json`:
```json
{
"mcpServers": {
"payment-api-docs": { "url": "/mcp" }
}
}
```
=== "Codex"
`~/.codex/config.toml`:
```toml
[mcp_servers.payment-api-docs]
url = "/mcp"
```
=== "ChatGPT"
In ChatGPT: **Settings → Apps & Connectors → Advanced → Developer mode**, then
**Create**, enter the server URL and choose **No authentication**. ChatGPT connects only
to a public `https://` address: for a local copy of the docs, use a tunnel.
## Prompt for your assistant { #prompt }
Paste at the start of a conversation:
```text
You are helping integrate the payments API. The source of truth is the docs:
/mcp (MCP) or llms.txt. Rules:
- Every POST is signed per RFC 9421 with Ed25519: use the ready-made function from
get_code_example(, "sign_request"); do not write signing from scratch.
- Check the function against the test vector on the signing page.
- merchant_payment_id is the idempotency key: on retry keep the id, re-sign the request.
- Verify callbacks with HMAC-SHA256 over the raw body: get_code_example(, "verify_callback").
- Keep the API base URL {{base_url}} (sandbox and production differ) in configuration, not in code.
```