Webhook vs API:Push vs Pull

API 和 Webhook 解的是同一件事的兩個方向:「A 系統要知道 B 系統的資料」。

API(Pull model):A 主動問 B「現在的狀態是什麼?」

A → GET /payments/pay_123 → B
A ← { status: "succeeded" }  ← B

A 要自己決定什麼時候去查、查多頻繁。不查就不知道。

Webhook(Push model):B 在事情發生的時候主動通知 A

(支付完成時)
B → POST /webhooks/stripe → A
    { event: "payment.succeeded", data: { ... } }

A 不需要輪詢,事情發生時立刻收到通知。

Webhook 是協議還是只是 HTTP?

Webhook 是建立在 HTTP 上的慣例(convention),不是獨立的協議(像 WebSocket 或 gRPC 那樣)。它就是一個 HTTP POST,但圍繞它有一套設計慣例:簽名驗證、retry 機制、事件格式、delivery guarantee。這些慣例讓它成為系統間整合的標準做法。

Axios instance interceptor ≠ Webhook

Axios instance 的 interceptor 是你的 server 發出 request 時的中間層——處理的是你主動去呼叫別人的情境(如呼叫 Stripe API、呼叫 Google Maps)。Webhook 是別人主動 POST 進來的情境。方向相反。

Axios interceptor → 你的 server → [interceptor 在這裡] → 外部 API(你在呼叫別人)
Webhook          → 外部系統 → [POST 進來] → 你的 server(別人在呼叫你)

如果你的 server 對外部 API 的呼叫有 always-on 的 callback 需求(例如:每次呼叫 Stripe 後自動記 log),那是 axios interceptor 做的事;如果你需要「Stripe 支付完成時通知我」,那是 webhook。


Webhook 的兩個身份

如果你在做支付整合,你同時扮演兩個角色:

  • 接收方:Stripe 把支付結果 push 到你的 /webhooks/stripe——你要驗這個 request 真的是 Stripe 發的,不是有人偽造的
  • 發送方:你的系統把訂單狀態變更 push 給你的 B2B 客戶的系統——你要保證對方能收到,收不到要 retry

這兩個問題要分開討論。


接收方:Signature 驗證

HTTP POST 是任何人都能發的。當 Stripe 送來一個 payment.succeeded 事件,你怎麼知道這不是攻擊者偽造的?

答案:HMAC-SHA256 signature

Stripe 會用你們共享的 secret 對 request body 做 hash,把 hash 值放在 header 裡。你收到 request 時,用同一個 secret 對 body 做同樣的 hash,比對是否一致。

import crypto from 'crypto';
 
// Stripe Webhook 驗證
export const verifyStripeWebhook = (req: express.Request, secret: string): boolean => {
  const signature = req.headers['stripe-signature'] as string;
  const timestamp = signature.match(/t=(\d+)/)?.[1];
  const receivedHash = signature.match(/v1=([a-f0-9]+)/)?.[1];
 
  if (!timestamp || !receivedHash) return false;
 
  // 防 replay attack:timestamp 超過 5 分鐘的 webhook 拒絕
  const tolerance = 5 * 60;  // 5 分鐘
  if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > tolerance) {
    return false;
  }
 
  // 計算期望的 signature
  const payload = `${timestamp}.${req.rawBody}`;  // 注意:要用 rawBody
  const expectedHash = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
 
  // 用 timingSafeEqual 防 timing attack
  return crypto.timingSafeEqual(
    Buffer.from(receivedHash),
    Buffer.from(expectedHash)
  );
};
 
// Middleware
app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),  // 必須用 raw body,不能 JSON.parse
  (req, res) => {
    const isValid = verifyStripeWebhook(req, process.env.STRIPE_WEBHOOK_SECRET);
    if (!isValid) return res.status(400).json({ error: 'Invalid signature' });
 
    const event = JSON.parse(req.body.toString());
    webhookQueue.add('stripe-event', event);  // 丟進 queue 異步處理
    res.status(200).json({ received: true });  // 立刻回 200,不要在這裡做業務邏輯
  }
);

