Bỏ qua đến nội dung chính
webhookAPI designevent-drivenbackendintegration

Webhook Design: Retry, Signature Verify, Idempotency

Webhook design chuẩn production: HMAC signature verify, retry với exponential backoff, idempotency, dead letter queue. Code Node.js minh hoạ.

Xuất bản 8 phút đọc

Webhook design tốt khác xa endpoint REST thông thường. Bài này đi qua 4 trụ cột bắt buộc cho webhook production: signature, idempotency, retry, dead letter — kèm code Node.js minh hoạ.

Webhook là gì? Khi nào dùng?

Webhook là HTTP callback — provider gọi URL của anh khi có event. Stripe, GitHub, Slack đều dùng webhook để báo: "payment thành công", "PR merged", "user mention". Khác polling (anh hỏi liên tục), webhook là push — chỉ gọi khi có chuyện.

Khi nào dùng webhook:

  • Event không thường xuyên (1-100 event/giờ): webhook hiệu quả
  • Event real-time quan trọng: thanh toán, fraud alert
  • Tránh polling tốn tài nguyên cả 2 phía

Khi không nên webhook:

  • Stream data liên tục (1000+ event/giây): dùng message queue / Kafka
  • Client không có public endpoint (mobile app)
  • Không cần real-time (cron job pull cuối ngày là đủ)

1. HMAC signature verify — bắt buộc

Endpoint webhook public — không có signature, attacker chỉ cần đoán URL là gửi event giả được. Provider sign payload bằng secret share với anh:

// Stripe-style signature header:
// Stripe-Signature: t=1735000000,v1=abc123...,v0=...

import crypto from 'node:crypto'

function verifyStripeSignature(payload, header, secret) {
  const parts = Object.fromEntries(header.split(',').map(s => s.split('=')))
  const timestamp = parts.t
  const signature = parts.v1

  // Reject if timestamp older than 5 minutes (replay protection)
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp)
  if (age > 300) throw new Error('Timestamp too old')

  // Compute expected signature
  const signedPayload = `${timestamp}.${payload}`
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex')

  // Constant-time compare để chống timing attack
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
    throw new Error('Invalid signature')
  }
}

3 chi tiết quan trọng:

  • Verify trước khi parse JSON — payload phải là raw string, không phải object đã parse
  • Reject timestamp cũ > 5 phút — chống replay attack (capture request rồi gửi lại sau)
  • timingSafeEqual — compare từng byte cùng thời gian, chống timing attack đoán signature

2. Idempotency — provider có thể duplicate

Network glitch khiến provider không nhận được response → retry. Anh có thể nhận cùng event 2-3 lần. Mỗi event có ID duy nhất:

app.post('/webhook/stripe', async (req, res) => {
  // 1. Verify signature
  try { verifyStripeSignature(req.rawBody, req.headers['stripe-signature'], SECRET) }
  catch { return res.status(400).json({ error: 'Invalid signature' }) }

  const event = JSON.parse(req.rawBody)

  // 2. Idempotency check
  try {
    await db.webhookEvents.insert({
      id: event.id,           // UNIQUE — duplicate sẽ throw
      type: event.type,
      received_at: new Date(),
    })
  } catch (err) {
    if (err.code === '23505') {  // PostgreSQL unique violation
      return res.status(200).json({ status: 'duplicate' })
    }
    throw err
  }

  // 3. Process
  await handleEvent(event)
  res.status(200).json({ status: 'ok' })
})

UNIQUE constraint trên event ID là dòng phòng vệ quan trọng nhất. Đừng dùng SELECT ... THEN INSERT — race condition.

3. Status code: 2xx vs 5xx có nghĩa khác

2xx (200, 204): "tôi đã xử lý xong, đừng retry"
4xx (400, 403): "bạn gửi sai, đừng retry — bug ở anh"
5xx (500-503):  "tôi lỗi tạm thời, retry sau"

Đừng trả 200 khi xử lý fail — provider sẽ tưởng OK và không retry. Trả 500 đúng để provider retry. Đừng trả 4xx khi đó là lỗi của anh — provider sẽ bỏ event mất.

4. Retry với exponential backoff (phía anh khi gọi webhook)

Khi anh là provider gọi webhook khách hàng, không phải mọi attempt đều thành công. Retry policy chuẩn:

async function deliverWebhook(url, payload, secret) {
  const delays = [1, 5, 30, 120, 600, 3600] // giây: 1s, 5s, 30s, 2m, 10m, 1h
  let lastError = null

  for (let attempt = 0; attempt < delays.length; attempt++) {
    if (attempt > 0) {
      const jitter = Math.random() * delays[attempt] * 0.3
      await sleep((delays[attempt] + jitter) * 1000)
    }
    try {
      const ts = Math.floor(Date.now() / 1000)
      const sig = crypto.createHmac('sha256', secret)
        .update(`${ts}.${JSON.stringify(payload)}`)
        .digest('hex')

      const r = await fetch(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-Webhook-Signature': `t=${ts},v1=${sig}`,
        },
        body: JSON.stringify(payload),
        signal: AbortSignal.timeout(15000), // 15s timeout
      })

      if (r.ok) return { ok: true, attempt }
      if (r.status >= 400 && r.status < 500) {
        return { ok: false, reason: 'client_error', status: r.status }
      }
      lastError = `status ${r.status}`
    } catch (err) {
      lastError = err.message
    }
  }

  // All retries failed → DLQ
  await db.webhookDlq.insert({ url, payload, last_error: lastError })
  return { ok: false, reason: 'exhausted' }
}

Jitter ngăn "thundering herd" khi nhiều webhook đồng thời retry — không thì 1000 worker cùng đập endpoint một giây. Giới thiệu chi tiết hơn ở Background jobs.

5. Dead Letter Queue — an toàn cuối

Sau retry exhausted, event vào DLQ. Đừng discard:

  • Có thể là bug ở anh (sửa rồi replay được)
  • Customer endpoint của khách down kéo dài → email nhắc
  • Investigate tại sao fail (audit log)

UI admin để dev review + manual replay là minimum viable.

Document webhook cho khách hàng

Documentation cần có:

  • Event types — list đầy đủ + payload schema
  • Signature verify guide cho ngôn ngữ phổ biến
  • Retry policy — khách biết khi nào không nhận event
  • Webhook simulator (Stripe có CLI: stripe trigger payment_intent.succeeded)
  • IP allowlist (nếu có) — để khách firewall

Checklist webhook design production

  • ✅ HMAC signature + timestamp tolerance < 5 phút
  • ✅ Idempotency check qua UNIQUE constraint event ID
  • ✅ Status code đúng nghĩa (2xx/4xx/5xx)
  • ✅ Retry exponential backoff + jitter, timeout 15s
  • ✅ Dead letter queue + admin UI replay
  • ✅ Webhook event lưu raw payload để audit/replay
  • ✅ Rate limit per consumer endpoint
  • ✅ Doc + CLI/dashboard simulator

Kết luận

Webhook design đúng phân biệt API "ổn" và API "production-grade". 4 trụ cột — signature, idempotency, status code, retry+DLQ — đều bắt buộc. Đừng skip vì tưởng đơn giản. Tham khảo Stripe webhook docs là chuẩn vàng anh có thể học hỏi.

Zalo