Безопасность API

API Buluthat управляет вашей телефонной АТС: запускает звонки, завершает вызовы, отправляет SMS, обрабатывает номера клиентов. Поэтому утечка ключа означает не «будет виден какой-то отчёт», а «звонки будут совершаться с вашего аккаунта». На этой странице описано, как защитить ключ и какие меры защиты Buluthat применяет за вас.

Базовый адрес: https://api.buluthat.com/api/

Уровни одним взглядом

Каждый запрос последовательно проходит через следующие шлюзы. Если один из них отклоняет запрос, он не обрабатывается и записывается в журнал.

ПорядокШлюзЧто делаетОшибка
1Блокировка от перебораЕсли один IP за 10 мин 20 раз использует неверный ключ или подпись, он временно блокируется429 ip_locked
2Ограничение тела запросатело запроса размером более 5 МБ не читается413 payload_too_large
3КлючКлюч принимается только в заголовке; в системе хранится только хеш SHA-256401 invalid_token
4Срок и отменаКлюч с истёкшим сроком или отозванный отклоняется401 token_expired
5Разрешённый IPЕсли для ключа задан список IP, проходят только запросы с этих адресов403 ip_not_allowed
6Статус аккаунтаКлючи закрытого или неактивного аккаунта не работают403 account_inactive
7ОбластьКлюч допускает только разрешённые API403 scope_denied
8ПодписьЕсли у ключа включено «обязательна подпись запроса», проверяются подпись HMAC, метка времени и одноразовый nonce401 signature_*
9Лимит запросовОграничение в минуту на ключ и на весь аккаунт429 rate_limited

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

  1. В панели Аккаунт и поддержка > Ключи API откройте страницу (видит только уполномоченное лицо аккаунта).
  2. Новый ключ: задайте имя, отметьте только необходимые права, укажите исходящий IP вашего сервера, Подписанный запрос обязателен— откройте.
  3. Скопируйте два значения, показанные на экране один раз: Ключ API (bt_…, в каждом запросе Authorization передаётся в заголовке) и секрет подписи (bts_…, используется для подписи запроса, никогда не отправляется).
  4. Проверьте соединение:
curl "https://api.buluthat.com/api/autocall.php?action=ping" \
  -H "Authorization: Bearer bt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Если подпись обязательна, этот запрос 401 signature_required возвращается; ниже Подпись запросов используйте один из примеров в разделе.

Ключ и секрет подписи в системе не хранятся в читаемом виде не хранится. Если вы его потеряете, восстановить его мы не сможем; обновите ключ в панели.

Области

Каждый ключ создаётся с одной или несколькими областями доступа. Запрос к API вне области 403 scope_denied возвращается.

ОбластьAPI, которые он открывает
autocallАвтодозвон (api/autocall.php). Для совместимости со старыми интеграциями также открывает методы голосового ассистента, голосового подтверждения и управления вызовами
callУправление вызовами и очереди: begin_call, bridge, hangup, transfer, mute, queues, queues_pending, queue_user_list, queue_manage_users, agent_statuses
voicebotГолосовой ассистент (api/voicebot_api.php) и голосовое подтверждение
voice_otpГолосовой код подтверждения (api/voice_otp.php)
smsSMS API (api/sms.php). Никакие другие области не дают доступа к SMS
bridgeЖивые данные АТС (api/crm_bridge.php): живые вызовы, статус операторов, записи звонков, запись разговоров, чёрный список, аудиообъявления. Только аккаунт, к которому относится ключ; отправленные tenant_id игнорируется
*Все API. Только если действительно необходимо

Принцип: отдельный ключ на каждую интеграцию, минимальные права на каждый ключ. Если утечёт SMS-ключ вашего интернет-магазина, злоумышленник не сможет запускать звонки; вы отзываете только этот ключ.

Не передавайте ключ

Ключ в заголовке отправляется:

Authorization: Bearer bt_xxxxxxxx

Authorization для сред, где нельзя задать заголовок X-Api-Key: bt_xxxxxxxx тоже принимается.

