> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ycloud.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 实现 Webhook 接收器

> 创建端点、验证签名、持久化接收事件，并处理 Webhook 重试与挂起。

在公共端点接收 YCloud 事件并对其进行处理，避免丢失或重复执行业务操作。在生产环境中请使用 HTTPS，保留原始请求正文，并在接收事件前验证每个签名。

## 注册端点

在 [YCloud 控制台](https://www.ycloud.com/console)中，打开 **开发者 > Webhook**，选择 **添加端点**，输入 **端点 URL**，选择 **事件**，然后点击 **确认**保存。您也可以使用 `POST /v2/webhookEndpoints`；有关请求和响应，请参阅 [配置 Webhook](/zh/api-reference/guides/api-fundamentals/configure-webhooks)。

* 每个账户最多可配置 20 个端点。
* URL 必须可公开访问，且不得解析为私有地址。
* URL 最多支持 500 个字符；可选描述最多支持 400 个字符。
* 安全保存返回的签名 `secret`。

## 读取事件请求

```http theme={"theme":{"light":"github-light","dark":"github-dark"}}
Content-Type: application/json
YCloud-Signature: t=1762224357,s=SIGNATURE_HEX
X-Webhook-Endpoint-ID: WEBHOOK_ENDPOINT_ID
```

| 字段 | 含义 |
| - | - |
| `id` | 事件 ID。用于去重投递。 |
| `type` | 事件类型，例如 `whatsapp.message.updated`。 |
| `apiVersion` | 事件使用的 API 版本。 |
| `createTime` | 事件创建时间戳。 |
| 事件专属对象 | 载荷，如 `whatsappMessage`、`whatsappInboundMessage` 或 `contactCreated`。 |

有关完整示例，请参阅 [Webhook 载荷](/zh/api-reference/guides/examples/webhook-examples/webhook-payload-examples)。

## 验证签名

`YCloud-Signature` 标头的格式为 `t=TIMESTAMP,s=SIGNATURE`。
时间戳为 Unix 时间（秒）。

1. 从标头中提取 `t` 和 `s`。
2. 将时间戳、英文句点以及完全一致的原始请求正文字节拼接在一起。
3. 使用端点的签名密钥计算 HMAC-SHA256。
4. 使用恒定时间比较法比对十六进制结果。

切勿通过序列化解析后的 JSON 来重构正文。空白字符、键顺序或 Unicode 转义均会改变签名输入。

以下示例还使用了可配置的 5 分钟时间戳容差以降低重放风险。此容差属于应用程序策略，并非 YCloud 重试截止时间。请保持服务器时钟同步。

```javascript theme={"theme":{"light":"github-light","dark":"github-dark"}}
import crypto from "node:crypto";

export function verifySignature(rawBody, header, secret, now = Date.now()) {
  if (!Buffer.isBuffer(rawBody) || typeof header !== "string" || !secret) return false;
  const fields = header.split(",").map(part => part.trim().split("="));
  const timestamps = fields.filter(([key]) => key === "t").map(([, value]) => value);
  const signatures = fields.filter(([key]) => key === "s").map(([, value]) => value);
  if (timestamps.length !== 1 || !/^\d+$/.test(timestamps[0])) return false;
  const timestamp = timestamps[0];
  if (Math.abs(Math.floor(now / 1000) - Number(timestamp)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret)
    .update(timestamp + ".")
    .update(rawBody)
    .digest();
  return signatures.some(signature =>
    typeof signature === "string" && /^[a-fA-F0-9]{64}$/.test(signature) &&
    crypto.timingSafeEqual(expected, Buffer.from(signature, "hex"))
  );
}
```

## 确认应答前持久化接收

在返回 `2xx` 之前，将验证通过的事件持久化到持久队列或事务收件箱中。若存储不可用，请返回失败以便重试投递。持久化接收后，让您的 Worker 处理业务逻辑失败并执行其自身的重试。

此 Express 处理程序使用应用程序提供的 `persistEvent` 操作。将其实现为以事件 `id` 为键的原子插入；已存储的事件必须视为成功。切勿在其业务事务提交之前将事件标记为已处理。

```javascript theme={"theme":{"light":"github-light","dark":"github-dark"}}
import express from "express";
import { verifySignature } from "./signature.js";
import { persistEvent } from "./event-inbox.js";

const app = express();
const secret = process.env.YCLOUD_WEBHOOK_SECRET;
if (!secret) throw new Error("YCLOUD_WEBHOOK_SECRET is required");

// Register the raw-body route before any JSON-parsing middleware.
app.post("/webhook", express.raw({ type: "application/json", limit: "1mb" }), async (req, res) => {
  if (!verifySignature(req.body, req.get("YCloud-Signature"), secret)) {
    return res.sendStatus(401);
  }
  let event;
  try {
    event = JSON.parse(req.body.toString("utf8"));
  } catch {
    return res.sendStatus(400);
  }
  if (!event || typeof event.id !== "string" || typeof event.type !== "string") {
    return res.sendStatus(400);
  }
  try {
    await persistEvent(event.id, event);
    return res.sendStatus(204);
  } catch {
    return res.sendStatus(503);
  }
});

app.listen(3000);
```

### Java 和 Spring 示例

此 Java 17 示例采用相同的验证与持久化接收顺序。提供一个基于事务存储的 `EventInbox` bean，并对事件 ID 设置唯一约束。`insertIfAbsent` 必须在返回前提交完整事件；重复的 ID 将成功返回。然后，您的 Worker 可以在自己的事务中处理并标记已存储的事件。

```java theme={"theme":{"light":"github-light","dark":"github-dark"}}
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;

@RestController
public class WebhookController {
    private final String secret;
    private final ObjectMapper mapper;
    private final EventInbox inbox;

    public WebhookController(@Value("${YCLOUD_WEBHOOK_SECRET}") String secret,
                             ObjectMapper mapper, EventInbox inbox) {
        if (secret.isBlank()) throw new IllegalArgumentException("Missing webhook secret");
        this.secret = secret;
        this.mapper = mapper;
        this.inbox = inbox;
    }

    @PostMapping("/webhook")
    public ResponseEntity<Void> receive(
            @RequestHeader(value = "YCloud-Signature", required = false) String header,
            @RequestBody byte[] body) {
        if (!verify(body, header)) return ResponseEntity.status(401).build();
        JsonNode event;
        try {
            event = mapper.readTree(body);
            if (event == null || !event.path("id").isTextual() || !event.path("type").isTextual()) {
                return ResponseEntity.badRequest().build();
            }
        } catch (Exception invalidJson) {
            return ResponseEntity.badRequest().build();
        }
        try {
            inbox.insertIfAbsent(event.path("id").asText(), body);
            return ResponseEntity.noContent().build();
        } catch (Exception storageFailure) {
            return ResponseEntity.status(503).build();
        }
    }

    private boolean verify(byte[] body, String header) {
        if (header == null) return false;
        String timestamp = null;
        java.util.List<String> signatures = new java.util.ArrayList<>();
        for (String part : header.split(",")) {
            String[] pair = part.trim().split("=", 2);
            if (pair.length != 2) continue;
            if (pair[0].equals("t")) {
                if (timestamp != null) return false;
                timestamp = pair[1];
            }
            if (pair[0].equals("s")) signatures.add(pair[1]);
        }
        if (timestamp == null || !timestamp.matches("[0-9]{1,12}")) return false;
        try {
            long age = Instant.now().getEpochSecond() - Long.parseLong(timestamp);
            if (Math.abs(age) > 300) return false;
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
            byte[] expected = mac.doFinal(body);
            for (String signature : signatures) {
                if (signature.matches("[a-fA-F0-9]{64}") &&
                    MessageDigest.isEqual(expected, HexFormat.of().parseHex(signature))) return true;
            }
            return false;
        } catch (Exception invalidSignature) {
            return false;
        }
    }

    public interface EventInbox {
        void insertIfAbsent(String eventId, byte[] eventBody) throws Exception;
    }
}
```

切勿在将事件排入队列前先执行单独的“已处理”Redis 写入：如果在该写入后入队失败，重试可能会被丢弃。请使用原子、持久的收件箱，或者使用其接收和重复处理均为原子的队列。

## 耗时、重试和挂起

及时返回 `2xx` 响应；目标耗时应小于 6 秒。超过 10 秒的缓慢响应可能会降低投递优先级。切勿在 HTTP 处理程序内部执行耗时的业务工作。

对于非 `2xx` 响应或未响应的情况，默认重试间隔为：

| 重试次数 | 延迟 |
| - | - |
| 1 | 10 秒 |
| 2 | 30 秒 |
| 3 | 5 分钟 |
| 4 | 30 分钟 |
| 5 | 1 小时 |
| 6 | 2 小时 |
| 7 | 2 小时 |

达到配置的重试限制后，YCloud 将停止重试该事件。在默认设置下，当某个 URL 在一分钟内达到 200 次失败，或跨并发请求在一分钟内的累计失败时间达到 10 分钟时，该 URL 可能会被挂起 3 分钟。挂起期间请求将暂停，挂起结束后恢复。

此外，还请监控端点的 `status`。处于 `pending` 状态的端点不会接收事件；请参阅 [端点配置](/zh/api-reference/guides/api-fundamentals/configure-webhooks)。

## 验证接收器

* 签名有效且事件持久化存储后返回 `2xx`。
* 正文被篡改、签名格式错误以及过期的时间戳均会被拒绝。
* 重复事件会被接收，但不会重复执行其业务操作。
* 存储故障时返回失败，以便后续重新投递。
* 未知的事件类型不会导致接收端崩溃。
* 接收请求后，处理失败的任务会由您的 worker 进行重试。
* 机密信息和完整的客户有效载荷不会被写入应用程序日志。


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.