Webhook 实战指南

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 的三个特征:

  1. 事件驱动 — 是"某件事发生了"(支付成功、代码 push),不是人想发就手动发一次

  2. 预先登记 — URL 是提前在对方平台注册过的,不是随机的

  3. 平台主动推送 — 发起方是"事件所在的服务方",不是你

⚠️ 注意:随手用 curl POST 一条数据到某个接口,只是普通 HTTP 调用,不算 Webhook;但被预留出来专门接收事件通知的接口,就叫 webhook endpoint(接收器)。

⚠️ 方向陷阱:飞书/钉钉的"群机器人 Webhook"是 Incoming Webhook,方向相反——是你向平台 POST 消息,不是平台推给你。

分工原则:查询用 API,变更通知用 Webhook。两者互补而非替代——Webhook 只告诉你"发生了什么",细节仍要靠 API 去查;重要系统常两者并存(Webhook 丢了还有轮询兜底)。


2. Webhook 的组成五要素

  1. 回调 URL — 你在平台后台登记的接收地址,必须公网可达,通常要求 HTTPS

  2. 事件订阅 — 只声明你关心的事件类型(如 pay.success),不相关的不推

  3. POST Payload — 事件详情以 JSON 放在请求体里,附签名头

  4. 验签 — 用平台给的 secret 校验签名(通常是 HMAC-SHA256),防止别人伪造请求

  5. 应答与重试 — 先快速返回 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",模式成立——哪怕公司内部两个系统之间也完全可以用。但生产上会踩三个坑:

  1. 对方暂时挂了怎么办 → 需要重试(退避:10s → 1m → 10m)

  2. 我这边重启了,还没发出去的通知就丢了 → 需要先落库/进队列

  3. 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 方向相反)