# V-DAR — Оплата по QR СБП

```text
API base: https://api.v-dar.ru
API version: v2
API prefix: /api/v2/sbp-qr
Primary result delivery: webhook (payment.status_changed)
Polling fallback: GET /api/v2/sbp-qr/payments/{payment_id} every 3–5s
Authentication: header X-API-Key (permission full_access)
Money format: decimal string, 2 fraction digits (e.g. "1250.00") — never kopecks/minor units
Fiat: RUB | Charge: USDT | Final debit: payment.total_charge (= charge.total)
Limits source: GET /api/v2/sbp-qr/limits
Webhook timeout: CALLBACK_TIMEOUT (default 10 seconds)
Error schema: { "error": { "code", "message", "retryable", "details" } }
Swagger: https://api.v-dar.ru/api/v2/docs#/sbp-qr
HTML: https://api.v-dar.ru/api/docs/integrator/payments (tab «Оплата по QR СБП»)
```

Сущности для мерчанта: **payment**, **merchant site**, **balance**, **deposit**, **webhook**, **error**, **dispute**.

---

## 1. Quick Start — три сценария

### A. QR с суммой, auto confirm (рекомендуется)

```http
POST /api/v2/sbp-qr/payments
Content-Type: application/json
X-API-Key: YOUR_KEY

{
  "merchant_site_id": "YOUR_SITE_ID",
  "qr_payload": "https://qr.nspk.ru/...",
  "idempotency_key": "order-123-try-1",
  "external_id": "order-123",
  "confirm": true
}
```

```text
→ processing → webhook payment.status_changed → succeeded | failed | cancelled
```

### B. QR с суммой, manual confirm

```json
{ "...": "...", "confirm": false }
```

```text
→ requires_confirmation
→ POST /api/v2/sbp-qr/payments/{payment_id}/confirm
→ processing → result
```

### C. QR без суммы

```text
POST /payments → amount_required
POST /payments/{payment_id}/amount  {"fiat_amount":"1250.00"}
→ processing → result
```

**Важно:** для платежей без суммы вызов `/amount` одновременно задаёт сумму и подтверждает исполнение платежа. Дополнительный `/confirm` **не требуется** (даже если create был с `confirm=false`).

### Пример ответа create

```json
{
  "payment": {
    "payment_id": "8be0c1a2-1111-2222-3333-444444444444",
    "external_id": "order-123",
    "status": "processing",
    "required_action": null,
    "fiat_amount": "1250.00",
    "fiat_currency": "RUB",
    "charge": {
      "amount": "13.15",
      "fee": "0.26",
      "total": "13.41",
      "max_total": "13.41",
      "currency": "USDT"
    },
    "total_charge": "13.41",
    "created_at": "2026-10-28T12:00:00+00:00",
    "completed_at": null,
    "failure": null
  }
}
```

`total_charge` — итоговая сумма USDT, списываемая с баланса торговой точки. Не считайте её вручную из rate/fee.

---

## 2. Endpoints

| Method | Path | Назначение |
|--------|------|------------|
| POST | `/api/v2/sbp-qr/payments` | Создать оплату |
| POST | `/api/v2/sbp-qr/payments/{payment_id}/amount` | Сумма + confirm для QR без суммы |
| POST | `/api/v2/sbp-qr/payments/{payment_id}/confirm` | Confirm после `confirm=false` (QR с суммой) |
| GET | `/api/v2/sbp-qr/payments/{payment_id}` | Статус (fallback) |
| GET | `/api/v2/sbp-qr/payments?merchant_site_id=&external_id=` | Найти по external_id |
| GET | `/api/v2/sbp-qr/payments/history` | Список (`status`, `limit`, `offset`) |
| GET | `/api/v2/sbp-qr/limits` | Актуальные лимиты |
| GET | `/api/v2/sbp-qr/balance` | Баланс USDT |
| GET | `/api/v2/sbp-qr/deposit-address` | Адрес пополнения |
| GET | `/api/v2/sbp-qr/deposits` | История депозитов |
| POST | `/api/v2/sbp-qr/payments/{payment_id}/disputes` | Создать спор |
| GET | `/api/v2/sbp-qr/payments/{payment_id}/disputes` | Список споров |

`history` **не** поддерживает period/from/to — только `status`, `limit`, `offset`.

---

## 3. Payment lifecycle

```text
                ┌─────────────────┐
                │ resolving_amount│
                └────────┬────────┘
                         │
          ┌──────────────┼──────────────────┐
          ▼              ▼                  ▼
 amount_required   requires_confirmation   processing
      │                    │                  │
      │ POST /amount       │ POST /confirm    │
      ▼                    ▼                  │
 processing            processing             │
          \                |                 /
           \               |                /
            └──────────────┼───────────────┘
                           │
                ┌──────────┼──────────┐
                ▼          ▼          ▼
            succeeded    failed    cancelled
                │
                └─────► charged_back
```