Ключ в URL (?key=)

?key=bt_… формат у новых ключей отключён и 401 query_key_disabled возвращается. URL попадают в логи веб-сервера, записи прокси, историю браузера и Referer попадает в заголовок; ключ утекает оттуда. Только для старой системы, не способной отправлять заголовок, в настройках ключа «Принимать ключ в URL» можно открыть. Панель показывает эти ключи красным ?key= отображает с меткой.

Для ключей, созданных до V54, это разрешение оставлено включённым, чтобы не оборвались старые интеграции. Отключите его после переноса интеграции на заголовок.

Разрешённый IP

В настройках ключа Разрешённые IP-адреса в поле указывается по одному IP или блоку CIDR на строку:

85.111.89.220
10.20.0.0/24
2a01:4f8:c0c:1234::/64

Если список пуст, принимается любой IP. Если он заполнен, запрос с адреса вне списка, даже при верном ключе, 403 ip_not_allowed возвращается. Адрес для записи — тот, который вызывает API это исходящий IP вашего сервера (не вашего компьютера). Если не уверены, посмотрите в журнале столбец IP у запроса, пришедшего с этого сервера.

Подпись запросов

Подпись гарантирует, что даже при перехвате ключа запрос отправить нельзя: злоумышленнику дополнительно нужен секрет подписи, а этот секрет ни в одном запросе по сети не передаётся. Подпись также:

  • Фиксирует тело: если в пути изменится хотя бы один символ, подпись не совпадёт.
  • Предотвращает повторное воспроизведение: каждый nonce принимается лишь один раз; перехваченный запрос повторно отправить нельзя.
  • Отклоняет устаревший запрос: запрос отклоняется, если метка времени отличается от времени сервера более чем на ±5 мин.

У ключа Подписанный запрос обязателен если включено, каждый запрос должен быть подписан. Даже если выключено, при отправке заголовков подписи подпись всё равно проверяется; неверная подпись молча не проходит.

Заголовки

ЗаголовокЗначение
AuthorizationBearer bt_…
X-Bt-TimestampВремя Unix, секунды (напр. 1790802088)
X-Bt-NonceНовый в каждом запросе, 16–64 символов A-Z a-z 0-9 _ - (напр. 32 hex)
X-Bt-Signaturev1= + подпись в шестнадцатеричном виде строчными буквами

Каноническая строка

Подписываемая строка состоит из \n (LF) — шесть строк:

v1
POST
/api/sms.php?action=send
1790802088
9f86d081884c7d659a2feaa0c55ad015
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
СтрокаСодержимое
1Версия, фиксированная v1
2HTTP-метод, заглавными буквами (GET, POST)
3Путь и строка запроса, в том виде, в котором запрос был отправлен (/api/sms.php?action=send). Доменное имя и схема не включены
4X-Bt-Timestamp значение
5X-Bt-Nonce значение
6Хеш SHA-256 тела строчными hex-символами. При запросе без тела и multipart/form-data Хеш пустого тела для запросов (загрузка файлов): e3b0c442…b855

Подпись: hex( HMAC-SHA256( anahtar = imza_sırrı, mesaj = kanonik_metin ) )

PHP

function buluthat_request(string $method, string $url, string $token, string $secret, ?array $data = null): array
{
    $body = $data === null ? '' : json_encode($data, JSON_UNESCAPED_UNICODE);
    $p = parse_url($url);
    $uri = ($p['path'] ?? '/') . (isset($p['query']) ? '?' . $p['query'] : '');
    $ts = (string)time();
    $nonce = bin2hex(random_bytes(16));
    $canonical = implode("\n", ['v1', strtoupper($method), $uri, $ts, $nonce, hash('sha256', $body)]);

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 30,
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . $token,
            'Content-Type: application/json',
            'X-Bt-Timestamp: ' . $ts,
            'X-Bt-Nonce: ' . $nonce,
            'X-Bt-Signature: v1=' . hash_hmac('sha256', $canonical, $secret),
        ],
    ]);
    if ($body !== '') {
        curl_setopt($ch, CURLOPT_POSTFIELDS, $body);   // imzalanan gövdenin AYNISI
    }
    $res = json_decode((string)curl_exec($ch), true) ?: [];
    curl_close($ch);
    return $res;
}

