API личного аккаунта

Account API v1: подключение ваших серверных приложений.

Редакция от 10 сентября 2026 г.

Подключение получателя к вашему сайту

Функция доступна всем активным пользователям API без заявки администратору. Уже выпущенный Account API токен не нужно менять. Его права не расширяются: для чтения связей и расчёта нужны существующие transfers:read, для выплаты transfers:write и включённая 2FA владельца.

Для управления API включите двухфакторную защиту. После входа повторный пароль/код не требуются. Один раз зарегистрируйте приложение: название и точный HTTPS callback. Регистрация, смена callback или секрета и отключение приложения требуют включённой 2FA владельца. Пока callback неизвестен, приложение автоматически не создаётся. Получите отдельные client_id и client_secret; секрет показывается только сейчас. Это не финансовый API-ключ.

Callback допускает только HTTPS и ASCII DNS-домен, без IP, localhost, wildcard, userinfo, query, fragment и явного порта. Регистрируйте конечный обработчик, не endpoint переадресации. Для разработки используйте отдельный HTTPS домен. Сопоставление побайтовое, включая путь и завершающий слеш. Домен на экране согласия берётся из callback; название указано владельцем и не означает проверку Amypay.

  1. На своём сервере создайте случайные state и PKCE verifier, привяжите их к авторизованной сессии пользователя вашего сайта и сроку до 10 минут. Отправьте браузер на GET /oauth/payout/authorize с параметрами ниже.
  2. Пользователь входит в Amypay, завершает 2FA входа и явно разрешает или отклоняет подключение. Старый API bearer не авторизует получателя. Автоматического согласия нет даже при повторном подключении.
  3. В callback сначала сравните state с сохранённым через constant-time compare, проверьте срок и ту же локальную сессию. Одноразово удалите intent. При error не обменивайте code. Не доверяйте connection_id из браузерного запроса.
  4. Только сервер выполняет POST token с HTTP Basic и PKCE verifier. Код действует 60 секунд и обменивается один раз; неправильный PKCE его не расходует. Повтор после неизвестного результата обмена требует нового согласия, refresh token нет.
  5. Сервер вызывает wallets, сохраняет выданный connection_id вместе с app_id и ID пользователя собственного сайта. Ответ не является удостоверением личности или глобальным SSO.
GET /oauth/payout/authorize?
client_id=<CLIENT_ID>&redirect_uri=<URL_ENCODED_REGISTERED_CALLBACK>
&response_type=code&scope=payoutwallets%3Aread
&state=<RANDOM_SESSION_BOUND_STATE>
&code_challenge=<BASE64URL_SHA256_VERIFIER_NO_PADDING>
&code_challenge_method=S256

Передавайте параметры одной URL-строкой. state: 16–256 символов, рекомендуется 32 случайных байта в base64url. verifier: 43–128 символов RFC 7636. Только S256. State возвращается и при отказе. Невалидный callback не вызывает redirect, ответ содержит безопасный OAuth error.

cURL: только на сервере

Переменные ниже поступают из менеджера секретов и проверенной серверной сессии, не из браузерного JavaScript. Отключите shell tracing и журналирование request/response. В production избегайте секретов в аргументах процессов: используйте HTTP-клиент либо защищённый конфиг cURL.

curl --request POST "$AMY_ORIGIN/oauth/payout/token" \
  --user "$CLIENT_ID:$CLIENT_SECRET" \
  --data-urlencode 'grant_type=authorization_code' \
  --data-urlencode "code=$CODE" \
  --data-urlencode "redirect_uri=$REGISTERED_CALLBACK" \
  --data-urlencode "code_verifier=$SESSION_VERIFIER"

# Access token хранится только на сервере, TTL 1800 секунд.
curl "$AMY_ORIGIN/oauth/payout/wallets" \
  --header "Authorization: Bearer $LINK_ACCESS_TOKEN"
{"subject":"<PAIRWISE_ID>","connection_id":"<SAME_PAIRWISE_ID>",
 "wallets":[{"wallet_tag":"<WALLET_TAG>","currency":"RUB"}]}

Только адреса и валюты: нет email, имён, баланса, истории или profile scope. Токен фиксированного scope payoutwallets:read привязан к одному приложению и одной связи, не работает в Account API и не может списывать деньги. Ответы не кешируются.

PHP: state и PKCE на сервере

Эти функции вызываются только после входа в ваш сайт. Сессию настройте Secure, HttpOnly, SameSite=Lax; $localUserId берите из своего auth middleware. AMY_ORIGIN, callback и credentials задаются серверной конфигурацией, не HTTP-параметрами. Функция callback возвращает данные только вашему серверному коду: сохраните связь и перенаправьте браузер на собственную страницу результата, не выводите token JSON.

