API Security

The Buluthat API manages your phone PBX: it starts calls, ends calls, sends SMS and processes customer numbers. That is why a leaked key does not mean "a report becomes visible" but "calls get made from your account". This page explains how to protect your key and which protections Buluthat applies on your behalf.

Base address: https://api.buluthat.com/api/

The layers at a glance

Each request passes through these gates in order. If one rejects, the request is not processed and is written to the log.

OrderGateWhat it doesError
1Brute-force lockIf an IP makes 20 wrong key or signature attempts in 10 minutes, that IP is temporarily locked429 ip_locked
2Body limitA request body larger than 5 MB is not read413 payload_too_large
3KeyThe key is accepted only in the header; only a SHA-256 digest is kept in the system401 invalid_token
4Duration and cancellationAn expired or revoked key is rejected401 token_expired
5Allowed IPIf an IP list is defined for the key, only requests from those addresses pass403 ip_not_allowed
6Account statusKeys of a closed or inactive account do not work403 account_inactive
7ScopeThe key only gets into the APIs it is allowed to use403 scope_denied
8SignatureIf "signed request required" is on for the key, the HMAC signature, timestamp and one-time nonce are verified401 signature_*
9Rate limitPer-minute limit per key and on account total429 rate_limited

Quick start

  1. In the panel Account and Support > API Keys open the page (only the account administrator sees it).
  2. New key: give it a name, tick only the permissions you need, enter your server's outbound IP, Signed request requiredOpen it.
  3. Copy the two values shown once on the screen: API key (bt_…, in every request Authorization goes in the header) and signing secret (bts_…, used to sign the request, is never sent).
  4. Try the connection:
curl "https://api.buluthat.com/api/autocall.php?action=ping" \
  -H "Authorization: Bearer bt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

If a signature is required, this request 401 signature_required returns; the following Request signing use one of the examples in the section.

The key and signing secret are in a readable form in the system is not stored. If you lose it we cannot recover it; you renew the key from the panel.

Scopes

Each key is created with one or more scopes. A request to an API outside its scope 403 scope_denied returns.

ScopeAPIs it opens
autocallAuto Call (api/autocall.php). For compatibility with older integrations it also opens the voice assistant, voice verification and call control endpoints
callCall control and queues: begin_call, bridge, hangup, transfer, mute, queues, queues_pending, queue_user_list, queue_manage_users, agent_statuses
voicebotVoice Assistant (api/voicebot_api.php) and voice verification
voice_otpVoice Verification Code (api/voice_otp.php)
smsSMS API (api/sms.php). No other scope can access SMS
bridgeLive PBX data (api/crm_bridge.php): live calls, agent status, call recordings, audio recording, blocklist, announcements. Only the key's own account; sent tenant_id is ignored
*All APIs. Only if truly needed

Principle: a separate key for each integration, minimum permissions for each key. If your e-commerce site's SMS key leaks, an attacker cannot start calls; you revoke only that key.

Sending the key

Key in the header is sent:

Authorization: Bearer bt_xxxxxxxx

Authorization for environments that cannot set the header X-Api-Key: bt_xxxxxxxx is also accepted.

Key in URL (?key=)

?key=bt_… the format on new keys is off and 401 query_key_disabled returns. URLs end up in web server logs, proxy records, browser history and Referer it lands in the header; the key leaks from there. Only for an old system that cannot send a header, in the key settings "Accept key in URL" can be opened. The panel shows these keys in red ?key= shows with the badge.

This permission was left on for keys created before V54 so that old integrations don't break. Turn it off after you move your integration to the header.

Allowed IP

In the key settings Allowed IP addresses in the field, write one IP or CIDR block per line:

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

If the list is empty every IP is accepted. If it has entries, a request from outside the listed addresses is rejected even if the key is correct 403 ip_not_allowed returns. The address to write is the one that calls the API is your server's outbound IP (not your own computer's). If you are unsure, check the IP column of the request coming from that server in the log.

Request signing

