Перейти к содержанию

Аутентификация

Все запросы к 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, не в git
  • webhook_secret — только на твоём бэкенде, для проверки HMAC подписи webhook'ов

Правила хранения

  • Никогда не клади в git
  • Никогда не отдавай клиенту (браузер/мобилка)
  • Не пересылай в чатах
  • Логируй только api_key, не api_secret

Idempotency-Key

Обязательно для всех POST запросов, которые создают ресурсы.

Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

Формат: 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 /payments
  • POST /exchanges
  • POST /subscriptions
  • POST /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. Limit: 60 per minute."}}
{"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) и ретрай.