function b64url(string $bytes): string {
    return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
}
function startLink(string $localUserId): never {
    $v = b64url(random_bytes(32)); $state = b64url(random_bytes(32));
    $_SESSION['amy_link'] = ['uid'=>$localUserId, 'v'=>$v,
        'state'=>$state, 'expires'=>time()+600];
    $q = ['client_id'=>getenv('AMY_CLIENT_ID'),
        'redirect_uri'=>getenv('AMY_CALLBACK'), 'response_type'=>'code',
        'scope'=>'payoutwallets:read', 'state'=>$state,
        'code_challenge'=>b64url(hash('sha256', $v, true)),
        'code_challenge_method'=>'S256'];
    header('Location: '.getenv('AMY_ORIGIN').'/oauth/payout/authorize?'.
        http_build_query($q, '', '&', PHP_QUERY_RFC3986)); exit;
}
function finishLink(string $localUserId): array {
    $p = $_SESSION['amy_link'] ?? null;
    unset($_SESSION['amy_link']);
    if (!$p || $p['uid'] !== $localUserId || $p['expires'] <= time()
        || !is_string($_GET['state'] ?? null)
        || !hash_equals($p['state'], $_GET['state'])) {
        throw new RuntimeException('Invalid linking state');
    }
    if (isset($_GET['error']) || !is_string($_GET['code'] ?? null)) {
        throw new RuntimeException('Linking not approved');
    }
    $c = curl_init(getenv('AMY_ORIGIN').'/oauth/payout/token');
    curl_setopt_array($c, [CURLOPT_POST=>true, CURLOPT_RETURNTRANSFER=>true,
        CURLOPT_FOLLOWLOCATION=>false, CURLOPT_TIMEOUT=>15,
        CURLOPT_USERPWD=>getenv('AMY_CLIENT_ID').':'.getenv('AMY_CLIENT_SECRET'),
        CURLOPT_POSTFIELDS=>http_build_query(['grant_type'=>'authorization_code',
            'code'=>$_GET['code'], 'redirect_uri'=>getenv('AMY_CALLBACK'),
            'code_verifier'=>$p['v']])]);
    $raw = curl_exec($c); $status = curl_getinfo($c, CURLINFO_RESPONSE_CODE);
    curl_close($c);
    if ($raw === false || $status !== 200) throw new RuntimeException('Exchange failed');
    $token = json_decode($raw, true, 16, JSON_THROW_ON_ERROR)['access_token'];
    $c = curl_init(getenv('AMY_ORIGIN').'/oauth/payout/wallets');
    curl_setopt_array($c, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>15,
        CURLOPT_FOLLOWLOCATION=>false,
        CURLOPT_HTTPHEADER=>['Authorization: Bearer '.$token]]);
    $raw = curl_exec($c); $status = curl_getinfo($c, CURLINFO_RESPONSE_CODE);
    curl_close($c);
    if ($raw === false || $status !== 200) throw new RuntimeException('Wallet read failed');
    return json_decode($raw, true, 16, JSON_THROW_ON_ERROR);
}

Python: серверные обработчики

session ниже должна быть серверным хранилищем, а не cookie с открытым содержимым. Подключите функции к auth middleware своего framework; callback также не должен возвращать access token браузеру.

import base64, hashlib, hmac, os, secrets, time
from urllib.parse import urlencode
import requests

ORIGIN = os.environ['AMY_ORIGIN']
CLIENT = os.environ['AMY_CLIENT_ID']
SECRET = os.environ['AMY_CLIENT_SECRET']
CALLBACK = os.environ['AMY_CALLBACK']

def start_link(session, local_user_id):
    verifier, state = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
    session['amy_link'] = dict(uid=local_user_id, verifier=verifier,
                              state=state, expires=time.time()+600)
    challenge = base64.urlsafe_b64encode(
        hashlib.sha256(verifier.encode('ascii')).digest()).rstrip(b'=').decode('ascii')
    return ORIGIN + '/oauth/payout/authorize?' + urlencode(dict(
        client_id=CLIENT, redirect_uri=CALLBACK, response_type='code',
        scope='payoutwallets:read', state=state,
        code_challenge=challenge, code_challenge_method='S256'))

