Webhook Design Patterns — Thiết kế hệ thống event notification đáng tin cậy

Posted on: 4/21/2026 2:13:47 PM

1. Webhook là gì và tại sao bạn cần quan tâm?

Webhook là cơ chế HTTP callback — khi một sự kiện xảy ra ở hệ thống A, A sẽ gửi HTTP POST request đến một URL mà hệ thống B đã đăng ký trước đó. Khác với polling (B liên tục hỏi A "có gì mới không?"), webhook là push-based: A chủ động thông báo cho B ngay khi event xảy ra.

~85% SaaS platforms hỗ trợ webhook (2026)
<500ms Thời gian thông báo trung bình
10x Giảm API calls so với polling
99.9% Delivery rate mục tiêu với retry

Các hệ thống lớn như Stripe, GitHub, Shopify, Twilio đều dùng webhook làm xương sống cho việc tích hợp. Khi bạn thanh toán trên Stripe, webhook gửi event payment_intent.succeeded về server của bạn. Khi có push mới trên GitHub, webhook push trigger CI/CD pipeline.

Polling vs Webhook — Bài toán kinh điển

Giả sử bạn cần biết khi nào đơn hàng được thanh toán. Với polling, server bạn gọi API mỗi 5 giây → 17,280 request/ngày, 99% là vô nghĩa. Với webhook, bạn chỉ nhận đúng 1 request khi thanh toán thành công. Giảm load cho cả hai phía, response gần như real-time.

2. Kiến trúc tổng quan của hệ thống Webhook

Một hệ thống webhook production-ready không chỉ là "gửi HTTP POST". Nó bao gồm nhiều thành phần phối hợp với nhau để đảm bảo reliability, security, và observability.

graph LR
    A[Event Source] -->|Publish| B[Event Queue]
    B --> C[Webhook Dispatcher]
    C -->|HTTP POST| D[Consumer Endpoint]
    D -->|2xx OK| E[Mark Delivered]
    D -->|4xx/5xx/Timeout| F[Retry Queue]
    F -->|Exponential Backoff| C
    F -->|Max retries exceeded| G[Dead Letter Queue]
    C --> H[Delivery Log]

    style A fill:#e94560,stroke:#fff,color:#fff
    style B fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style C fill:#2c3e50,stroke:#fff,color:#fff
    style D fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style G fill:#ff9800,stroke:#fff,color:#fff

Kiến trúc tổng quan hệ thống Webhook với retry và dead letter queue

Các thành phần chính:

  • Event Source: Nơi phát sinh sự kiện (thanh toán thành công, user đăng ký, file upload xong...)
  • Event Queue: Buffer giữa event source và dispatcher, đảm bảo không mất event khi traffic spike
  • Webhook Dispatcher: Lấy event từ queue, build payload, gửi HTTP POST đến consumer
  • Retry Queue: Chứa các delivery thất bại, lên lịch retry với exponential backoff
  • Dead Letter Queue (DLQ): Chứa các event đã retry hết số lần cho phép — cần human intervention
  • Delivery Log: Lưu toàn bộ lịch sử gửi/nhận để debug và audit

3. Thiết kế Webhook Payload — Đừng gửi quá nhiều, đừng gửi quá ít

Có hai trường phái chính khi thiết kế payload:

ApproachMô tảƯu điểmNhược điểm
Fat payload Gửi toàn bộ dữ liệu trong webhook Consumer không cần gọi API thêm Payload lớn, risk data stale
Thin payload Chỉ gửi event type + resource ID Payload nhỏ, data luôn fresh Consumer phải gọi API để lấy chi tiết
Hybrid Gửi event type + ID + snapshot những field hay dùng nhất Cân bằng giữa tiện lợi và hiệu năng Cần document rõ field nào có trong payload

Stripe dùng hybrid approach — gửi object snapshot trong webhook nhưng khuyến khích consumer gọi API để verify:

{
  "id": "evt_1R2x3Y4Z5",
  "type": "payment_intent.succeeded",
  "created": 1713700000,
  "data": {
    "object": {
      "id": "pi_abc123",
      "amount": 5000,
      "currency": "vnd",
      "status": "succeeded",
      "metadata": { "order_id": "ORD-2026-001" }
    }
  }
}

Best Practice: Luôn bao gồm các field sau trong payload

id (unique event ID cho idempotency), type (event type), created (timestamp), data (resource snapshot hoặc ID). Thêm api_version nếu API có versioning.

4. Idempotency — Bài toán quan trọng nhất khi nhận Webhook

Webhook có thể được gửi nhiều hơn một lần (at-least-once delivery). Network timeout, retry, hay consumer trả về 200 nhưng sender không nhận được response — tất cả đều dẫn đến duplicate delivery. Consumer phải xử lý idempotent.

