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

<div class="pp-seq" data-lanes="Your server|API|Payer" markdown>

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`

</div>

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

<div class="pp-statusmap" data-lang="en"></div>

| Status | Meaning | What to do |
| --- | --- | --- |
| <span class="pp-status">created</span> | Accepted, no requisites yet | Wait. The response usually already says `processing` |
| <span class="pp-status pp-status--wait">processing</span> | Requisites issued, waiting for the transfer | Show the requisites to the payer |
| <span class="pp-status pp-status--ok">completed</span> | Payment confirmed | Deliver the goods or service |
| <span class="pp-status">canceled</span> | Not paid: the lifetime ran out, it was canceled, or the payment could not be confirmed | Offer to pay again with a new payment |
| <span class="pp-status pp-status--bad">error</span> | No requisites were issued, reason in `error_code` | Offer to pay again with a new payment |
| <span class="pp-status pp-status--warn">appeal</span> | 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.