The signature ensures that requests can't be made even if the key is captured: an attacker also needs the signing secret, and this secret is never sent over the network in any request. The signature also:

  • Locks the body: if a single character in the path changes, the signature won't match.
  • Prevents replay: each nonce is accepted only once; a captured request cannot be sent a second time.
  • Rejects a stale request: if the timestamp deviates from the server time by more than ±5 minutes, the request is rejected.

In the key Signed request required if on, every request must be signed. Even if off, if you send signature headers the signature is still verified; a wrong signature does not pass silently.

Headers

Sender IDValue
AuthorizationBearer bt_…
X-Bt-TimestampUnix time, seconds (e.g. 1790802088)
X-Bt-NonceNew in every request, 16-64 characters A-Z a-z 0-9 _ - (e.g. 32 hex)
X-Bt-Signaturev1= + lowercase hex form of the signature

Canonical text

The signed text is, between them \n (LF) there are six lines:

v1
POST
/api/sms.php?action=send
1790802088
9f86d081884c7d659a2feaa0c55ad015
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
RowContent
1Version, fixed v1
2HTTP method, uppercase (GET, POST)
3Path and query string, as the request was sent (/api/sms.php?action=send). Domain name and schema not included
4X-Bt-Timestamp the value
5X-Bt-Nonce the value
6SHA-256 digest of the body, lowercase hex. For a request without a body and multipart/form-data (file upload) requests, the digest of the empty body: e3b0c442…b855

Signature: 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'],
]));

Ready-made clients sign too: buluthat-autocall-client.php and buluthat-voice-otp-client.php in the fourth parameter ['signing_secret' => 'bts_…'] receives.

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"}]}))

