Khách hàng gọi lên báo: “Chúng tôi không nhận được webhook từ hệ thống của bạn.” Mình mở log ra, thấy rõ ràng request đã gửi đi, response trả về 200 OK, timestamp khớp. Phía họ log không có gì. Vậy ai đúng?

Cả hai đều đúng — và đây chính là vấn đề cốt lõi của webhook: HTTP không phải là giao thức có delivery guarantee. Request thoát khỏi server của mình không có nghĩa là consumer đã nhận và xử lý được. Giữa “gửi” và “delivered” là một khoảng tối mà bug thường trú ngụ ở đó.

Bài này mình sẽ đi qua cách xây webhook system có thể tự recover khi thứ gì đó fail giữa chừng — không phải vì muốn overengineering, mà vì mình đã vá đủ loại incident sinh ra từ webhook naive rồi.


At-least-once, at-most-once, exactly-once — chọn cái nào?

Ba guarantees này xuất hiện trong mọi tài liệu messaging system. Nhưng với HTTP webhook, bức tranh đơn giản hơn nhiều.

At-most-once: Gửi một lần, không retry. Consumer không nhận được thì mất vĩnh viễn. Phù hợp với event không quan trọng, hoặc khi consumer idempotent không phải yêu cầu (nhưng thực tế hiếm trường hợp nào như vậy).

Exactly-once: Nghe hay, không khả thi trong thực tế. Để implement exactly-once qua HTTP, bạn cần distributed transaction giữa sender và receiver — chi phí quá lớn, và consumer vẫn có thể crash sau khi ack nhưng trước khi persist. Không ai thật sự làm được exactly-once với HTTP webhook mà không có side effect.

At-least-once: Consumer có thể nhận cùng một event nhiều lần, nhưng đảm bảo nhận ít nhất một lần nếu system không sập vĩnh viễn. Đây là option khả thi duy nhất. Trade-off: consumer phải idempotent.

Hiểu nôm na thì at-least-once giống bưu chính thời xưa: họ đảm bảo thư đến tay bạn, nhưng đôi khi gửi nhầm hai lần. Trách nhiệm xử lý trùng lặp nằm ở người nhận.

Toàn bộ bài này xoay quanh at-least-once — cách implement đúng, cách xử lý idempotency, và cách làm cho consumer không “bị điên” khi nhận event trùng.


Retry strategy: exponential backoff với jitter

Khi webhook fail (timeout, 5xx, connection refused), có hai cách sai phổ biến:

  1. Retry ngay lập tức liên tiếp → DDOS chính consumer đang có vấn đề
  2. Không retry → mất event

Cách đúng là exponential backoff: mỗi lần retry, delay tăng theo lũy thừa. Cộng thêm jitter (random offset) để tránh thundering herd khi nhiều webhook fail cùng lúc và cùng retry vào một thời điểm.

Schedule retry của mình thường dùng: 1m → 5m → 15m → 1h → 6h → 24h. Sau 6 lần fail, event vào Dead Letter Queue.

// webhook-retry.ts
interface WebhookJob {
  id: string;
  url: string;
  payload: object;
  attemptCount: number;
  maxAttempts: number;
}

const RETRY_DELAYS_MS = [
  1 * 60 * 1000, // 1 phút
  5 * 60 * 1000, // 5 phút
  15 * 60 * 1000, // 15 phút
  60 * 60 * 1000, // 1 giờ
  6 * 60 * 60 * 1000, // 6 giờ
  24 * 60 * 60 * 1000, // 24 giờ
];

function getNextRetryDelay(attemptCount: number): number {
  const baseDelay =
    RETRY_DELAYS_MS[attemptCount] ??
    RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1];
  // Jitter: ±20% của base delay để tránh nhiều job retry cùng lúc
  const jitter = baseDelay * 0.2 * (Math.random() * 2 - 1);
  return Math.round(baseDelay + jitter);
}

async function scheduleRetry(job: WebhookJob, queue: Queue): Promise<void> {
  if (job.attemptCount >= job.maxAttempts) {
    await moveToDLQ(job);
    return;
  }

  const delayMs = getNextRetryDelay(job.attemptCount);
  await queue.add("send-webhook", job, {
    delay: delayMs,
    attempts: 1, // Queue level không retry — mình tự quản lý
  });
}

Lý do không dùng retry built-in của queue (BullMQ, Celery, etc.) là vì mình muốn control logic retry riêng — đặc biệt khi cần log từng attempt, alert sau N lần fail, hoặc cho phép manual retry từ dashboard.


Timeout phía sender: 5 giây, không thương lượng

Một gotcha mà mình thấy rất nhiều hệ thống bỏ qua: sender phải có timeout cứng.

Nếu consumer response chậm (do đang overload, DB lock, hoặc đang xử lý request khác), sender sẽ chờ mãi. Thread bị block. Connection pool cạn dần. Toàn bộ webhook worker bị treo.