def finish_link(session, local_user_id, query):
    p = session.pop('amy_link', None)
    state = query.get('state')
    if (not p or p['uid'] != local_user_id or p['expires'] <= time.time()
            or not isinstance(state, str)
            or not hmac.compare_digest(p['state'].encode(), state.encode())):
        raise ValueError('Invalid linking state')
    if 'error' in query or not isinstance(query.get('code'), str):
        raise ValueError('Linking not approved')
    r = requests.post(ORIGIN+'/oauth/payout/token', auth=(CLIENT, SECRET),
        data=dict(grant_type='authorization_code', code=query['code'],
                  redirect_uri=CALLBACK, code_verifier=p['verifier']),
        timeout=15, allow_redirects=False)
    if r.status_code != 200:
        raise ValueError('Exchange failed')
    token = r.json()['access_token']  # server memory only
    r = requests.get(ORIGIN+'/oauth/payout/wallets',
        headers={'Authorization': 'Bearer '+token}, timeout=15,
        allow_redirects=False)
    if r.status_code != 200:
        raise ValueError('Wallet read failed')
    return r.json()  # persist connection_id mapped to local_user_id and CLIENT

Чтение после 30 минут и выплата

Refresh token не нужен: согласие хранится до отключения. Собственный Account API токен проекта с transfers:read читает связь через POST /api/account/v1/connections/wallets. JSON: {"app_id":"CLIENT_ID","connection_id":"CONNECTION_ID"}. Ответ: обычный Account API envelope {"ok":true,"data":{...}} с теми же минимальными полями. Не передавайте финансовый токен получателя.

POST /api/account/v1/transfers/quote
Authorization: Bearer <PROJECT_OWNER_ACCOUNT_API_TOKEN>
Content-Type: application/json

{"app_id":"<CLIENT_ID>","connection_id":"<CONNECTION_ID>",
 "currency":"RUB","amount":"100.25","comment":"Выплата"}

Для создания отправьте поля котировки без recipient и expires_at в POST /api/account/v1/transfers с Idempotency-Key. Не добавляйте wallet_tag: варианты строго взаимоисключающие. Котировка фиксирует фактический кошелёк и версию согласия. Отключение, повторное согласие, смена callback/секрета или отзыв приложения до списания делают старую котировку непригодной. Чужой владелец ключа не может использовать связь. Приложения одного владельца делят его финансовые полномочия; это не изоляция отдельных финансовых ключей по app_id.

Успешный idempotent replay возвращает прежний результат даже после отключения, не создавая новый платёж. Новый запрос по отключённой связи отклоняется. Старые переводы по wallet_tag не меняются: известный адрес не отзывается отключением сайта. Риск-ограничения, 2FA, срок финансового токена, комиссии и денежные лимиты остаются штатными. Исключение API-переводов из накопления риска не меняется.

Журналы callback на вашем сервере

Обязательно исключите code, state, Authorization, тело запроса, Location и Referer из журналов callback. Защита маршрутов Amypay не распространяется на сервер вашего сайта. Проверьте отдельно reverse proxy, web server, WAF, APM, framework и обработчики ошибок. Не включайте debug и трассировку HTTP-клиента. Ответ callback должен содержать Cache-Control: no-store и Referrer-Policy: no-referrer, затем переходить на чистый локальный URL без code/state.

Nginx: формат определяется в http, а отдельный callback location должен сам отправлять запрос в ваш обработчик, не делать внутренний переход в общий location с combined/error log. Пример ниже для PHP callback; замените путь и socket своими. error_log /dev/null подавляет подробности только callback; сохраняйте безопасные счётчики, статусы и время, а ошибки приложения журналируйте без параметров. Остальные payment/audit logs не отключайте.

# http context
log_format callback_safe '$request_method $uri $server_protocol $status $request_time';
# Inside the HTTPS server, not the global PHP location:
location = /amy/callback {
    access_log /var/log/nginx/callback-safe.log callback_safe;
    error_log /dev/null crit;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME /srv/project/public/callback.php;
    fastcgi_param SCRIPT_NAME /amy/callback;
    fastcgi_param HTTP_PROXY "";
    fastcgi_pass unix:/run/php/project.sock;
    add_header Cache-Control no-store always;
    add_header Referrer-Policy no-referrer always;
}

Apache: используйте отдельный HTTPS virtual host только для callback, чтобы не отключать error log остальных функций. %U не включает query, в отличие от %r и %q. Удалите из этого virtual host другие CustomLog/TransferLog и проверьте глобальные GlobalLog. Подключение TLS и PHP/FastCGI остаётся вашим штатным, не используйте пример как замену всему конфигу.

# Dedicated callback VirtualHost; existing TLS and PHP handler required.
CustomLog /var/log/apache2/callback-safe.log "%m %U %H %>s %D"
ErrorLog /dev/null
Header always set Cache-Control "no-store"
Header always set Referrer-Policy "no-referrer"
# Do not add %{Referer}i, %{Authorization}i, %r, %q or body tracing.

Проверяйте конфигурацию синтетическим уникальным маркером в query, Referer, Authorization и теле запроса. После штатного отказа и искусственной upstream-ошибки маркер не должен встречаться ни в access, ни в error/application logs. Сам безопасный статус запроса должен оставаться видимым. Конфигурацию чужого сайта Amypay проверить автоматически не может.

