Webhook

Webhook 通知

不用再轮询。短信验证码一到达,我们就带签名推送到你的服务器,失败会自动重试。

为什么用 Webhook

轮询既消耗请求配额又增加延迟。Webhook 在验证码落地的同一秒抵达你的服务器。

  • 无需轮询循环:每日请求配额用在真正的业务上,而不是查询状态。
  • 更低延迟:我们收到验证码时你的系统同时收到,不必等下一次轮询。
  • 终态事件同样推送:取消和超时会带上退款状态,你的账目始终对得上。

接入

用 API 密钥为账户注册一个 URL。签名密钥只返回一次。

curl -X POST https://smsbulk.net/api/users/webhook \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://your-app.com/smsbulk/webhook"}'

响应

{
  "id": "wep_2f8c1a9d",
  "url": "https://your-app.com/smsbulk/webhook",
  "sourceFilter": "API",
  "secret": "whsec_5f3a9c1e8b2d47a6903c5e1f7d8b4a26"
}

立即保存密钥

明文密钥只在这个响应里出现,之后读取只会返回掩码值。如果丢失,请在控制台或用 rotate 接口重新生成。

URL 要求

  • 必须是 443 端口的 HTTPS。普通 HTTP 和其他端口会被拒绝。
  • 必须是公网地址。私有网段、回环地址和云元数据地址在注册时以及每次投递时都会被拒绝。
  • 不跟随跳转。3xx 响应不会被跟随,会计为一次失败尝试。
方法路径用途
POST/api/users/webhook注册或替换接收地址,密钥只返回一次
GET/api/users/webhook读取当前接收地址,密钥为掩码
PATCH/api/users/webhook修改 URL 或渠道过滤,或重新启用已停用的地址
POST/api/users/webhook/rotate带过渡窗口更换签名密钥
POST/api/users/webhook/test发送不含真实激活数据的测试事件
DELETE/api/users/webhook删除接收地址并停止全部通知
GET/api/users/webhook/deliveries最近 20 次投递记录,仅元数据

事件

三个事件,都是关于同一次激活。

事件触发时机
activation.code_received短信验证码到达时。大多数集成都基于这个事件处理业务。
activation.cancelled激活被取消时。消息体包含取消原因以及余额是否已退回。
activation.expired激活超时且未收到验证码时。消息体包含退款状态。

渠道过滤

默认只有通过 API 下的订单才会触发通知。如果也需要在控制台下的订单,把 sourceFilter 设为 null。

请求与消息体

每次投递都是带 JSON 消息体的 POST。字段逐个挑选:内部路由和成本细节从不出现。

请求结构

POST /smsbulk/webhook HTTP/1.1
Content-Type: application/json
User-Agent: SMSBulk-Webhook/1
X-SMSBulk-Signature: t=1785312000,v1=8f2a...c41d
X-SMSBulk-Event-Id: evt_del_9f2c1a
X-SMSBulk-Event-Type: activation.code_received
X-SMSBulk-Delivery-Attempt: 1

activation.code_received

{
  "id": "evt_del_9f2c1a",
  "type": "activation.code_received",
  "created": 1785312000,
  "data": {
    "activation_id": "cms2u6x80d1twmkudp2cfbqo4",
    "status": "RECEIVED",
    "phone_number": "447700900123",
    "service": "wa",
    "country": "GB",
    "code": "483920",
    "price": "0.42",
    "currency": "USD",
    "source": "API",
    "created_at": "2026-08-04T09:12:00.000Z",
    "sms_text": "Your code is 483920",
    "received_at": "2026-08-04T09:12:34.000Z"
  }
}

activation.cancelled 与 activation.expired

{
  "id": "evt_del_7b31de",
  "type": "activation.cancelled",
  "created": 1785312600,
  "data": {
    "activation_id": "cms2u6x80d1twmkudp2cfbqo4",
    "status": "CANCELLED",
    "phone_number": "447700900123",
    "service": "wa",
    "country": "GB",
    "code": null,
    "price": "0.42",
    "currency": "USD",
    "source": "API",
    "created_at": "2026-08-04T09:12:00.000Z",
    "reason": "user_cancel",
    "refunded": true,
    "refund_amount": "0.42"
  }
}

