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
5IP 白名单若密钥已设置 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)
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 分钟,请求将被拒绝。

密钥中的 必须签名请求 开启后,每个请求都必须带签名。即使关闭,若您发送签名请求头,签名仍会被验证;错误的签名不会被悄悄放过。

请求头

标题值
AuthorizationBearer bt_…
X-Bt-TimestampUnix 时间,秒(例如 1790802088)
X-Bt-Nonce每次请求都是新的,16-64 个字符 A-Z a-z 0-9 _ - (例如 32 个十六进制字符)
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 摘要,小写十六进制。对于无正文的请求以及 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 状态码返回纯文本消息。

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 令牌实时界面 / CDR 对接(crm_bridge);由 Buluthat 团队提供,与上方密钥不同

建议顺序:先将密钥 必须签名请求 请先关闭,在 CRM 中填写密钥和签名密钥,在日志中查看请求的 imzalı 标识,看到它出现后,再在密钥上强制要求签名。同时将 CRM 服务器的 IP 添加到密钥中。

点击拨号(begin_call)现在在请求头而非 URL 中发送密钥。CRM 更新后,您可以关闭旧密钥上的“在 URL 中使用密钥”权限。

ByCRM 安装

在 ByCRM 中,每家公司向 Buluthat 使用其自己的密钥 绑定;各公司之间无法看到彼此的数据。

  1. 在 Buluthat 面板中以公司账户 API 密钥 > 新建密钥:权限 实时总机数据, 呼叫控制与队列, 自动外呼 (若使用 语音助手, 语音验证码)。请填写 ByCRM 服务器的 IP。
  2. ByCRM > 集成 > Buluthat:
区域值
Buluthat API 令牌bt_…
签名密钥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. 试用实时界面和点击拨号,在日志中 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 添加这些值。在我们审核并修复之前,请勿公开细节。