# Oblodai Rust SDK
Официальный Rust SDK для платёжного шлюза **Oblodai**: приём платежей, выплаты, массовые операции
(пачки), платёжные и payout-ссылки, сплит-платежи, счета на e-mail, статические кошельки, вебхуки.
Автоподпись запросов, разбор ответов в типизированные структуры, обработка ошибок и автоматические
повторы с защитой от дублей (`Idempotency-Key`).
> **Базовый URL.** По умолчанию — `https://api.oblodai.com`. При необходимости переопределите через `Config::base_url(...)` и свои ключи.
## Установка
```toml
[dependencies]
oblodai = "1"
serde_json = "1"
```
Требуется Rust 1.75+.
## Учётные данные
Храните ключи в переменных окружения (см. `.env.example`):
```bash
export OBLODAI_PUBLIC_ID=oblodai_...
export OBLODAI_SECRET=oblodai_live_...
# необязательно: export OBLODAI_BASE_URL=https://api.oblodai.com
```
```rust
// читает OBLODAI_PUBLIC_ID / OBLODAI_SECRET / OBLODAI_BASE_URL
let client = oblodai::Client::from_env()?;
```
## Быстрый старт
```rust
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`.
```rust
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 несут машиночитаемый код.
```rust
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 с).
```rust
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` в параметрах создающего вызова — оно уйдёт в заголовок и **не попадёт в тело**:
```rust
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`]:
```toml
[dependencies]
oblodai = { version = "1", default-features = false }
```
```rust
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();
```
## Обзор методов
```rust
// Платежи
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.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`.
```rust
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