5 giây là con số mình hay dùng (đôi khi 10 giây cho webhook business-critical). Nếu consumer không response trong 5 giây, treat as failure và schedule retry. Bất kể consumer đang làm gì phía sau.

// webhook-sender.ts
import crypto from "crypto";

interface SendWebhookOptions {
  url: string;
  payload: object;
  secret: string;
  webhookId: string; // unique ID cho deduplication
  timeoutMs?: number;
}

async function sendWebhook(
  options: SendWebhookOptions
): Promise<{ success: boolean; statusCode?: number }> {
  const { url, payload, secret, webhookId, timeoutMs = 5000 } = options;

  const body = JSON.stringify(payload);
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const signature = computeHmacSignature(secret, timestamp, body);

  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-Webhook-ID": webhookId, // deduplication key
        "X-Webhook-Timestamp": timestamp,
        "X-Webhook-Signature": signature,
      },
      body,
      signal: controller.signal,
    });

    // Chỉ 2xx là success — redirect (3xx) cũng treat as failure
    const success = response.status >= 200 && response.status < 300;
    return { success, statusCode: response.status };
  } catch (error) {
    if (error instanceof Error && error.name === "AbortError") {
      // Timeout — consumer quá chậm, không phải lỗi mạng
      console.warn(`Webhook ${webhookId} timed out after ${timeoutMs}ms`);
    }
    return { success: false };
  } finally {
    clearTimeout(timeoutId);
  }
}

HMAC signature: tại sao cần và cách implement

Vậy tại sao cần ký webhook? Vì bất kỳ ai cũng có thể gửi POST request đến endpoint của consumer và giả mạo event. Không có signature verification, consumer không có cách nào biết request đến từ nguồn hợp lệ.

HMAC-SHA256 là standard: sender và consumer chia sẻ một secret, sender ký payload bằng secret đó, consumer verify chữ ký trước khi xử lý.

// Phía sender: tạo signature
function computeHmacSignature(
  secret: string,
  timestamp: string,
  body: string
): string {
  // Đưa timestamp vào message để tránh replay attack
  const message = `${timestamp}.${body}`;
  return crypto.createHmac("sha256", secret).update(message).digest("hex");
}

// Phía consumer: verify signature
function verifyWebhookSignature(
  receivedSignature: string,
  timestamp: string,
  body: string,
  secret: string
): boolean {
  // Reject nếu timestamp cũ hơn 5 phút — replay attack protection
  const now = Math.floor(Date.now() / 1000);
  const webhookTime = parseInt(timestamp, 10);
  if (Math.abs(now - webhookTime) > 300) {
    return false;
  }

  const expectedSignature = computeHmacSignature(secret, timestamp, body);

  // timing-safe comparison để tránh timing attack
  return crypto.timingSafeEqual(
    Buffer.from(receivedSignature, "hex"),
    Buffer.from(expectedSignature, "hex")
  );
}

Hai điểm quan trọng: đưa timestamp vào message để ngăn replay attack (kẻ tấn công capture và gửi lại request hợp lệ), và dùng timingSafeEqual thay vì === để tránh timing attack (so sánh byte-by-byte cho phép attacker đoán được signature bằng cách đo thời gian response).


Dead Letter Queue: khi retry cũng không cứu được

Sau N lần retry thất bại, event vào Dead Letter Queue (DLQ). DLQ không phải “thùng rác” — đây là nơi để:

  • Alert on-call engineer (PagerDuty, Slack)
  • Dashboard xem event nào đang stuck và lý do
  • Manual retry sau khi consumer fix xong
  • Audit trail cho các event business-critical
// dlq.ts
interface DLQEntry {
  webhookId: string;
  url: string;
  payload: object;
  lastError: string;
  attemptCount: number;
  firstAttemptAt: Date;
  lastAttemptAt: Date;
  createdAt: Date;
}

async function moveToDLQ(job: WebhookJob, lastError: string): Promise<void> {
  const entry: DLQEntry = {
    webhookId: job.id,
    url: job.url,
    payload: job.payload,
    lastError,
    attemptCount: job.attemptCount,
    firstAttemptAt: job.firstAttemptAt,
    lastAttemptAt: new Date(),
    createdAt: new Date(),
  };

  // Persist vào DB để có thể query và retry thủ công
  await db.webhookDLQ.insert(entry);

  // Alert ngay lập tức
  await alerting.send({
    severity: "warning",
    title: `Webhook stuck in DLQ`,
    message: `${job.id} đến ${job.url} fail sau ${job.attemptCount} lần`,
  });
}