Отключение и ошибки

Пользователь отзывает согласие в подключённых сайтах. POST /oauth/payout/revoke с HTTP Basic и form-полем token удаляет только указанный access token, не постоянное согласие. Неизвестный или чужой токен даёт одинаковый пустой успешный ответ. Владелец отключает приложение или меняет секрет/callback в настройках при включённой 2FA, без повторного пароля или кода; все его прежние связи требуют нового согласия. При отключённой 2FA владельцу сначала нужно включить защиту, в том числе для отключения приложения. Это условие управления не добавляет требований к согласию получателя или отключению подключённого сайта.

OAuth ошибки: invalid_request, invalid_client (401), invalid_grant, unsupported_grant_type, invalid_token (401), rate_limited (429). Account API также возвращает connection_unavailable и quote_changed. Token endpoint: IP 120/мин, credentials 20/мин, успешно найденное приложение 60/мин. Чтение связей Account API расходует общий owner recipient budget; quote и create используют прежние отдельные бюджеты. Нет CORS-разрешений для браузерного обмена.

Доступ без согласования

API доступен каждому активному пользователю. Касса, одобрение администратора и подтверждение личности не требуются. Создайте токен в настройках аккаунта, задайте срок, права и при необходимости IP-ограничения. По умолчанию срок — 30 дней; можно явно выбрать «Бессрочно». Секрет показывается только один раз. Бессрочный токен не истекает: самостоятельно отзовите его в настройках, если доступ больше не нужен или секрет мог попасть к посторонним. Базовый путь: /api/account/v1 на HTTPS-домене Amypay.

В настройках можно продлить действующий или истёкший, но не отозванный токен, без замены Bearer. Требуется включённая 2FA, без повторного подтверждения после входа. Добавляются 7, 30, 90 или 365 дней к более поздней из дат: сейчас / текущий срок. Явное «Бессрочно» убирает срок; уже бессрочный токен продлевать не требуется (запрос вернёт ошибку). Возобновление истёкшего токена учитывает максимум 20 действующих. Секрет, публичный ID, права и IP не меняются, секрет повторно не показывается. Отозванный токен восстановить нельзя.

Authorization: Bearer <ACCOUNT_API_TOKEN>
Content-Type: application/json

Полный секрет показывается один раз, непосредственно после создания. На сервере хранится только SHA-256; секрет нельзя восстановить. Не передавайте токен в URL, браузерный JavaScript, логи, репозитории или поддержку. Ключи касс и мобильные токены здесь не работают; этот токен не работает вместо них. Смена пароля не заменяет отзыв токенов: при подозрении на утечку отзовите скомпрометированный токен отдельно.

Для управления API включите двухфакторную защиту. После входа повторный пароль/код не требуются. Создание любых токенов, включая только чтение, продление и отзыв доступны только при включённой 2FA. Если защита отключена, перед отзывом её необходимо включить. Существующие токены не изменяются и автоматически не отзываются. Права withdrawals:write и transfers:write требуют включённой 2FA и перестают работать после её отключения. Использование остальных прав существующих токенов не требует 2FA. Bearer-запросы не требуют пароля или одноразовых кодов. Изменение прав и IP существующего токена не предусмотрено: создайте замену и отзовите старый. Новые права не выдаются старым токенам автоматически. Сроки: 7, 30, 90 или 365 дней; максимум 20 действующих токенов.

IP-список поддерживает IPv4, IPv6 и CIDR, до 20 записей. Пустой список снимает IP-ограничение. Используется адрес, определённый сервером с учётом доверенных прокси. API предназначен для серверных интеграций: CORS-разрешений нет, cookies не авторизуют API. Формы управления токенами защищены CSRF.

Методы и права

Метод и путьПраво токена
GET /balancesbalances:read
GET /historyhistory:read
GET /withdrawal-methodswithdrawals:read
POST /withdrawals/quotewithdrawals:read
POST /withdrawalswithdrawals:write
GET /withdrawals/{id}withdrawals:read
POST /withdrawals/{id}/cancelwithdrawals:write
POST /transfers/recipienttransfers:read
POST /connections/walletstransfers:read
POST /transfers/quotetransfers:read
POST /transferstransfers:write
GET /profileprofile:read
PUT /profileprofile:write
GET /notification-preferencesnotifications:read
PUT /notification-preferencesnotifications:write

Балансы, история и профиль всегда принадлежат владельцу токена. Параметры user_id, merchant_id, wallet_id не принимаются; чужая заявка вывода возвращает 404. Проверка получателя перевода возвращает только ограниченные поля, описанные ниже, без чужих балансов. API не позволяет менять пароль, email, телефон, 2FA, ключи API или настройки безопасности. Управление кассами не входит в v1.

