Đăng ký webhook URL
PUT /api/v2/partners/update-webhook
{ "webhookUrl": "https://partner.example.com/zenith/callback" }- Cần scope
webhook. - URL bắt buộc HTTPS, không chứa credential.
- Protocol, host và port phải khớp chính xác một trong các domain Zenith đã đăng ký cho partner — sai sẽ nhận
invalid_webhook_url. partnerCallbackUrltheo đơn (set lúc tạo) sẽ thắngwebhookUrlđã đăng ký.
Định dạng delivery
Zenith POST một JSON một dòng (key xếp theo bảng chữ cái) khi đơn checkout tới trạng thái cuối:
POST {callback url của bạn}
Content-Type: application/json
X-Data-Signature: <64 ký tự hex>
X-Data-Timestamp: 2026-10-23T15:04:06.123Z
{"amount":"1000000","currency":"vnd","eventId":"3f9a1c0e5b7d4e2a8c6b1d9f0a2e4c7b:paid","kind":"paid","orderId":"3f9a1c0e5b7d4e2a8c6b1d9f0a2e4c7b","paidAt":"2026-10-23T15:04:05.000Z","reference":"INV-2026-0001","status":"paid"}| Trường | Ý nghĩa |
|---|---|
eventId | <orderId>:<kind> — giữ nguyên qua mọi lần gửi lại của cùng sự kiện; dùng làm khoá chống trùng. |
kind | paid đã trả · failed thất bại · due hết hạn chưa trả. |
orderId | Mã order do Zenith cấp (32 ký tự hex) — khớp orderId trả về lúc tạo đơn. |
reference | Reference bạn gửi lúc tạo đơn. |
amount / currency | Chuỗi số tiền và mã tiền tệ viết thường. |
status | Trạng thái order lúc gửi: paid, failed hoặc expired. |
paidAt | Thời điểm thanh toán ISO-8601, null khi chưa trả. |
Kiểm tra chữ ký
Mọi callback đều được ký bằng webhook secret của bạn — Zenith không bao giờ gửi callback không chữ ký. Bạn cần lấy webhook secret từ Zenith để kiểm tra chữ ký. Tính lại và so sánh trước khi đụng vào body:
X-Data-Signature = hex( HMAC-SHA256( webhookSecret, X-Data-Timestamp + rawBody ) )rawBodylà chuỗi body nguyên bản nhận được — không parse rồi stringify lại, lệch một khoảng trắng hay thứ tự key là chữ ký sai.- Nối
X-Data-Timestamptrực tiếp trướcrawBody, không ký tự phân cách. - So sánh bằng hàm so sánh hằng thời gian.
- Từ chối callback có timestamp lệch quá 5 phút so với giờ server — mỗi lần gửi lại đều ký với timestamp mới nên không mất callback.
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyZenithCallback(rawBody, headers, webhookSecret) {
const timestamp = headers["x-data-timestamp"];
const signature = headers["x-data-signature"];
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() - Date.parse(timestamp)) > 5 * 60 * 1000) return false;
const expected = createHmac("sha256", webhookSecret)
.update(timestamp + rawBody)
.digest();
const received = Buffer.from(signature, "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}Ngữ nghĩa delivery
- Phản hồi 2xx nhanh — timeout 10 giây; chậm hoặc non-2xx bị tính là delivery hỏng.
- Delivery hỏng được sweeper gửi lại mỗi ~15 phút cho tới khi ghi nhận 2xx.
- Delivery là at-least-once — dedupe theo
eventId, không dùngorderId(một đơn có thể nhậnduerồipaidnếu payer trả muộn). - Chữ ký sai thì trả non-2xx và không xử lý body.
Đối chiếu trước khi giao hàng
Sau callback
paid hợp lệ, xác nhận đơn từ server bằng GET /api/v2/payment-gateway/public/orders/{orderId} — kiểm status, amount, currency và reference khớp bản ghi của bạn trước khi giao hàng.