后续版本可能新增字段。遇到未知字段请忽略,不要直接报错。

验证签名

每个请求都带有由时间戳和原始消息体生成的 HMAC SHA256 签名。

X-SMSBulk-Signature: t=<unix_seconds>,v1=<hex_hmac_sha256>
signed_string = "<t>.<raw_request_body>"

对原始字节验签

用你收到的原始消息体计算签名。如果先解析 JSON 再重新序列化,键顺序和空格会变化,签名将无法匹配。

v1 可能不止一个

密钥轮换期间请求头会带两个 v1 值。请收集到列表里而不是字典:字典只会保留最后一个,新密钥会悄悄没有被验证。

Node.js

const crypto = require('crypto');

function verify(rawBody, header, secret) {
  const pairs = header.split(',').map((p) => {
    const i = p.indexOf('=');
    return [p.slice(0, i), p.slice(i + 1)];
  });

  const t = pairs.find(([k]) => k === 't')?.[1];
  // During a rotate window more than one signature arrives: collect them all.
  const signatures = pairs.filter(([k]) => k === 'v1').map(([, v]) => v);
  if (!t || signatures.length === 0) return false;

  // Replay window: reject anything older than 5 minutes.
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex');

  return signatures.some(
    (s) =>
      s.length === expected.length &&
      crypto.timingSafeEqual(
        Buffer.from(s, 'hex'),
        Buffer.from(expected, 'hex'),
      ),
  );
}

Python

import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    pairs = [p.split("=", 1) for p in header.split(",")]

    t = next((v for k, v in pairs if k == "t"), None)
    # During a rotate window more than one signature arrives: collect them all.
    signatures = [v for k, v in pairs if k == "v1"]
    if t is None or not signatures:
        return False

    # Replay window: reject anything older than 5 minutes.
    if abs(time.time() - int(t)) > 300:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{t}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return any(hmac.compare_digest(s, expected) for s in signatures)

时间戳是签名字符串的一部分,你可以拒绝几分钟以前的通知。签名比较请使用常数时间算法。

响应与重试

尽快响应。我们最多等待 10 秒。

建议

  • 事件一存下来就返回 2xx。
  • 把耗时处理放进队列,先返回响应。
  • 把自己返回的 4xx 当作缺陷:它会终止重试。

避免

  • 不要在响应前执行长任务,请求 10 秒会超时。
  • 不要返回 3xx,跳转不会被跟随。
  • 不要构造很大的响应体,它会被直接丢弃不做读取。
第几次之前的等待activation.code_receivedcancelled 与 expired
1立即
210s
360s
45m
515m
630m

网络错误、超时、408、429 和 5xx 会重试。其他 4xx 会立刻终止重试阶梯。code_received 在第 4 次停止,因为后面的步骤还没轮到,激活早已结束。

去重

把 X-SMSBulk-Event-Id 当作幂等键。同一事件的重试携带相同 id。

至少一次,而不是恰好一次

每次激活的每种事件只创建一条记录,所以重复很少见。但卡住的任务被恢复时可能重复投递同一条记录。请保存事件 id,遇到已处理过的 id 就忽略。

熔断

失效的接收地址不会被无限调用。

  • 连续 20 次投递失败后,接收地址会被停用。
  • 你会收到邮件,控制台也会显示停用原因。
  • 停用期间产生的事件不会补发。请用 GET /v1/activations 补齐。

更换密钥

无中断地更换签名密钥。

  • rotate 接口一次性返回新密钥。
  • 过渡窗口内会同时发送新旧两个签名。
  • 按自己的节奏切换到新密钥,窗口结束后只发送新签名。

配置你的接收地址

注册 URL,发送测试事件,然后在控制台查看投递记录。