重要:webhook handler 立刻回 200,把處理丟進 queue。如果你在 handler 裡做業務邏輯,Stripe(或任何發送方)會等你 response,超時就認為送達失敗開始 retry——你就會收到重複的事件。

一定要用 express.raw():JSON.parse 後再 hash 的結果跟直接 hash raw body 不一樣(whitespace、key 排序可能不同)。


接收方的冪等性

Webhook 發送方會 retry——你的 handler 要能處理重複的事件:

// 用 event ID 做冪等保護
const worker = new Worker('stripe-event', async (job) => {
  const event = job.data;
  const eventId = event.id;  // Stripe 每個事件有唯一 ID
 
  // 查是否已處理過
  const processed = await WebhookEvent.findOne({ where: { externalId: eventId } });
  if (processed) {
    logger.info('Duplicate webhook, skipping', { eventId });
    return;
  }
 
  // 標記為處理中
  await WebhookEvent.create({ externalId: eventId, status: 'processing' });
 
  try {
    await handleStripeEvent(event);
    await WebhookEvent.update({ status: 'completed' }, { where: { externalId: eventId } });
  } catch (error) {
    await WebhookEvent.update({ status: 'failed', error: error.message }, { where: { externalId: eventId } });
    throw error;  // 讓 queue 的 retry 機制處理
  }
});

發送方:保證送達

你的系統要發 webhook 給 B2B 客戶時,要解決「如何保證對方收到」的問題。

基本發送流程

// 事件發生時不直接 HTTP call,先存進 queue
async function dispatchWebhook(event: string, payload: unknown, subscribers: Subscriber[]) {
  for (const subscriber of subscribers) {
    await webhookDeliveryQueue.add('deliver', {
      url: subscriber.webhookUrl,
      secret: subscriber.signingSecret,
      event,
      payload,
      subscriberId: subscriber.id,
    });
  }
}
 
// Worker 負責實際發送
const deliveryWorker = new Worker('deliver', async (job) => {
  const { url, secret, event, payload } = job.data;
  const body = JSON.stringify({ event, data: payload, timestamp: Date.now() });
 
  // 簽名
  const signature = crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');
 
  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Webhook-Signature': `sha256=${signature}`,
      'X-Webhook-Event': event,
    },
    body,
    signal: AbortSignal.timeout(10000),  // 10 秒 timeout
  });
 
  if (!response.ok) {
    throw new Error(`Delivery failed: ${response.status}`);
  }
 
  // 記錄發送成功
  await WebhookDelivery.create({
    subscriberId: job.data.subscriberId,
    event,
    statusCode: response.status,
    deliveredAt: new Date(),
  });
}, {
  connection: redis,
  defaultJobOptions: {
    attempts: 10,
    backoff: { type: 'exponential', delay: 5000 },  // 最久等到 ~85 分鐘
  }
});

Retry 時間設計(參考 Stripe 的做法):

第 1 次失敗 → 等 5 秒
第 2 次失敗 → 等 10 秒
第 3 次失敗 → 等 20 秒
...
第 10 次失敗 → 等 ~85 分鐘
超過 10 次 → 送進 DLQ,通知訂閱者停用 webhook endpoint

發送方的 Dashboard 和 Debug

B2B webhook 系統一定要有發送紀錄,讓訂閱者能自己查哪些事件沒收到:

// API endpoint
GET /api/webhooks/deliveries?event=order.created&status=failed&from=2026-04-01
{
  "data": [
    {
      "id": "delivery-abc",
      "event": "order.created",
      "url": "https://partner.example.com/webhooks",
      "status": "failed",
      "statusCode": 503,
      "attempts": 5,
      "nextRetryAt": "2026-04-22T11:30:00Z",
      "createdAt": "2026-04-22T10:00:00Z"
    }
  ]
}

也要提供手動重送(resend)的功能——訂閱者修好他們的 endpoint 後,可以讓失敗的 event 重新發送,不需要等下一次事件自然發生。


延伸閱讀