⌘K
Postman
QR Payments API
Платежи по QR и через биллеров

Принимайте платежи по QR-коду (динамическому или статическому) и через биллеров одним эндпоинтом. На сегодня live-коридор — Таиланд (PromptPay и сети биллеров). Архитектура мультикоридорная: новые валюты подключаются без изменения базового контракта, а отдельные методы котировки для специфичных валют будут описаны здесь по мере подключения.

Production
https://api.doverkapay.com/api/v1/paymentsLIVE
Боевые платежи, реальные средства. Токен с префиксом dpm_.
Sandbox
https://api.doverkapay.com/api/v1/paymentsTEST
Тот же хост, что и production. Токен с префиксом test_dpm_. Смена статусов через /api/test/payments/....
Сценарий интеграции #
Три шага от регистрации пользователя до подтверждённого платежа.
1
Зарегистрируйте конечного пользователя
Один раз на пользователя вызовите POST /users. Получите public_id формата DPPU-XXXXXXXXXX. Каждый платёж ссылается на этот id — без него KYC-данные не уйдут провайдеру и платёж не пройдёт.
2
Покажите расчёт стоимости (опционально)
Через POST /quote получите эффективный курс, комиссию провайдера и итоговое списание ещё до создания платежа. Доступно для коридоров, где имеет смысл расчёт по placeholder-получателю; для QR-payload-only флоу ответом будет PROVIDER_REQUIRES_QR (400) — вызывайте POST /transactions с реальным QR напрямую.
3
Создайте платёж
Отправьте POST /transactions с QR-payload или с прямыми реквизитами биллера. Баланс списывается синхронно — на момент ответа 201/200 деньги уже зарезервированы. Дальше слушаете вебхук или поллите GET /transactions/{id}.

Аутентификация #
Bearer-токен в каждом запросе. IP-allowlist на токене — серверный.
Заголовок запроса
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 клиента должен быть в allowlist токена. Запрос с чужого адреса вернёт IP_NOT_ALLOWED (403). Allowlist редактируется через панель партнёра, изменения применяются мгновенно.

Идемпотентность #
Безопасные повторы POST /transactions и POST /users.

Ваш ключ Idempotency-Key попадает в строку запросов вместе с хешем тела. Повтор того же ключа возвращает исходный ответ — даже если первый запрос отвалился по сетевому таймауту до получения ответа. Ключ живёт 24 часа, дальше может быть переиспользован.

Заголовок
Idempotency-Key: pay-20260518-inv-0042
i Ключи изолированы по типу эндпоинта. Тот же ключ на 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)>"
}
i Nonce уникален на каждый запрос. Платформа атомарно проверяет повтор в окне 15 минут — захваченный конверт нельзя переслать ещё раз, даже если в нём корректный 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),
    }))
}

Вебхуки #
Подписанные POST на ваш callback_url при каждой смене статуса.

URL берётся с платежа (body.callback_url), а если не задан — с токена. HTTPS обязателен, частные/loopback адреса блокируются на этапе валидации (SSRF guard). Endpoint должен ответить 2xx в разумный таймаут, иначе доставка уходит в очередь повторов.

График доставки
10s 2+5s 3+15s 4+30s 5+60s 6+10m 7+60m 8+12h 8 попыток · итого ≈ 13ч 12м
~ Доставка: 8 попыток (0s, 5s, 15s, 30s, 60s, 10m, 60m, 12h). После восьмой неудачи доставка отмечается как failed в нашем журнале и автоматически больше не повторяется — повтор только через ручной replay аккаунт-менеджером.
i Секрет = 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"
}

Пользователи #
Каждый платёж ссылается на зарегистрированного конечного пользователя.
i Префикс public_id в Payments API — DPPU-XXXXXXXXXX (в Transfers API — DPTU-, не путайте). Пользователи изолированы по сервису: запись из Transfers здесь не подойдёт.
POST /users Регистрация пользователя #
POST/api/v1/payments/users

Создаёт запись о конечном пользователе и возвращает public_id (DPPU-XXXXXXXXXX). Повтор с тем же Idempotency-Key вернёт исходное тело и тот же id, без новой строки в БД. PII (document_number, phone) шифруется AES-256-GCM перед записью.