GET /balances возвращает items с полями currency, balance, held, available. Денежные значения всегда строки. GET /history возвращает операции кошелька, включая резервирование и освобождение резерва: id, type, amount, currency, balance_after, created_at. Реквизиты, комментарии с персональными данными и данные касс не выдаются.

История сортируется по убыванию ID. Параметры: limit от 1 до 100 (по умолчанию 20), before_id, точные фильтры currency и type, from включительно и to исключительно в Unix-секундах. Для следующей страницы передайте next_before_id как before_id, сохранив фильтры; null означает конец.

Здесь amount отражает движение по балансу в журнале. У hold/release оно может быть нулевым: резерв меняет доступность средств, а не общий баланс. balance_after не является доступным остатком; для него используйте available из /balances.

GET /profile возвращает только first_name и last_name. PUT /profile заменяет оба поля: строки до 100 символов, пустые допустимы. Настройки уведомлений: email_notifications (boolean) и объект preferences. Для PUT обязательны все поля из GET, с настоящими JSON boolean, не строками. Это только предпочтения, не отправка уведомлений.

{"first_name":"Анна","last_name":"Иванова"}

Ключи preferences: login_new_ip, withdraw_created, withdraw_done, password_changed, order_paid, transfer_in, deposit_done, p2p_deal, service_order, sbp_qr_payment. Полная схема запроса и ответа: OpenAPI JSON.

Вывод на любые поддерживаемые реквизиты

Сначала запросите GET /withdrawal-methods: items содержит код метода, валюту, точность, допустимые исходные валюты, лимиты, необходимость банка и регулярное выражение реквизитов. banks содержит актуальные коды банков. Включённый метод может оказаться временно недоступен у провайдера; окончательная проверка выполняется при расчёте и создании.

Сохранённые получатели не требуются. Передавайте destination нужного формата, а для метода с needs_bank=true также bank_code из справочника. Адрес криптовалюты должен соответствовать выбранной сети. Обычные проверки риска, реквизитов, комиссии, средств и лимитов не обходятся. Финансовый риск не блокирует просмотр балансов, истории и профиля.

  1. Отправьте POST /withdrawals/quote с method_code, source_currency, amount, destination и при необходимости bank_code.
  2. Проверьте ответ: quote_id, expires_at, expected_fee, expected_payout и параметры вывода. Расчёт действует 120 секунд. Для RUB сумма целая; для других валют точность указана методом. Экспоненты, числа JSON, запятые, лишние знаки и суммы с более чем 14 значащими цифрами в минимальных единицах отклоняются.
  3. Создайте заявку через POST /withdrawals с теми же полями, quote_id, expected_fee, expected_payout и заголовком Idempotency-Key. Не передавайте служебные currency и expires_at из ответа расчёта. При изменении комиссии нужен новый расчёт, новое подтверждение и новый ключ операции.
  4. Ответ 201 содержит снимок созданной заявки и Location. Актуальное состояние проверяйте через GET /withdrawals/{id}. pending не означает выплату. Для отмены отправьте POST /withdrawals/{id}/cancel с телом {}; отмена разрешена только пока обычный сервис вывода допускает её.

Примеры серверной интеграции

Примеры не выполняются на странице. BASE задайте как HTTPS-адрес вашего сервиса с путём /api/account/v1, ACCOUNT_API_TOKEN загрузите из менеджера секретов. Код метода и реквизиты ниже задаются вами после чтения справочника. Не включайте HTTP debug-логи.

cURL и jq

# input.json: method_code, source_currency, amount (string), destination, bank_code if needed
# Protect local files containing destination data. Never put the token in these files.
quote=$(curl --fail-with-body -sS "$BASE/withdrawals/quote" \
  -H "Authorization: Bearer $ACCOUNT_API_TOKEN" -H 'Content-Type: application/json' \
  --data-binary @input.json)
printf '%s' "$quote" | jq '.data | {method_code,source_currency,amount,destination,bank_code,quote_id,expected_fee,expected_payout}' > confirmed.json
# Inspect expected_fee and expected_payout and explicitly approve before proceeding.
# Generate a unique operation ID once; persist it with confirmed.json for retries.
curl --fail-with-body -sS "$BASE/withdrawals" \
  -H "Authorization: Bearer $ACCOUNT_API_TOKEN" -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $OPERATION_ID" --data-binary @confirmed.json
# For a known withdrawal ID, poll status rather than submitting another withdrawal.
curl --fail-with-body -sS "$BASE/withdrawals/$WITHDRAWAL_ID" \
  -H "Authorization: Bearer $ACCOUNT_API_TOKEN"

