Аутентификация
Все запросы к API (кроме health-check) требуют два заголовка:
| Header | Значение |
|---|---|
X-Api-Key |
Публичный идентификатор мерчанта. Строка, начинающаяся с xh_live_ (продакшн) или xh_test_ (sandbox) |
X-Api-Secret |
Секрет мерчанта (64 hex символа) |
При невалидных ключах (в т.ч. без префикса xh_live_/xh_test_) — 401 Unauthorized.
Где хранить ключи
api_key— можно логировать, не секретapi_secret— только на твоём бэкенде в env/secret manager, не в gitwebhook_secret— только на твоём бэкенде, для проверки HMAC подписи webhook'ов
Правила хранения
- Никогда не клади в git
- Никогда не отдавай клиенту (браузер/мобилка)
- Не пересылай в чатах
- Логируй только
api_key, неapi_secret
Idempotency-Key
Обязательно для всех POST запросов, которые создают ресурсы.
Формат: UUID (любая версия, RFC 4122). Не-UUID строка → 400 VALIDATION_ERROR ("Idempotency-Key must be a valid UUID").
Как работает
- При первом вызове — создаётся новый ресурс
- Повторный вызов с тем же ключом вернёт исходный ресурс (без дубликата) — в любой момент: уникальность ключа вечная в рамках пары (аккаунт, режим test/live). Статус-код при создании —
201, при idempotent-повторе —200 - Срока годности у ключа нет: повтор с тем же ключом хоть через месяц вернёт исходный ресурс, а не создаст новый
- Тело запроса при повторе не сверяется — возвращается ответ первого вызова. Генерируй новый
Idempotency-Keyдля каждой логически новой операции - Идемпотентность изолирована между режимами: тот же ключ в тестовом (
xh_test_) и боевом (xh_live_) режимах — это два независимых платежа
Зачем
Предотвращает двойное списание при сетевых сбоях. Если твой POST упал по таймауту — ретрай с тем же ключом безопасен.
Плохо и хорошо
// ❌ НЕПРАВИЛЬНО: генерируем UUID на каждый retry
async function createPayment(amount) {
return fetch('...', {
headers: { 'Idempotency-Key': crypto.randomUUID() }, // каждый retry = новый UUID → дубли
});
}
// ❌ НЕПРАВИЛЬНО: не-UUID строка
// 'Idempotency-Key': `order-${orderId}` → 400 VALIDATION_ERROR
// ✅ ПРАВИЛЬНО: UUID генерируется один раз на заказ,
// сохраняется рядом с заказом в своей БД и переиспользуется при ретраях
async function createPayment(orderId, amount) {
let idempotencyKey = await db.getIdempotencyKey(orderId);
if (!idempotencyKey) {
idempotencyKey = crypto.randomUUID();
await db.saveIdempotencyKey(orderId, idempotencyKey);
}
return fetch('...', {
headers: { 'Idempotency-Key': idempotencyKey }, // тот же UUID при ретраях того же заказа
});
}
Rate Limiting
Лимитов четыре, и они разбиты на две независимые корзины: «денежные» ручки (создание приёма денег) и всё остальное в /api/v1. Внутри каждой корзины отдельно считаются лимит на ключ и лимит на IP.
Денежными считаются ровно эти вызовы:
POST /paymentsPOST /exchangesPOST /subscriptionsPOST /subscriptions/{id}/charges
Список закрытый — денежная ровно эта четвёрка. Всё остальное идёт в общую корзину, включая вложенные пути: POST /refunds, POST /refunds/{id}/cancel, POST /subscriptions/{id}/cancel, POST /kyc/register.
| Корзина | Лимит | На что считается | Окно | Порог |
|---|---|---|---|---|
| Денежные ручки | Per-key | значение заголовка X-Api-Key |
60 сек | 120 запросов |
| Денежные ручки | Per-IP | IP-адрес, с которого пришёл запрос | 60 сек | 120 запросов |
Всё остальное в /api/v1 |
Per-key | значение заголовка X-Api-Key |
60 сек | 60 запросов |
Всё остальное в /api/v1 |
Per-IP | IP-адрес, с которого пришёл запрос | 60 сек | 300 запросов |
Корзины не сообщаются друг с другом. Шторм опроса статусов выедает только свои 60/300 и не может заблокировать создание платежа — ради этого разделение и сделано.
Для одного IP это ужесточение
Раньше денежные запросы делили общий IP-бюджет в 300 req/min. Теперь у них собственные 120 req/min на адрес. Если несколько твоих сервисов или несколько мерчант-аккаунтов создают платежи через один NAT/egress-адрес, вы упрётесь в лимит раньше, чем до этого изменения. Планируй нагрузку по созданию платежей отдельно от нагрузки по чтению.
При превышении любого — 429 Too Many Requests с кодом RATE_LIMIT_EXCEEDED. Различить, какой именно лимит сработал, можно по message и по значению X-RateLimit-Limit:
{"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "Too many requests for API. Limit: 300 per minute per IP."}}
{"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "Too many payment requests. Limit: 120 per minute. Retry with the same Idempotency-Key."}}
{"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "Too many payment requests from this IP. Limit: 120 per minute. Retry with the same Idempotency-Key."}}
Повтор отбитого денежного запроса безопасен: с тем же Idempotency-Key второй платёж не создастся.
Все лимиты работают ДО авторизации
Счётчики увеличиваются на каждом запросе, даже если ключ неверный, отозван или относится к неактивному режиму. Запрос, который в итоге получит 401, всё равно съедает квоту.
Практические следствия:
- Per-key лимит считается по строке ключа, а не по аккаунту. Тестовый (
xh_test_) и боевой (xh_live_) ключи — независимые бюджеты. После ротации ключа новый ключ стартует с чистым счётчиком. - Per-IP лимит — общий на весь исходящий IP. Если несколько твоих сервисов (или несколько мерчант-аккаунтов) ходят к нам через один NAT/egress-адрес, они делят одни и те же 300 req/min на чтение и одни и те же 120 req/min на создание платежей. Ретрай-штормы одного сервиса упрутся в лимит у соседнего.
Заголовки ответа (отдаём оба семейства — legacy и draft-6 standard) приходят на каждом ответе, не только на 429:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1776166800
RateLimit-Policy: 60;w=60
RateLimit-Limit: 60
RateLimit-Remaining: 42
RateLimit-Reset: 38
X-RateLimit-Reset— Unix timestamp (секунды с эпохи), когда окно сбрасываетсяRateLimit-Reset(draft-6) — секунды до сброса окнаRetry-After(секунды) — добавляется только к ответу429
Заголовки описывают тот лимит, до которого запрос дошёл последним. Порядок проверки: сначала IP-лимит своей корзины, затем key-лимит своей корзины. Для чтения в штатной ситуации это Limit: 60, для создания платежа — Limit: 120. Если запрос отбит IP-лимитом, до key-счётчика он не доходит — тогда в заголовках будет Limit: 300 (чтение) или Limit: 120 (создание платежа).
Если получил 429 — подожди Retry-After секунд (или до X-RateLimit-Reset) и ретрай.