sequenceDiagram
    participant S as Webhook Sender
    participant C as Consumer
    participant DB as Database

    S->>C: POST /webhook (event_id: evt_001)
    C->>DB: Check evt_001 đã xử lý chưa?
    DB-->>C: Chưa có
    C->>DB: INSERT processed_events(evt_001)
    C->>DB: Thực hiện business logic
    C-->>S: 200 OK

    Note over S: Timeout, không nhận được 200

    S->>C: POST /webhook (event_id: evt_001) [RETRY]
    C->>DB: Check evt_001 đã xử lý chưa?
    DB-->>C: Đã xử lý rồi!
    C-->>S: 200 OK (skip processing)

Idempotency flow — event_id là chìa khóa để tránh xử lý trùng lặp

Cách triển khai idempotency trong .NET:

public class WebhookController : ControllerBase
{
    [HttpPost("webhook")]
    public async Task<IActionResult> HandleWebhook(
        [FromBody] WebhookPayload payload)
    {
        // Bước 1: Check idempotency
        var alreadyProcessed = await _db.ProcessedEvents
            .AnyAsync(e => e.EventId == payload.Id);

        if (alreadyProcessed)
            return Ok(); // Trả 200 để sender không retry

        // Bước 2: Xử lý trong transaction
        await using var transaction = await _db.Database
            .BeginTransactionAsync();

        try
        {
            _db.ProcessedEvents.Add(new ProcessedEvent
            {
                EventId = payload.Id,
                EventType = payload.Type,
                ProcessedAt = DateTime.UtcNow
            });

            await ProcessEvent(payload);
            await _db.SaveChangesAsync();
            await transaction.CommitAsync();

            return Ok();
        }
        catch
        {
            await transaction.RollbackAsync();
            return StatusCode(500);
        }
    }
}

Cẩn thận: Race condition khi concurrent webhooks

Nếu hai request trùng event_id đến cùng lúc, cả hai đều check "chưa xử lý" → cả hai đều INSERT. Giải pháp: dùng UNIQUE constraint trên EventId và handle duplicate key exception, hoặc dùng distributed lock (Redis SETNX) cho các trường hợp phức tạp hơn.

5. Retry Strategy — Exponential Backoff với Jitter

Khi delivery thất bại, bạn không nên retry ngay lập tức (thundering herd problem) hay retry với interval cố định (vẫn gây spike). Pattern chuẩn là exponential backoff with jitter:

public class RetryPolicy
{
    private static readonly int[] BaseDelaysSeconds = { 10, 30, 60, 300, 900, 3600, 7200 };

    public static TimeSpan GetDelay(int attemptNumber)
    {
        var index = Math.Min(attemptNumber, BaseDelaysSeconds.Length - 1);
        var baseDelay = BaseDelaysSeconds[index];
        // Jitter: ±25% để tránh thundering herd
        var jitter = Random.Shared.NextDouble() * 0.5 + 0.75;
        return TimeSpan.FromSeconds(baseDelay * jitter);
    }
}
Retry #Base DelayVới Jitter (range)Mục đích
110s7.5s – 12.5sTransient error (network blip)
230s22.5s – 37.5sService đang restart
31 phút45s – 75sMinor outage
45 phút3.75m – 6.25mDeployment đang diễn ra
515 phút11.25m – 18.75mModerate outage
61 giờ45m – 75mExtended outage
72 giờ1.5h – 2.5hLần cuối trước khi vào DLQ

Tổng thời gian retry: khoảng 4-5 giờ. Stripe retry trong 72 giờ, GitHub trong 3 ngày — tùy vào SLA của bạn mà điều chỉnh.

6. Bảo mật Webhook — Signature Verification

Webhook endpoint của bạn là một URL public — bất kỳ ai biết URL đều có thể gửi fake request. Bạn bắt buộc phải verify rằng request đến từ sender hợp lệ.

Pattern phổ biến nhất: HMAC-SHA256 signature.

graph LR
    A[Sender] -->|1. HMAC-SHA256 payload + secret| B[Signature]
    A -->|2. Gửi payload + signature header| C[Consumer]
    C -->|3. HMAC-SHA256 payload + shared secret| D[Expected Signature]
    C -->|4. Compare B == D?| E{Match?}
    E -->|Yes| F[Process ✅]
    E -->|No| G[Reject 401 🚫]

    style A fill:#e94560,stroke:#fff,color:#fff
    style C fill:#2c3e50,stroke:#fff,color:#fff
    style F fill:#4CAF50,stroke:#fff,color:#fff
    style G fill:#ff9800,stroke:#fff,color:#fff

HMAC-SHA256 signature verification flow

