Webhook 实战指南

从概念到 Java 落地的完整笔记
1. Webhook 是什么
一句话定义:
Webhook = "反向 API"。平时是你的服务去调别人的接口拉数据;Webhook 则是事件一发生,对方主动向你预留的 URL 发一次 HTTP POST。
1.1 与轮询(Polling)对比
维度
轮询
Webhook
实时性
取决于轮询间隔
事件发生即推送
无效开销
大量空查询请求
基本没有
接入前提
有查询接口即可
需公网可达 URL + 验签
可靠性
漏了下一轮再问
平台重试 + 你做幂等
轮询的时序:你的服务反复问"有新的吗?",绝大多数回答是"无"; Webhook 的时序:事件发生 → 平台一次 POST 事件数据 → 你回 200 OK,结束。

1.2 概念本质
Webhook 不是框架组件,没有 webhook.jar 这种东西。它是一种集成模式/约定,本质上就四个字:HTTP 回调。任何语言、任何框架都能实现。
概念关系辨析:
-
回调(callback) — 编程通用思想:"你先留个函数/地址,条件到了我来调"
-
Webhook — 回调思想在 Web 层的标准实现,载体是 HTTP POST
-
MQ/消息队列 — 也是"事件通知",但需要你连消息中间件;Webhook 只要求对方能访问你的 HTTP 服务,所以跨企业集成(微信、GitHub、飞书)几乎都用它
成立 Webhook 的三个特征:
-
事件驱动 — 是"某件事发生了"(支付成功、代码 push),不是人想发就手动发一次
-
预先登记 — URL 是提前在对方平台注册过的,不是随机的
-
平台主动推送 — 发起方是"事件所在的服务方",不是你
⚠️ 注意:随手用 curl POST 一条数据到某个接口,只是普通 HTTP 调用,不算 Webhook;但被预留出来专门接收事件通知的接口,就叫 webhook endpoint(接收器)。
⚠️ 方向陷阱:飞书/钉钉的"群机器人 Webhook"是 Incoming Webhook,方向相反——是你向平台 POST 消息,不是平台推给你。
分工原则:查询用 API,变更通知用 Webhook。两者互补而非替代——Webhook 只告诉你"发生了什么",细节仍要靠 API 去查;重要系统常两者并存(Webhook 丢了还有轮询兜底)。

