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: 1activation.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_received | cancelled 与 expired |
|---|---|---|---|
| 1 | 立即 | ||
| 2 | 10s | ||
| 3 | 60s | ||
| 4 | 5m | ||
| 5 | 15m | ||
| 6 | 30m |
网络错误、超时、408、429 和 5xx 会重试。其他 4xx 会立刻终止重试阶梯。code_received 在第 4 次停止,因为后面的步骤还没轮到,激活早已结束。
去重
把 X-SMSBulk-Event-Id 当作幂等键。同一事件的重试携带相同 id。
至少一次,而不是恰好一次
每次激活的每种事件只创建一条记录,所以重复很少见。但卡住的任务被恢复时可能重复投递同一条记录。请保存事件 id,遇到已处理过的 id 就忽略。
熔断
失效的接收地址不会被无限调用。
- 连续 20 次投递失败后,接收地址会被停用。
- 你会收到邮件,控制台也会显示停用原因。
- 停用期间产生的事件不会补发。请用 GET /v1/activations 补齐。
更换密钥
无中断地更换签名密钥。
- rotate 接口一次性返回新密钥。
- 过渡窗口内会同时发送新旧两个签名。
- 按自己的节奏切换到新密钥,窗口结束后只发送新签名。