public class WebhookSignatureValidator
{
    public static bool Validate(string payload, string signature,
        string secret)
    {
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
        var computedHash = hmac.ComputeHash(
            Encoding.UTF8.GetBytes(payload));
        var computedSignature = "sha256=" +
            Convert.ToHexString(computedHash).ToLowerInvariant();

        // Timing-safe comparison để chống timing attack
        return CryptographicOperations
            .FixedTimeEquals(
                Encoding.UTF8.GetBytes(computedSignature),
                Encoding.UTF8.GetBytes(signature));
    }
}

// Trong controller:
[HttpPost("webhook")]
public async Task<IActionResult> HandleWebhook()
{
    var payload = await new StreamReader(Request.Body)
        .ReadToEndAsync();
    var signature = Request.Headers["X-Webhook-Signature"]
        .FirstOrDefault();

    if (!WebhookSignatureValidator.Validate(
        payload, signature, _config["WebhookSecret"]))
        return Unauthorized();

    var data = JsonSerializer.Deserialize<WebhookPayload>(payload);
    // Xử lý tiếp...
}

Sai lầm phổ biến: Dùng == để so sánh signature

So sánh chuỗi bằng == sẽ short-circuit khi gặp ký tự khác đầu tiên → attacker có thể đo thời gian response để brute-force từng byte (timing attack). Luôn dùng CryptographicOperations.FixedTimeEquals (.NET) hoặc crypto.timingSafeEqual (Node.js).

7. Timestamp Validation — Chống Replay Attack

Chỉ verify signature là chưa đủ. Attacker có thể capture một request hợp lệ và replay nó sau đó. Giải pháp: bao gồm timestamp trong signed payload và reject request quá cũ.

public bool IsTimestampValid(long webhookTimestamp,
    int toleranceSeconds = 300)
{
    var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
    return Math.Abs(now - webhookTimestamp) <= toleranceSeconds;
}

// Kết hợp: sign = HMAC(timestamp + "." + payload)
// Header: X-Webhook-Signature: t=1713700000,v1=abc123...

Stripe dùng chính xác pattern này — tolerance mặc định 5 phút (300 giây). Nếu request cũ hơn 5 phút, reject ngay lập tức dù signature hợp lệ.

8. Xử lý Webhook ở Consumer — Respond Fast, Process Later

Quy tắc vàng: trả về 200 OK trong vòng 5 giây. Nếu business logic phức tạp, đừng xử lý ngay trong request handler — enqueue rồi process async.

graph LR
    A[Webhook Request] --> B[Controller]
    B -->|Verify signature| C{Valid?}
    C -->|No| D[Return 401]
    C -->|Yes| E[Save to local queue]
    E --> F[Return 200 OK]
    E --> G[Background Worker]
    G --> H[Process business logic]
    H --> I[Update database]
    H --> J[Send notifications]

    style A fill:#e94560,stroke:#fff,color:#fff
    style F fill:#4CAF50,stroke:#fff,color:#fff
    style G fill:#2c3e50,stroke:#fff,color:#fff

Pattern "Accept then Process" — trả 200 trước, xử lý sau

// Minimal controller — chỉ verify + enqueue
[HttpPost("webhook")]
public async Task<IActionResult> HandleWebhook()
{
    var payload = await ReadAndVerifySignature();
    if (payload == null) return Unauthorized();

    // Lưu raw event vào database/queue
    await _db.WebhookEvents.AddAsync(new WebhookEvent
    {
        EventId = payload.Id,
        EventType = payload.Type,
        RawPayload = payload.RawJson,
        Status = "pending",
        ReceivedAt = DateTime.UtcNow
    });
    await _db.SaveChangesAsync();

    return Ok(); // Respond ASAP
}

// Background service xử lý async
public class WebhookProcessorService : BackgroundService
{
    protected override async Task ExecuteAsync(
        CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            var pending = await _db.WebhookEvents
                .Where(e => e.Status == "pending")
                .OrderBy(e => e.ReceivedAt)
                .Take(50)
                .ToListAsync(ct);

            foreach (var evt in pending)
            {
                try
                {
                    await ProcessEvent(evt);
                    evt.Status = "processed";
                }
                catch (Exception ex)
                {
                    evt.Status = "failed";
                    evt.ErrorMessage = ex.Message;
                    evt.RetryCount++;
                }
            }

            await _db.SaveChangesAsync(ct);
            await Task.Delay(1000, ct);
        }
    }
}

9. Webhook Sender — Thiết kế phía gửi

Nếu bạn đang xây dựng platform và cần cung cấp webhook cho khách hàng, đây là những thành phần cần thiết:

9.1 Subscription Management

CREATE TABLE webhook_subscriptions (
    id BIGINT IDENTITY PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    url NVARCHAR(2048) NOT NULL,
    secret NVARCHAR(256) NOT NULL,
    events NVARCHAR(MAX) NOT NULL,  -- ["order.created","payment.succeeded"]
    is_active BIT DEFAULT 1,
    created_at DATETIME2 DEFAULT GETUTCDATE(),
    INDEX ix_tenant_active (tenant_id, is_active)
);