Command line (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"

Why doesn't the signature match?

SymptomReason
signature_invalid only on POSTThe body you signed differs from the body you sent. Re-serialize the JSON once generate it, and give the same variable to both the summary and the request
signature_invalid In a query with Turkish charactersYou joined the path yourself and encoded it differently. In the signature use the path and query from the URL the client actually sends (parse_url / new URL())
signature_expiredYour server's clock has drifted. NTP (timedatectl set-ntp true) open; tolerance ±300 sec
signature_replayedThe same nonce was sent twice. On a retry, change the nonce and timestamp regenerate and re-sign
signature_requiredThe key requires a signature but the headers are missing
signature_not_configuredThe key has no signing secret; generate a "New signing secret" from the panel

Error codes

Identity and security errors are the same on all JSON endpoints code returns with the values. Call control and queue endpoints (Verimor-compatible) return the same HTTP code with a plain-text message.

HTTPcodeWhat to do
401missing_tokenAuthorization: Bearer … add the header
401invalid_tokenThe key is wrong, revoked or missing. Do not retry, fix the setting
401token_expiredRenew the key from the panel
401query_key_disabledSend the key in the header instead of the URL
401signature_*See the table above
403ip_not_allowedAdd your server's outbound IP to the key's list
403scope_deniedGrant the key the relevant permission or use the right key
403account_inactiveAccount closed; contact support
403module_disabledThe related service is not enabled in your package
413payload_too_largeSplit the request into parts (e.g. add_leads at most 5.000 records)
429rate_limited / tenant_rate_limitedRetry-After wait until
429ip_lockedMany failed attempts came from this IP; fix the faulty configuration and the lock will lift by itself

Error response example:

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

Rate limits

LimitDefault
Per key120 requests per minute (can be lowered in the key settings)
Total of all keys of the account600 requests per minute
Agent statuses (agent_statuses)plus 2 requests per minute per account

In every successful response the remaining allowance comes in the headers:

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

429 on receiving Retry-After wait for (seconds). Network errors and 5xx use exponential backoff for (1 sec, 2 sec, 4 sec… at most 5 attempts). 401/403 the errors do not retry: it is a configuration error; attempts trigger the brute-force lock.

Key renewal

Renew keys every 90-180 days, when staff leave, or if you suspect a leak. For a seamless transition:

  1. API Keys > the relevant key > Settings > Renew key. Choose "The old one stays valid for 24 hours".
  2. A new key and a new signing secret are generated with the same settings and shown once on screen.
  3. Update your integration with the new values.
  4. In the log, the prefix of the old key (bt_7820d84…) is no longer shown, wait; the old key closes by itself when the time is up.

On suspected compromise Choose "Close the old one immediately"; requests using the old key are rejected at once.

Request log

at the bottom of the API Keys page Request log shows every request: time, key prefix, IP, endpoint and action, HTTP status, error code, duration, whether signed. A summary of the last 24 hours (requests, errors, rate limits, auth errors, signed requests, number of distinct IPs) is at the top of the page. Records are kept for 90 days.

In every response X-Request-Id header is present. Write this value in your support ticket; we will find your request in the log immediately. Never put the key or signing secret in a support ticket, an email or a screenshot.

If you see requests from an IP you don't recognize or at unexpected hours, renew the key immediately with "Close the old one immediately".

Byfix CRM setup

Byfix CRM connects to Buluthat with two separate identities:

Setting (CRM)Value
Settings > Auto Call > API KeyProduced in Buluthat bt_… the key (scope: autocall + voicebot + voice_otp + call)
Settings > Auto Call > Signing SecretThe same key's bts_… secret. If filled, every request the CRM sends to Buluthat is signed
VoIP settings > Buluthat API TokenLive screen / CDR bridge (crm_bridge); provided by the Buluthat team, separate from the key above

Recommended order: the key Signed request required generate it off, enter the key and secret in the CRM, and in the log the requests imzalı see it arriving with the badge, then make the signature mandatory on the key. Also add the CRM server's IP to the key.

Click-to-call (begin_call) now sends the key in the header instead of the URL. After updating your CRM you can turn off the "Key in URL" permission on the old key.

ByCRM setup

In ByCRM, each company to Buluthat with its own key is connected; companies cannot see each other's data.

  1. In the Buluthat panel, with the company's account API Keys > New key: permissions Live PBX Data, Call Control and Queues, Auto Call (if used Voice Assistant, Voice Verification Code). Enter the IP of the ByCRM server.
  2. ByCRM > Integrations > Buluthat:
FieldValue
Buluthat API Tokenbt_…
Signing secretbts_…
Bridge Tokenthe same bt_… the key
CRM Bridge URLhttps://api.buluthat.com/api/crm_bridge.php
Autocall URLhttps://api.buluthat.com/api/autocall.php
Buluthat Tenant ID / PBX Server IDNot used; the data of whichever account the key belongs to is returned
  1. Try the live screen and click-to-call, in the log imzalı see the badges, then on the key Signed request requiredOpen it.
Because the live screen polls the bridge every few seconds, bridge requests have a separate, wider rate limit (1.200 per minute per key); only failed bridge requests are written to the log.

Verifying webhooks

Webhooks Buluthat sends to you are also signed (X-Buluthat-Signature: sha256=…). Verify the signature on your server raw body do not process any webhook without verifying it through; X-Buluthat-Delivery do not process the same delivery twice with. Details: Webhooks.

Security checklist

  • [ ] Each integration has its own key, with only the scopes it needs
  • [ ] Keys are not in code but in environment variables or secret management (.env not committed to Git)
  • [ ] The key is only on the server side; not in browser JavaScript, a mobile app or an Excel macro
  • [ ] The allowed IP list is filled in
  • [ ] Signed request required is on
  • [ ] Key in URL (?key=) off
  • [ ] There is an expiry date or a renewal reminder is set in the calendar
  • [ ] The client verifies the TLS certificate (CURLOPT_SSL_VERIFYPEER on); with verification off, someone in between can read and change the request
  • [ ] Server time is synced with NTP
  • [ ] Webhook signature is verified
  • [ ] The log is reviewed monthly; keys accessed by departed staff have been renewed

Vulnerability report

If you think you have found a security vulnerability in the Buluthat API, use the Support Center inside the panel "Security notification" open a ticket on the subject. Include how you reproduced the vulnerability and, if any X-Request-Id add the values. We ask that you not share details until we have reviewed and fixed the report.