创建订阅
所有 Webhook 管理接口需要账户 Cookie 会话并实施租户隔离。目标须为公网 HTTPS,禁止私网、本机、云元数据地址与重定向绕过。签名密钥只在创建时返回一次。
{
"url": "https://your-domain.com/webhooks/ir21",
"events": [
"ir21.number_range.updated"
],
"countries": [
"CN"
],
"operators": []
}| 接口 | 请求 / 返回 data |
|---|---|
GET /console/webhooks | {items:WebhookSubscription[]} |
POST /console/webhooks | body {url,events,countries?,operators?} → {item,secret} |
PATCH /console/webhooks/:id | body {url?,events?,countries?,operators?,enabled?} → {item} |
DELETE /console/webhooks/:id | {deleted:true} |
POST /console/webhooks/:id/test | {delivery_id,queued:true},测试事件标记 test:true。 |
GET /console/webhooks/:id/deliveries?cursor&limit&status&event_id | {items:WebhookDelivery[],total,next_cursor}; limit=1–100 |
GET /console/webhooks/:id/deliveries/:deliveryId | {item,attempts:[{id,attempted_at,response_status,error}],event} |
POST /console/webhooks/:id/deliveries/retry-batch | body {delivery_ids:string[]} (1–20) → {queued,delivery_ids} |
POST /console/webhooks/:id/rotate-secret | body {grace_seconds?:0–86400} → {item,secret,previous_secret_expires_at} |
DELETE /console/webhooks/:id/previous-secret | {revoked:true} |
POST /console/webhooks/:id/deliveries/:deliveryId/retry | {queued:true},保持相同 event ID。 |
事件类型与载荷
ir21.number_range.added|removed|updated、ir21.operator.added|removed|updated、ir21.network.added|removed|updated、ir21.capability.changed、ir21.catalog.updated。
{
"id": "example-event",
"type": "ir21.number_range.updated",
"occurred_at": "2026-09-08T00:00:00Z",
"effective_at": null,
"dataset_version": "example-v2",
"previous_version": "example-v1",
"country_iso2": "CN",
"operator_id": "example-operator",
"data": {
"summary": "合成事件,仅用于签名演示"
},
"test": true
}签名验证
| Header | 含义 |
|---|---|
X-IR21-Event-Id | 稳定事件 ID;接收端以此去重。 |
X-IR21-Timestamp | Unix 秒,用于检查接收时间窗口。 |
X-IR21-Signature | v1=<hex>;HMAC-SHA256(secret, `${timestamp}.${rawBody}`)。 |
先保留未经重新序列化的原始 body 字节,再验证签名。建议 5 分钟窗口,采用常量时间比较。验证通过后再解析 JSON、去重并处理。
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyWebhook(rawBody, headers, secrets) {
const timestamp = headers['x-ir21-timestamp'];
if (typeof timestamp !== 'string' || !/^\d+$/.test(timestamp)) return false;
const seconds = Number(timestamp);
if (!Number.isSafeInteger(seconds) || Math.abs(Date.now() / 1000 - seconds) > 300) return false;
const signatures = [headers['x-ir21-signature'], headers['x-ir21-signature-previous']]
.filter(value => typeof value === 'string' && /^v1=[a-f0-9]{64}$/i.test(value));
return secrets.filter(Boolean).some(secret => {
const expected = createHmac('sha256', secret)
.update(timestamp + '.').update(rawBody).digest();
return signatures.some(signature => {
const received = Buffer.from(signature.slice(3), 'hex');
return received.length === expected.length && timingSafeEqual(expected, received);
});
});
}
// rawBody 使用原始 Buffer;宽限期结束后移除本地旧密钥。
// 验签通过后核对 header event ID 与 body.id,并在事务中去重后处理。投递、失败与恢复
轮换前先部署支持双签名的接收端。宽限期默认 1 小时,最长 24 小时,可选立即失效。新密钥对应 X-IR21-Signature;旧密钥在宽限期内对应 X-IR21-Signature-Previous。接收端可验证任一有效签名,到期后移除本地旧密钥;如果疑似泄露,应立即撤销旧密钥。一次只允许一组旧密钥宽限期。API Key 使用“创建新 Key→替换服务配置并验证调用→撤销旧 Key”的重叠轮换流程。
仓库 examples/webhook-receiver.mjs 提供完整接收端:使用 CELRYN_WEBHOOK_SECRET 和私有 CELRYN_WEBHOOK_INBOX。如设置旧密钥 CELRYN_WEBHOOK_PREVIOUS_SECRET,必须同时设置固定的 ISO 到期时间 CELRYN_WEBHOOK_PREVIOUS_SECRET_EXPIRES_AT,重启不会延长宽限期。它监听 127.0.0.1:4200 的 POST /webhooks/celryn,生产置于 HTTPS 反向代理后;在验签、事件 ID 去重与原子入箱后才返回成功。通知应触发同步补拉,不按可能乱序到达的通知直接覆写目录。
ir21.catalog.updated 表示全局重置,即使设置了地区或运营商筛选也必须处理。增量查询的 category 筛选同样不能屏蔽它;来源元数据变化可能要求重新获取完整快照。
WebhookDelivery 包含 id,event_id,event_type,status,attempts,response_status,last_error,created_at,next_attempt_at。状态为 pending / delivered / failed / cancelled。队列成功只表示已排队,不代表目的地收到。
平台执行有限超时和重试,并持久化结果。停用订阅会停止待发任务;手动重投仍保留原 event ID。接收端应快速应答成功状态,后续处理独立入队。若通知遗漏,使用 /v1/changes 按版本补拉,恢复时检查事件顺序和生效时间。
响应、鉴权与错误
数据接口支持X-API-Key 或同源 Cookie 会话。匿名访问为受限公共投影,完整字段权限由 API 服务执行。JSON 成功响应统一为{code:0,data,meta};文件导出直接返回文件。
| 字段 | 说明 |
|---|---|
meta.request_id | 本次请求标识,用于排查错误,不包含密钥。 |
meta.dataset_version / source_mode | 当前数据版本与 demo / ir21 / reference / mixed 来源模式。 |
meta.source_published_at | 源发布时间;未知为 null。 |
meta.ingested_at | 平台导入时间,ISO 格式 UTC。 |
400 表示参数不符,401 表示需要有效凭证,403 表示权限不足,429 表示调用限制,503 表示服务或数据源暂不可用。请读取错误响应的 code、message、request_id,详见错误与重试。
{
"code": "EXAMPLE_ERROR_CODE",
"message": "具体错误原因由服务端返回",
"request_id": "example-request-id"
}