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 重新發送,不需要等下一次事件自然發生。
