Webhook 通知(接入方需实现)
Webhook 是小红书主动向接入方发起的服务端反向通知,用于将授权相关事件实时同步到接入方后端。
当前支持两类事件:授权撤销(authorization_revoked,用于同步清理会话与 Token)
与扫码确认加速(device_authorization_confirmed,用于设备授权流程的低时延通知)。
所有事件共享同一个 webhookUrl 与 webhookSecret,
接入方通过 X-Xhs-Event-Type 请求头区分事件类型并分派到对应处理逻辑。
authorization_revoked Webhook(避免与接入方自身操作形成回声)。
仅「用户在小红书 App 内主动解绑」这一场景,才会推送。
1. 接入前置
- 在管理中心提交 Webhook 接入申请,由小红书侧为应用开启
webhookEnabled=true - 提供接收地址
webhookUrl(必须为 HTTPS,公网可访问;同一地址同时接收两类事件) - 由小红书侧下发
webhookSecret(HMAC-SHA256 签名密钥,长度 ≥ 32 字符,只在申请时明文展示一次) - 接入方 Webhook 服务需具备幂等与快速返回能力(详见 §6)
- 按需订阅事件类型:授权撤销默认开启;扫码确认加速为可选(设备授权应用建议开启)
2. 请求规范
| 字段 | 值 |
|---|---|
| Method | POST |
| Path | 由接入方在 webhookUrl 中定义,示例 https://openapi.example.com/xhs/oauth/webhook |
| Content-Type | application/json; charset=UTF-8 |
| User-Agent | XiaohongshuOpenPlatform-Webhook/1.0 |
| 协议 | HTTPS(TLS 1.2+) |
| 连接超时 / 读超时 | 3s / 5s(读超时即 §6 要求的 5s 内返回) |
3. 请求 Header
除 Content-Type / User-Agent 外,小红书会在请求头中附加以下 4 个 X-Xhs-* Header:
| Header | 说明 | 示例 |
|---|---|---|
X-Xhs-Event-Id | 本次事件的全局唯一 ID,接入方以此做幂等键 | evt_9a7f8c3b2e1d4a5f |
X-Xhs-Event-Type | 事件类型,取值 authorization_revoked 或 device_authorization_confirmed,接入方按此字段分派处理 | authorization_revoked |
X-Xhs-Timestamp | 请求发起时的毫秒级 Unix 时间戳(13 位),参与签名计算 | 1786608000000 |
X-Xhs-Signature | 请求签名,算法见 §5 | 3e5a8c9d...(64 位 hex) |
4. 请求 Body
所有事件共享统一的顶层字段(event_id / event_type / app_id / event_time),
各事件专属字段见对应小节。所有时间戳均为毫秒级 Unix 时间戳(13 位)。
4.1 授权撤销事件 authorization_revoked
当用户在小红书 App「账号与安全 → 已授权的应用」中主动解除对某个应用的授权时触发, 接入方收到后应同步清理本地会话、Token 缓存或用户绑定关系。
{
"event_id": "evt_9a7f8c3b2e1d4a5f",
"event_type": "authorization_revoked",
"app_id": "xhs_abc123",
"open_id": "5f4cxxxxxxxx",
"revoke_source": "user_app", // 撤销来源,见下表
"reason": "user_unbind", // 可选:解绑原因,App 内用户解绑时不下发本字段
"event_time": 1786608000000 // 事件发生时间(毫秒级),与 X-Xhs-Timestamp 一致
}
| 字段 | 类型 | 说明 |
|---|---|---|
event_id | string | 与 X-Xhs-Event-Id 相同;幂等键,同一 event_id 可能因重试到达多次 |
event_type | string | 固定为 authorization_revoked |
app_id | string | 被撤销授权的应用 ID |
open_id | string | 被撤销授权的用户在该应用下的 open_id |
revoke_source | string | 撤销来源,取值:user_app(用户在小红书 App 内主动解绑,当前唯一会触发 Webhook 的来源)/ system(系统清理场景)。partner_api(接入方主动撤销)场景不会推送该事件 |
reason | string · 可选 | 解绑原因;App 内用户解绑时不下发该字段 |
event_time | int64 | 事件发生时间(毫秒级 Unix 时间戳),与 X-Xhs-Timestamp 一致;用于时钟偏移校验 |
4.2 扫码确认加速事件 device_authorization_confirmed
设备授权流程(车机 / Web 扫码)专属的可选加速事件。
当用户在小红书 App 内完成扫码授权时触发,接入方收到后可立即根据本地映射拿到 device_code
调用 轮询设备 Token 接口 换取 Token,
将端到端等待时延从「下一个轮询周期」压缩到毫秒级。
{
"event_id": "5e4c9c2a-3b8d-4a67-b0f1-c9e2d7a83f11",
"event_type": "device_authorization_confirmed",
"app_id": "car_oem_ABC123",
"open_id": "5f4cxxxxxxxx", // 可选:授权用户的 OpenID
"user_code": "WDJB-MJHT", // 与 deviceCode 接口返回值完全一致的展示形式
"scene": "car", // 可选:与创建设备授权时的 scene 一致
"event_time": 1786600000000 // 事件发生时间(毫秒级),与 X-Xhs-Timestamp 一致
}
| 字段 | 类型 | 说明 |
|---|---|---|
event_id | string | 与 X-Xhs-Event-Id 相同;幂等键,同一 event_id 可能因重试到达多次 |
event_type | string | 固定为 device_authorization_confirmed |
app_id | string | 本次设备授权对应的应用 ID |
open_id | string · 可选 | 授权用户的 OpenID,少数场景下可能不下发 |
user_code | string | 本次授权对应的用户码,形式为 XXXX-XXXX,与 deviceCode 接口返回值、二维码链接、手机授权页展示完全一致。接入方以 app_id + user_code 定位本地设备授权记录并取出 device_code,原样比对即可,无需自行去连字符 |
scene | string · 可选 | 设备授权场景,例如 car,与创建设备授权时传入的一致 |
event_time | int64 | 事件发生时间(毫秒级 Unix 时间戳),与 X-Xhs-Timestamp 一致;用于时钟偏移校验 |
device_code / access_token / refresh_token,
避免任何令牌类高熵凭证在反向通道上传输。
user_code 是短生命周期的一次性凭证,仅用于接入方本地映射查找,
本身不能换取 Token。
接入方处理建议:
- 验签通过后立刻按
app_id + user_code在本地查询已下发的device_code - 使用查到的
device_code调用轮询设备 Token 接口换取 Token,无需再等下一个轮询周期 - 若本地找不到
user_code(已过期 / 时序上早于本地落库),直接返回 HTTP 200 结束本次事件,等待轮询兜底,不要返回失败 - Token 换取的实际业务处理建议异步执行,Webhook 响应体尽早返回 200
5. HMAC-SHA256 签名算法
小红书使用申请时下发的 webhookSecret 对 timestamp + "." + body 计算
HMAC-SHA256,结果以小写 hex 字符串放入 X-Xhs-Signature。
接入方需按同样算法在本地重算并做常量时间比较,防止请求被伪造 / 重放。
签名字符串
signBase = X-Xhs-Timestamp + "." + rawRequestBody
signature = hex_lower( HMAC_SHA256(webhookSecret, signBase) )
注意:rawRequestBody 必须使用接收到的原始字节参与签名,
不要先反序列化再重新序列化(字段顺序 / 空白字符差异会导致签名不一致)。
Java 参考实现
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
public boolean verify(String secret, String timestamp, byte[] rawBody, String receivedSig) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update(timestamp.getBytes(StandardCharsets.UTF_8));
mac.update((byte) '.');
mac.update(rawBody);
byte[] expected = mac.doFinal();
StringBuilder hex = new StringBuilder(expected.length * 2);
for (byte b : expected) {
hex.append(String.format("%02x", b & 0xff));
}
// 常量时间比较,避免 timing attack
return MessageDigest.isEqual(
hex.toString().getBytes(StandardCharsets.UTF_8),
receivedSig.getBytes(StandardCharsets.UTF_8)
);
} catch (Exception e) {
return false;
}
}
6. 期望响应(回执规范)
接入方必须在 5 秒内 返回 HTTP 200,响应 Body 为如下 JSON 结构。
下文称之为回执(Ack)——它是接入方告知小红书「本次事件处理结果」的唯一通道,
直接决定小红书是否重投。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
code | int | 是 | 回执码,取值必须来自 §6.1 码表。0 表示处理成功(含幂等命中) |
msg | string | 否 | 可读的补充描述,仅用于排障,不参与任何判定。建议携带接入方自己的 traceId |
// 成功(含幂等命中:该 event_id 之前已处理过)
{ "code": 0, "msg": "success" }
// 处理失败(是否重投由 code 决定,见 §6.1)
{ "code": 3001, "msg": "db timeout, trace=7f3a2b" }
6.1 回执码表(接入方 → 小红书 方向)
error_code(5 位分段,见
错误码说明)是两套独立的命名空间,回执码统一为 4 位。
接入方返回码表之外的值(例如自定义的 40003)时,小红书无法识别其重试语义,
只能按「可重试」兜底处理——结果就是本可以立刻暴露的配置问题,被拖成 8 次退避、约 15.7 小时后才告警。
| code | 含义 | 小红书是否重投 | 典型场景 |
|---|---|---|---|
0 | success | 不重投(标记成功) | 处理成功,或 event_id 幂等命中 |
1001 | invalid_signature | 不重投,立即死信并告警 | 签名校验不通过。密钥不一致或验签实现有误,重投结果不变,需人工介入 |
1002 | timestamp_expired | 重投 | 时间戳超出 ±5min 或判定为重放。小红书每次重投都会用新时间戳重新签名,时钟恢复后即可成功 |
1003 | invalid_payload | 不重投,立即死信并告警 | 报文无法解析或必填字段缺失 |
1004 | unknown_event_type | 不重投,立即死信并告警 | 接入方未实现该 event_type 的处理分支 |
1005 | unknown_app | 不重投,立即死信并告警 | payload 中的 app_id 在接入方侧不存在或已下线 |
2001 | resource_not_found | 不重投 | 本地无对应记录而主动放弃。如 device_authorization_confirmed 到达时本地 device_code 已过期——此时轮询仍会兜底兑付 Token |
3001 | internal_error | 重投 | 接入方内部异常(DB 抖动、下游超时) |
3002 | rate_limited | 重投 | 接入方限流,小红书按指数退避错峰重投 |
「立即死信」指小红书停止重投、将事件置为 FAILED 并触发告警;事件仍保留在小红书侧,
双方修复后可联系接口人人工补投,不会丢失。
6.2 小红书侧的完整判定规则
| 响应 | 小红书侧行为 |
|---|---|
HTTP 2xx + {"code": 0, ...} | 视为投递成功,标记事件为 SUCCESS,不再重投 |
HTTP 2xx + {"code": <非0>, ...} | 按 §6.1 码表的重投语义处理;错误详情落库便于排障 |
| HTTP 2xx + 码表之外的非 0 码 | 无法识别语义,一律按「可重投」兜底走满 §7 退避;日志标记 ack code out of spec,请接入方对齐码表 |
HTTP 2xx + 无回执码字段(e.g. 空 / "ok") | 历史兼容:仍视为投递成功。强烈建议按本节结构返回,否则处理失败会被误判为成功且永不重投 |
| HTTP 3xx 跳转 | 不跟随,判定为投递失败并重投。webhookUrl 必须填最终地址(含正确协议与结尾斜杠),否则请求体不会送达,接入方必然验签失败 |
| HTTP 401 / 403 / 404 / 410 | 视为接入方明确拒绝或端点不存在,不重投,立即死信并告警 |
| 其余 4xx(含 408 / 429)、全部 5xx、超时、连接失败 | 按 §7 退避策略重投 |
字段名兼容:除 code 外,小红书也会识别 error_code / errcode / errno
(描述取 msg / error_msg / message / errmsg),用于兼容历史接入方;
新接入一律使用 code + msg。
7. 重试策略(指数退避,共 8 次)
投递模型:事件落库后小红书会立即通过专用异步线程池发起一次首投, 典型端到端延迟为一次 HTTP RTT(P50 亚秒);首投失败或高峰被降级后,由后台扫表任务在数秒内接管重试。 接入方看到的 payload / 签名在首投和重试之间完全一致。
首次投递失败后,小红书会按以下间隔依次重试;累计 8 次重试全部失败后,
事件标记为 FAILED 并触发告警,接入方可联系小红书人工补投。
1001 / 1003 / 1004 / 1005 / 2001,
或 HTTP 返回 401 / 403 / 404 / 410 时,
重投同样的 payload 结果不会改变,小红书会跳过下表的退避序列直接置死信并告警。
这是刻意设计:把配置类故障的暴露时间从约 15.7 小时压缩到一次投递以内。
| 次数 | 距离上一次的间隔 | 距离首次投递的累计时间 |
|---|---|---|
| 1 | 30 秒 | 30 秒 |
| 2 | 2 分钟 | 约 2.5 分钟 |
| 3 | 10 分钟 | 约 12.5 分钟 |
| 4 | 30 分钟 | 约 42.5 分钟 |
| 5 | 1 小时 | 约 1.7 小时 |
| 6 | 2 小时 | 约 3.7 小时 |
| 7 | 4 小时 | 约 7.7 小时 |
| 8 | 8 小时 | 约 15.7 小时 |
同一 event_id 在重试期间可能被多次投递,接入方必须以 event_id 作为幂等键去重。
8. 时间与时钟
- 请求头
X-Xhs-Timestamp与 Body 中event_time保持一致,均为毫秒级 Unix 时间戳(UTC,13 位) - 接入方应校验
|now_ms - X-Xhs-Timestamp| ≤ 300 000(毫秒),超出视为过期或重放,回执{"code":1002}拒绝(该码可重投,小红书会用新时间戳重新签名) - 接入方服务器应开启 NTP 时钟同步,避免时钟漂移导致签名 / 时间戳校验误判
9. 安全要求
- 必须 校验
X-Xhs-Signature,签名不通过禁止执行任何业务逻辑,并回执{"code":1001}(HTTP 200);若接入方框架习惯用 HTTP 状态码表达,返回401也会被识别为同一语义 - 必须 校验
X-Xhs-Timestamp时钟偏移(见 §8),防止请求重放 - 必须 使用
event_id做幂等去重,同一事件业务只执行一次 webhookSecret只能保存在接入方服务端,禁止下发到前端 / 客户端 / 日志- Webhook 接收端点应仅接受 POST,禁止暴露 GET / OPTIONS 等便于探测的方法
- 建议在网关层配置 IP 白名单(小红书出栈 IP 段将在申请时提供)
- 发生签名校验失败、时钟异常、幂等命中等情况时,接入方应输出结构化日志便于排查
10. 排查与联调
- 联调期请在管理中心切换
webhookUrl至沙箱地址,勿使用生产端点 - 可在管理中心「Webhook 事件」页查看最近 7 天的投递记录(含 event_id、请求头、Body、响应码、重试次数)
- 连续失败达到阈值时,小红书会自动临时降级该应用的
webhookEnabled并触发告警,接入方修复后需联系小红书重开 - 联调问题请携带
event_id与X-Xhs-Timestamp联系接口人
10.1 签名不通过(回执 1001 / HTTP 401)常见原因
签名不通过 99% 是「读 body 的方式不对」,而不是 secret 错。请按下列顺序自查:
-
务必读取原始字节 body,不要用解析后的 JSON 重新序列化。
Spring MVC / Servlet 场景推荐两种方案:
- 用
Filter缓存InputStream(ContentCachingRequestWrapper) - Controller 声明为
@RequestBody byte[] rawBody
objectMapper.readTree(body).toString()后再算签名——Jackson 会重排字段、插入空格或规范化数字,导致签名不一致。 - 用
-
不要 trim / 转码 / 美化 body。
签名内容严格是
X-Xhs-Timestamp + "." + rawBodyBytes(按 UTF-8 解码),一个多余空格 / 末尾\n/\uXXXX转义都会导致校验失败。 -
Hex 必须小写。
Java 17+ 可用
HexFormat.of(),或String.format("%02x", b);Apache Commons Codec 的Hex.encodeHexString(bytes, true)默认小写。 -
HMAC key 直接使用 secret 的 UTF-8 字节,不要做 Base64 / Hex 解码:
new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")。 -
用 curl 复现时,使用
--data-raw(或--data-binary @body.json),不要用-d。-d会去掉换行、合并空白,实际发到服务端的字节和你离线算签名用的 body 不同,验签必然失败;文件末尾也不要留\n。 -
确认
webhookUrl不会发生跳转。 若配置的地址会 301 / 302 到另一个地址(http→https、结尾斜杠差异、网关统一跳转), 小红书不会跟随重定向,请求体不会到达最终服务;若跳转由接入方自己的网关内部完成, 最终服务往往只拿到被网关重写过的 body 或空 body,验签必然失败。请把webhookUrl直接配成最终地址。 -
确认网关 / WAF 没有改写请求体。
body 压缩、JSON 美化、字符集转换、追加 traceId 字段等任何改写都会让下游算出不同签名,
可对比接入方入口日志的
Content-Length与最终服务读到的 body 字节数是否一致。 -
本地脚本复现验签。
从接入方访问日志抓出 body 原始字节(含长度),用 Python / Node 单独跑一遍 HMAC,与 header 中
X-Xhs-Signature比对;如果对得上但框架仍报 401,则框架读到的是旧的 InputStream 缓冲,回到第 1 步排查。
小红书侧发出的 body 保证是「单行、无多余空白、字段顺序按 §3 / §4 表格顺序」的紧凑 JSON(Jackson ObjectMapper 默认输出)。
下一步
- 查看 主动解绑接口(应用服务端主动撤销 access_token)
- 查看 37010-37014 解绑相关错误码
- 查看 设备授权(车机 / Web 扫码),了解如何配合扫码确认加速事件优化授权时延