oblodai 1.1.0

Официальный Rust SDK для платёжного шлюза Oblodai: приём платежей, выплаты, кошельки, вебхуки.
Documentation

Oblodai Rust SDK

Официальный Rust SDK для платёжного шлюза Oblodai: приём платежей, выплаты, массовые операции (пачки), платёжные и payout-ссылки, сплит-платежи, счета на e-mail, статические кошельки, вебхуки. Автоподпись запросов, разбор ответов в типизированные структуры, обработка ошибок и автоматические повторы с защитой от дублей (Idempotency-Key).

Базовый URL. По умолчанию — https://api.oblodai.com. При необходимости переопределите через Config::base_url(...) и свои ключи.

Установка

[dependencies]
oblodai = "1"
serde_json = "1"

Требуется Rust 1.75+.

Учётные данные

Храните ключи в переменных окружения (см. .env.example):

export OBLODAI_PUBLIC_ID=oblodai_...
export OBLODAI_SECRET=oblodai_live_...
# необязательно: export OBLODAI_BASE_URL=https://api.oblodai.com
// читает OBLODAI_PUBLIC_ID / OBLODAI_SECRET / OBLODAI_BASE_URL
let client = oblodai::Client::from_env()?;

Быстрый старт

use oblodai::{Client, Config};
use serde_json::json;

fn main() -> oblodai::Result<()> {
    // либо явно (эквивалент Client::from_env выше):
    let client = Client::new(
        Config::new("oblodai_...", "oblodai_live_...")
            .base_url("https://api.oblodai.com"),
    )?;

    let payment = client.payments().create(json!({
        "amount": "10",
        "currency": "USD",
        "order_id": "order-1",
        "to_currency": "USDT",
        "network": "tron",
    }))?;

    println!("{}", payment.address); // адрес для оплаты
    println!("{}", payment.url);     // hosted-страница оплаты
    Ok(())
}

Проверка вебхуков

Подпись вебхука отличается от подписи запроса — SDK делает и то, и другое. Для входящих вебхуков берите сырое тело и заголовки X-Webhook-Timestamp / X-Webhook-Signature.

use oblodai::{construct_event, verify_webhook, VerifyOptions, WebhookHeaders, Error};
use serde_json::Value;

fn handle(secret: &str, raw_body: &[u8], ts: &str, sig: &str) {
    let headers = WebhookHeaders { timestamp: ts, signature: sig };

    // По умолчанию проверяется подпись И свежесть (replay-защита, окно 5 минут).
    match construct_event::<Value>(secret, raw_body, &headers, &VerifyOptions::default()) {
        Ok(event) => {
            if event["type"] == "payment" && event["status"] == "paid" {
                // пометить заказ event["order_id"] оплаченным (идемпотентно по uuid + status)
            }
        }
        Err(Error::Signature(_)) => { /* вернуть 403 */ }
        Err(_) => { /* прочее */ }
    }
}

Чтобы отключить replay-защиту: VerifyOptions { max_age_seconds: 0, now: None }.

Обработка ошибок

Все ошибки — enum [Error]. Ошибки API несут машиночитаемый код.

use oblodai::Error;

match client.payouts().create(params) {
    Ok(payout) => { /* ... */ }
    Err(Error::Api { code, status, message, .. }) => {
        match code.as_str() {
            "payout.insufficient_funds" => { /* недостаточно средств */ }
            "payout.funds_maturing"    => { /* средства ещё дозревают — временно */ }
            _ => {}
        }
        eprintln!("{code} (HTTP {status}): {message}");
    }
    Err(e) => eprintln!("{e}"),
}

err.is_retriable() подскажет, временная ли ошибка; err.code() вернёт код для Error::Api.

Варианты ошибок

Вариант Когда
Error::Api { code, status, .. } API вернул конверт error.
Error::Connection(_) Сеть недоступна или таймаут.
Error::Signature(_) Не прошла проверка подписи вебхука.
Error::Serialization(_) Ошибка (де)сериализации.
Error::Config(_) Некорректная конфигурация.

Повторы (retry)

Временные ошибки (5xx, 429, сетевые сбои) повторяются автоматически с экспоненциальным backoff и джиттером. Ошибки запроса (4xx), а также payout.funds_maturing (средства ещё дозревают — немедленный повтор не поможет) считаются терминальными и не повторяются. Заголовок Retry-After от сервера соблюдается как есть (не обрезается до max_delay, лишь до абсолютного потолка в 300 с).

use oblodai::{Config, RetryConfig};
use std::time::Duration;

let config = Config::new("...", "...").retry(Some(RetryConfig {
    max_attempts: 4,
    initial_delay: Duration::from_millis(500),
    max_delay: Duration::from_secs(30),
}));
// .retry(None) — отключить повторы

Важно про таймаут. Таймаут не означает, что операция не прошла. Повтор безопасен благодаря заголовку Idempotency-Key (см. ниже): если операция уже создана — сервер вернёт её же, дубля не будет.

Идемпотентность (v1.1.0, ломающее изменение)

Создающие вызовы (payments().create/refund/resolve/create_batch/refund_batch, payouts().create/create_mass/create_batch/refund, account().transfer_to_personal) шлют заголовок Idempotency-Key — UUID v4, сгенерированный один раз до повторов: все внутренние ретраи одного вызова уходят с одним и тем же ключом, поэтому таймаут+повтор не создаёт дубль. Заголовок в подпись запроса не входит.

  • order_id больше НЕ подставляется автоматически (поведение v1.0.x удалено). Он уходит как есть — это ваш бизнес-идентификатор, задавайте его явно, чтобы потом находить операцию через info. Для выплат order_id обязателен всегда (payout.order_id_required).
  • Свой ключ идемпотентности (дедупликация между вызовами/процессами): передайте поле idempotency_key в параметрах создающего вызова — оно уйдёт в заголовок и не попадёт в тело:
