API 安全
Buluthat API 管理您的电话总机:发起呼叫、挂断通话、发送短信、处理客户号码。因此,密钥泄露不只是“报表被人看到”,而是“有人用您的账户打电话”。本页介绍如何保护您的密钥,以及 Buluthat 为您采取了哪些保护措施。
基础地址: https://api.buluthat.com/api/
各层概览
每个请求依次通过以下关卡。任何一关拒绝,请求都不会被处理,并写入日志。
| 序号 | 关卡 | 它能做什么 | 错误 |
|---|---|---|---|
| 1 | 暴力破解锁定 | 若同一 IP 在 10 分钟内 20 次使用错误的密钥或签名,该 IP 将被临时锁定 | 429 ip_locked |
| 2 | 正文限制 | 超过 5 MB 的请求正文不会被读取 | 413 payload_too_large |
| 3 | 密钥 | 密钥仅在请求头中被接受;系统中仅保存其 SHA-256 摘要 | 401 invalid_token |
| 4 | 时长与取消 | 已过期或已撤销的密钥会被拒绝 | 401 token_expired |
| 5 | IP 白名单 | 若密钥已设置 IP 列表,则只有来自这些地址的请求才能通过 | 403 ip_not_allowed |
| 6 | 账户状态 | 已关闭或停用账户的密钥无法使用 | 403 account_inactive |
| 7 | 权限范围 | 密钥只能访问被允许的 API | 403 scope_denied |
| 8 | 签名 | 若密钥开启了“必须签名请求”,则会验证 HMAC 签名、时间戳和一次性 nonce | 401 signature_* |
| 9 | 速率限制 | 每个密钥及账户总计的每分钟限额 | 429 rate_limited |
快速入门
- 在面板中 账户与支持 > API 密钥 页面(仅账户授权人可见)。
- 新密钥:请命名,只勾选所需权限,填写您服务器的出口 IP, 必须签名请求。
- 复制屏幕上仅显示一次的两个值: API 密钥 (
bt_…,每次请求Authorization在请求头中发送)以及 签名密钥 (bts_…,用于对请求签名, 绝不会发送). - 请尝试连接:
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) |
sms | 短信 API(api/sms.php)。其他任何权限均无法访问短信 |
bridge | 实时总机数据(api/crm_bridge.php):实时通话、坐席状态、通话记录、录音、黑名单、语音提示。仅限该密钥所属账户;发送的 tenant_id 被忽略 |
* | 所有 API。仅在确有必要时 |
原则: 每个集成使用独立密钥,每个密钥授予最小权限。即使电商网站的短信密钥泄露,攻击者也无法发起呼叫;您只需撤销该密钥。
发送密钥
密钥 在请求头中 发送:
Authorization: Bearer bt_xxxxxxxx
Authorization 对于无法设置请求头的环境 X-Api-Key: bt_xxxxxxxx 也被接受。
URL 中的密钥 (?key=)
?key=bt_… 对新密钥的格式 已关闭 和 401 query_key_disabled 返回。URL 会进入 Web 服务器日志、代理记录、浏览器历史,以及 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 分钟,请求将被拒绝。
密钥中的 必须签名请求 开启后,每个请求都必须带签名。即使关闭,若您发送签名请求头,签名仍会被验证;错误的签名不会被悄悄放过。
请求头
| 标题 | 值 |
|---|---|
Authorization | Bearer bt_… |
X-Bt-Timestamp | Unix 时间,秒(例如 1790802088) |
X-Bt-Nonce | 每次请求都是新的,16-64 个字符 A-Z a-z 0-9 _ - (例如 32 个十六进制字符) |
X-Bt-Signature | v1= + 签名的小写十六进制形式 |
规范文本
被签名的文本,其中 \n (LF)的六行:
v1
POST
/api/sms.php?action=send
1790802088
9f86d081884c7d659a2feaa0c55ad015
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
| 行 | 内容 |
|---|---|
| 1 | 版本,固定 v1 |
| 2 | HTTP 方法,大写(GET, POST) |
| 3 | 路径和查询字符串, 按请求发送时的原样 (/api/sms.php?action=send)。不含域名和数据库结构 |
| 4 | X-Bt-Timestamp 值 |
| 5 | X-Bt-Nonce 值 |
| 6 | 正文的 SHA-256 摘要,小写十六进制。对于无正文的请求以及 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 被再次发送。重试时请更换 nonce 和时间戳 重新生成后 请重新签名 |
signature_required | 密钥要求签名,但缺少请求头 |
signature_not_configured | 该密钥没有签名密钥;请在面板中生成“新的签名密钥” |
错误码
身份和安全错误在所有 JSON 接口中保持一致 code 返回这些值。呼叫控制和队列接口(兼容 Verimor)以相同的 HTTP 状态码返回纯文本消息。
| HTTP | code | 该怎么办 |
|---|---|---|
| 401 | missing_token | Authorization: Bearer … 添加请求头 |
| 401 | invalid_token | 密钥错误、已被撤销或根本不存在。 不要重试,请修正设置 |
| 401 | token_expired | 在面板中轮换密钥 |
| 401 | query_key_disabled | 请在请求头而非 URL 中发送密钥 |
| 401 | signature_* | 请见上表 |
| 403 | ip_not_allowed | 请将您服务器的出口 IP 添加到密钥的列表中 |
| 403 | scope_denied | 请为密钥授予相应权限,或使用正确的密钥 |
| 403 | account_inactive | 账户已关闭;请联系支持 |
| 403 | module_disabled | 您的套餐中未开通相关服务 |
| 413 | payload_too_large | 将请求拆分成多个部分(例如 add_leads 最多 5.000 条记录) |
| 429 | rate_limited / tenant_rate_limited | Retry-After 请等待 |
| 429 | ip_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 天、人员离职或怀疑泄露时轮换密钥。无缝切换:
- API 密钥 > 相应密钥 > 设置 > 轮换密钥。请选择“旧密钥继续有效 24 小时”。
- 将以相同设置生成新的密钥和新的签名密钥,并仅在屏幕上显示一次。
- 请用新值更新您的集成。
- 在日志中旧密钥的前缀(
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 令牌 | 实时界面 / CDR 对接(crm_bridge);由 Buluthat 团队提供,与上方密钥不同 |
建议顺序:先将密钥 必须签名请求 请先关闭,在 CRM 中填写密钥和签名密钥,在日志中查看请求的 imzalı 标识,看到它出现后,再在密钥上强制要求签名。同时将 CRM 服务器的 IP 添加到密钥中。
点击拨号(begin_call)现在在请求头而非 URL 中发送密钥。CRM 更新后,您可以关闭旧密钥上的“在 URL 中使用密钥”权限。
ByCRM 安装
在 ByCRM 中,每家公司向 Buluthat 使用其自己的密钥 绑定;各公司之间无法看到彼此的数据。
- 在 Buluthat 面板中以公司账户 API 密钥 > 新建密钥:权限 实时总机数据, 呼叫控制与队列, 自动外呼 (若使用 语音助手, 语音验证码)。请填写 ByCRM 服务器的 IP。
- ByCRM > 集成 > Buluthat:
| 区域 | 值 |
|---|---|
| Buluthat API 令牌 | bt_… |
| 签名密钥 | bts_… |
| Bridge Token | 相同的 bt_… 密钥 |
| 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 | 不使用;返回的是该密钥所属账户的数据 |
- 试用实时界面和点击拨号,在日志中
imzalı标识,然后在密钥上 必须签名请求。
由于实时界面每隔几秒轮询一次对接接口,对接请求拥有单独且更宽松的速率限制(每个密钥每分钟 1.200 次);日志中仅记录出错的对接请求。
Webhook 验证
Buluthat 发送给您的 Webhook 同样带有签名(X-Buluthat-Signature: sha256=…)。请在您的服务器上 原始正文 在未经验证之前,不要处理任何 Webhook; X-Buluthat-Delivery 不要重复处理同一次投递。详情: Webhook.
安全检查清单
- [ ] 每个集成都有自己的密钥,且只授予所需权限
- [ ] 密钥不在代码中,而是放在环境变量或密钥管理中(
.env不会进入 Git) - [ ] 密钥只存在于服务器端;不在浏览器 JavaScript、移动应用或 Excel 宏中
- [ ] 已填写 IP 白名单
- [ ] 已开启“必须签名请求”
- [ ] URL 中的密钥(
?key=)已关闭 - [ ] 设有到期日,或已在日历中设置续期提醒
- [ ] 客户端会验证 TLS 证书(
CURLOPT_SSL_VERIFYPEER开启);若不验证,中间人可读取并篡改请求 - [ ] 服务器时间已通过 NTP 同步
- [ ] 已验证 Webhook 签名
- [ ] 每月审查一次日志;已轮换离职员工曾接触过的密钥
安全漏洞报告
如果您认为发现了 Buluthat API 的安全漏洞,请通过面板内的帮助中心 “安全通知” 为主题创建工单。请说明您是如何复现该漏洞的,如有 X-Request-Id 添加这些值。在我们审核并修复之前,请勿公开细节。
