Webhook Design Patterns — Thiết kế hệ thống event notification đáng tin cậy
Posted on: 4/21/2026 2:13:47 PM
Table of contents
- 1. Webhook là gì và tại sao bạn cần quan tâm?
- 2. Kiến trúc tổng quan của hệ thống Webhook
- 3. Thiết kế Webhook Payload — Đừng gửi quá nhiều, đừng gửi quá ít
- 4. Idempotency — Bài toán quan trọng nhất khi nhận Webhook
- 5. Retry Strategy — Exponential Backoff với Jitter
- 6. Bảo mật Webhook — Signature Verification
- 7. Timestamp Validation — Chống Replay Attack
- 8. Xử lý Webhook ở Consumer — Respond Fast, Process Later
- 9. Webhook Sender — Thiết kế phía gửi
- 10. Monitoring & Observability
- 11. So sánh: Tự build vs Dịch vụ Managed
- 12. Checklist triển khai Webhook Production-Ready
- Nguồn tham khảo
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.
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:
| Approach | Mô tả | Ưu điểm | Nhượ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 Delay | Với Jitter (range) | Mục đích |
|---|---|---|---|
| 1 | 10s | 7.5s – 12.5s | Transient error (network blip) |
| 2 | 30s | 22.5s – 37.5s | Service đang restart |
| 3 | 1 phút | 45s – 75s | Minor outage |
| 4 | 5 phút | 3.75m – 6.25m | Deployment đang diễn ra |
| 5 | 15 phút | 11.25m – 18.75m | Moderate outage |
| 6 | 1 giờ | 45m – 75m | Extended outage |
| 7 | 2 giờ | 1.5h – 2.5h | Lầ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ện | Hà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:
// 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ự build | Managed (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
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.