ПолеТипОписание
first_namestringобязательныйИмя, 1–100 символов
last_namestringобязательныйФамилия, 1–100 символов
patronymicstringопциональныйОтчество, до 100 символов
external_idstringопциональныйВаш внутренний id пользователя. До 255 символов. Хранится как есть, чтобы вы могли мапить наш public_id на свою запись
birth_datedateопциональныйYYYY-MM-DD
phonestringопциональныйE.164, до 30 символов. Шифруется на диске
document_typestringопциональныйCCPT, NID, PSPT и др.
document_numberstringопциональныйДо 50 символов. Шифруется AES-256-GCM
nationalitystringопциональныйISO 3166-1 alpha-2 (например, RU, TH)
addressstringопциональныйДо 500 символов
citystringопциональныйДо 100 символов
statestringопциональныйРегион / штат, до 100 символов
zipcodestringопциональныйПочтовый индекс, до 20 символов
countrystringопциональный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"
}
Ответ 201 Created
{
  "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"
}
GET /users Список пользователей #
GET/api/v1/payments/users

Пагинированный список пользователей под вашим токеном. Сортировка по дате создания, новые сверху.

Query-параметрТипОписание
offsetintegerопциональныйДефолт 0
limitintegerопциональный1–200, дефолт 50
Запрос
GET /api/v1/payments/users?offset=0&limit=50
Authorization: Bearer dpm_your_token_here
Ответ 200 OK
{
  "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
}
GET /users/{public_id} Получить пользователя #
GET/api/v1/payments/users/{public_id}

Возвращает одного пользователя по публичному id. Чужой id (зарегистрированный под другим партнёром) даст 404, не 403 — мы не подтверждаем существование чужих записей.

Запрос
GET /api/v1/payments/users/DPPU-X9Y8Z7W6V5
Authorization: Bearer dpm_your_token_here
Ответ 200 OK
{
  "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"
}

Баланс #
GET /balance Текущий баланс #
GET/api/v1/payments/balance

Баланс токена плюс лимиты на сумму одной транзакции. Значения min_amount/max_amount в валюте баланса.

Запрос
GET /api/v1/payments/balance
Authorization: Bearer dpm_your_token_here
Ответ 200 OK
{
  "balance": "85000.00",
  "currency": "THB",
  "is_sandbox": false,
  "min_amount": "20.00",
  "max_amount": "50000.00"
}
i Сумма вне [min_amount, max_amount] вернёт AMOUNT_OUT_OF_RANGE (400). Лимиты редактируются менеджером, не через API.

Платежи #
Расчёт, создание и трекинг платежей PromptPay и биллеров.
POST /quote Расчёт стоимости #
POST/api/v1/payments/quote

Stateless-расчёт: эффективный курс, комиссия провайдера, итоговое списание. Транзакция не создаётся, Idempotency-Key не нужен. Запрос идёт в провайдер за реальной комиссией (placeholder destination), так что курс и fee — актуальные, не кэш.

! Для QR-only режима недоступно: токены, выпущенные для динамических QR, требуют конкретный payload в POST /transactions. Такой токен на POST /quote вернёт PROVIDER_REQUIRES_QR (400), расчёт делается только по фактическому QR, без placeholder destination.
ПолеТипОписание
amountdecimalобязательныйСумма счёта в валюте баланса. Два знака после запятой, > 0. Например, "1500.00"
Тело запроса
POST /api/v1/payments/quote
Authorization: Bearer dpm_your_token_here
Content-Type: application/json

{
  "amount": "1500.00"
}
Ответ 200 OK
{
  "amount": "1500.00",
  "currency": "THB",
  "effective_rate": "1.00000",
  "provider_fee_amount": "15.00",
  "final_amount": "1537.50",
  "source_currency": "THB"
}
i Что входит в final_amount: номинал счёта (amount в target_currency), пересчитанный по effective_rate в валюту баланса, плюс комиссия провайдера (provider_fee_amount). Списание с баланса равно final_amount. Для same-currency-флоу (THB→THB) effective_rate всегда 1.00000.
POST /transactions Создать платёж #
POST/api/v1/payments/transactions

Создаёт платёж и синхронно списывает баланс. Передавайте ровно одно из: qr_payload или biller_id (нарушение даст VALIDATION_FAILED). Если QR содержит сумму (динамический, Tag 54), поле amount в запросе игнорируется — берётся сумма из payload. Статический QR без суммы — задайте amount, иначе получите AMOUNT_REQUIRED.