| status | Смысл | Действие | Финальный? |
|--------|--------|----------|------------|
| `resolving_amount` | Сумма определяется из QR | Ждать | нет |
| `amount_required` | Нужна сумма | `POST …/amount` | нет |
| `requires_confirmation` | Расчёт готов (confirm=false, сумма есть) | `POST …/confirm` | нет |
| `processing` | Исполнение | Ждать webhook / poll | нет |
| `succeeded` | Успех | Готово; позже возможен `charged_back` | да* |
| `failed` / `cancelled` | Отказ / отмена | Смотреть `failure` | да |
| `charged_back` | Ранее успешная операция финансово отменена; списанное возвращено | Сверить баланс | да |

\* `succeeded` — финал обработки, но не абсолютный: возможен переход в `charged_back`.

Публичный статус `created` **не возвращается**.

---

## 4. confirm=true / confirm=false /amount

### QR содержит сумму

- `confirm=true` → `processing`
- `confirm=false` → `requires_confirmation` → `/confirm` → `processing`

### QR не содержит сумму

```text
→ amount_required → POST /amount → processing
```

`/amount` = задать сумму **и** подтвердить исполнение. `/confirm` не нужен.

---

## 5. Money

Все денежные поля — **decimal string**, scale **2**.

| Поле | Смысл |
|------|--------|
| `fiat_amount` | Сумма QR в RUB |
| `charge.amount` | База USDT |
| `charge.fee` | Комиссия USDT |
| `charge.total` / **`total_charge`** | **Итог списания с баланса** |
| `charge.max_total` | Потолок оценки/резерва |

---

## 6. Limits

```http
GET /api/v2/sbp-qr/limits
```

Актуальные `min_fiat_amount` / `max_fiat_amount` — только из этого ответа (конфигурируются на стороне платформы).  
Вне диапазона → `422 AMOUNT_OUT_OF_RANGE` с `details.min_amount`, `max_amount`, `currency`.

---

## 7. Idempotency

| | `idempotency_key` | `external_id` |
|--|-------------------|---------------|
| Роль | Safe retry HTTP | Бизнес-ID заказа |
| Scope | глобально на ключ | уникален на `merchant_site_id` |

- Тот же key + тот же request (`merchant_site_id`, `qr_payload`, `confirm`, `external_id`) → тот же payment.
- Тот же key + другое тело → `409 IDEMPOTENCY_KEY_REUSED` (`details.payment_id`).
- Повтор `external_id` → `409 DUPLICATE_EXTERNAL_ID`.

После timeout на POST — повторите **тот же** `idempotency_key`, не создавайте новый.

---

## 8. Duplicate QR

| | |
|--|--|
| Ключ | `merchant_site_id` + `qr_payload` |
| Окно | **60 секунд** |
| Сумма | не влияет |
| Активная / succeeded в окне | обычно тот же payment |
| Недавний failed/cancelled | `409 DUPLICATE_QR` + `details.payment_id` |
| После 60+ с | новый платёж (новый `idempotency_key`) |

---

## 9. Webhooks

Основной канал результата. Polling — fallback.

| | |
|--|--|
| Method | `POST` |
| Content-Type | `application/json` |
| Success | любой **2xx** |
| Timeout | **10 секунд** (default `CALLBACK_TIMEOUT`) |
| Retry | 30s, 2m, 10m, 30m, 1h, 2h, 4h, 8h, 12h, 24h |
| Exactly-once | **Нет** |
| Ordering | **Не гарантирован** |
| Dedup | по `callback_id` |

### Event types (реально реализованы)

- `payment.status_changed`
- `payment.dispute_updated`
- `deposit.credited` (USDT зачислен на баланс точки)
- `deposit.rejected` (при отклонении депозита)

События вроде `deposit.returned` **нет** — не ожидайте их.

### Headers / signature

1. `X-Vdar-Webhook-Secret` = ваш `webhook_secret` (если задан).
2. Поле `sign` — HMAC-SHA256 **hex**:
   - все поля payload кроме `sign`;
   - ключи отсортированы;
   - значения: `str(v)`; dict/list → `json.dumps(..., sort_keys=True, separators=(',',':'))`; null → `""`;
   - склейка через `+`;
   - ключ подписи — платформенный callback signing secret.
3. Отклоняйте `timestamp` старше ~5 минут (anti-replay на вашей стороне).

**Python:**

```python
import hmac, hashlib, json

def verify_sign(payload: dict, secret: str) -> bool:
    got = payload.get("sign") or ""
    parts = []
    for k in sorted(k for k in payload if k != "sign"):
        v = payload[k]
        if v is None:
            parts.append("")
        elif isinstance(v, (dict, list)):
            parts.append(json.dumps(v, sort_keys=True, separators=(",", ":")))
        else:
            parts.append(str(v))
    expect = hmac.new(secret.encode(), "+".join(parts).encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expect, got)
```

---

## 10. Balance and deposits

