Webhook 通知(接入方需实现)

最后更新:2026-09-01 · 阅读约 10 分钟

Webhook 是小红书主动向接入方发起的服务端反向通知,用于将授权相关事件实时同步到接入方后端。 当前支持两类事件:授权撤销(authorization_revoked,用于同步清理会话与 Token) 与扫码确认加速(device_authorization_confirmed,用于设备授权流程的低时延通知)。

所有事件共享同一个 webhookUrl 与 webhookSecret, 接入方通过 X-Xhs-Event-Type 请求头区分事件类型并分派到对应处理逻辑。

💡
Webhook 与主动解绑接口的关系
接入方通过 主动解绑接口 发起的撤销,不会 触发 authorization_revoked Webhook(避免与接入方自身操作形成回声)。 仅「用户在小红书 App 内主动解绑」这一场景,才会推送。

1. 接入前置

  • 在管理中心提交 Webhook 接入申请,由小红书侧为应用开启 webhookEnabled=true
  • 提供接收地址 webhookUrl(必须为 HTTPS,公网可访问;同一地址同时接收两类事件)
  • 由小红书侧下发 webhookSecret(HMAC-SHA256 签名密钥,长度 ≥ 32 字符,只在申请时明文展示一次)
  • 接入方 Webhook 服务需具备幂等与快速返回能力(详见 §6)
  • 按需订阅事件类型:授权撤销默认开启;扫码确认加速为可选(设备授权应用建议开启)

2. 请求规范

字段值
MethodPOST
Path由接入方在 webhookUrl 中定义,示例 https://openapi.example.com/xhs/oauth/webhook
Content-Typeapplication/json; charset=UTF-8
User-AgentXiaohongshuOpenPlatform-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请求签名,算法见 §53e5a8c9d...(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_idstring与 X-Xhs-Event-Id 相同;幂等键,同一 event_id 可能因重试到达多次
event_typestring固定为 authorization_revoked
app_idstring被撤销授权的应用 ID
open_idstring被撤销授权的用户在该应用下的 open_id
revoke_sourcestring撤销来源,取值:user_app(用户在小红书 App 内主动解绑,当前唯一会触发 Webhook 的来源)/ system(系统清理场景)。partner_api(接入方主动撤销)场景不会推送该事件
reasonstring · 可选解绑原因;App 内用户解绑时不下发该字段
event_timeint64事件发生时间(毫秒级 Unix 时间戳),与 X-Xhs-Timestamp 一致;用于时钟偏移校验

4.2 扫码确认加速事件 device_authorization_confirmed

设备授权流程(车机 / Web 扫码)专属的可选加速事件。 当用户在小红书 App 内完成扫码授权时触发,接入方收到后可立即根据本地映射拿到 device_code 调用 轮询设备 Token 接口 换取 Token, 将端到端等待时延从「下一个轮询周期」压缩到毫秒级。

⚠
Webhook 是加速通道,不替代轮询
Webhook 不是 Token 获取的唯一路径。投递失败、延迟或接入方接口不可用时, 设备授权 Step 3 的轮询会按时兑付 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_idstring与 X-Xhs-Event-Id 相同;幂等键,同一 event_id 可能因重试到达多次
event_typestring固定为 device_authorization_confirmed
app_idstring本次设备授权对应的应用 ID
open_idstring · 可选授权用户的 OpenID,少数场景下可能不下发
user_codestring本次授权对应的用户码,形式为 XXXX-XXXX,与 deviceCode 接口返回值、二维码链接、手机授权页展示完全一致。接入方以 app_id + user_code 定位本地设备授权记录并取出 device_code,原样比对即可,无需自行去连字符
scenestring · 可选设备授权场景,例如 car,与创建设备授权时传入的一致
event_timeint64事件发生时间(毫秒级 Unix 时间戳),与 X-Xhs-Timestamp 一致;用于时钟偏移校验
✕
安全约束
Body 不含 device_code / access_token / refresh_token, 避免任何令牌类高熵凭证在反向通道上传输。 user_code 是短生命周期的一次性凭证,仅用于接入方本地映射查找, 本身不能换取 Token。

接入方处理建议:

  1. 验签通过后立刻按 app_id + user_code 在本地查询已下发的 device_code
  2. 使用查到的 device_code 调用轮询设备 Token 接口换取 Token,无需再等下一个轮询周期
  3. 若本地找不到 user_code(已过期 / 时序上早于本地落库),直接返回 HTTP 200 结束本次事件,等待轮询兜底,不要返回失败
  4. 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)——它是接入方告知小红书「本次事件处理结果」的唯一通道, 直接决定小红书是否重投。

字段类型必填说明
codeint是回执码,取值必须来自 §6.1 码表。0 表示处理成功(含幂等命中)
msgstring否可读的补充描述,仅用于排障,不参与任何判定。建议携带接入方自己的 traceId
// 成功(含幂等命中:该 event_id 之前已处理过)
{ "code": 0, "msg": "success" }

// 处理失败(是否重投由 code 决定,见 §6.1)
{ "code": 3001, "msg": "db timeout, trace=7f3a2b" }

6.1 回执码表(接入方 → 小红书 方向)