i QR-флоу: отдаёте исходную строку EMVCo MPM из камеры или из приложения. Платформа парсит её, проверяет CRC, извлекает биллера и сумму. Поддерживается только Tag 30 (BILLERID) — другие типы дадут QR_NOT_BILLERID. Биллер-флоу: отдаёте biller_id, bill_reference1 и amount напрямую, без QR.
ПолеТипОписание
public_user_idstringобязательныйid пользователя из POST /users. Формат DPPU-XXXXXXXXXX. Без него платёж не пройдёт KYC у провайдера
qr_payloadstringобязательный*Исходная строка EMVCo MPM, до 2000 символов. Взаимоисключаемо с biller_id
biller_idstringобязательный*ID биллера, до 200 символов. Обязателен, когда нет qr_payload
amountdecimalобязательный*Сумма счёта. Обязательна для статического QR (без суммы внутри) и для биллер-флоу. Игнорируется, если QR динамический
biller_namestringопциональныйПерекрывает имя из QR Tag 59. До 200 символов
bill_reference1stringопциональныйBill reference 1. Обязателен в биллер-флоу. До 200 символов
bill_reference2stringопциональныйBill reference 2, до 200 символов
bill_reference3stringопциональныйBill reference 3, до 200 символов
external_user_idstringопциональныйВаш внутренний id пользователя, до 128 символов. Эхом приходит в каждом вебхуке
callback_urlstringопциональныйPer-tx webhook URL (HTTPS). Перекрывает URL, заданный на токене. Прогоняется через SSRF guard
reference_notestringопциональныйСвободный текст для ваших отчётов, до 200 символов
Запрос (QR)
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"
}
Ответ 201 Created
{
  "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
}
i Первый успешный create — HTTP 201. Повтор с тем же Idempotency-Key — HTTP 200 и тело исходного ответа байт-в-байт. Кэш ответа переживает рестарт сервиса.
GET /transactions/{payment_id} Получить платёж #
GET/api/v1/payments/transactions/{payment_id}

Текущее состояние платежа по payment_id (DPP-XXXXXXXXXX). Поллите его при отсутствии вебхуков, но основной канал — вебхуки.

Запрос
GET /api/v1/payments/transactions/DPP-1A2B3C4D5E
Authorization: Bearer dpm_your_token_here
Ответ 200 OK
{
  "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
}
GET /transactions Список платежей #
GET/api/v1/payments/transactions

Пагинированный список платежей под токеном, в обратном хронологическом порядке. Только сводные поля (нет callback_url, нет status_message) — за деталями идите в GET /transactions/{id}.

Query-параметрТипОписание
statusstringопциональныйФильтр по статусу: CREATED, SUBMITTED, CONFIRMED, COMPLETED, FAILED, REFUNDED, CANCELLED, REJECTED
offsetintegerопциональныйДефолт 0
limitintegerопциональный1–100, дефолт 20
Запрос
GET /api/v1/payments/transactions?offset=0&limit=20
Authorization: Bearer dpm_your_token_here
Ответ 200 OK
{
  "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
}