$token  = getenv('BULUTHAT_TOKEN');    // bt_...
$secret = getenv('BULUTHAT_SECRET');   // bts_...
print_r(buluthat_request('POST', 'https://api.buluthat.com/api/sms.php?action=send', $token, $secret, [
    'header' => 'FIRMAM', 'message' => 'Siparişiniz kargoya verildi.', 'phones' => ['05321234567'],
]));

Готовые клиенты тоже подписывают запросы: buluthat-autocall-client.php и buluthat-voice-otp-client.php в четвёртом параметре ['signing_secret' => 'bts_…'] получает.

Node.js

const crypto = require('crypto');

async function buluthatRequest(method, url, token, secret, data) {
  const body = data === undefined ? '' : JSON.stringify(data);
  const u = new URL(url);
  const ts = Math.floor(Date.now() / 1000).toString();
  const nonce = crypto.randomBytes(16).toString('hex');
  const canonical = ['v1', method.toUpperCase(), u.pathname + u.search, ts, nonce,
    crypto.createHash('sha256').update(body).digest('hex')].join('\n');
  const signature = crypto.createHmac('sha256', secret).update(canonical).digest('hex');

  const res = await fetch(url, {
    method,
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
      'X-Bt-Timestamp': ts,
      'X-Bt-Nonce': nonce,
      'X-Bt-Signature': `v1=${signature}`,
    },
    body: body || undefined,
  });
  return res.json();
}

buluthatRequest('GET', 'https://api.buluthat.com/api/voice_otp.php?action=status&id=42',
  process.env.BULUTHAT_TOKEN, process.env.BULUTHAT_SECRET).then(console.log);

Python

import hashlib, hmac, json, os, secrets, time, urllib.parse
import requests

def buluthat_request(method, url, token, secret, data=None):
    body = b"" if data is None else json.dumps(data, ensure_ascii=False).encode("utf-8")
    u = urllib.parse.urlsplit(url)
    uri = u.path + ("?" + u.query if u.query else "")
    ts = str(int(time.time()))
    nonce = secrets.token_hex(16)
    canonical = "\n".join(["v1", method.upper(), uri, ts, nonce, hashlib.sha256(body).hexdigest()])
    sig = hmac.new(secret.encode(), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
        "X-Bt-Timestamp": ts,
        "X-Bt-Nonce": nonce,
        "X-Bt-Signature": f"v1={sig}",
    }
    return requests.request(method, url, data=body or None, headers=headers, timeout=30).json()

print(buluthat_request("POST", "https://api.buluthat.com/api/autocall.php?action=add_leads",
      os.environ["BULUTHAT_TOKEN"], os.environ["BULUTHAT_SECRET"],
      {"campaign_id": 12, "leads": [{"phone": "05321234567", "name": "Ayşe Yılmaz"}]}))

Командная строка (bash + openssl)