Python (requests)

import os, requests

base = os.environ["BASE"].rstrip("/")
session = requests.Session()
session.headers["Authorization"] = "Bearer " + os.environ["ACCOUNT_API_TOKEN"]

def api(method, path, **kwargs):
    response = session.request(method, base + path, timeout=30, allow_redirects=False, **kwargs)
    response.raise_for_status()
    return response.json()["data"]

# These values must come from your integration, not floating-point calculations.
params = {"method_code": os.environ["METHOD_CODE"],
          "source_currency": os.environ["SOURCE_CURRENCY"],
          "amount": os.environ["AMOUNT_DECIMAL"],
          "destination": os.environ["DESTINATION"],
          "bank_code": os.environ.get("BANK_CODE", "")}
quote = api("POST", "/withdrawals/quote", json=params)
payload = {key: quote[key] for key in (
    "method_code", "source_currency", "amount", "destination", "bank_code",
    "quote_id", "expected_fee", "expected_payout")}
# Explicitly approve fee/payout, then persist payload + operation_id in your job store.
operation_id = os.environ["OPERATION_ID"]  # same value and payload on every retry
withdrawal = api("POST", "/withdrawals", json=payload,
                 headers={"Idempotency-Key": operation_id})
current = api("GET", "/withdrawals/" + withdrawal["id"])

PHP (cURL)

$base = rtrim(getenv('BASE'), '/');
$token = getenv('ACCOUNT_API_TOKEN');
$api = static function (string $method, string $path, ?array $body = null,
                        ?string $key = null) use ($base, $token): array {
    $curl = curl_init($base . $path);
    $headers = ['Authorization: Bearer ' . $token, 'Content-Type: application/json'];
    if ($key !== null) $headers[] = 'Idempotency-Key: ' . $key;
    curl_setopt_array($curl, [CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 30, CURLOPT_FOLLOWLOCATION => false]);
    if ($body !== null) curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($body, JSON_THROW_ON_ERROR));
    $raw = curl_exec($curl);
    $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    if ($raw === false || $status < 200 || $status >= 300) {
        throw new RuntimeException('API request failed; reconcile or retry the same operation.');
    }
    return json_decode($raw, true, 512, JSON_THROW_ON_ERROR)['data'];
};
$quote = $api('POST', '/withdrawals/quote', [
    'method_code' => getenv('METHOD_CODE'), 'source_currency' => getenv('SOURCE_CURRENCY'),
    'amount' => getenv('AMOUNT_DECIMAL'), 'destination' => getenv('DESTINATION'),
    'bank_code' => getenv('BANK_CODE') ?: '',
]);
$payload = array_intersect_key($quote, array_flip(['method_code', 'source_currency',
    'amount', 'destination', 'bank_code', 'quote_id', 'expected_fee', 'expected_payout']));
// Approve fee/payout. Persist this exact payload and a unique operation ID before submission.
$withdrawal = $api('POST', '/withdrawals', $payload, getenv('OPERATION_ID'));
$current = $api('GET', '/withdrawals/' . $withdrawal['id']);

Перевод другому пользователю

Успешные переводы через Account API не увеличивают риск ни суммой, ни частотой и не учитываются задним числом в частоте обычного вывода или оплаты услуг. Это серверная политика, клиентского поля для отключения риска нет. Ручные блокировки, уже высокий риск и ограничения получателя сохраняются; существующий риск не сбрасывается. Веб- и мобильные переводы, внешние выводы и остальные денежные операции сохраняют обычную оценку риска. Суточный денежный лимит не отменён: по умолчанию 100 000 RUB в эквиваленте для каждой валюты по действующему тарифу; настройка API-частоты его не меняет.

Нужны права transfers:read для проверки и расчёта и transfers:write для отправки. Используйте существующий адрес кошелька wallet_tag, полученный непосредственно от получателя. Поиск по email, телефону, имени или ID пользователя не поддерживается. Перевод самому себе запрещён. Применяются обычные ограничения отправителя и получателя, доступности переводов, средств, комиссии и суточного лимита.

  1. POST /transfers/recipient с {"wallet_tag":"AMY12345678RUB"}. Адрес здесь условный. Ответ содержит только wallet_tag, currency, decimals, display_name, masked_email, saved. Имя сокращено, email замаскирован; saved относится к списку самого отправителя. Эти поля доступны только для чтения.
  2. POST /transfers/quote: обязательны wallet_tag, amount, необязателен comment (строка до 255 байт, без управляющих символов). Деньги передаются строками без экспоненты и запятых. Точность определяется валютой кошелька: RUB до 2 знаков, TRX и стейблкоины до 6, остальные до 8; до 14 цифр в минимальных единицах.
  3. Проверьте recipient, currency, amount, expected_fee и expected_net. amount полностью списывается с отправителя; комиссия удерживается из этой суммы; получатель получает expected_net. Расчёт действует 120 секунд и связан с владельцем, действием перевода, конкретным кошельком получателя, валютой, курсом, тарифом и параметрами.
  4. POST /transfers: передайте wallet_tag, amount, currency, quote_id, expected_fee, expected_net и тот же comment, если был задан. Обязателен Idempotency-Key. Не передавайте recipient и expires_at. Ответ 201 со статусом done означает атомарно завершённый перевод: id, wallet_tag, currency, amount, fee, net_amount, status, created_at. Отдельного метода отмены или статуса нет; результат восстанавливается повтором того же запроса, движения видны в обычных балансах и истории (transfer_out/transfer_in).