```http
GET /api/v2/sbp-qr/balance?merchant_site_id=SITE
GET /api/v2/sbp-qr/deposit-address?merchant_site_id=SITE&network=TRC20
GET /api/v2/sbp-qr/deposits?merchant_site_id=SITE
```

- Token: USDT. Networks: TRC20, BEP20, SOL, ERC20, TON.
- **Автозачисление сейчас:** TRC20 и BEP20 (после N confirmations → баланс + webhook `deposit.credited`). SOL / ERC20 / TON — адрес может выдаваться, автозачисление будет позже.
- `status=active` — постоянный адрес; `temporary` — запрашивайте перед переводом (legacy QuRa).
- `min_amount` — в ответе адреса.

### Отклонённые депозиты / возврат

Публичные статусы депозита включают `credited`, `rejected`, `returned`, `pending`.

Для отклонённого депозита баланс точки **не** увеличивается. Возврат может потребовать процедуры на стороне платформы (в т.ч. ручной/отложенный этап). Статус смотрите в `GET /deposits`. **Не** отправляйте повторный депозит, пока статус не ясен. Срок возврата **не гарантирован** минутами/часами.

Wrong network / unsupported token — зачисление не происходит; обратитесь в поддержку.

---

## 11. Disputes / charged_back

`charged_back` — ранее успешная операция была **финансово отменена**, списанные средства возвращены на баланс точки.

Может появиться:

1. после принятого dispute;
2. при автоматическом reversal после поздней отмены уже settled-операции.

`succeeded` → `charged_back` возможен.

При `charged_back` на баланс возвращается **вся сумма, списанная по операции**, включая ранее списанную комиссию (`usdt_charged + usdt_fee_charged`).

```http
POST /api/v2/sbp-qr/payments/{payment_id}/disputes
Content-Type: multipart/form-data
```

Webhook: `payment.dispute_updated`, также `payment.status_changed` при смене статуса payment.

---

## 12. Errors (единая schema для /api/v2/sbp-qr)

```json
{
  "error": {
    "code": "INVALID_QR",
    "message": "QR code cannot be processed",
    "retryable": false,
    "details": {}
  }
}
```

| HTTP | code | retryable | Действие |
|-----:|------|-----------|----------|
| 400 | `VALIDATION_ERROR` | false | Исправить запрос |
| 401 | `UNAUTHORIZED` | false | API key |
| 403 | `FORBIDDEN` / `METHOD_DISABLED` | false / true | Права / позже |
| 404 | `PAYMENT_NOT_FOUND` / `MERCHANT_SITE_NOT_FOUND` | false | Проверить ID |
| 409 | `IDEMPOTENCY_KEY_REUSED` / `DUPLICATE_EXTERNAL_ID` / `DUPLICATE_QR` / `INVALID_STATE` | false / ~60s for QR | См. details |
| 422 | `INVALID_QR` / `UNSUPPORTED_QR` / `AMOUNT_OUT_OF_RANGE` / `INSUFFICIENT_BALANCE` | false | Исправить / пополнить |
| 429 | `RATE_LIMIT_EXCEEDED` | true | Header `Retry-After`, `details.retry_after_seconds` |
| 500 | `INTERNAL_ERROR` | false | Поддержка |
| 503 | `SERVICE_TEMPORARILY_UNAVAILABLE` | true | Retry с тем же idempotency_key |

### 429 example

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 6000
X-RateLimit-Remaining: 0
```

```json
{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Rate limit exceeded",
    "retryable": true,
    "details": { "retry_after_seconds": 60 }
  }
}
```

Не ретраить автоматически: `INVALID_QR`, `AMOUNT_OUT_OF_RANGE`, `IDEMPOTENCY_KEY_REUSED`, `METHOD_DISABLED` (пока выключен).  
Ретраить: `SERVICE_TEMPORARILY_UNAVAILABLE`, `RATE_LIMIT_EXCEEDED`, network timeout — с тем же `idempotency_key`.

---

## 13. Sandbox

Отдельного публичного sandbox URL нет. Тест — production API с тестовой точкой по согласованию с поддержкой.

---

## 14. FAQ

- **Timeout на POST?** Повторите с тем же `idempotency_key`.
- **Новый key?** Только новый заказ / сознательный restart после terminal.
- **`external_id` vs key?** Бизнес-ID vs technical retry key.
- **`amount_required`?** `POST …/amount` — сумма + исполнение.
- **`processing`?** Ждать webhook; poll ≤1/3–5с.
- **Один QR дважды?** В 60 с — нет; после окна — да.
- **`confirm=false`?** Только когда сумма уже известна и нужен просмотр `total_charge` до списания.
- **Сколько USDT?** `total_charge`.
- **Webhook подлинный?** Header secret + HMAC `sign`.
- **Webhook не пришёл?** `GET /payments/{id}`.
- **`succeeded` → `charged_back`?** Да (dispute или auto-reversal).
- **Лимиты?** `GET /limits`.