TOKEN=bt_xxx; SECRET=bts_xxx
URI='/api/autocall.php?action=ping'
TS=$(date +%s); NONCE=$(openssl rand -hex 16)
BODYHASH=$(printf '' | openssl dgst -sha256 -hex | awk '{print $NF}')
SIG=$(printf 'v1\nGET\n%s\n%s\n%s\n%s' "$URI" "$TS" "$NONCE" "$BODYHASH" \
      | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
curl "https://api.buluthat.com$URI" -H "Authorization: Bearer $TOKEN" \
  -H "X-Bt-Timestamp: $TS" -H "X-Bt-Nonce: $NONCE" -H "X-Bt-Signature: v1=$SIG"

Почему подпись не совпадает?

СимптомПричина
signature_invalid только в POSTТело, которое вы подписали, и тело, которое вы отправили, различаются. JSON один раз создайте, передайте одну и ту же переменную и в сводку, и в запрос
signature_invalid В запросе с турецкими символамиВы собрали путь сами и закодировали иначе. В подписи используйте путь и запрос из URL, который клиент действительно отправил (parse_url / new URL())
signature_expiredВремя на вашем сервере сбилось. NTP (timedatectl set-ntp true) откройте; допуск ±300 с
signature_replayedТот же nonce отправлен повторно. При повторной попытке (retry) обновите nonce и метку времени создав заново, подпишите заново
signature_requiredДля ключа требуется подпись, но заголовки отсутствуют
signature_not_configuredУ ключа нет секрета подписи; создайте «Новый секрет подписи» в панели

Коды ошибок

Ошибки аутентификации и безопасности одинаковы во всех JSON-методах code возвращается со значениями. Методы управления вызовами и очередями (совместимые с Verimor) возвращают тот же HTTP-код с текстовым сообщением.

HTTPcodeЧто делать
401missing_tokenAuthorization: Bearer … добавьте заголовок
401invalid_tokenКлюч неверный, отозван или отсутствует. Не повторять попытку, исправьте настройку
401token_expiredОбновите ключ в панели
401query_key_disabledПередавайте ключ в заголовке, а не в URL
401signature_*См. таблицу выше
403ip_not_allowedДобавьте исходящий IP вашего сервера в список ключа
403scope_deniedВыдайте ключу нужное право или используйте правильный ключ
403account_inactiveАккаунт закрыт; обратитесь в поддержку
403module_disabledСоответствующая услуга не включена в ваш пакет
413payload_too_largeРазбейте запрос на части (напр. add_leads не более 5.000 записей)
429rate_limited / tenant_rate_limitedRetry-After подождите до
429ip_lockedС этого IP поступило много неудачных попыток; исправьте конфигурацию, блокировка снимется сама

Пример ответа с ошибкой:

{ "ok": false, "error": "Bu anahtarın bu API için yetkisi yok.", "code": "scope_denied", "request_id": "df5fca3a599653f6" }

Лимиты запросов

ЛимитПо умолчанию
На каждый ключ120 запросов в минуту (можно снизить в настройках ключа)
Сумма по всем ключам аккаунта600 запросов в минуту
Статусы операторов (agent_statuses)а также 2 запросов в минуту на аккаунт

В каждом успешном ответе остаток лимита приходит в заголовках:

X-RateLimit-Limit: 120
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1790802120

429 получив Retry-After (секунд). Ошибка сети и 5xx используйте экспоненциальную задержку для повторов (1 с, 2 с, 4 с… не более 5 попыток). 401/403 ошибки не повторять попытку: это ошибка конфигурации, попытки сработают как блокировка от перебора.

Обновление ключа

Обновляйте ключи раз в 90–180 дней, при увольнении сотрудника или подозрении на утечку. Бесперебойный переход:

  1. Ключи API > нужный ключ > Настройки > Обновить ключ. Выберите «Старый работает ещё 24 ч».
  2. С теми же настройками создаются новый ключ и новый секрет подписи, показываются на экране один раз.
  3. Обновите интеграцию, указав новые значения.
  4. В журнале префикс старого ключа (bt_7820d84…) больше не отображается, подождите; по истечении срока старый ключ закроется сам.

При подозрении на утечку Выберите «Закрыть старый сразу»; запросы со старым ключом будут немедленно отклонены.

Журнал запросов

В нижней части страницы «Ключи API» Журнал запросов показывает каждый запрос: время, префикс ключа, IP, метод и действие, статус HTTP, код ошибки, длительность, подписан ли. Сводка за последние 24 ч (запросы, ошибки, лимит, ошибки аутентификации, подписанные запросы, число различных IP) — вверху страницы. Записи хранятся 90 дней.

В каждом ответе X-Request-Id есть заголовок. Укажите это значение в заявке в поддержку; мы сразу найдём ваш запрос в журнале. Никогда не вкладывайте ключ или секрет подписи в заявки в поддержку, письма и скриншоты.

Если вы видите незнакомый IP или запросы в неожиданное время, немедленно обновите ключ с опцией «Закрыть старый сразу».

Подключение Byfix CRM

Byfix CRM подключается к Buluthat с двумя отдельными идентификаторами:

Параметр (CRM)Значение
Настройки > Автодозвон > Ключ APIСоздаваемые в Buluthat bt_… ключ (область: autocall + voicebot + voice_otp + call)
Настройки > Автодозвон > Секрет подписиОдного и того же ключа bts_… секрет. Если заполнен, каждый запрос CRM к Buluthat подписывается
Настройки VoIP > Buluthat API TokenМост живого экрана / CDR (crm_bridge); выдаётся командой Buluthat, отличается от ключа выше

Рекомендуемый порядок: ключ Подписанный запрос обязателен создайте закрытый, введите в CRM ключ и секрет, в журнале запросы imzalı убедитесь, что приходит с меткой, затем сделайте подпись на ключе обязательной. Добавьте в ключ также IP-адрес сервера CRM.

Click-to-call (begin_call) теперь передаёт ключ не в URL, а в заголовке. После обновления CRM можно отключить разрешение «Ключ в URL» у старого ключа.

Подключение ByCRM

В ByCRM каждая компания подключается к Buluthat с собственным ключом привязывается; компании не видят данные друг друга.

  1. В панели Buluthat — с аккаунтом компании Ключи API > Новый ключ: права Живые данные АТС, Управление вызовами и очереди, Автодозвон (если используется Голосовой ассистент, Голосовой код подтверждения). Укажите IP-адрес сервера ByCRM.
  2. ByCRM > Интеграции > Buluthat:
ПолеЗначение
Buluthat API Tokenbt_…
Секрет подписиbts_…
Bridge Tokenтот же bt_… ключ
CRM Bridge URLhttps://api.buluthat.com/api/crm_bridge.php
Autocall URLhttps://api.buluthat.com/api/autocall.php
Buluthat Tenant ID / PBX Server IDНе используется; приходят данные того аккаунта, которому принадлежит ключ
  1. Попробуйте живой экран и click-to-call, в журнале imzalı посмотрите метки, затем на ключе Подписанный запрос обязателен— откройте.
Поскольку живой экран опрашивает мост каждые несколько секунд, для запросов моста действует отдельный, более широкий лимит (на ключ — 1.200 в минуту); в журнал записываются только ошибочные запросы моста.

Проверка webhook

Webhook, которые Buluthat отправляет вам, тоже подписываются (X-Buluthat-Signature: sha256=…). Подпись на вашем сервере исходное тело не обрабатывайте ни один webhook без проверки через; X-Buluthat-Delivery не обрабатывайте одну и ту же доставку дважды. Подробности: Webhook.

Контрольный список по безопасности

  • [ ] У каждой интеграции свой ключ, только с необходимыми областями
  • [ ] Ключи не в коде, а в переменных окружения или менеджере секретов (.env не попадает в Git)
  • [ ] Ключ только на стороне сервера; его нет ни в JavaScript браузера, ни в мобильном приложении, ни в макросе Excel
  • [ ] Список разрешённых IP заполнен
  • [ ] Обязательная подпись запроса включена
  • [ ] Ключ в URL (?key=) отключён
  • [ ] Есть срок действия или в календаре стоит напоминание об обновлении
  • [ ] Клиент проверяет сертификат TLS (CURLOPT_SSL_VERIFYPEER включено); при отключённой проверке вмешавшийся человек может прочитать и изменить запрос
  • [ ] Время сервера синхронизировано по NTP
  • [ ] Подпись webhook проверяется
  • [ ] Журнал просматривается раз в месяц; ключи, к которым имел доступ уволенный сотрудник, обновлены

Сообщение об уязвимости

Если вы считаете, что нашли уязвимость в API Buluthat, обратитесь через Центр поддержки внутри панели «Уведомление о безопасности» создайте заявку по теме. Опишите, как воспроизвести уязвимость, и, если есть, X-Request-Id добавьте значения. Просим не делиться подробностями, пока мы не рассмотрим и не устраним уведомление.