Неверный или недоступный адрес при проверке/расчёте: 404 recipient_unavailable. При отправке блокировка получателя: 422 recipient_unavailable; отключённые переводы: transfer_unavailable; суточный лимит: daily_limit; недостаток средств: insufficient_funds. Риск отправителя: 403 risk_restricted, но чтение своих данных остаётся доступным. Истёкший или чужой расчёт: 422 invalid_quote; несовпадение параметров, получателя, курса или тарифа: 409 quote_changed. Нужны новый расчёт, подтверждение и новый ключ, только если предыдущий результат уже известен.

cURL и jq: перевод

# BASE=https://amypay.ru/api/account/v1; WALLET_TAG supplied by recipient
curl --fail-with-body -sS "$BASE/transfers/recipient" \
  -H "Authorization: Bearer $ACCOUNT_API_TOKEN" -H 'Content-Type: application/json' \
  --data "$(jq -nc --arg tag "$WALLET_TAG" '{wallet_tag:$tag}')"
quote=$(curl --fail-with-body -sS "$BASE/transfers/quote" \
  -H "Authorization: Bearer $ACCOUNT_API_TOKEN" -H 'Content-Type: application/json' \
  --data "$(jq -nc --arg tag "$WALLET_TAG" --arg amount "$AMOUNT_DECIMAL" '{wallet_tag:$tag,amount:$amount}')")
# Verify recipient, currency, full debit, fee and net; explicitly approve.
printf '%s' "$quote" | jq '.data | {wallet_tag,amount,comment,currency,quote_id,expected_fee,expected_net}' > transfer-confirmed.json
# Persist this exact payload and one unique OPERATION_ID before the first attempt.
curl --fail-with-body -sS "$BASE/transfers" \
  -H "Authorization: Bearer $ACCOUNT_API_TOKEN" -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $OPERATION_ID" --data-binary @transfer-confirmed.json

Python: перевод

Использует функцию api из примера Python выше, вместо блока вывода.

recipient = api("POST", "/transfers/recipient",
                json={"wallet_tag": os.environ["WALLET_TAG"]})
quote = api("POST", "/transfers/quote", json={
    "wallet_tag": recipient["wallet_tag"], "amount": os.environ["AMOUNT_DECIMAL"]})
payload = {key: quote[key] for key in (
    "wallet_tag", "amount", "comment", "currency", "quote_id", "expected_fee", "expected_net")}
# Verify quote recipient/currency/debit/fee/net; approve, persist payload + OPERATION_ID.
transfer = api("POST", "/transfers", json=payload,
               headers={"Idempotency-Key": os.environ["OPERATION_ID"]})
assert transfer["status"] == "done"

PHP: перевод

Использует функцию $api из примера PHP выше, вместо блока вывода.

$recipient = $api('POST', '/transfers/recipient', ['wallet_tag' => getenv('WALLET_TAG')]);
$quote = $api('POST', '/transfers/quote', [
    'wallet_tag' => $recipient['wallet_tag'], 'amount' => getenv('AMOUNT_DECIMAL'),
]);
$payload = array_intersect_key($quote, array_flip(['wallet_tag', 'amount', 'comment',
    'currency', 'quote_id', 'expected_fee', 'expected_net']));
// Verify recipient/currency/debit/fee/net; approve and persist payload + OPERATION_ID.
$transfer = $api('POST', '/transfers', $payload, getenv('OPERATION_ID'));

Ошибки, лимиты и безопасные повторы

{"ok":true,"data":{...}}
{"ok":false,"error":{"code":"insufficient_funds","message":"insufficient_funds"}}

Коды HTTP: 400 некорректный JSON; 401 отсутствующий, истёкший, отозванный токен или неактивный аккаунт; 403 недостаточные права, IP, 2FA или финансовый риск; 404 заявка не найдена; 409 конфликт операции или условий; 413 тело более 8192 байт; 415 нужен application/json; 422 ошибка полей или обычной проверки вывода; 429 лимит; 500 внутренняя ошибка. Ориентируйтесь на error.code, а не текст. Неизвестные и лишние поля отклоняются; для POST/PUT query-параметры запрещены.

