Đọc · 9 phút Cập nhật 30/04/2026 Cấp độ · Trung cấp

Webhook event-driven & retry

Hệ thống event tin cậy với HMAC signature verification, exponential backoff lên đến 12 giờ, và dead-letter queue cho events fail. Phù hợp tích hợp Shopee/TikTok Shop, đồng bộ Zalo OA, fan-out notification.

Connector — đầu nhận webhook

Trước tiên đăng ký endpoint của bạn để Zeni biết gửi event đến đâu:

bashcurl -X POST "https://zenicloud.io/api/v1/automation/connectors?ws=prod" \
  -H "Authorization: Bearer $ZENI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "shopee_order_sync",
    "type": "webhook_outbound",
    "target_url": "https://app.example.com/hooks/order",
    "events": ["order.created", "order.paid", "order.shipped"],
    "secret": "auto",
    "retry": {
      "max_attempts": 5,
      "schedule": "exponential"
    }
  }'

Response:

json{
  "connector_id": "conn_3a8f9b1c",
  "name": "shopee_order_sync",
  "secret": "whsec_HxJ4n2pKvL7wRtY9zM",
  "verify_url": "https://zenicloud.io/api/v1/connectors/conn_3a8f9b1c/verify",
  "status": "pending_verification"
}
Lưu secret ngay
Trường secret chỉ trả về một lần — dùng để verify HMAC signature. Lưu vào env variable, không hardcode.

Trigger event

Khi business logic của bạn cần fan-out, gửi event:

bashcurl -X POST "https://zenicloud.io/api/v1/automation/events?ws=prod" \
  -H "Authorization: Bearer $ZENI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "order.created",
    "payload": {
      "order_id": "ORD-2026-0042",
      "amount_vnd": 1250000,
      "customer_email": "khach@example.com",
      "items": [
        {"sku": "ABC-001", "qty": 2, "price": 625000}
      ]
    }
  }'

Zeni tự fan-out đến tất cả connectors đã subscribe order.created với retry policy.

Nhận webhook — verify HMAC

Khi Zeni POST đến URL của bạn, header gồm:

httpPOST /hooks/order HTTP/1.1
Host: app.example.com
Content-Type: application/json
X-Zeni-Event: order.created
X-Zeni-Event-Id: evt_8f3a9b1c2d4e
X-Zeni-Timestamp: 1714435200
X-Zeni-Signature: t=1714435200,v1=a3f8b2c1...

Verify HMAC trong Python

pythonimport hmac, hashlib, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = b"whsec_HxJ4n2pKvL7wRtY9zM"

def verify_signature(payload, sig_header):
    """Verify HMAC SHA256 từ Zeni."""
    parts = dict(p.split("=", 1) for p in sig_header.split(","))
    timestamp = int(parts["t"])
    signature = parts["v1"]

    # Chống replay: chỉ chấp nhận event trong 5 phút
    if abs(time.time() - timestamp) > 300:
        return False

    # Tính HMAC từ timestamp + body
    signed = f"{timestamp}.{payload.decode()}".encode()
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()

    return hmac.compare_digest(expected, signature)

@app.post("/hooks/order")
def handle_order():
    sig = request.headers.get("X-Zeni-Signature")
    if not verify_signature(request.data, sig):
        abort(401, "Invalid signature")

    event_type = request.headers["X-Zeni-Event"]
    event_id = request.headers["X-Zeni-Event-Id"]

    # Idempotency: kiểm tra event_id đã xử lý chưa
    if event_already_processed(event_id):
        return "", 200

    # Xử lý event
    data = request.get_json()
    process_order(event_type, data)
    mark_processed(event_id)

    return "", 200

Verify HMAC trong Node.js

javascriptimport express from "express";
import crypto from "crypto";

const app = express();
const SECRET = process.env.ZENI_WEBHOOK_SECRET;

app.post("/hooks/order",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.headers["x-zeni-signature"];
    const parts = Object.fromEntries(
      sig.split(",").map(p => p.split("="))
    );
    const timestamp = parseInt(parts.t);

    // Chống replay 5 phút
    if (Math.abs(Date.now() / 1000 - timestamp) > 300) {
      return res.status(401).send("Stale");
    }

    const signed = `${timestamp}.${req.body.toString()}`;
    const expected = crypto
      .createHmac("sha256", SECRET)
      .update(signed)
      .digest("hex");

    if (!crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(parts.v1)
    )) {
      return res.status(401).send("Invalid signature");
    }

    const event = JSON.parse(req.body);
    console.log("Received:", req.headers["x-zeni-event"], event);
    res.status(200).send("ok");
  }
);

Retry policy — exponential backoff

Khi endpoint trả non-2xx hoặc timeout (>10s), Zeni retry theo lịch:

Try 1
0s
ngay lập tức
Try 2
1m
sau 1 phút
Try 3
5m
sau 5 phút
Try 4
30m
sau 30 phút
Try 5
2h
sau 2 giờ
Try 6
12h
sau 12 giờ

Sau 6 lần fail, event chuyển sang Dead-Letter Queue.

Dead-Letter Queue (DLQ)

Events fail >5 lần được lưu DLQ trong 30 ngày. Bạn có thể inspect và replay thủ công:

bash# Liệt kê events trong DLQ
curl "https://zenicloud.io/api/v1/automation/dlq?ws=prod" \
  -H "Authorization: Bearer $ZENI_TOKEN"

# Replay 1 event
curl -X POST "https://zenicloud.io/api/v1/automation/dlq/evt_8f3a/replay?ws=prod" \
  -H "Authorization: Bearer $ZENI_TOKEN"

# Replay tất cả từ thời điểm
curl -X POST "https://zenicloud.io/api/v1/automation/dlq/replay-bulk?ws=prod" \
  -H "Authorization: Bearer $ZENI_TOKEN" \
  -d '{"event_type":"order.created","since":"2026-04-29T00:00:00Z"}'

Best practice cho receiver

  1. Luôn return 2xx nhanh — Zeni timeout sau 10s. Xử lý nặng phải đẩy vào queue nội bộ rồi return 200 ngay.
  2. Idempotency bằng Event-Id — lưu vào DB unique để tránh xử lý trùng.
  3. Verify signature MOI request — không bao giờ skip dù tin tưởng IP source.
  4. Log đủ thông tin — event_id, timestamp, response time để debug.
  5. Healthcheck endpoint — Zeni ping HEAD /your-url mỗi giờ; trả 200 là ok.

Event types phổ biến

EventMô tả
order.createdĐơn hàng mới tạo
order.paidĐơn hàng thanh toán xong
order.shippedĐơn hàng đã giao
user.signupNgười dùng đăng ký
payment.successThanh toán VNPay/Momo thành công
payment.failedThanh toán thất bại
webhook.testTest event để verify connector

Tự định nghĩa event với prefix riêng, ví dụ my_app.user_promoted.

Chi phí

Bước tiếp theo