2. Webhook 的组成五要素
-
回调 URL — 你在平台后台登记的接收地址,必须公网可达,通常要求 HTTPS
-
事件订阅 — 只声明你关心的事件类型(如
pay.success),不相关的不推 -
POST Payload — 事件详情以 JSON 放在请求体里,附签名头
-
验签 — 用平台给的 secret 校验签名(通常是 HMAC-SHA256),防止别人伪造请求
-
应答与重试 — 先快速返回 2xx 表示收到,耗时业务丢异步队列;超时或 5xx 时平台会自动重试,所以处理逻辑必须幂等
一次调用的完整生命周期:
① 注册回调 URL 在平台后台登记接收地址 + 订阅事件
② 事件发生 用户支付成功 / 代码 push / 审批通过……
③ 平台推送 POST JSON body + 签名头(如 X-Sign: sha256=…)
④ 验签并快速应答 校验签名 → 业务丢异步队列 → 几秒内返回 2xx
⑤ 重试与幂等 超时/5xx 触发自动重试;接收方按 event_id 去重
3. Java 实战:接收方向(你是接收方)
关键认知:接收 Webhook 不需要"引入什么依赖",你只需要在已有 Web 服务里暴露一个 HTTP 端点。依赖清单就一项:Web 框架(Spring Boot,项目里本来就有)。
3.1 Controller
@RestController
@RequestMapping("/hooks")
public class PayWebhookController {
private static final String SECRET = "平台后台发放的密钥";
private final PayEventService service;
public PayWebhookController(PayEventService service) {
this.service = service;
}
@PostMapping("/pay")
public ResponseEntity<String> onPay(
@RequestBody String rawBody, // 用原始字符串验签,别先反序列化
@RequestHeader("X-Sign") String sign,
@RequestHeader("X-Timestamp") long ts) {
// 1. 验签,防伪造/防重放
if (!HmacUtil.verify(rawBody, sign, ts, SECRET)) {
return ResponseEntity.status(401).body("invalid sign");
}
// 2. 交给异步处理(内部按 event_id 去重 = 幂等)
service.handleAsync(Json.parse(rawBody, PayEvent.class));
// 3. 快速应答,几秒内返回 2xx
return ResponseEntity.ok("ok");
}
}
3.2 验签(JDK 标准库即可)
public final class HmacUtil {
public static boolean verify(String body, String sign, long ts, String secret) {
try {
if (Math.abs(System.currentTimeMillis() - ts) > 5 * 60_000) return false; // 防重放
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = HexFormat.of().formatHex(
mac.doFinal((ts + "." + body).getBytes(StandardCharsets.UTF_8)));
return MessageDigest.isEqual(expected.getBytes(), sign.getBytes());
} catch (Exception e) {
return false;
}
}
}
3.3 平台官方 SDK 的角色
wechatpay-java、stripe-java 这类 SDK 不是"webhook 客户端",只是帮你把验签和事件解析封装好了,省去手写 HMAC,流程与上面完全一致。
3.4 本地调试:内网穿透
平台必须能从公网访问到你的 URL:
ngrok http 8080
# 把生成的 https://xxx.ngrok.io/hooks/pay 填到平台后台
4. Java 实战:发送方向(你是发送方)
比如给飞书群机器人推消息,任何 HTTP 客户端都行,JDK 自带就够:
HttpClient client = HttpClient.newHttpClient();
String payload = "{\"msg_type\":\"text\",\"content\":{\"text\":\"部署完成\"}}";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://open.feishu.cn/open-apis/bot/v2/hook/xxx"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
client.send(req, HttpResponse.BodyHandlers.ofString());
发送方的两个镜像义务(对应接收方的"验签、幂等"):
-
payload 里必须带唯一
event_id,让对方能去重(对方要做幂等,你得给他钥匙) -
URL 由对方在你的平台/对接文档里登记,签名 secret 也是此时发给对方的
5. 发送方生产化:从"写在支付代码里"到可靠推送
5.1 最朴素版(能跑,但有隐患)
public void onPaySuccess(Order order) {
saveOrder(order);
// 通知下游系统
httpClient.post("https://partner.com/hooks/pay", toJson(order));
}
这"就是 webhook",模式成立——哪怕公司内部两个系统之间也完全可以用。但生产上会踩三个坑:
-
对方暂时挂了怎么办 → 需要重试(退避:10s → 1m → 10m)
-
我这边重启了,还没发出去的通知就丢了 → 需要先落库/进队列
-
HTTP 请求卡住,支付主流程被拖慢甚至失败 → 必须异步解耦
5.2 生产版
public void onPaySuccess(Order order) {
saveOrder(order);
eventRepository.save(new PayEvent(order)); // 1. 只落一条事件记录,主流程到此结束
// 2. 独立的推送任务/定时器异步消费:POST 到对方登记的 URL
// → 成功标记 done
// → 失败更新 next_retry_time,退避重试
}
数据流:
支付成功 → 写事件表 → [推送服务] → POST 商户URL → 2xx 完成
↘ 超时/5xx → 按退避表重试,多次失败告警
5.3 "独立推送任务"的三种实现(定时任务不是必须的)
核心诉求只有两个:异步(不拖慢主流程)+ 可重试(丢了能补)。按项目现有条件选:
方案一:有消息队列(最主流)
支付成功 → 发一条消息到 MQ → 消费者 POST 给对方
↘ 失败:MQ 自动重试 / 延迟消息 / 死信队列告警
RocketMQ 的重试和延迟消息天然适合这种场景,重试逻辑都不用自己写。
方案二:没有 MQ,事件落库 + 定时任务(轻量常用)
// 1. 支付成功只落库,主流程立刻结束
eventRepository.save(new PushEvent(orderId, "PENDING"));
// 2. 定时任务扫描待推送/待重试的事件
@Scheduled(fixedDelay = 10_000)
public void pushPendingEvents() {
List<PushEvent> list = eventRepository
.findTop100ByStatusInAndNextRetryTimeBefore("PENDING,RETRY", Instant.now());
for (PushEvent e : list) {
try {
httpClient.post(e.getUrl(), e.getPayload());
e.markDone();
} catch (Exception ex) {
e.markRetry(); // retry_count+1, next_retry_time = now + 10s*2^n
}
eventRepository.save(e);
}
}
Spring 自带的 @Scheduled 就够了,不需要 XXL-Job/Quartz(除非有分片、大量商户的规模需求)。
方案三:折中(实时性最好 + 兜底)
// 支付成功后异步直接发一次(准实时)
@Async
public void notifyPartner(Order order) {
try { httpClient.post(url, payload); }
catch (Exception e) {
eventRepository.save(new PushEvent(order, "RETRY")); // 失败才落库
}
}
// 定时任务只负责捞落库的失败件重试 —— 平时它基本是空转的
选型建议:
场景
建议
项目里已经有 MQ
方案一,别重复造轮子
小系统、内部两三个服务
方案二,一张表 + @Scheduled 就能稳定跑
要兼顾实时性和可靠性
方案三
一句话:定时任务不是 webhook 的组成部分,只是"补偿重试"最朴素的载体。
6. 实践检查清单
接收方(你提供 webhook endpoint)
-
HTTPS + 公网可达
-
用原始请求体验签(不要先反序列化再拼回去)
-
校验时间戳,防重放
-
几秒内返回 2xx,耗时业务进异步队列
-
按
event_id做幂等去重 -
记录请求日志,便于和平台方对账排障
发送方(你推送通知给别人)
-
事件先落库/进队列,主流程不直接发 HTTP
-
异步推送,失败按退避策略重试(如 10s → 1m → 10m → 1h)
-
重试有上限,多次失败后告警 + 人工介入入口
-
payload 带唯一
event_id和完整事件数据 -
签名(HMAC-SHA256)+ 时间戳头,让对方能验
-
提供重推入口/接口(商户漏收时能手动补发)
7. 速记
-
Webhook = 事件驱动的、发往预登记 URL 的、HTTP POST 实现的回调
-
接收 = Web 服务加一个 POST 接口 + 验签 + 异步 + 幂等
-
发送 = 普通 HTTP 调用;可靠发送 = 事件落库 + 异步推送 + 退避重试
-
依赖几乎不存在:Web 框架 +(可选)平台 SDK
-
典型场景:支付回调、GitHub 触发 CI、监控告警、IM 机器人(注意 Incoming Webhook 方向相反)