Базовые лимиты в минуту: 600 запросов на IP, включая неавторизованные; 300 на токен; отдельные бюджеты аккаунта: 120 проверок получателя, 120 расчётов перевода, 60 попыток создания перевода. Только /transfers/recipient расходует бюджет проверки получателя. Расчёт, создание и отмена вывода сохраняют отдельные общие 10 запросов в минуту на аккаунт. API не расходует лимиты веб-переводов. Все токены одного владельца делят бюджеты действий, независимо от IP; IP-бюджет делят все аккаунты с этого адреса. Чтение балансов не расходует бюджеты переводов, но общий токен/IP-бюджет действует на каждый запрос, включая повторы.

Администратор может изменить базовые и персональные лимиты от 1 до 6000 запросов/мин; персональные значения не заменяют общий IP-потолок. Это лимиты запросов, не гарантия числа завершённых переводов. Например, 60 переводов с проверкой получателя и расчётом требуют 180 запросов, плюс чтения и повторы. Действует самый узкий применимый бюджет, а также финансовые ограничения. Окна фиксированные, по 60 секунд. Retry-After: 60 при 429 является консервативным ожиданием полного окна, а не точным оставшимся временем. Управление токенами: 10 попыток на аккаунт и 30 на IP за 10 минут. Ответы API не кешируются; поддержке сообщайте X-Request-Id, не токен и не тело запроса.

Очередь на стороне интеграции

Серверная массовая очередь этим API не предоставляется. Храните задания у себя в надёжной БД, ограничивайте параллелизм и согласуйте бюджеты с администратором. Проверку получателя можно сделать заранее, а расчёт на 120 секунд получайте непосредственно перед отправкой, не для всей длинной очереди сразу. До первого create атомарно сохраните ключ, точный payload и состояние задания. На 429, тайм-ауте и 500 сохраняйте тот же ключ и payload, учитывайте Retry-After и добавляйте случайную задержку. После перезапуска продолжайте из сохранённого состояния.

Не повторяйте 422 вслепую. Если известен окончательный отказ invalid_quote или quote_changed, завершите старую попытку и получите новый расчёт с новым ключом после проверки условий. При неизвестном результате сначала восстановите его тем же ключом и старым payload, даже если расчёт истёк. Нельзя подменять payload новым расчётом под старым ключом или создавать новый ключ после тайм-аута: это риск двойной выплаты.

# Python: one durable create attempt; db_job already contains immutable key + payload.
# Queue storage, leases and transaction commits belong to your application.
import random
# Reuse base and authenticated session from the Python example above.
try:
    response = session.post(base + "/transfers", json=db_job.payload,
        headers={"Idempotency-Key": db_job.key}, timeout=30, allow_redirects=False)
except requests.RequestException:
    queue.defer_same_attempt(db_job.id, delay=60 + random.uniform(0, 5))
else:
    if response.status_code == 429 or response.status_code >= 500:
        wait = max(60, int(response.headers.get("Retry-After", "60")))
        queue.defer_same_attempt(db_job.id, delay=wait + random.uniform(0, 5))
    elif response.status_code == 201:
        queue.record_success(db_job.id, response.json()["data"])
    else:
        queue.record_failure_for_review(db_job.id, response.status_code,
            response.json().get("error", {}).get("code"))
# Log job ID / X-Request-Id only, never Authorization or financial payload.

Idempotency-Key: 16–128 символов A–Z a–z 0–9 _ . : -. Область уникальности: аккаунт + вид операции Account API v1 (создание вывода или перевода), не отдельный токен. Замена токена не создаёт новую область. Тот же ключ и тот же нормализованный payload возвращают сохранённый результат с Idempotency-Replayed: true, даже после истечения расчёта. Другой payload возвращает 409 idempotency_conflict. Сохранённые отказы тоже воспроизводятся; исправленная операция требует нового ключа и при необходимости нового расчёта. Для повтора нужны действующий токен с правом записи и включённая 2FA. Котировку вывода нельзя использовать для перевода и наоборот.

При тайм-ауте, потере соединения или 500 не создавайте новый ключ: повторите сохранённый запрос с тем же ключом после задержки. Если ID уже известен, сначала запросите статус. Не пересчитывайте quote автоматически в повторе неизвестного результата. Отзыв токена прекращает новые запросы, но не отменяет уже принятую заявку. Для отмены используйте отдельный метод. Сбой между фиксацией заявки и запуском обработки может оставить её ожидающей обычной обработки; повтор не запускает провайдера заново.