# Xác thực và kiểm tra (/vi/webhooks/receiving)



Xác thực mọi webhook trước khi sử dụng payload, sau đó gửi một lần kiểm tra để xác nhận bên nhận của bạn hoạt động từ đầu đến cuối.

## Trước khi bắt đầu [#before-you-start]

* Một [địa chỉ đích đã lưu](/webhooks/configuration#save-a-destination) và signing secret (khóa ký) của địa chỉ đó, `twhsec_...`. Không dùng Server Secret Token để xác thực webhook.
* Một route bên nhận có thể đọc nội dung yêu cầu gốc trước khi bất kỳ bộ phân tích JSON nào thay đổi nội dung đó.
* Một kho lưu trữ có ràng buộc duy nhất trên `id` của sự kiện, để các bản trùng lặp được xử lý an toàn.

## Nhận webhook [#receive-a-webhook]

<div className="fd-steps [&_h3]:fd-step">
  ### Đọc nội dung gốc [#read-the-raw-body]

  Tapaya gửi một yêu cầu `POST` JSON với các header sau. Giữ nội dung ở dạng byte gốc; nội dung đã được phân tích rồi tuần tự hóa lại sẽ không còn khớp với chữ ký.

  ```http
  Content-Type: application/json
  X-Tapaya-Event-Id: 019a0000-0000-7000-8000-000000000001
  X-Tapaya-Signature: t=1793361600,v1=<lowercase hexadecimal HMAC-SHA256>
  ```

  * Với Express, sử dụng `express.raw({ type: "application/json" })` cho route này, trước bộ phân tích JSON toàn cục.

  ### Kiểm tra chữ ký [#check-the-signature]

  Tính HMAC-SHA256 trên dấu thời gian, một dấu chấm và nội dung gốc, rồi so sánh kết quả với `v1` bằng phép so sánh có thời gian không đổi.

  ```js title="verify-tapaya-webhook.mjs"
  import { createHmac, timingSafeEqual } from "node:crypto";

  export function verifyTapayaWebhook(rawBody, signatureHeader, secret, now = Date.now()) {
    if (!Buffer.isBuffer(rawBody) || typeof signatureHeader !== "string") {
      throw new Error("Missing raw body or signature");
    }
    if (typeof secret !== "string" || !secret.startsWith("twhsec_")) {
      throw new Error("Missing signing secret");
    }

    const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(signatureHeader);
    if (!match) throw new Error("Malformed signature");

    const timestamp = Number(match[1]);
    if (!Number.isSafeInteger(timestamp) || Math.abs(now / 1000 - timestamp) > 300) {
      throw new Error("Expired signature");
    }

    const expected = createHmac("sha256", Buffer.from(secret, "utf8"))
      .update(`${match[1]}.`, "utf8")
      .update(rawBody)
      .digest();
    const received = Buffer.from(match[2], "hex");
    if (!timingSafeEqual(expected, received)) throw new Error("Invalid signature");

    return JSON.parse(rawBody.toString("utf8"));
  }
  ```

  * Dùng toàn bộ giá trị bí mật `twhsec_...` làm khóa UTF-8. Không giải mã giá trị bí mật và không xóa tiền tố của nó.
  * Từ chối dấu thời gian lệch quá năm phút so với đồng hồ của bạn và giữ đồng hồ luôn đồng bộ.
  * Trong khi bạn [xoay vòng signing secret](/webhooks/configuration#configuration-changes), hãy thử secret mới trước, sau đó đến secret cũ, cho đến khi hàng đợi đã được xử lý hết.

  ### Lưu và xác nhận [#store-and-acknowledge]

  Chỉ chấp nhận sự kiện sau khi đã lưu an toàn, để lỗi phía bạn sẽ kích hoạt việc thử lại.

  ```js title="server.mjs"
  import express from "express";
  import { verifyTapayaWebhook } from "./verify-tapaya-webhook.mjs";
  import { saveEventOnce } from "./events.mjs"; // your store, unique on event id

  const app = express();

  app.post(
    "/webhooks/tapaya",
    express.raw({ type: "application/json" }),
    async (req, res) => {
      let event;
      try {
        event = verifyTapayaWebhook(
          req.body,
          req.get("X-Tapaya-Signature"),
          process.env.TAPAYA_WEBHOOK_SECRET,
        );
      } catch {
        return res.sendStatus(400);
      }
      if (event.organizationId !== process.env.TAPAYA_ORGANIZATION_ID) {
        return res.sendStatus(400);
      }

      await saveEventOnce(event); // skips an id it already stored
      res.sendStatus(204);
    },
  );
  ```

  * Trả về `2xx` cho sự kiện bạn đã lưu. Các lần thử lại giữ nguyên `id` và nội dung, với dấu thời gian và chữ ký mới.
  * Trả về phản hồi khác `2xx` khi bạn không thể lưu một sự kiện mới, để việc gửi được thử lại.
  * Xử lý các sự kiện đã lưu từ hàng đợi của riêng bạn. Thứ tự gửi không được đảm bảo, vì vậy hãy đối soát các bản lưu cũ hơn với trạng thái hiện tại.
</div>

## Gửi webhook kiểm tra [#send-a-test-webhook]

Thao tác kiểm tra của Platform gửi một yêu cầu `webhook.test` có chữ ký đến URL đã lưu. Thao tác này hoạt động ngay cả khi webhook bị tắt hoặc loại sự kiện không có trong đăng ký của bạn, và không tạo khoản thanh toán, khoản hoàn tiền hay merchant nào.

1. Mở **API Keys** của tổ chức và lưu địa chỉ đích.
2. Dùng thao tác kiểm tra webhook.
3. Kiểm tra kết quả, thời lượng và JSON đã gửi được hiển thị trong Platform.

```json
{
  "id": "019a0000-0000-7000-8000-000000000010",
  "type": "webhook.test",
  "apiVersion": "1",
  "organizationId": "019a0000-0000-7000-8000-000000000002",
  "merchantId": null,
  "createdAt": "2026-10-30T12:00:00.000000Z",
  "test": true,
  "data": {
    "message": "This is a test webhook from Tapaya. No payment was created."
  }
}
```

<Accordions type="multiple">
  <Accordion title="Phạm vi của một lần kiểm tra" id="what-a-test-covers">
    * Một lần thử với thời hạn 10 giây và không tự động thử lại.
    * Lần kiểm tra chỉ kiểm tra việc gửi và xử lý chữ ký, không kiểm tra payload thanh toán hoặc onboarding. Hãy kiểm tra các payload đó bằng hoạt động trong sandbox.
    * Việc gửi thất bại được hiển thị dưới dạng kết quả kiểm tra, không phải lỗi trong Platform.
  </Accordion>

  <Accordion title="Giới hạn kiểm tra" id="test-limits">
    * Các lần gửi trong hàng đợi và kiểm tra thủ công dùng chung giới hạn 2 yêu cầu đồng thời cho mỗi tổ chức.
    * Kiểm tra thủ công còn giới hạn 10 lần bắt đầu trong mỗi khoảng 60 giây trượt cho mỗi tổ chức, dùng chung cho tất cả người dùng.
    * Khi vượt một trong hai giới hạn, lần kiểm tra trả về `429` với `Retry-After` tính bằng giây và không gửi gì.
    * Các lần kiểm tra đã được chấp nhận vẫn được tính dù thất bại hoặc bị hủy. Tạo lại địa chỉ đích không đặt lại số lần đã tính.
  </Accordion>
</Accordions>

## Bên nhận cục bộ và bên nhận tự động hóa [#local-and-automation-receivers]

Hướng webhook đến máy phát triển hoặc công cụ không cần code khi máy chủ production của bạn chưa sẵn sàng.

<Accordions type="multiple">
  <Accordion title="Kiểm tra bên nhận cục bộ" id="test-a-local-receiver">
    * Lưu URL của một đường hầm HTTPS công khai làm địa chỉ đích; URL localhost và địa chỉ IP riêng bị từ chối.
    * Giữ xác thực chữ ký được bật, và đảm bảo đường hầm chuyển tiếp nội dung gốc và header Tapaya mà không sửa đổi.
    * Khi URL của đường hầm thay đổi, lưu lại địa chỉ đích. Giữ đường hầm cũ hoạt động cho đến khi các sự kiện đã đưa vào hàng đợi gửi đến đó được xử lý hết.
  </Accordion>

  <Accordion title="Kết nối công cụ tự động hóa" id="connect-an-automation-tool">
    * Điểm nhận phải không cần header xác thực tùy chỉnh, giữ nguyên nội dung gốc và phản hồi trong vòng 10 giây.
    * Điểm nhận phải tự kiểm tra HMAC của nội dung gốc, hoặc nằm sau một bộ chuyển tiếp xác thực làm việc đó.
    * Khi công cụ trả về `2xx`, việc gửi được tính là thành công. Lỗi quy trình xảy ra sau đó và các sự kiện trùng lặp do công cụ tự xử lý; chúng không kích hoạt lần gửi khác.
  </Accordion>
</Accordions>

## Xử lý lỗi [#handle-errors]

<Accordions type="multiple">
  <Accordion title="Kết quả kiểm tra" id="test-results">
    | Kết quả           | Ý nghĩa                                                                                             |
    | ----------------- | --------------------------------------------------------------------------------------------------- |
    | `delivered: true` | Bên nhận trả về HTTP `2xx`.                                                                         |
    | `http_error`      | Bên nhận trả về mã trạng thái khác `2xx`.                                                           |
    | `transport_error` | Không thể truy cập địa chỉ đích, bao gồm lỗi TLS, DNS hoặc kiểm tra tính hợp lệ của mạng công khai. |
    | `timeout`         | Bên nhận không xác nhận trong vòng 10 giây.                                                         |
  </Accordion>

  <Accordion title="Kiểm tra chữ ký" id="signature-checks">
    | Triệu chứng       | Kiểm tra                                                                                                                  |
    | ----------------- | ------------------------------------------------------------------------------------------------------------------------- |
    | Chữ ký không khớp | Dùng toàn bộ giá trị bí mật và byte gốc. JSON đã định dạng lại, hoặc dùng token API làm khóa, sẽ khiến xác thực thất bại. |
    | Chữ ký hết hạn    | Đồng bộ đồng hồ bên nhận và xác thực dựa trên header của yêu cầu, không dựa trên `createdAt`.                             |
  </Accordion>

  <Accordion title="Gửi sự kiện" id="delivery">
    | Triệu chứng                                         | Kiểm tra                                                                                                                              |
    | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
    | URL bị từ chối hoặc kết nối bị chặn                 | Dùng HTTPS công khai trên cổng 443, không có chuyển hướng, thông tin đăng nhập hay phần fragment.                                     |
    | Không có sự kiện webhook                            | Kiểm tra tổ chức, trạng thái bật và các loại sự kiện đã chọn. Các thay đổi trong quá khứ không được bổ sung hồi tố.                   |
    | Sự kiện trùng lặp hoặc cũ hơn                       | Loại bỏ trùng lặp theo `id` và đối soát với trạng thái hiện tại thay vì giả định thứ tự. Xem [quy tắc gửi](/webhooks#delivery-rules). |
    | Kiểm tra thành công nhưng xử lý thanh toán thất bại | `webhook.test` có cấu trúc `data` khác. Định tuyến theo `type` và kiểm tra bằng các sự kiện sandbox thực tế.                          |
  </Accordion>
</Accordions>
