Платежи по QR и через биллеров
Принимайте платежи по QR-коду (динамическому или статическому) и через биллеров одним эндпоинтом. На сегодня live-коридор — Таиланд (PromptPay и сети биллеров). Архитектура мультикоридорная: новые валюты подключаются без изменения базового контракта, а отдельные методы котировки для специфичных валют будут описаны здесь по мере подключения.
dpm_.test_dpm_. Смена статусов через /api/test/payments/....POST /users. Получите public_id формата DPPU-XXXXXXXXXX. Каждый платёж ссылается на этот id — без него KYC-данные не уйдут провайдеру и платёж не пройдёт.
POST /quote получите эффективный курс, комиссию провайдера и итоговое списание ещё до создания платежа. Доступно для коридоров, где имеет смысл расчёт по placeholder-получателю; для QR-payload-only флоу ответом будет PROVIDER_REQUIRES_QR (400) — вызывайте POST /transactions с реальным QR напрямую.
POST /transactions с QR-payload или с прямыми реквизитами биллера. Баланс списывается синхронно — на момент ответа 201/200 деньги уже зарезервированы. Дальше слушаете вебхук или поллите GET /transactions/{id}.
Authorization: Bearer dpm_your_token_here
| Префикс | Окружение | Описание |
|---|---|---|
| dpm_ | Production | Боевые платежи, реальный баланс |
| test_dpm_ | Sandbox | Симуляция, виртуальный баланс |
| Заголовок | Обязательность | Описание |
|---|---|---|
| Authorization | обязательный | Bearer <token> |
| Idempotency-Key | обязательный* | Для POST /transactions и POST /users. Длина 8–200 символов |
| Content-Type | опциональный | application/json для POST с телом |
IP_NOT_ALLOWED (403). Allowlist редактируется через панель партнёра, изменения применяются мгновенно.
POST /transactions и POST /users.Ваш ключ Idempotency-Key попадает в строку запросов вместе с хешем тела. Повтор того же ключа возвращает исходный ответ — даже если первый запрос отвалился по сетевому таймауту до получения ответа. Ключ живёт 24 часа, дальше может быть переиспользован.
Idempotency-Key: pay-20260518-inv-0042
POST /transactions и на POST /users вернёт DUPLICATE_REQUEST (409). Держите счётчики раздельно — например, префикс user- и pay-.
Если у токена выдан ключ шифрования, POST-запросы POST /transactions и POST /users должны быть упакованы в AES-256-GCM-конверт. Нешифрованное тело на такой токен — это ENCRYPTION_REQUIRED (400). И наоборот: если у токена шифрования нет, отправка конверта даст ENCRYPTION_KEY_MISSING. POST /quote — pricing preview без PII — всегда принимает cleartext. Включение/отключение шифрования — операция со стороны менеджера, на лету это не переключается.
{
"data": "<base64(AES-256-GCM ciphertext + tag)>",
"nonce": "<base64(12-byte GCM nonce)>"
}{
"data": "<base64(encrypted response + tag)>",
"nonce": "<base64(12-byte nonce)>"
}Idempotency-Key. Берите 12 случайных байт из crypto.getRandomValues или os.urandom(12), не из time-based генератора.
# Быстрый smoke-test: AES-256-GCM конверт через openssl + curl. # openssl ниже работает с GCM на 1.1.1+; для прод-интеграции используйте # библиотечный пример (Python / JS / Rust) — там tag и nonce обрабатываются явно. KEY_B64="your_base64_encryption_key" BODY='{"public_user_id":"DPPU-X9Y8Z7W6V5","qr_payload":"00020101021230740016A000000677010111011300660000000000021700000000000000000530376454031500802TH91047CBC","external_user_id":"user_456","callback_url":"https://your.domain/webhooks/payments"}' # 1) Сгенерировать случайный 12-байтный nonce NONCE=$(openssl rand 12 | base64) NONCE_HEX=$(echo "$NONCE" | base64 -d | xxd -p -c 256) KEY_HEX=$(echo "$KEY_B64" | base64 -d | xxd -p -c 256) # 2) Зашифровать (ciphertext + GCM tag) и base64-кодировать CIPHERTEXT=$(echo -n "$BODY" | openssl enc -aes-256-gcm -K "$KEY_HEX" -iv "$NONCE_HEX" -nopad | base64) # 3) Завернуть в конверт и отправить curl -X POST https://api.doverkapay.com/api/v1/payments/transactions \ -H "Authorization: Bearer dpm_your_token_here" \ -H "Idempotency-Key: pay-20260520-inv-0042" \ -H "Content-Type: application/json" \ -d "{\"data\":\"$CIPHERTEXT\",\"nonce\":\"$NONCE\"}"
import os, json, base64 from cryptography.hazmat.primitives.ciphers.aead import AESGCM def encrypt_envelope(key: bytes, payload: dict) -> dict: nonce = os.urandom(12) ct = AESGCM(key).encrypt(nonce, json.dumps(payload).encode(), None) return { "data": base64.b64encode(ct).decode(), "nonce": base64.b64encode(nonce).decode(), } def decrypt_envelope(key: bytes, envelope: dict) -> dict: nonce = base64.b64decode(envelope["nonce"]) ct = base64.b64decode(envelope["data"]) return json.loads(AESGCM(key).decrypt(nonce, ct, None))
// Node 18+: WebCrypto API (crypto.subtle). For browser code it is identical. export async function encryptEnvelope(key, payload) { const nonce = crypto.getRandomValues(new Uint8Array(12)); const pt = new TextEncoder().encode(JSON.stringify(payload)); const ct = new Uint8Array( await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, key, pt) ); const b64 = (u8) => Buffer.from(u8).toString("base64"); return { data: b64(ct), nonce: b64(nonce) }; }
<?php // PHP 7.2+: AES-256-GCM через openssl_encrypt с параметром tag. // $key — это 32 сырых байта (ваш encryption_key, после base64-decode). function encryptEnvelope(string $key, array $payload): array { $nonce = random_bytes(12); $tag = ''; $ciphertext = openssl_encrypt( json_encode($payload, JSON_UNESCAPED_UNICODE), 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, '', 16 ); return [ 'data' => base64_encode($ciphertext . $tag), 'nonce' => base64_encode($nonce), ]; } function decryptEnvelope(string $key, array $envelope): array { $blob = base64_decode($envelope['data']); $nonce = base64_decode($envelope['nonce']); $tag = substr($blob, -16); $ciphertext = substr($blob, 0, -16); $plaintext = openssl_decrypt( $ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag ); return json_decode($plaintext, true); }
// Cargo.toml: aes-gcm = "0.10"; rand = "0.8"; base64 = "0.22"; serde_json = "1" use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead}; use base64::{Engine, engine::general_purpose::STANDARD}; use rand::RngCore; use serde_json::Value; type BoxError = Box<dyn std::error::Error + Send + Sync>; pub fn encrypt_envelope(key: &[u8; 32], payload: &Value) -> Result<Value, BoxError> { let cipher = Aes256Gcm::new(key.into()); let mut nonce_bytes = [0u8; 12]; rand::thread_rng().fill_bytes(&mut nonce_bytes); let ct = cipher .encrypt(Nonce::from_slice(&nonce_bytes), serde_json::to_vec(payload)?.as_ref()) .map_err(|e| format!("encrypt: {e}"))?; Ok(serde_json::json!({ "data": STANDARD.encode(&ct), "nonce": STANDARD.encode(&nonce_bytes), })) }
callback_url при каждой смене статуса.URL берётся с платежа (body.callback_url), а если не задан — с токена. HTTPS обязателен, частные/loopback адреса блокируются на этапе валидации (SSRF guard). Endpoint должен ответить 2xx в разумный таймаут, иначе доставка уходит в очередь повторов.
encryption_key токена. Один ключ покрывает и transport encryption, и HMAC-подпись webhook. Ротация только через перевыпуск токена. Рекомендуем допуск ±5 минут по timestamp, чтобы не выкинуть валидное событие из-за дрейфа часов.
# Проверка X-Signature на входящем webhook. # secret = encryption_key вашего токена (один общий секрет). SECRET="your_encryption_key" RAW_BODY='{"event":"payment.status_changed","payment_id":"DPP-1A2B3C4D5E","status":"COMPLETED"}' SIG_HEADER='t=1716126000,v1=ab12cd34ef56...' TS=$(echo "$SIG_HEADER" | awk -F',' '{print $1}' | cut -d'=' -f2) SIG=$(echo "$SIG_HEADER" | awk -F',' '{print $2}' | cut -d'=' -f2) # Отбрасываем, если дрейф timestamp больше 5 минут NOW=$(date +%s) DRIFT=$(( NOW > TS ? NOW - TS : TS - NOW )) if [ "$DRIFT" -gt 300 ]; then echo "FAIL: stale timestamp"; exit 1 fi # Пересчитываем HMAC-SHA256 и сравниваем EXPECTED=$(printf '%s.%s' "$TS" "$RAW_BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}') if [ "$EXPECTED" = "$SIG" ]; then echo "OK" else echo "FAIL: signature mismatch" fi
// X-Signature: t=<unix>,v1=<hex(HMAC-SHA256(secret, "{t}.{body}"))> // Отклоняйте, если |now - t| > 300 секунд (±5 минут tolerance) import { createHmac, timingSafeEqual } from "node:crypto"; export function verify(secret, header, rawBody) { const parts = new Map(header.split(",").map((p) => p.split("=", 2))); const ts = Number(parts.get("t")); const sig = parts.get("v1"); if (!ts || !sig) return false; if (Math.abs(Date.now() / 1000 - ts) > 300) return false; const expected = createHmac("sha256", secret) .update(`${ts}.` + rawBody.toString("utf8")) .digest("hex"); const a = Buffer.from(expected, "hex"); const b = Buffer.from(sig, "hex"); return a.length === b.length && timingSafeEqual(a, b); }
# X-Signature: t=<unix>,v1=<hex(HMAC-SHA256(secret, "{t}.{body}"))> # Отклоняйте, если |now - t| > 300 секунд (±5 минут tolerance) import hmac, hashlib, time def verify(secret: str, header: str, body: bytes) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) ts, sig = parts.get("t"), parts.get("v1") if not ts or not sig: return False if abs(time.time() - int(ts)) > 300: return False payload = f"{ts}.".encode() + body expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, sig)
<?php // X-Signature: t=<unix>,v1=<hex(HMAC-SHA256(secret, "{t}.{body}"))> // Отклоняйте, если |now - t| > 300 секунд (±5 минут tolerance) function verifySignature(string $secret, string $sigHeader, string $rawBody): bool { $parts = []; foreach (explode(',', $sigHeader) as $pair) { [$k, $v] = explode('=', $pair, 2); $parts[$k] = $v; } $ts = (int)($parts['t'] ?? 0); $sig = $parts['v1'] ?? ''; if (abs(time() - $ts) > 300) { return false; } $expected = hash_hmac('sha256', $ts . '.' . $rawBody, $secret); return hash_equals($expected, $sig); }
// X-Signature: t=<unix>,v1=<hex(HMAC-SHA256(secret, "{t}.{body}"))> // Отклоняйте, если |now - t| > 300 секунд (±5 минут tolerance) use hmac::{Hmac, Mac}; use sha2::Sha256; use std::time::{SystemTime, UNIX_EPOCH}; pub fn verify(secret: &[u8], header: &str, body: &[u8]) -> bool { let mut ts: i64 = 0; let mut sig_hex = ""; for part in header.split(',') { if let Some((k, v)) = part.split_once('=') { match k { "t" => ts = v.parse().unwrap_or(0), "v1" => sig_hex = v, _ => {} } } } let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64; if (now - ts).abs() > 300 { return false; } let mut mac = Hmac::<Sha256>::new_from_slice(secret).expect("hmac key"); mac.update(format!("{}.", ts).as_bytes()); mac.update(body); let expected = mac.finalize().into_bytes(); let sig_bytes = (0..sig_hex.len() / 2) .map(|i| u8::from_str_radix(&sig_hex[i * 2..i * 2 + 2], 16).unwrap_or(0)) .collect::<Vec<_>>(); expected.as_slice() == sig_bytes.as_slice() }
{
"event": "payment.status_changed",
"payment_id": "DPP-1A2B3C4D5E",
"status": "COMPLETED",
"source_amount": "1537.50",
"source_currency": "THB",
"target_amount": "1500.00",
"target_currency": "THB",
"effective_rate": "1.00000",
"provider_fee_amount": "15.00",
"is_sandbox": false,
"status_message": null,
"reference_note": "Order #4242",
"correlation_id": "01JVWXYZ1234567890ABCDEF",
"external_user_id": "user_456",
"created_at": "2026-05-18T10:05:00+00:00",
"updated_at": "2026-05-18T14:30:00+00:00"
}
public_id в Payments API — DPPU-XXXXXXXXXX (в Transfers API — DPTU-, не путайте). Пользователи изолированы по сервису: запись из Transfers здесь не подойдёт.
Создаёт запись о конечном пользователе и возвращает public_id (DPPU-XXXXXXXXXX). Повтор с тем же Idempotency-Key вернёт исходное тело и тот же id, без новой строки в БД. PII (document_number, phone) шифруется AES-256-GCM перед записью.
| Поле | Тип | Описание | |
|---|---|---|---|
| first_name | string | обязательный | Имя, 1–100 символов |
| last_name | string | обязательный | Фамилия, 1–100 символов |
| patronymic | string | опциональный | Отчество, до 100 символов |
| external_id | string | опциональный | Ваш внутренний id пользователя. До 255 символов. Хранится как есть, чтобы вы могли мапить наш public_id на свою запись |
| birth_date | date | опциональный | YYYY-MM-DD |
| phone | string | опциональный | E.164, до 30 символов. Шифруется на диске |
| document_type | string | опциональный | CCPT, NID, PSPT и др. |
| document_number | string | опциональный | До 50 символов. Шифруется AES-256-GCM |
| nationality | string | опциональный | ISO 3166-1 alpha-2 (например, RU, TH) |
| address | string | опциональный | До 500 символов |
| city | string | опциональный | До 100 символов |
| state | string | опциональный | Регион / штат, до 100 символов |
| zipcode | string | опциональный | Почтовый индекс, до 20 символов |
| country | string | опциональный | ISO 3166-1 alpha-2 |
POST /api/v1/payments/users Authorization: Bearer dpm_your_token_here Idempotency-Key: user-pay-20260518001 Content-Type: application/json { "first_name": "Maria", "last_name": "Ivanova", "external_id": "user_456", "birth_date": "1995-07-22", "nationality": "RU" }
{
"public_id": "DPPU-X9Y8Z7W6V5",
"external_id": "user_456",
"masked_full_name": "Ivanova M.",
"masked_phone": null,
"is_active": true,
"is_sandbox": false,
"created_at": "2026-05-18T10:00:00+00:00"
}
Пагинированный список пользователей под вашим токеном. Сортировка по дате создания, новые сверху.
| Query-параметр | Тип | Описание | |
|---|---|---|---|
| offset | integer | опциональный | Дефолт 0 |
| limit | integer | опциональный | 1–200, дефолт 50 |
GET /api/v1/payments/users?offset=0&limit=50 Authorization: Bearer dpm_your_token_here
{
"items": [
{
"public_id": "DPPU-X9Y8Z7W6V5",
"external_id": "user_456",
"masked_full_name": "Ivanova M.",
"masked_phone": null,
"is_active": true,
"is_sandbox": false,
"created_at": "2026-05-18T10:00:00+00:00"
}
],
"total": 1,
"offset": 0,
"limit": 50
}
Возвращает одного пользователя по публичному id. Чужой id (зарегистрированный под другим партнёром) даст 404, не 403 — мы не подтверждаем существование чужих записей.
GET /api/v1/payments/users/DPPU-X9Y8Z7W6V5 Authorization: Bearer dpm_your_token_here
{
"public_id": "DPPU-X9Y8Z7W6V5",
"external_id": "user_456",
"masked_full_name": "Ivanova M.",
"masked_phone": null,
"is_active": true,
"is_sandbox": false,
"created_at": "2026-05-18T10:00:00+00:00"
}
Баланс токена плюс лимиты на сумму одной транзакции. Значения min_amount/max_amount в валюте баланса.
GET /api/v1/payments/balance Authorization: Bearer dpm_your_token_here
{
"balance": "85000.00",
"currency": "THB",
"is_sandbox": false,
"min_amount": "20.00",
"max_amount": "50000.00"
}
[min_amount, max_amount] вернёт AMOUNT_OUT_OF_RANGE (400). Лимиты редактируются менеджером, не через API.
Stateless-расчёт: эффективный курс, комиссия провайдера, итоговое списание. Транзакция не создаётся, Idempotency-Key не нужен. Запрос идёт в провайдер за реальной комиссией (placeholder destination), так что курс и fee — актуальные, не кэш.
POST /transactions. Такой токен на POST /quote вернёт PROVIDER_REQUIRES_QR (400), расчёт делается только по фактическому QR, без placeholder destination.
| Поле | Тип | Описание | |
|---|---|---|---|
| amount | decimal | обязательный | Сумма счёта в валюте баланса. Два знака после запятой, > 0. Например, "1500.00" |
POST /api/v1/payments/quote Authorization: Bearer dpm_your_token_here Content-Type: application/json { "amount": "1500.00" }
{
"amount": "1500.00",
"currency": "THB",
"effective_rate": "1.00000",
"provider_fee_amount": "15.00",
"final_amount": "1537.50",
"source_currency": "THB"
}
final_amount: номинал счёта (amount в target_currency), пересчитанный по effective_rate в валюту баланса, плюс комиссия провайдера (provider_fee_amount). Списание с баланса равно final_amount. Для same-currency-флоу (THB→THB) effective_rate всегда 1.00000.
Создаёт платёж и синхронно списывает баланс. Передавайте ровно одно из: qr_payload или biller_id (нарушение даст VALIDATION_FAILED). Если QR содержит сумму (динамический, Tag 54), поле amount в запросе игнорируется — берётся сумма из payload. Статический QR без суммы — задайте amount, иначе получите AMOUNT_REQUIRED.
QR_NOT_BILLERID. Биллер-флоу: отдаёте biller_id, bill_reference1 и amount напрямую, без QR.
| Поле | Тип | Описание | |
|---|---|---|---|
| public_user_id | string | обязательный | id пользователя из POST /users. Формат DPPU-XXXXXXXXXX. Без него платёж не пройдёт KYC у провайдера |
| qr_payload | string | обязательный* | Исходная строка EMVCo MPM, до 2000 символов. Взаимоисключаемо с biller_id |
| biller_id | string | обязательный* | ID биллера, до 200 символов. Обязателен, когда нет qr_payload |
| amount | decimal | обязательный* | Сумма счёта. Обязательна для статического QR (без суммы внутри) и для биллер-флоу. Игнорируется, если QR динамический |
| biller_name | string | опциональный | Перекрывает имя из QR Tag 59. До 200 символов |
| bill_reference1 | string | опциональный | Bill reference 1. Обязателен в биллер-флоу. До 200 символов |
| bill_reference2 | string | опциональный | Bill reference 2, до 200 символов |
| bill_reference3 | string | опциональный | Bill reference 3, до 200 символов |
| external_user_id | string | опциональный | Ваш внутренний id пользователя, до 128 символов. Эхом приходит в каждом вебхуке |
| callback_url | string | опциональный | Per-tx webhook URL (HTTPS). Перекрывает URL, заданный на токене. Прогоняется через SSRF guard |
| reference_note | string | опциональный | Свободный текст для ваших отчётов, до 200 символов |
POST /api/v1/payments/transactions Authorization: Bearer dpm_your_token_here Idempotency-Key: pay-inv-0042 Content-Type: application/json { "public_user_id": "DPPU-X9Y8Z7W6V5", "qr_payload": "00020101021230740016A000000677010111011300660000000000021700000000000000000530376454031500802TH91047CBC", "external_user_id": "user_456", "callback_url": "https://your.domain/webhooks/payments" }
POST /api/v1/payments/transactions Authorization: Bearer dpm_your_token_here Idempotency-Key: pay-inv-0043 Content-Type: application/json { "public_user_id": "DPPU-X9Y8Z7W6V5", "biller_id": "0660000000000", "amount": "1500.00", "bill_reference1": "INV-2026-0042", "biller_name": "Bangkok Electricity", "external_user_id": "user_456", "callback_url": "https://your.domain/webhooks/payments" }
{
"payment_id": "DPP-1A2B3C4D5E",
"status": "CREATED",
"source_amount": "1537.50",
"source_currency": "THB",
"target_amount": "1500.00",
"target_currency": "THB",
"effective_rate": "1.00000",
"provider_fee_amount": "15.00",
"created_at": "2026-05-18T10:05:00+00:00",
"is_sandbox": false
}
Idempotency-Key — HTTP 200 и тело исходного ответа байт-в-байт. Кэш ответа переживает рестарт сервиса.
Текущее состояние платежа по payment_id (DPP-XXXXXXXXXX). Поллите его при отсутствии вебхуков, но основной канал — вебхуки.
GET /api/v1/payments/transactions/DPP-1A2B3C4D5E Authorization: Bearer dpm_your_token_here
{
"payment_id": "DPP-1A2B3C4D5E",
"status": "COMPLETED",
"source_amount": "1537.50",
"source_currency": "THB",
"target_amount": "1500.00",
"target_currency": "THB",
"effective_rate": "1.00000",
"provider_fee_amount": "15.00",
"created_at": "2026-05-18T10:05:00+00:00",
"updated_at": "2026-05-18T10:05:18+00:00",
"is_sandbox": false,
"callback_url": "https://your.domain/webhooks/payments",
"status_message": null
}
Пагинированный список платежей под токеном, в обратном хронологическом порядке. Только сводные поля (нет callback_url, нет status_message) — за деталями идите в GET /transactions/{id}.
| Query-параметр | Тип | Описание | |
|---|---|---|---|
| status | string | опциональный | Фильтр по статусу: CREATED, SUBMITTED, CONFIRMED, COMPLETED, FAILED, REFUNDED, CANCELLED, REJECTED |
| offset | integer | опциональный | Дефолт 0 |
| limit | integer | опциональный | 1–100, дефолт 20 |
GET /api/v1/payments/transactions?offset=0&limit=20 Authorization: Bearer dpm_your_token_here
{
"items": [
{
"payment_id": "DPP-1A2B3C4D5E",
"status": "COMPLETED",
"source_amount": "1537.50",
"source_currency": "THB",
"target_amount": "1500.00",
"target_currency": "THB",
"created_at": "2026-05-18T10:05:00+00:00",
"updated_at": "2026-05-18T10:05:18+00:00",
"is_sandbox": false
}
],
"total": 1,
"limit": 20,
"offset": 0
}
INVALID_STATUS_TRANSITION.| Статус | Что значит |
|---|---|
| CREATED | Баланс уже списан, платёж в очереди на отправку провайдеру |
| SUBMITTED | Провайдер принял запрос и вернул свой reference |
| CONFIRMED | Провайдер подтвердил, что средства в пути к получателю |
| COMPLETED | Терминал. Получатель деньги получил, расчёт закрыт |
| FAILED | Платёж не прошёл. Через короткое время автоматически уйдёт в REFUNDED |
| REJECTED | Провайдер отклонил платёж (валидация на их стороне). Тоже идёт в REFUNDED |
| CANCELLED | Провайдер сам отменил платёж (редко). Возврат средств следом |
| REFUNDED | Терминал. Баланс восстановлен compensating entry в ledger |
request_id в каждом ответе — кладите его в обращение в поддержку.{
"error": "INVALID_QR",
"message": "QR payload could not be decoded",
"request_id": "01JVWXYZ1234567890ABCDEF"
}| HTTP | Код | Описание |
|---|---|---|
| 400 | VALIDATION_FAILED | Тело запроса не прошло Pydantic-валидацию. Частая причина: одновременно заданы qr_payload и biller_id либо отсутствуют оба |
| 400 | IDEMPOTENCY_KEY_REQUIRED | Idempotency-Key отсутствует или вне диапазона 8–200 символов |
| 400 | IDEMPOTENCY_KEY_MISMATCH | Значение в заголовке и в теле расходятся (если поле дублируется в payload) |
| 400 | INSUFFICIENT_BALANCE | Баланс ниже полного final_amount с учётом всех комиссий |
| 400 | AMOUNT_OUT_OF_RANGE | Сумма вне диапазона [min_amount, max_amount] токена |
| 400 | PROVIDER_REQUIRES_QR | Токен в QR-only режиме вызван на POST /quote. Используйте POST /transactions с фактическим QR |
| 400 | CURRENCY_NOT_SUPPORTED | Запрошенная валюта не поддерживается выбранным провайдером |
| 400 | ENCRYPTION_REQUIRED | Токен сконфигурирован с transport encryption, а тело пришло в открытом виде |
| 400 | ENCRYPTION_KEY_MISSING | Тело зашифровано, но у токена не задан ключ |
| 400 | INVALID_PAYLOAD | Конверт не расшифровывается: невалидный base64, повреждённый GCM tag, или после расшифровки тело не JSON |
| 400 | NONCE_REPLAY | Тот же nonce уже использовался на этом токене в окне 15 минут. Сгенерируйте новый 12-байтный random nonce |
| 400 | PROVIDER_REJECTED | Провайдер отклонил операцию по бизнес-правилу (невалидный получатель, лимит и т.п.) |
| 401 | UNAUTHORIZED | Заголовок Authorization отсутствует или не в формате Bearer … |
| 401 | INVALID_TOKEN | Токен не найден в БД (несовпадение хеша) |
| 401 | TOKEN_EXPIRED | Срок действия токена истёк. Перевыпуск через менеджера |
| 403 | TOKEN_DISABLED | Токен отключён администратором |
| 403 | FORBIDDEN_TOKEN_TYPE | Тип токена не подходит для Payments API (например, transfers-токен) |
| 403 | IP_NOT_ALLOWED | IP клиента вне allowlist токена |
| 403 | ENDPOINT_DISABLED | Эндпоинт отключён на уровне токена |
| 403 | SANDBOX_ONLY | Test-эндпоинт вызван production-токеном |
| 404 | PAYMENT_NOT_FOUND | Платёж не существует или принадлежит другому токену |
| 409 | DUPLICATE_REQUEST | Тот же Idempotency-Key использован на другом типе эндпоинта |
| 409 | PROVIDER_NOT_CONFIGURED | Не настроена комиссия для пары (партнёр, провайдер, валюты). Конфигурируется аккаунт-менеджером. |
| 409 | PROVIDER_DUPLICATE_REFERENCE | Провайдер уже принял этот reference. Запросите статус и закрывайте операцию |
| 409 | INVALID_STATUS_TRANSITION | FSM-валидатор отклонил переход (sandbox/test endpoint) |
| 422 | INVALID_QR | QR не парсится как EMVCo MPM. Часто прилетает base64 вместо raw payload или повреждённая строка |
| 422 | INVALID_QR_CRC | CRC-16 в Tag 63 не совпала с расчётной |
| 422 | QR_NOT_BILLERID | QR не содержит Tag 30 BILLERID (например, PromptPay account). Поддерживается только BILLERID |
| 422 | AMOUNT_REQUIRED | Статический QR без суммы и без amount в теле запроса |
| 422 | BILLER_ID_REQUIRED | biller_id обязателен, когда не передан qr_payload |
| 422 | PRICING_INVALID | Расчёт цены недоступен (нет актуального курса). Повторите запрос через минуту |
| 422 | INVALID_STATUS | Sandbox: переданный status не входит в TransactionStatus |
| 429 | RATE_LIMIT_EXCEEDED | Превышен минутный лимит токена. Скользящее окно — повторяйте через несколько секунд. |
| 429 | DAILY_LIMIT_EXCEEDED | Достигнут суточный лимит объёма платежей по токену |
| 500 | INTERNAL_ERROR | Непредвиденная ошибка. Передайте request_id в поддержку |
| 502 | PROVIDER_INVALID_RESPONSE | Ответ провайдера не парсится. Логируется и эскалируется |
| 503 | PROVIDER_UNAVAILABLE | Маршрут до провайдера временно недоступен. Повторите через несколько минут. |
| 503 | PROVIDER_INSUFFICIENT_FUNDS | Провайдер отчитался о нехватке средств на своей стороне. Не путать с INSUFFICIENT_BALANCE |
| 503 | BALANCE_NOT_FOUND | Строка баланса для токена отсутствует. Конфигурационная ошибка, обратитесь в поддержку |
| 503 | POOL_NOT_FOUND | Нет активного provider pool для вашего scope. Обратитесь в поддержку |
| 504 | PROVIDER_TIMEOUT | Провайдер не уложился в таймаут-бюджет |
Sandbox — это тот же код, что и production: auth, encryption, pricing, ledger, вебхуки. Отличие одно — реальные провайдеры не вызываются. Платежи стартуют в CREATED и сидят там, пока вы не двинете их через POST /api/test/payments/transactions/{id}/status. Префикс токена test_dpm_.
10**12, так что симулируйте платежи на любую сумму. Тот же min/max_amount от токена применяется — это сделано специально, чтобы поймать AMOUNT_OUT_OF_RANGE до прода.
| Аспект | Поведение в sandbox |
|---|---|
| Базовый URL | Тот же, что и production: https://api.doverkapay.com |
| Префикс токена | test_dpm_ вместо dpm_ |
| Баланс | Виртуальный, безлимитный sandbox-пул |
| Управление статусом | Вручную, через POST /api/test/payments/transactions/{payment_id}/status |
| Вебхуки | Доставляются как в проде, можно отлаживать HMAC end-to-end |
| Провайдеры | Реальные API не вызываются, sandbox-адаптер возвращает фикстуру |
| Encryption | AES-GCM transport encryption работает идентично проду, если включён на токене |
| Сценарий | Как воспроизвести |
|---|---|
| Успешный платёж | Force COMPLETED |
| Неудача с refund | Force FAILED — баланс восстановится автоматически переходом в REFUNDED |
| Отказ провайдера | Force REJECTED — тоже компенсируется refund'ом |
| Постепенная проводка | Force SUBMITTED → CONFIRMED → COMPLETED, по одному переходу за вызов |
Принудительно двигает sandbox-платёж в указанный статус. FSM-валидатор работает — обратные переходы отклоняются. Вебхуки и compensating entry'и в ledger срабатывают как в проде. Полный URL: https://api.doverkapay.com/api/test/payments/transactions/{payment_id}/status.
SANDBOX_ONLY (403). Чужой платёж — PAYMENT_NOT_FOUND (404). Неизвестный статус — INVALID_STATUS (422). Запрещённый переход FSM — INVALID_STATUS_TRANSITION (422).| Поле | Тип | Описание | |
|---|---|---|---|
| status | string | обязательный | Любое значение TransactionStatus: SUBMITTED, CONFIRMED, COMPLETED, FAILED, REJECTED, CANCELLED, REFUNDED |
POST /api/test/payments/transactions/DPP-1A2B3C4D5E/status Authorization: Bearer test_dpm_sandbox_token Content-Type: application/json { "status": "COMPLETED" }
{
"payment_id": "DPP-1A2B3C4D5E",
"status": "COMPLETED",
"source_amount": "1537.50",
"source_currency": "THB",
"target_amount": "1500.00",
"target_currency": "THB",
"effective_rate": "1.00000",
"provider_fee_amount": "15.00",
"created_at": "2026-05-18T10:05:00+00:00",
"updated_at": "2026-05-18T10:06:00+00:00",
"is_sandbox": true,
"callback_url": "https://your.domain/webhooks/payments",
"status_message": null
}