Жизненный цикл статусов #
FSM только вперёд. Обратные переходы запрещены валидатором, такая попытка даёт INVALID_STATUS_TRANSITION.
CREATED SUBMITTED CONFIRMED COMPLETED
С любого этапа → FAILED REFUNDED
Со стороны провайдера → CANCELLED REFUNDED / REJECTED
СтатусЧто значит
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КодОписание
400VALIDATION_FAILEDТело запроса не прошло Pydantic-валидацию. Частая причина: одновременно заданы qr_payload и biller_id либо отсутствуют оба
400IDEMPOTENCY_KEY_REQUIREDIdempotency-Key отсутствует или вне диапазона 8–200 символов
400IDEMPOTENCY_KEY_MISMATCHЗначение в заголовке и в теле расходятся (если поле дублируется в payload)
400INSUFFICIENT_BALANCEБаланс ниже полного final_amount с учётом всех комиссий
400AMOUNT_OUT_OF_RANGEСумма вне диапазона [min_amount, max_amount] токена
400PROVIDER_REQUIRES_QRТокен в QR-only режиме вызван на POST /quote. Используйте POST /transactions с фактическим QR
400CURRENCY_NOT_SUPPORTEDЗапрошенная валюта не поддерживается выбранным провайдером
400ENCRYPTION_REQUIREDТокен сконфигурирован с transport encryption, а тело пришло в открытом виде
400ENCRYPTION_KEY_MISSINGТело зашифровано, но у токена не задан ключ
400INVALID_PAYLOADКонверт не расшифровывается: невалидный base64, повреждённый GCM tag, или после расшифровки тело не JSON
400NONCE_REPLAYТот же nonce уже использовался на этом токене в окне 15 минут. Сгенерируйте новый 12-байтный random nonce
400PROVIDER_REJECTEDПровайдер отклонил операцию по бизнес-правилу (невалидный получатель, лимит и т.п.)
401UNAUTHORIZEDЗаголовок Authorization отсутствует или не в формате Bearer …
401INVALID_TOKENТокен не найден в БД (несовпадение хеша)
401TOKEN_EXPIREDСрок действия токена истёк. Перевыпуск через менеджера
403TOKEN_DISABLEDТокен отключён администратором
403FORBIDDEN_TOKEN_TYPEТип токена не подходит для Payments API (например, transfers-токен)
403IP_NOT_ALLOWEDIP клиента вне allowlist токена
403ENDPOINT_DISABLEDЭндпоинт отключён на уровне токена
403SANDBOX_ONLYTest-эндпоинт вызван production-токеном
404PAYMENT_NOT_FOUNDПлатёж не существует или принадлежит другому токену
409DUPLICATE_REQUESTТот же Idempotency-Key использован на другом типе эндпоинта
409PROVIDER_NOT_CONFIGUREDНе настроена комиссия для пары (партнёр, провайдер, валюты). Конфигурируется аккаунт-менеджером.
409PROVIDER_DUPLICATE_REFERENCEПровайдер уже принял этот reference. Запросите статус и закрывайте операцию
409INVALID_STATUS_TRANSITIONFSM-валидатор отклонил переход (sandbox/test endpoint)
422INVALID_QRQR не парсится как EMVCo MPM. Часто прилетает base64 вместо raw payload или повреждённая строка
422INVALID_QR_CRCCRC-16 в Tag 63 не совпала с расчётной
422QR_NOT_BILLERIDQR не содержит Tag 30 BILLERID (например, PromptPay account). Поддерживается только BILLERID
422AMOUNT_REQUIREDСтатический QR без суммы и без amount в теле запроса
422BILLER_ID_REQUIREDbiller_id обязателен, когда не передан qr_payload
422PRICING_INVALIDРасчёт цены недоступен (нет актуального курса). Повторите запрос через минуту
422INVALID_STATUSSandbox: переданный status не входит в TransactionStatus
429RATE_LIMIT_EXCEEDEDПревышен минутный лимит токена. Скользящее окно — повторяйте через несколько секунд.
429DAILY_LIMIT_EXCEEDEDДостигнут суточный лимит объёма платежей по токену
500INTERNAL_ERRORНепредвиденная ошибка. Передайте request_id в поддержку
502PROVIDER_INVALID_RESPONSEОтвет провайдера не парсится. Логируется и эскалируется
503PROVIDER_UNAVAILABLEМаршрут до провайдера временно недоступен. Повторите через несколько минут.
503PROVIDER_INSUFFICIENT_FUNDSПровайдер отчитался о нехватке средств на своей стороне. Не путать с INSUFFICIENT_BALANCE
503BALANCE_NOT_FOUNDСтрока баланса для токена отсутствует. Конфигурационная ошибка, обратитесь в поддержку
503POOL_NOT_FOUNDНет активного provider pool для вашего scope. Обратитесь в поддержку
504PROVIDER_TIMEOUTПровайдер не уложился в таймаут-бюджет

Sandbox #
Полная симуляция флоу без реальных средств.

Sandbox — это тот же код, что и production: auth, encryption, pricing, ledger, вебхуки. Отличие одно — реальные провайдеры не вызываются. Платежи стартуют в CREATED и сидят там, пока вы не двинете их через POST /api/test/payments/transactions/{id}/status. Префикс токена test_dpm_.

i Баланс sandbox безлимитный. Sandbox-пул всегда имеет реальный balance 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-адаптер возвращает фикстуру
EncryptionAES-GCM transport encryption работает идентично проду, если включён на токене
СценарийКак воспроизвести
Успешный платёжForce COMPLETED
Неудача с refundForce FAILED — баланс восстановится автоматически переходом в REFUNDED
Отказ провайдераForce REJECTED — тоже компенсируется refund'ом
Постепенная проводкаForce SUBMITTEDCONFIRMEDCOMPLETED, по одному переходу за вызов
POST /api/test/payments/transactions/{payment_id}/status [Sandbox] Сменить статус #
POST/api/test/payments/transactions/{payment_id}/status

Принудительно двигает sandbox-платёж в указанный статус. FSM-валидатор работает — обратные переходы отклоняются. Вебхуки и compensating entry'и в ledger срабатывают как в проде. Полный URL: https://api.doverkapay.com/api/test/payments/transactions/{payment_id}/status.

!Prod-токен — SANDBOX_ONLY (403). Чужой платёж — PAYMENT_NOT_FOUND (404). Неизвестный статус — INVALID_STATUS (422). Запрещённый переход FSM — INVALID_STATUS_TRANSITION (422).
ПолеТипОписание
statusstringобязательныйЛюбое значение 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"
}
Ответ 200 OK
{
  "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
}