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.
| Order | Gate | What it does | Error |
|---|---|---|---|
| 1 | Brute-force lock | If an IP makes 20 wrong key or signature attempts in 10 minutes, that IP is temporarily locked | 429 ip_locked |
| 2 | Body limit | A request body larger than 5 MB is not read | 413 payload_too_large |
| 3 | Key | The key is accepted only in the header; only a SHA-256 digest is kept in the system | 401 invalid_token |
| 4 | Duration and cancellation | An expired or revoked key is rejected | 401 token_expired |
| 5 | Allowed IP | If an IP list is defined for the key, only requests from those addresses pass | 403 ip_not_allowed |
| 6 | Account status | Keys of a closed or inactive account do not work | 403 account_inactive |
| 7 | Scope | The key only gets into the APIs it is allowed to use | 403 scope_denied |
| 8 | Signature | If "signed request required" is on for the key, the HMAC signature, timestamp and one-time nonce are verified | 401 signature_* |
| 9 | Rate limit | Per-minute limit per key and on account total | 429 rate_limited |
Quick start
- In the panel Account and Support > API Keys open the page (only the account administrator sees it).
- New key: give it a name, tick only the permissions you need, enter your server's outbound IP, Signed request requiredOpen it.
- Copy the two values shown once on the screen: API key (
bt_…, in every requestAuthorizationgoes in the header) and signing secret (bts_…, used to sign the request, is never sent). - 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.
| Scope | APIs it opens |
|---|---|
autocall | Auto Call (api/autocall.php). For compatibility with older integrations it also opens the voice assistant, voice verification and call control endpoints |
call | Call control and queues: begin_call, bridge, hangup, transfer, mute, queues, queues_pending, queue_user_list, queue_manage_users, agent_statuses |
voicebot | Voice Assistant (api/voicebot_api.php) and voice verification |
voice_otp | Voice Verification Code (api/voice_otp.php) |
sms | SMS API (api/sms.php). No other scope can access SMS |
bridge | Live 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 ID | Value |
|---|---|
Authorization | Bearer bt_… |
X-Bt-Timestamp | Unix time, seconds (e.g. 1790802088) |
X-Bt-Nonce | New in every request, 16-64 characters A-Z a-z 0-9 _ - (e.g. 32 hex) |
X-Bt-Signature | v1= + 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
| Row | Content |
|---|---|
| 1 | Version, fixed v1 |
| 2 | HTTP method, uppercase (GET, POST) |
| 3 | Path and query string, as the request was sent (/api/sms.php?action=send). Domain name and schema not included |
| 4 | X-Bt-Timestamp the value |
| 5 | X-Bt-Nonce the value |
| 6 | SHA-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?
| Symptom | Reason |
|---|---|
signature_invalid only on POST | The 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 characters | You 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_expired | Your server's clock has drifted. NTP (timedatectl set-ntp true) open; tolerance ±300 sec |
signature_replayed | The same nonce was sent twice. On a retry, change the nonce and timestamp regenerate and re-sign |
signature_required | The key requires a signature but the headers are missing |
signature_not_configured | The 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.
| HTTP | code | What to do |
|---|---|---|
| 401 | missing_token | Authorization: Bearer … add the header |
| 401 | invalid_token | The key is wrong, revoked or missing. Do not retry, fix the setting |
| 401 | token_expired | Renew the key from the panel |
| 401 | query_key_disabled | Send the key in the header instead of the URL |
| 401 | signature_* | See the table above |
| 403 | ip_not_allowed | Add your server's outbound IP to the key's list |
| 403 | scope_denied | Grant the key the relevant permission or use the right key |
| 403 | account_inactive | Account closed; contact support |
| 403 | module_disabled | The related service is not enabled in your package |
| 413 | payload_too_large | Split the request into parts (e.g. add_leads at most 5.000 records) |
| 429 | rate_limited / tenant_rate_limited | Retry-After wait until |
| 429 | ip_locked | Many 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
| Limit | Default |
|---|---|
| Per key | 120 requests per minute (can be lowered in the key settings) |
| Total of all keys of the account | 600 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:
- API Keys > the relevant key > Settings > Renew key. Choose "The old one stays valid for 24 hours".
- A new key and a new signing secret are generated with the same settings and shown once on screen.
- Update your integration with the new values.
- 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 Key | Produced in Buluthat bt_… the key (scope: autocall + voicebot + voice_otp + call) |
| Settings > Auto Call > Signing Secret | The same key's bts_… secret. If filled, every request the CRM sends to Buluthat is signed |
| VoIP settings > Buluthat API Token | Live 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.
- 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.
- ByCRM > Integrations > Buluthat:
| Field | Value |
|---|---|
| Buluthat API Token | bt_… |
| Signing secret | bts_… |
| Bridge Token | the same bt_… the key |
| CRM Bridge URL | https://api.buluthat.com/api/crm_bridge.php |
| Autocall URL | https://api.buluthat.com/api/autocall.php |
| Buluthat Tenant ID / PBX Server ID | Not used; the data of whichever account the key belongs to is returned |
- 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 (
.envnot 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_VERIFYPEERon); 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.
