Платежи по 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.aggregator.stage.doverkasend.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",
"previous_status": "CONFIRMED",
"source_amount": "1015.00",
"source_currency": "THB",
"target_amount": "1000.00",
"target_currency": "THB",
"effective_rate": "1.0150",
"fee": "5.00",
"fee_currency": "THB",
"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"
}
Ваш endpoint получает это событие каждый раз, когда статус платежа меняется. Одно событие на один переход.
| Поле | Тип | Описание |
|---|---|---|
| event | string | Всегда "payment.status_changed" |
| payment_id | string | Идентификатор платежа (DPP-XXXXXXXXXX) |
| status | string | Новый статус (см. жизненный цикл) |
| previous_status | string | Предыдущий статус или null |
| source_amount | string | Итоговое списание с баланса партнёра |
| source_currency | string | Валюта списания |
| target_amount | string | Сумма, поступающая биллеру |
| target_currency | string | Валюта получателя |
| effective_rate | string | Единиц source на одну target |
| fee | string | Фиксированная комиссия |
| fee_currency | string | Валюта комиссии (= source_currency) |
| status_message | string | Текст статуса или null |
| reference_note | string | Ваш memo из запроса |
| correlation_id | string | Трейс-идентификатор для поддержки |
| external_user_id | string | Ваш ID пользователя из запроса |
| is_sandbox | boolean | true для sandbox-токенов |
| created_at | string | ISO 8601, UTC |
| updated_at | string | ISO 8601, UTC |
{
"event": "payment.status_changed",
"payment_id": "DPP-1A2B3C4D5E",
"status": "COMPLETED",
"previous_status": "CONFIRMED",
"source_amount": "1015.00",
"source_currency": "THB",
"target_amount": "1000.00",
"target_currency": "THB",
"effective_rate": "1.0150",
"fee": "5.00",
"fee_currency": "THB",
"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"
}
- Вы:
POST /transactions→ HTTP 201 →"status": "CREATED" - Webhook:
"status": "SUBMITTED" - Webhook:
"status": "CONFIRMED" - Webhook:
"status": "COMPLETED"
- Вы:
POST /transactions→ HTTP 201 - Webhook:
"status": "SUBMITTED" - Webhook:
"status": "REJECTED",status_messageсодержит причину
- Вы:
POST /transactions→ HTTP 201 - Webhook:
"status": "SUBMITTED" - Webhook:
"status": "FAILED"
- Webhook:
"status": "REFUNDED","previous_status": "COMPLETED"
Используйте POST /api/test/payments/transactions/{id}/status с нужным статусом FSM:
| Сценарий | Последовательность статусов |
|---|---|
| Happy path | SUBMITTED → CONFIRMED → COMPLETED |
| Отклонение | SUBMITTED → REJECTED |
| Таймаут | SUBMITTED → FAILED |
| Возврат | SUBMITTED → CONFIRMED → COMPLETED → REFUNDED |
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" }
{
"target_amount": "1000.00",
"target_currency": "THB",
"source_amount": "1015.00",
"source_currency": "THB",
"effective_rate": "1.0150",
"fee": "5.00",
"fee_currency": "THB"
}
source_amount: target_amount (сумма на счёте биллера), пересчитанная по effective_rate в валюту баланса, плюс fee (фиксированная комиссия). Списание с баланса равно source_amount. Для same-currency-флоу (THB→THB) effective_rate всегда 1.0000.
Создаёт платёж и синхронно списывает баланс. Передавайте ровно одно из: qr_payload или biller_id (нарушение даст VALIDATION_FAILED). Если QR содержит сумму (динамический QR), поле 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 символов |
| amount | decimal | обязательный* | Сумма счёта. Обязательна для статического QR (без суммы внутри), игнорируется для динамического |
| biller_name | string | опциональный | Перекрывает имя получателя из QR. До 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" }
| Поле | Тип | Описание | |
|---|---|---|---|
| public_user_id | string | обязательный | id пользователя из POST /users. Формат DPPU-XXXXXXXXXX. Без него платёж не пройдёт KYC-проверку |
| biller_id | string | обязательный | ID биллера, до 200 символов |
| amount | decimal | обязательный | Сумма счёта. Обязательна в биллер-флоу |
| bill_reference1 | string | обязательный | Bill reference 1. Обязателен в биллер-флоу. До 200 символов |
| bill_reference2 | string | опциональный | Bill reference 2, до 200 символов |
| bill_reference3 | string | опциональный | Bill reference 3, до 200 символов |
| biller_name | string | опциональный | Имя биллера для отображения. До 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-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": "1015.00",
"source_currency": "THB",
"target_amount": "1000.00",
"target_currency": "THB",
"effective_rate": "1.0150",
"fee": "5.00",
"fee_currency": "THB",
"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": "1015.00",
"source_currency": "THB",
"target_amount": "1000.00",
"target_currency": "THB",
"effective_rate": "1.0150",
"fee": "5.00",
"fee_currency": "THB",
"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": "1015.00",
"source_currency": "THB",
"target_amount": "1000.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 | Баланс ниже полного source_amount с учётом всех комиссий |
| 400 | INSUFFICIENT_AMOUNT_FOR_FEES | Сумма меньше суммарного размера комиссий. Увеличьте значение поля amount |
| 400 | PRICING_CONFIG_INACTIVE | Ценообразование для этого направления временно отключено. Обратитесь к аккаунт-менеджеру |
| 400 | AMOUNT_OUT_OF_RANGE | Сумма вне диапазона [min_amount, max_amount] токена |
| 400 | PROVIDER_REQUIRES_QR | POST /quote недоступен для токенов в режиме QR-payload. Создайте платёж через 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 | Платёж с таким ID не найден или принадлежит другому токену |
| 404 | MERCHANT_USER_NOT_FOUND | Переданный public_user_id не найден или принадлежит другому токену |
| 409 | DUPLICATE_REQUEST | Тот же Idempotency-Key использован на другом типе эндпоинта |
| 409 | PROVIDER_NOT_CONFIGURED | Провайдер для этого направления не сконфигурирован. Обратитесь к аккаунт-менеджеру |
| 409 | PROVIDER_DUPLICATE_REFERENCE | Этот reference уже принят системой обработки. Запросите статус и закрывайте операцию |
| 409 | INVALID_STATUS_TRANSITION | FSM-валидатор отклонил переход (sandbox/test endpoint) |
| 409 | MERCHANT_USER_INACTIVE | Пользователь деактивирован. Зарегистрируйте нового |
| 400 | INVALID_QR | QR не парсится как EMVCo MPM. Часто прилетает base64 вместо raw payload или повреждённая строка |
| 400 | INVALID_QR_CRC | CRC-16 не совпала с расчётной |
| 400 | QR_NOT_BILLERID | QR не содержит BILLERID (например, PromptPay account). Поддерживается только BILLERID |
| 400 | AMOUNT_REQUIRED | Статический QR без суммы и без amount в теле запроса |
| 400 | BILLER_ID_REQUIRED | biller_id обязателен, когда не передан qr_payload |
| 400 | INVALID_STATUS | Sandbox: переданный status не входит в TransactionStatus |
| 422 | UNSUPPORTED_CURRENCY | QR содержит валюту (Tag 53), которую платформа не поддерживает |
| 429 | RATE_LIMIT_EXCEEDED | Превышен минутный лимит токена. Скользящее окно — повторяйте через несколько секунд. |
| 429 | DAILY_LIMIT_EXCEEDED | Достигнут суточный лимит объёма платежей по токену |
| 500 | INTERNAL_ERROR | Непредвиденная ошибка. Передайте request_id в поддержку |
| 502 | PROVIDER_INVALID_RESPONSE | Получен некорректный ответ от обработчика. Залогировано и эскалировано |
| 502 | MERCHANT_USER_REGISTRATION_FAILED | Регистрация пользователя у обработчика не прошла. Попробуйте ещё раз или создайте нового пользователя |
| 503 | PROVIDER_UNAVAILABLE | Обработка временно недоступна. Повторите через несколько минут |
| 503 | PROVIDER_INSUFFICIENT_FUNDS | Недостаточно ликвидности на стороне обработки. Повторите позже |
| 503 | RATE_UNAVAILABLE | Актуальный курс временно недоступен. Повторите через несколько минут |
| 503 | ACCOUNT_NOT_PROVISIONED | Аккаунт не полностью сконфигурирован для этой операции. Обратитесь в поддержку |
| 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 | https://api.aggregator.stage.doverkasend.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.aggregator.stage.doverkasend.com/api/test/payments/transactions/{payment_id}/status.
SANDBOX_ONLY (403). Чужой платёж — PAYMENT_NOT_FOUND (404). Неизвестный статус — INVALID_STATUS (400). Запрещённый переход FSM — INVALID_STATUS_TRANSITION (409).| Поле | Тип | Описание | |
|---|---|---|---|
| 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": "1015.00",
"source_currency": "THB",
"target_amount": "1000.00",
"target_currency": "THB",
"effective_rate": "1.0150",
"fee": "5.00",
"fee_currency": "THB",
"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
}