// API endpoint để manual retry từ dashboard
async function retryFromDLQ(webhookId: string): Promise<void> {
  const entry = await db.webhookDLQ.findById(webhookId);
  if (!entry) throw new Error("DLQ entry not found");

  await queue.add("send-webhook", {
    id: entry.webhookId,
    url: entry.url,
    payload: entry.payload,
    attemptCount: 0, // reset về 0 để có đủ retry budget
    maxAttempts: 6,
  });

  await db.webhookDLQ.delete(webhookId);
}

Idempotency: consumer phải xử lý được event trùng lặp

Đây là trách nhiệm của consumer, nhưng sender cần tạo điều kiện bằng cách gửi X-Webhook-ID nhất quán — cùng event phải có cùng ID qua mọi lần retry.

// Consumer-side: Express middleware xử lý deduplication
import { Request, Response, NextFunction } from "express";
import { redis } from "./redis";

async function webhookIdempotencyMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> {
  const webhookId = req.headers["x-webhook-id"] as string;

  if (!webhookId) {
    res.status(400).json({ error: "Missing X-Webhook-ID header" });
    return;
  }

  const key = `webhook:processed:${webhookId}`;
  const alreadyProcessed = await redis.set(key, "1", {
    NX: true, // Chỉ set nếu chưa tồn tại
    EX: 86400 * 7, // TTL 7 ngày — đủ lâu để cover mọi retry window
  });

  if (!alreadyProcessed) {
    // Event này đã xử lý rồi, return 200 để sender không retry thêm
    res.status(200).json({ status: "already_processed" });
    return;
  }

  next();
}

Lưu ý quan trọng: return 200 cho duplicate event, không phải 409 Conflict. Nếu trả 4xx hay 5xx, sender sẽ retry tiếp — đúng là không xử lý lại, nhưng sender lại tốn thêm attempt.


Gotcha: consumer trả 200 nhưng xử lý async

Đây là bug kinh điển và mình thấy rất thường xuyên (kể cả trong code của mình trước đây).

// ❌ Pattern nguy hiểm
app.post("/webhook", async (req, res) => {
  res.status(200).send("OK"); // Trả 200 ngay

  // Xử lý async phía sau — nếu crash ở đây, sender không biết
  await processOrderEvent(req.body);
  await updateInventory(req.body.orderId);
});

Sender nhận 200, đánh dấu delivered, không retry. Nhưng processOrderEvent có thể throw exception, DB có thể timeout, hoặc process crash ngay sau khi trả response. Event bị mất mà không ai hay.

// ✅ Pattern đúng: xử lý sync trước khi response
app.post("/webhook", async (req, res) => {
  try {
    // Xử lý toàn bộ trong một transaction nếu cần
    await db.transaction(async (trx) => {
      await processOrderEvent(req.body, trx);
      await updateInventory(req.body.orderId, trx);
      // Chỉ commit khi tất cả xong
    });

    res.status(200).json({ status: "ok" });
  } catch (error) {
    // Trả 500 để sender retry
    res.status(500).json({ error: "Processing failed" });
  }
});

Nếu processing thực sự cần async (vì tốn thời gian), giải pháp đúng là: persist event vào DB trong handler (nhanh, sync), trả 200, rồi một worker riêng pick up từ DB để xử lý. Consumer của mình trở thành queue nhỏ, event không bao giờ mất.


Luồng hoàn chỉnh: delivery + retry + DLQ


  sequenceDiagram
    participant S as Sender
    participant Q as Retry Queue
    participant C as Consumer
    participant D as DLQ

    S->>Q: Enqueue webhook job
    Q->>C: POST /webhook (attempt 1)
    C-->>Q: Timeout / 5xx

    Note over Q: Retry sau 1 phút
    Q->>C: POST /webhook (attempt 2)
    C-->>Q: 500 Internal Error

    Note over Q: Retry sau 5 phút
    Q->>C: POST /webhook (attempt 3)
    C-->>Q: 200 OK ✓

    alt Nếu tất cả attempts đều fail
        Q->>D: Move to DLQ
        D-->>S: Alert on-call team
    end

Chi phí thực của webhook production-grade

Với mỗi webhook event, nếu assume 10% fail rate (không phải số hiếm khi consumer có bất kỳ dependency nào), và retry schedule 6 lần: trung bình mỗi failed event tốn thêm khoảng 2-3 actual HTTP request (vì phần lớn recover trong 1-2 lần retry đầu). Ở scale 1M events/ngày với 10% fail rate, đó là 100k–300k extra request ngày nào cũng có.

DLQ threshold cần tuning theo SLA — nếu consumer SLA là “event phải xử lý trong 24h”, retry schedule kết thúc ở 24h là đủ. Nếu SLA là 1h, schedule cần ngắn hơn và alert phải đến sớm hơn.

Webhook trông đơn giản từ bên ngoài — gửi POST, nhận 200. Nhưng làm production-grade thì phần lớn code là xử lý trường hợp 200 không đến được, hoặc đến nhưng không đúng lý do.