⚠
回执码由小红书定义,请勿自行造码
本码表与小红书 OpenAPI 对外返回的 error_code(5 位分段,见 错误码说明)是两套独立的命名空间,回执码统一为 4 位。 接入方返回码表之外的值(例如自定义的 40003)时,小红书无法识别其重试语义, 只能按「可重试」兜底处理——结果就是本可以立刻暴露的配置问题,被拖成 8 次退避、约 15.7 小时后才告警。
code含义小红书是否重投典型场景
0success不重投(标记成功)处理成功,或 event_id 幂等命中
1001invalid_signature不重投,立即死信并告警签名校验不通过。密钥不一致或验签实现有误,重投结果不变,需人工介入
1002timestamp_expired重投时间戳超出 ±5min 或判定为重放。小红书每次重投都会用新时间戳重新签名,时钟恢复后即可成功
1003invalid_payload不重投,立即死信并告警报文无法解析或必填字段缺失
1004unknown_event_type不重投,立即死信并告警接入方未实现该 event_type 的处理分支
1005unknown_app不重投,立即死信并告警payload 中的 app_id 在接入方侧不存在或已下线
2001resource_not_found不重投本地无对应记录而主动放弃。如 device_authorization_confirmed 到达时本地 device_code 已过期——此时轮询仍会兜底兑付 Token
3001internal_error重投接入方内部异常(DB 抖动、下游超时)
3002rate_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。

⚠
务必先快速返回,再异步处理
建议接入方收到请求后:先落幂等表 → 立刻返回 200 → 再异步执行清理业务。 同步执行业务耗时超过 5s 会导致小红书判定为超时并重试,产生重复投递(P99 建议控制在 1s 以内)。

7. 重试策略(指数退避,共 8 次)

投递模型:事件落库后小红书会立即通过专用异步线程池发起一次首投, 典型端到端延迟为一次 HTTP RTT(P50 亚秒);首投失败或高峰被降级后,由后台扫表任务在数秒内接管重试。 接入方看到的 payload / 签名在首投和重试之间完全一致。

首次投递失败后,小红书会按以下间隔依次重试;累计 8 次重试全部失败后, 事件标记为 FAILED 并触发告警,接入方可联系小红书人工补投。

⚠
不可重投的回执会跳过退避,立即死信
当接入方回执为 1001 / 1003 / 1004 / 1005 / 2001, 或 HTTP 返回 401 / 403 / 404 / 410 时, 重投同样的 payload 结果不会改变,小红书会跳过下表的退避序列直接置死信并告警。 这是刻意设计:把配置类故障的暴露时间从约 15.7 小时压缩到一次投递以内。
次数距离上一次的间隔距离首次投递的累计时间
130 秒30 秒
22 分钟约 2.5 分钟
310 分钟约 12.5 分钟
430 分钟约 42.5 分钟
51 小时约 1.7 小时
62 小时约 3.7 小时
74 小时约 7.7 小时
88 小时约 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 错。请按下列顺序自查:

  1. 务必读取原始字节 body,不要用解析后的 JSON 重新序列化。 Spring MVC / Servlet 场景推荐两种方案:
    • 用 Filter 缓存 InputStream(ContentCachingRequestWrapper)
    • Controller 声明为 @RequestBody byte[] rawBody
    禁止使用 objectMapper.readTree(body).toString() 后再算签名——Jackson 会重排字段、插入空格或规范化数字,导致签名不一致。
  2. 不要 trim / 转码 / 美化 body。 签名内容严格是 X-Xhs-Timestamp + "." + rawBodyBytes(按 UTF-8 解码),一个多余空格 / 末尾 \n / \uXXXX 转义都会导致校验失败。
  3. Hex 必须小写。 Java 17+ 可用 HexFormat.of(),或 String.format("%02x", b);Apache Commons Codec 的 Hex.encodeHexString(bytes, true) 默认小写。
  4. HMAC key 直接使用 secret 的 UTF-8 字节,不要做 Base64 / Hex 解码: new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")。
  5. 用 curl 复现时,使用 --data-raw(或 --data-binary @body.json),不要用 -d。 -d 会去掉换行、合并空白,实际发到服务端的字节和你离线算签名用的 body 不同,验签必然失败;文件末尾也不要留 \n。
  6. 确认 webhookUrl 不会发生跳转。 若配置的地址会 301 / 302 到另一个地址(http→https、结尾斜杠差异、网关统一跳转), 小红书不会跟随重定向,请求体不会到达最终服务;若跳转由接入方自己的网关内部完成, 最终服务往往只拿到被网关重写过的 body 或空 body,验签必然失败。请把 webhookUrl 直接配成最终地址。
  7. 确认网关 / WAF 没有改写请求体。 body 压缩、JSON 美化、字符集转换、追加 traceId 字段等任何改写都会让下游算出不同签名, 可对比接入方入口日志的 Content-Length 与最终服务读到的 body 字节数是否一致。
  8. 本地脚本复现验签。 从接入方访问日志抓出 body 原始字节(含长度),用 Python / Node 单独跑一遍 HMAC,与 header 中 X-Xhs-Signature 比对;如果对得上但框架仍报 401,则框架读到的是旧的 InputStream 缓冲,回到第 1 步排查。

小红书侧发出的 body 保证是「单行、无多余空白、字段顺序按 §3 / §4 表格顺序」的紧凑 JSON(Jackson ObjectMapper 默认输出)。

下一步