CREATE TABLE webhook_deliveries (
    id BIGINT IDENTITY PRIMARY KEY,
    subscription_id BIGINT FOREIGN KEY REFERENCES webhook_subscriptions(id),
    event_id NVARCHAR(128) NOT NULL,
    event_type NVARCHAR(128) NOT NULL,
    payload NVARCHAR(MAX),
    status NVARCHAR(20) DEFAULT 'pending',
    attempt_count INT DEFAULT 0,
    next_retry_at DATETIME2,
    last_response_code INT,
    last_response_body NVARCHAR(MAX),
    created_at DATETIME2 DEFAULT GETUTCDATE(),
    INDEX ix_status_retry (status, next_retry_at)
);

9.2 Circuit Breaker cho từng Subscription

Khi endpoint của consumer liên tục fail, bạn không nên retry mãi — áp dụng circuit breaker pattern để tạm ngưng gửi và thông báo cho consumer.

StateĐiều kiệnHành vi
Closed (bình thường) Failure rate < 50% trong 10 phút gần nhất Gửi webhook bình thường
Open (tạm ngưng) 5 lần delivery liên tiếp fail Skip delivery, queue events, gửi email cảnh báo cho consumer
Half-Open (thử lại) Sau 30 phút ở trạng thái Open Thử gửi 1 event, nếu OK → Closed, nếu fail → Open tiếp

10. Monitoring & Observability

Một hệ thống webhook mà không có monitoring thì như lái xe ban đêm không đèn. Các metric quan trọng cần track:

P99 Latency Thời gian từ event → delivery (mục tiêu: <2s)
Success Rate % delivery thành công ở lần đầu (mục tiêu: >95%)
DLQ Size Số event trong dead letter queue (mục tiêu: gần 0)
Active Circuits Số subscription đang bị circuit break
// Sử dụng .NET Metrics API
public class WebhookMetrics
{
    private static readonly Meter Meter = new("Webhook.Delivery");

    public static readonly Counter<long> DeliveryAttempts =
        Meter.CreateCounter<long>("webhook.delivery.attempts");

    public static readonly Counter<long> DeliverySuccesses =
        Meter.CreateCounter<long>("webhook.delivery.successes");

    public static readonly Histogram<double> DeliveryDuration =
        Meter.CreateHistogram<double>("webhook.delivery.duration_ms");

    public static readonly UpDownCounter<long> DlqSize =
        Meter.CreateUpDownCounter<long>("webhook.dlq.size");
}

11. So sánh: Tự build vs Dịch vụ Managed

Không phải lúc nào cũng nên tự xây webhook infrastructure. Dưới đây là so sánh để giúp bạn quyết định:

Tiêu chíTự buildManaged (Svix, Hookdeck)Cloud-native (Azure Event Grid, AWS SNS)
Chi phí khởi đầu Cao (2-4 tuần dev) Thấp (tích hợp <1 ngày) Thấp (pay-per-use)
Tùy biến Toàn quyền Hạn chế theo API của vendor Trung bình
Retry & DLQ Phải tự implement Có sẵn Có sẵn
Monitoring Tự build dashboard Dashboard + alerts có sẵn Tích hợp CloudWatch/Monitor
Scale Phụ thuộc infra Auto-scale Auto-scale, global
Phù hợp Team lớn, yêu cầu đặc thù Startup, MVP nhanh Đã dùng AWS/Azure

12. Checklist triển khai Webhook Production-Ready

Checklist cho Webhook Sender

✅ HMAC-SHA256 signature cho mọi delivery
✅ Timestamp trong signed payload (chống replay)
✅ Exponential backoff với jitter cho retry
✅ Circuit breaker per subscription
✅ Dead letter queue + alerting
✅ Delivery log có thể query (ít nhất 30 ngày)
✅ Rate limiting per subscription (tránh overwhelm consumer)
✅ API cho consumer xem delivery history và retry thủ công
✅ Webhook testing endpoint (echo server)

Checklist cho Webhook Consumer

✅ Verify signature TRƯỚC KHI parse payload
✅ Validate timestamp (reject request > 5 phút tuổi)
✅ Idempotent processing dựa trên event_id
✅ Respond 200 trong <5 giây, process async
✅ HTTPS endpoint bắt buộc
✅ Handle out-of-order delivery (event B đến trước A)
✅ Log mọi webhook nhận được để debug
✅ Alert khi processing failure rate tăng

Webhook tưởng đơn giản nhưng làm đúng thì không dễ. Từ idempotency, signature verification, retry strategy đến circuit breaker — mỗi layer đều có pitfall riêng. Hy vọng bài viết giúp bạn có một bản thiết kế hoàn chỉnh khi cần triển khai webhook cho hệ thống production.

Nguồn tham khảo