client.payments().create(json!({
    "amount": "10", "currency": "USD", "order_id": "ord-1",
    "idempotency_key": "3f8a2c1e-...-ваш-uuid",
}))?;
  • payout_links() — исключение: эндпоинты /v1/payout/link* заголовок не поддерживают, SDK его не шлёт. Дедуплицируйте создание ссылок через per-link reference.

Свой HTTP-транспорт

По умолчанию используется встроенный клиент на reqwest (фича reqwest-client, включена). Чтобы подставить свой транспорт (или для тестов без сети), отключите фичу и реализуйте трейт [HttpTransport]:

[dependencies]
oblodai = { version = "1", default-features = false }
use oblodai::{Client, Config, HttpTransport, HttpResponse, Error};
use std::sync::Arc;

struct MyTransport;
impl HttpTransport for MyTransport {
    fn post(&self, url: &str, headers: &[(String, String)], body: &[u8])
        -> Result<HttpResponse, Error> {
        // ваш HTTP-вызов
        # let _ = (url, headers, body);
        Ok(HttpResponse { status: 200, body: b"{}".to_vec() })
    }
}

let client = Client::with_transport(Config::new("p", "s"), Arc::new(MyTransport)).unwrap();

Обзор методов

// Платежи
client.payments().create(params)
client.payments().info(uuid, order_id)
client.payments().history(params)
client.payments().services()
client.payments().qr(uuid, order_id)
client.payments().resend(uuid, order_id)
client.payments().refund(params)
client.payments().set_accepted(methods) / list_accepted()
client.payments().set_accuracy(params) / get_accuracy()
client.payments().set_autorefund(params) / get_autorefund()
client.payments().create_batch(payments, on_error)      // пачка платежей (до 5000)
client.payments().refund_batch(refunds, on_error)       // пачка возвратов
client.payments().send_email(uuid, order_id, email)     // счёт на e-mail
client.payments().resolve(ResolveAction::Accept, params) // судьба недоплаты: Accept | Refund

// Выплаты
client.payouts().create(params)
client.payouts().create_mass(payouts, source)
client.payouts().create_batch(payouts, on_error)        // пачка выплат (до 5000)
client.payouts().info(uuid, order_id)
client.payouts().history(params)
client.payouts().services()
client.payouts().calculate(params)
client.payouts().approve(uuid)
client.payouts().refund(params)
client.payouts().get_fee_config() / set_fee_config(bool)
client.payouts().get_refund_fee_config() / set_refund_fee_config(bool)

// Кошельки
client.wallets().create(params)
client.wallets().block(address, force_block)
client.wallets().blocked_address_refund(uuid, address)
client.wallets().qr(address)

// Аккаунт
client.account().balance()
client.account().referral()
client.account().transfer_to_personal(params)
client.account().vrcs(enabled)

// Вебхуки
client.webhooks().register(url)
client.webhooks().deliveries()
client.webhooks().test_payment(params)

// Настройки
client.settings().list_auto_withdraw() / set_auto_withdraw(params) / delete_auto_withdraw(currency)
client.settings().list_allowlist() / add_allowlist(cidr) / remove_allowlist(cidr) / enable_allowlist(bool)

// Массовые операции: состояние пачки
client.batches().info(batch_id, limit, offset)

// Платёжные ссылки (переиспользуемые, «донатные»)
client.payment_links().create(params)
client.payment_links().list(limit, offset) / info(link_id) / toggle(link_id, active)
client.payment_links().public_get(link_id)              // публично, без подписи
client.payment_links().checkout(link_id, params)        // публично, без подписи

// Сплит-платежи
client.splits().create_rule(params)                     // {address,network} ИЛИ {merchant_id} + percent
client.splits().list_rules() / delete_rule(rule_id)
client.splits().get_config() / set_config(refund_hold_hours)

// Payout-ссылки («крипто-чеки», payout-ключ)
client.payout_links().create(params)                    // задавайте expires_in_hours явно!
client.payout_links().create_batch(links)               // до 500 ссылок
client.payout_links().list(limit, offset) / info(link_id) / cancel(link_id)
client.payout_links().claim_info(token)                 // публично, без подписи
client.payout_links().claim(token, address, memo)       // публично, без подписи

// Курсы (публично, без ключа)
client.rates().list(Some("ETH"))

Payout-ссылки: коротко

Вы резервируете средства в ссылку, не зная кошелька получателя; получатель открывает публичную страницу claim_url, вводит адрес — порождается обычная выплата. claim_token/claim_url возвращаются только в ответе create — сохраните их сразу. Задавайте expires_in_hours (1–720) явно: при 0/отсутствии бэкенд клампит срок к 1 часу. Непорученная ссылка возвращает резерв по истечении срока или при cancel.

let link = client.payout_links().create(json!({
    "currency": "USDT", "network": "tron", "amount": "25",
    "reference": "bonus-42",            // ваш ключ дедупликации
    "expires_in_hours": 168,            // 7 дней; без этого поля — всего 1 час!
    "email": "user@example.com",        // опционально: claim-письмо получателю
}))?;
println!("{}", link.claim_url); // отдайте получателю

Замечания

  • Суммы — строки в единицах валюты ("25.00"), не числа. Так сохраняется точность.
  • Дубли исключает заголовок Idempotency-Key (см. раздел «Идемпотентность»). order_id — ваш бизнес-идентификатор для поиска операции, задавайте его явно; SDK его не подставляет.
  • Секрет — только на сервере. SDK серверный; не встраивайте ключ в клиентские приложения.
  • Тела запросов принимаются как serde_json::Value — стройте макросом json!.

Лицензия

MIT