<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>realtime 流派：server 推 client 的對外承諾差異 on Tarragon</title><link>https://tarrragon.github.io/blog/backend/11-api-design/styles/realtime/</link><description>Recent content in realtime 流派：server 推 client 的對外承諾差異 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Sat, 04 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/backend/11-api-design/styles/realtime/index.xml" rel="self" type="application/rss+xml"/><item><title>持久連線推送：WebSocket、SSE、long-polling 的承諾差異</title><link>https://tarrragon.github.io/blog/backend/11-api-design/styles/realtime/realtime-push-mechanisms/</link><pubDate>Sat, 04 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/11-api-design/styles/realtime/realtime-push-mechanisms/</guid><description>&lt;p>WebSocket、SSE、long-polling 都在解同一個需求：server 有事情要主動送給 client、而不是等 client 來問。三者的差別在推的時候對消費者承諾了什麼 —— 連線斷了誰負責重連、訊息會不會漏、能不能雙向 —— 而不在能不能推。這三條承諾線決定選型、比「哪個比較新」有用得多 —— 選型看的是消費者形狀：這個 client 需要單向還是雙向、能不能容忍漏訊、網路可不可控。以下把三者的承諾攤開、對到這三個問題。webhook 這種 server 對 server 的事件推送形狀不同、收在&lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/styles/realtime/realtime-webhook-contract/" data-link-title="webhook 對外承諾：投遞保證不是預設、consumer 負責一半" data-link-desc="webhook 是盡力而為的事件推送不是可靠佇列：投遞保證逐 vendor 讀、可靠性責任分一半給 consumer">另一篇&lt;/a>。&lt;/p>
&lt;h2 id="協議各給多少保證">協議各給多少保證&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/sse/" data-link-title="Server-Sent Events (SSE)" data-link-desc="說明 SSE 如何透過 HTTP 長連線向 client 單向推送事件">SSE&lt;/a>（Server-Sent Events）把重連寫進協議本身。WHATWG 的規範定義：連線斷掉時 user agent 自動重連、重連時把最後收到的 event id 放進 &lt;code>Last-Event-ID&lt;/code> header 送回 server、server 據此可從斷點往後補送（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/sse-whatwg-spec-reconnection/" data-link-title="11.C55 WHATWG SSE spec：內建自動重連與 Last-Event-ID 補送" data-link-desc="SSE 把重連與斷點續傳的協商鉤子寫進協議：自動重連、Last-Event-ID 補送、retry 欄位；補送實際保證仍看 server replay">11.C55&lt;/a>）。所以 SSE 對消費者的承諾是「自動重連加一個補送的協商鉤子」。要注意這個承諾的邊界：spec 只保證瀏覽器會送 &lt;code>Last-Event-ID&lt;/code>、實際補不補送由 server 有沒有實作 replay 決定；而且 SSE 是單向的、client 要傳資料回 server 得另開通道。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> 走相反的路：給雙向管線、不給保證。RFC 6455 只定義 handshake 與 framing、對投遞保證、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack&lt;/a>（收到確認）、重連、斷點續傳全部沉默（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/websocket-rfc6455-transport/" data-link-title="11.C56 RFC 6455：WebSocket 是雙向 transport、不內建投遞保證" data-link-desc="WebSocket 給雙向管線、不給保證：協議層對投遞保證、ack、重連、斷點續傳全部沉默、留給應用層">11.C56&lt;/a>）。這個沉默是設計、不是遺漏 —— 可靠性語意留給應用層。這代表每個用 WebSocket 的服務都要自己蓋一套：Slack 的 Socket Mode 在 WebSocket 上加了 envelope ack（每則事件回一個確認）、未 ack 就 retry、多連線熱備、斷線前預警（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/websocket-slack-socket-mode/" data-link-title="11.C57 Slack Socket Mode：WebSocket 上自建 ack、retry 與多連線熱備" data-link-desc="協議不給保證、vendor 在應用層自建整套可靠性：envelope ack、未 ack 就 retry、多連線熱備、斷線預警">11.C57&lt;/a>）—— 這套是 Slack 自己補的、不是 WebSocket 標準行為、換一個 vendor 補的方式又不一樣。&lt;/p>
&lt;p>long-polling 是把普通 HTTP 請求 hold 住到有事件才回。RFC 6202 講清楚它的機制代價：每則訊息一次完整 request/response、帶完整 HTTP headers（payload 小時 header 佔比高）、最大延遲跨三段網路傳輸、每個 client 佔一條連線（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/longpolling-rfc6202-mechanics/" data-link-title="11.C58 RFC 6202：long-polling 的機制代價與 fallback 定位" data-link-desc="long-polling hold 住請求到有事件才回：header 開銷、三段網路延遲、每 client 佔一條連線是機制決定的、不是實作品質">11.C58&lt;/a>）。這些重量是機制決定的、不是實作品質問題。它的價值在相容性下限：WebSocket 連線不保證每個環境都建得起來（少數受限網路 —— 舊企業 proxy、深度封包檢查 —— 仍可能擋）、所以 Socket.IO 預設先用 long-polling 建連、再嘗試 upgrade 到 WebSocket（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/longpolling-socketio-negotiation/" data-link-title="11.C59 Socket.IO：先 long-polling 再 upgrade WebSocket 的 transport negotiation" data-link-desc="WebSocket 不保證能建立（proxy/防火牆會擋）、所以先用 long-polling 建連再 upgrade：fallback 的價值是相容性下限、不是效能">11.C59&lt;/a>；這是 Socket.IO 的策略、有些 vendor 反向、先試 WebSocket 再退回）。&lt;/p></description><content:encoded><![CDATA[<p>WebSocket、SSE、long-polling 都在解同一個需求：server 有事情要主動送給 client、而不是等 client 來問。三者的差別在推的時候對消費者承諾了什麼 —— 連線斷了誰負責重連、訊息會不會漏、能不能雙向 —— 而不在能不能推。這三條承諾線決定選型、比「哪個比較新」有用得多 —— 選型看的是消費者形狀：這個 client 需要單向還是雙向、能不能容忍漏訊、網路可不可控。以下把三者的承諾攤開、對到這三個問題。webhook 這種 server 對 server 的事件推送形狀不同、收在<a href="/blog/backend/11-api-design/styles/realtime/realtime-webhook-contract/" data-link-title="webhook 對外承諾：投遞保證不是預設、consumer 負責一半" data-link-desc="webhook 是盡力而為的事件推送不是可靠佇列：投遞保證逐 vendor 讀、可靠性責任分一半給 consumer">另一篇</a>。</p>
<h2 id="協議各給多少保證">協議各給多少保證</h2>
<p><a href="/blog/backend/knowledge-cards/sse/" data-link-title="Server-Sent Events (SSE)" data-link-desc="說明 SSE 如何透過 HTTP 長連線向 client 單向推送事件">SSE</a>（Server-Sent Events）把重連寫進協議本身。WHATWG 的規範定義：連線斷掉時 user agent 自動重連、重連時把最後收到的 event id 放進 <code>Last-Event-ID</code> header 送回 server、server 據此可從斷點往後補送（見 <a href="/blog/backend/11-api-design/cases/sse-whatwg-spec-reconnection/" data-link-title="11.C55 WHATWG SSE spec：內建自動重連與 Last-Event-ID 補送" data-link-desc="SSE 把重連與斷點續傳的協商鉤子寫進協議：自動重連、Last-Event-ID 補送、retry 欄位；補送實際保證仍看 server replay">11.C55</a>）。所以 SSE 對消費者的承諾是「自動重連加一個補送的協商鉤子」。要注意這個承諾的邊界：spec 只保證瀏覽器會送 <code>Last-Event-ID</code>、實際補不補送由 server 有沒有實作 replay 決定；而且 SSE 是單向的、client 要傳資料回 server 得另開通道。</p>
<p><a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 走相反的路：給雙向管線、不給保證。RFC 6455 只定義 handshake 與 framing、對投遞保證、<a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack</a>（收到確認）、重連、斷點續傳全部沉默（見 <a href="/blog/backend/11-api-design/cases/websocket-rfc6455-transport/" data-link-title="11.C56 RFC 6455：WebSocket 是雙向 transport、不內建投遞保證" data-link-desc="WebSocket 給雙向管線、不給保證：協議層對投遞保證、ack、重連、斷點續傳全部沉默、留給應用層">11.C56</a>）。這個沉默是設計、不是遺漏 —— 可靠性語意留給應用層。這代表每個用 WebSocket 的服務都要自己蓋一套：Slack 的 Socket Mode 在 WebSocket 上加了 envelope ack（每則事件回一個確認）、未 ack 就 retry、多連線熱備、斷線前預警（見 <a href="/blog/backend/11-api-design/cases/websocket-slack-socket-mode/" data-link-title="11.C57 Slack Socket Mode：WebSocket 上自建 ack、retry 與多連線熱備" data-link-desc="協議不給保證、vendor 在應用層自建整套可靠性：envelope ack、未 ack 就 retry、多連線熱備、斷線預警">11.C57</a>）—— 這套是 Slack 自己補的、不是 WebSocket 標準行為、換一個 vendor 補的方式又不一樣。</p>
<p>long-polling 是把普通 HTTP 請求 hold 住到有事件才回。RFC 6202 講清楚它的機制代價：每則訊息一次完整 request/response、帶完整 HTTP headers（payload 小時 header 佔比高）、最大延遲跨三段網路傳輸、每個 client 佔一條連線（見 <a href="/blog/backend/11-api-design/cases/longpolling-rfc6202-mechanics/" data-link-title="11.C58 RFC 6202：long-polling 的機制代價與 fallback 定位" data-link-desc="long-polling hold 住請求到有事件才回：header 開銷、三段網路延遲、每 client 佔一條連線是機制決定的、不是實作品質">11.C58</a>）。這些重量是機制決定的、不是實作品質問題。它的價值在相容性下限：WebSocket 連線不保證每個環境都建得起來（少數受限網路 —— 舊企業 proxy、深度封包檢查 —— 仍可能擋）、所以 Socket.IO 預設先用 long-polling 建連、再嘗試 upgrade 到 WebSocket（見 <a href="/blog/backend/11-api-design/cases/longpolling-socketio-negotiation/" data-link-title="11.C59 Socket.IO：先 long-polling 再 upgrade WebSocket 的 transport negotiation" data-link-desc="WebSocket 不保證能建立（proxy/防火牆會擋）、所以先用 long-polling 建連再 upgrade：fallback 的價值是相容性下限、不是效能">11.C59</a>；這是 Socket.IO 的策略、有些 vendor 反向、先試 WebSocket 再退回）。</p>
<p>本文聚焦這三個已成熟的主流。WebTransport（HTTP/3 之上的新興雙向 transport）正在補 WebSocket 的多 stream 與 datagram 缺口、但瀏覽器與生態支援仍在成熟；HTTP/2 Server Push 已被主流棄用退場。兩者都不進本文的選型、但值得知道它們落在光譜的哪一端。</p>
<h2 id="承諾差異並排">承諾差異並排</h2>
<p>把三者的承諾攤成一張表。下表依各機制的一手定義（RFC 6455、RFC 6202、WHATWG SSE spec）整理、不是單一 vendor 的對照宣傳。</p>
<table>
  <thead>
      <tr>
          <th>機制</th>
          <th>方向</th>
          <th>重連</th>
          <th>投遞保證</th>
          <th>相容性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SSE</td>
          <td>單向</td>
          <td>協議內建</td>
          <td>補送靠 server replay</td>
          <td>一般（HTTP 之上）</td>
      </tr>
      <tr>
          <td>WebSocket</td>
          <td>雙向</td>
          <td>應用層自建</td>
          <td>應用層自建</td>
          <td>可能被 proxy 擋</td>
      </tr>
      <tr>
          <td>long-polling</td>
          <td>請求驅動</td>
          <td>每次請求即重連</td>
          <td>每則一次完整回應</td>
          <td>最高（就是普通 HTTP）</td>
      </tr>
  </tbody>
</table>
<p>表只是索引、每一格的成立條件要回到情境判讀。以「重連」欄為例：SSE 的「協議內建」對消費者是零成本的自動重連、但補送的完整性要 server 端配合；WebSocket 的「應用層自建」意思是這件事跑不掉、Slack Socket Mode 那套 ack 加 retry 是最低成本、不是可選；long-polling 沒有「重連」這個概念、因為每則訊息本來就是一次新請求、斷線在下一次請求自然癒合、代價是延遲與 header 開銷。</p>
<h2 id="選型對到消費者形狀">選型：對到消費者形狀</h2>
<p>三條承諾線對到三種消費者形狀。消費者只需要 server 單向推、又想要開箱即用的重連（儀表板、通知流、log tail）—— SSE 的承諾剛好、不必自己蓋重連。消費者需要雙向、低延遲、高頻互動（協作編輯、遊戲、互動終端）—— WebSocket 給你管線、但要接受「可靠性得自己蓋」這筆帳、參考 Slack 那套 ack 加 retry 的形狀。消費者在不可控的網路環境、WebSocket 不保證連得上 —— 要一條相容性 fallback、long-polling 是那個下限、常搭配 transport negotiation（能 upgrade 就 upgrade、方向因 library 而異）。</p>
<p>這三格對應 <a href="/blog/backend/11-api-design/api-style-selection/" data-link-title="11.2 風格選型總覽" data-link-desc="REST 式 HTTP&#43;JSON、GraphQL、gRPC、tRPC、JSON-RPC、event 之間選哪個 — 用消費者形狀、演進成本、操作可及性三軸判讀">11.2 消費者形狀軸</a> 的推送情境。共同的判讀是：這三種都不自帶「訊息一定不漏」的保證 —— SSE 的補送、WebSocket 的 ack、都要 server 或應用層主動實作。真的要「事件一定送達、可重放」的語意、那是佇列的責任、路由到 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 訊息佇列</a>、不是在推送機制上硬蓋。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>server 對 server 的事件推送：<a href="/blog/backend/11-api-design/styles/realtime/realtime-webhook-contract/" data-link-title="webhook 對外承諾：投遞保證不是預設、consumer 負責一半" data-link-desc="webhook 是盡力而為的事件推送不是可靠佇列：投遞保證逐 vendor 讀、可靠性責任分一半給 consumer">webhook 對外承諾</a></li>
<li>消費者形狀選型軸：<a href="/blog/backend/11-api-design/api-style-selection/" data-link-title="11.2 風格選型總覽" data-link-desc="REST 式 HTTP&#43;JSON、GraphQL、gRPC、tRPC、JSON-RPC、event 之間選哪個 — 用消費者形狀、演進成本、操作可及性三軸判讀">11.2 風格選型總覽</a></li>
<li>要可靠送達與重放的事件語意：<a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 訊息佇列</a></li>
<li>案例原文：<a href="/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">模組十一案例庫</a></li>
</ul>
]]></content:encoded></item><item><title>webhook 對外承諾：投遞保證不是預設、consumer 負責一半</title><link>https://tarrragon.github.io/blog/backend/11-api-design/styles/realtime/realtime-webhook-contract/</link><pubDate>Sat, 04 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/11-api-design/styles/realtime/realtime-webhook-contract/</guid><description>&lt;p>webhook 是 server 主動 POST 到你提供的 URL、把「有事發生了」推給你。它跟&lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/styles/realtime/realtime-push-mechanisms/" data-link-title="持久連線推送：WebSocket、SSE、long-polling 的承諾差異" data-link-desc="server 推 client 的持久連線機制對消費者承諾什麼：重連誰負責、訊息會不會漏、單向還是雙向；選型看消費者形狀">持久連線推送&lt;/a>是不同形狀 —— server 對 server、無持久連線、事件觸發。採用 webhook 的核心判讀不在「怎麼收」、而在「這個 vendor 對投遞承諾了什麼、你要自己負責什麼」；關鍵是每個 vendor 的承諾不一樣、不能假設一個通用行為。&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/webhook/" data-link-title="Webhook" data-link-desc="說明外部系統回呼事件的接收、驗證與處理邊界">webhook 知識卡&lt;/a>是概念定義、本文講的是選型與使用層的承諾判讀。&lt;/p>
&lt;h2 id="投遞保證不是預設">投遞保證不是預設&lt;/h2>
&lt;p>webhook 不一定會重試 —— 這是採用前最該先確認的承諾。GitHub 明文（文件裡重述兩次）不自動重投失敗的 webhook、失敗條件是 server down 或回應超過 10 秒、補救要你手動重送或自寫排程查 API 補投（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/webhook-github-no-retry/" data-link-title="11.C61 GitHub webhooks：不自動重試的反向承諾" data-link-desc="at-least-once 不是所有 vendor 都給：GitHub 明文只試一次、失敗靠 consumer 自建排程補投；逼你讀 vendor 明文而非假設">11.C61&lt;/a>）。對照 Stripe：live mode 對失敗投遞重試最多三天、指數退避（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/webhook-stripe-delivery-contract/" data-link-title="11.C60 Stripe webhooks：at-least-once 加簽章、明文要求 consumer 冪等與不依賴順序" data-link-desc="webhook 對外承諾的教科書樣本：三天重試、重複投遞、no-ordering、簽章驗證的責任在同一頁明文轉移給 consumer">11.C60&lt;/a>）。同樣叫 webhook、一個試三天、一個一次都不重試。&lt;/p>
&lt;p>重試的「形狀」本身就是一條要讀清楚的承諾。四個 vendor 四種形狀：Stripe 的長視窗指數退避、Slack 的固定三次（幾乎立即、1 分鐘後、5 分鐘後、見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/webhook-slack-events-retry/" data-link-title="11.C62 Slack Events API：3 秒 ack 上限加固定三次重試" data-link-desc="「慢等於失敗」寫死成 3 秒硬上限、逼 consumer 走立即 2xx 加背景處理；retry header 讓 consumer 辨識重投">11.C62&lt;/a>）、GitHub 的完全不重試、Shopify 連投遞本身都不保證（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/webhook-shopify-ordering-dedup/" data-link-title="11.C63 Shopify webhooks：ordering 不保證、指定 header 去重、投遞不保證" data-link-desc="跨 vendor 佐證 ordering-not-guaranteed 加冪等 header 是通則；甚至連投遞本身都不保證、需 reconciliation 兜底">11.C63&lt;/a>）。假設「webhook 會自動重試到成功」、會讓你漏事件卻不自知。payload 格式層有 CloudEvents 這類標準化嘗試、但它標準化的是事件信封的欄位、不是投遞語意 —— 重試、ack、去重這些承諾仍逐 vendor 各異、還是得逐家讀。&lt;/p>
&lt;h2 id="consumer-要扛的五件事">consumer 要扛的五件事&lt;/h2>
&lt;p>webhook 把可靠性的一部分交給 consumer 自己扛、有五件事跑不掉。&lt;/p>
&lt;p>去重（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">冪等&lt;/a>）是第一件。&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">at-least-once&lt;/a> 的投遞會重複、vendor 明文要你用某個 header 當冪等 key 去重 —— Stripe 用 event ID、Shopify 指定 &lt;code>X-Shopify-Webhook-Id&lt;/code>、GitHub 給 &lt;code>X-GitHub-Delivery&lt;/code> GUID。就算 GitHub 不自動重試、手動重投也會重複、去重照樣跑不掉。這條對到 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/api-idempotency-design/" data-link-title="11.8 API 層冪等設計" data-link-desc="idempotency key 誰生成、存多久、replay 回什麼、衝突怎麼回 — 對外冪等契約的條款設計與無標準現況">11.8 API 層冪等設計&lt;/a> 的 consumer 側。&lt;/p>
&lt;p>不依賴順序是第二件。ordering 不保證是通則、Stripe 與 Shopify 都明文（Shopify 建議用 &lt;code>updated_at&lt;/code> 自己排）—— 事件處理邏輯不能假設收到的順序等於發生的順序。&lt;/p>
&lt;p>快速 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack&lt;/a>（回覆確認收到）是第三件：慢等於失敗。Slack 寫死 3 秒、GitHub 10 秒、超過就算投遞失敗。這逼出「先回 2xx、再背景處理」的拆分、複雜邏輯不能擋在 ack 前面、否則一個慢查詢就讓整批事件被判失敗。&lt;/p>
&lt;p>簽章驗證是第四件。webhook 是打到你公開 URL 的請求、要驗它真的來自該 vendor。每個 vendor 一套 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/message-authentication/" data-link-title="Message Authentication" data-link-desc="兩個系統用共享密鑰互相呼叫時，用來判斷驗證值保護到什麼範圍、撤銷粒度落在哪一層">HMAC&lt;/a>（雜湊訊息鑑別碼）方案（Stripe-Signature、GitHub 的 &lt;code>X-Hub-Signature-256&lt;/code>、Shopify 的 &lt;code>X-Shopify-Hmac-Sha256&lt;/code>）—— header 名不同、驗法類似：用雙方共享的 secret 對 body 算一段雜湊、比對請求帶的簽章、對不上就丟。簽章之外還有一格常被略過：驗簽章通過只證明內容沒被改、不證明這是新的請求，要擋 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/replay-attack/" data-link-title="Replay Attack" data-link-desc="攔截到的合法請求被原封不動再送一次時，用來判斷哪一層該負責擋、以及擋不住會發生什麼">重放&lt;/a> 得另外檢查 vendor 附的 timestamp 是否在容忍窗口內。窗口要開多寬有上下界可以換算、比對簽章那一行要用什麼函式、以及對方的文件與實際送出的內容不一致時怎麼辦，見 &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/signature-integration-verification/" data-link-title="7.35 簽章對接的驗證收斂：驗簽通過之後還缺哪一塊" data-link-desc="接收外部推送或用共享密鑰簽章對接時，用來判斷進入計算的素材要怎麼定義、時間戳窗口要開多寬才擋得住重放、以及比對方式怎麼抵銷機制強度">7.35 簽章對接的驗證收斂&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>webhook 是 server 主動 POST 到你提供的 URL、把「有事發生了」推給你。它跟<a href="/blog/backend/11-api-design/styles/realtime/realtime-push-mechanisms/" data-link-title="持久連線推送：WebSocket、SSE、long-polling 的承諾差異" data-link-desc="server 推 client 的持久連線機制對消費者承諾什麼：重連誰負責、訊息會不會漏、單向還是雙向；選型看消費者形狀">持久連線推送</a>是不同形狀 —— server 對 server、無持久連線、事件觸發。採用 webhook 的核心判讀不在「怎麼收」、而在「這個 vendor 對投遞承諾了什麼、你要自己負責什麼」；關鍵是每個 vendor 的承諾不一樣、不能假設一個通用行為。<a href="/blog/backend/knowledge-cards/webhook/" data-link-title="Webhook" data-link-desc="說明外部系統回呼事件的接收、驗證與處理邊界">webhook 知識卡</a>是概念定義、本文講的是選型與使用層的承諾判讀。</p>
<h2 id="投遞保證不是預設">投遞保證不是預設</h2>
<p>webhook 不一定會重試 —— 這是採用前最該先確認的承諾。GitHub 明文（文件裡重述兩次）不自動重投失敗的 webhook、失敗條件是 server down 或回應超過 10 秒、補救要你手動重送或自寫排程查 API 補投（見 <a href="/blog/backend/11-api-design/cases/webhook-github-no-retry/" data-link-title="11.C61 GitHub webhooks：不自動重試的反向承諾" data-link-desc="at-least-once 不是所有 vendor 都給：GitHub 明文只試一次、失敗靠 consumer 自建排程補投；逼你讀 vendor 明文而非假設">11.C61</a>）。對照 Stripe：live mode 對失敗投遞重試最多三天、指數退避（見 <a href="/blog/backend/11-api-design/cases/webhook-stripe-delivery-contract/" data-link-title="11.C60 Stripe webhooks：at-least-once 加簽章、明文要求 consumer 冪等與不依賴順序" data-link-desc="webhook 對外承諾的教科書樣本：三天重試、重複投遞、no-ordering、簽章驗證的責任在同一頁明文轉移給 consumer">11.C60</a>）。同樣叫 webhook、一個試三天、一個一次都不重試。</p>
<p>重試的「形狀」本身就是一條要讀清楚的承諾。四個 vendor 四種形狀：Stripe 的長視窗指數退避、Slack 的固定三次（幾乎立即、1 分鐘後、5 分鐘後、見 <a href="/blog/backend/11-api-design/cases/webhook-slack-events-retry/" data-link-title="11.C62 Slack Events API：3 秒 ack 上限加固定三次重試" data-link-desc="「慢等於失敗」寫死成 3 秒硬上限、逼 consumer 走立即 2xx 加背景處理；retry header 讓 consumer 辨識重投">11.C62</a>）、GitHub 的完全不重試、Shopify 連投遞本身都不保證（見 <a href="/blog/backend/11-api-design/cases/webhook-shopify-ordering-dedup/" data-link-title="11.C63 Shopify webhooks：ordering 不保證、指定 header 去重、投遞不保證" data-link-desc="跨 vendor 佐證 ordering-not-guaranteed 加冪等 header 是通則；甚至連投遞本身都不保證、需 reconciliation 兜底">11.C63</a>）。假設「webhook 會自動重試到成功」、會讓你漏事件卻不自知。payload 格式層有 CloudEvents 這類標準化嘗試、但它標準化的是事件信封的欄位、不是投遞語意 —— 重試、ack、去重這些承諾仍逐 vendor 各異、還是得逐家讀。</p>
<h2 id="consumer-要扛的五件事">consumer 要扛的五件事</h2>
<p>webhook 把可靠性的一部分交給 consumer 自己扛、有五件事跑不掉。</p>
<p>去重（<a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">冪等</a>）是第一件。<a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">at-least-once</a> 的投遞會重複、vendor 明文要你用某個 header 當冪等 key 去重 —— Stripe 用 event ID、Shopify 指定 <code>X-Shopify-Webhook-Id</code>、GitHub 給 <code>X-GitHub-Delivery</code> GUID。就算 GitHub 不自動重試、手動重投也會重複、去重照樣跑不掉。這條對到 <a href="/blog/backend/11-api-design/api-idempotency-design/" data-link-title="11.8 API 層冪等設計" data-link-desc="idempotency key 誰生成、存多久、replay 回什麼、衝突怎麼回 — 對外冪等契約的條款設計與無標準現況">11.8 API 層冪等設計</a> 的 consumer 側。</p>
<p>不依賴順序是第二件。ordering 不保證是通則、Stripe 與 Shopify 都明文（Shopify 建議用 <code>updated_at</code> 自己排）—— 事件處理邏輯不能假設收到的順序等於發生的順序。</p>
<p>快速 <a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack</a>（回覆確認收到）是第三件：慢等於失敗。Slack 寫死 3 秒、GitHub 10 秒、超過就算投遞失敗。這逼出「先回 2xx、再背景處理」的拆分、複雜邏輯不能擋在 ack 前面、否則一個慢查詢就讓整批事件被判失敗。</p>
<p>簽章驗證是第四件。webhook 是打到你公開 URL 的請求、要驗它真的來自該 vendor。每個 vendor 一套 <a href="/blog/backend/knowledge-cards/message-authentication/" data-link-title="Message Authentication" data-link-desc="兩個系統用共享密鑰互相呼叫時，用來判斷驗證值保護到什麼範圍、撤銷粒度落在哪一層">HMAC</a>（雜湊訊息鑑別碼）方案（Stripe-Signature、GitHub 的 <code>X-Hub-Signature-256</code>、Shopify 的 <code>X-Shopify-Hmac-Sha256</code>）—— header 名不同、驗法類似：用雙方共享的 secret 對 body 算一段雜湊、比對請求帶的簽章、對不上就丟。簽章之外還有一格常被略過：驗簽章通過只證明內容沒被改、不證明這是新的請求，要擋 <a href="/blog/backend/knowledge-cards/replay-attack/" data-link-title="Replay Attack" data-link-desc="攔截到的合法請求被原封不動再送一次時，用來判斷哪一層該負責擋、以及擋不住會發生什麼">重放</a> 得另外檢查 vendor 附的 timestamp 是否在容忍窗口內。窗口要開多寬有上下界可以換算、比對簽章那一行要用什麼函式、以及對方的文件與實際送出的內容不一致時怎麼辦，見 <a href="/blog/backend/07-security-data-protection/signature-integration-verification/" data-link-title="7.35 簽章對接的驗證收斂：驗簽通過之後還缺哪一塊" data-link-desc="接收外部推送或用共享密鑰簽章對接時，用來判斷進入計算的素材要怎麼定義、時間戳窗口要開多寬才擋得住重放、以及比對方式怎麼抵銷機制強度">7.35 簽章對接的驗證收斂</a>。</p>
<p>對帳兜底是第五件。Shopify 文件最直接：投遞不保證、app 可能漏事件、要另備 <a href="/blog/backend/knowledge-cards/data-reconciliation/" data-link-title="Data Reconciliation" data-link-desc="說明多個資料來源不一致時如何比對、修復與留下證據">reconciliation</a>（對帳）或 polling 補漏。webhook 是盡力而為的推送、要做到不漏、consumer 得在 webhook 之外自備對帳。</p>
<h2 id="採用前要讀完的四個承諾">採用前要讀完的四個承諾</h2>
<p>採一個 vendor 的 webhook 前、把承諾讀成四題：重試是什麼形狀（三天、三次、還是不重試）、ack timeout 幾秒、用哪個 header 去重、投遞保不保證。這四題的答案決定 consumer 端要蓋多少機制 —— 不讀清楚就上、會在漏事件或重複處理時、才發現承諾跟假設的不一樣。本文引的具體數字（三天、3 秒、10 秒、固定三次）是各 vendor 當前的承諾、採用前以官方 docs 現值為準。</p>
<p>反過來當 producer、要決定對外推事件用不用 webhook：適不適合不取決於事件關不關鍵、而取決於 consumer 端能不能補齊冪等與對帳 —— Stripe 用 webhook 推付款這種最關鍵的事件、靠的正是 consumer 側的去重與對帳補到接近可靠。真正把 webhook 排除掉的、是「需要 producer 端就保證有序、durable、可重放」的場景：那種可靠語意 webhook 這種盡力而為的形狀補不出來、該換有 durable 保證的佇列 —— 見 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 訊息佇列</a>。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>簽章原語的選型（這個機制擋得住誰、金鑰放哪一格）：<a href="/blog/backend/07-security-data-protection/cryptographic-primitive-selection/" data-link-title="7.28 密碼學原語選型：金鑰位置決定威脅模型" data-link-desc="決定用加密、簽章、編碼還是單向轉換保護一段資料時，用來判斷各原語的保護範圍與失效條件">7.28 密碼學原語選型</a></li>
<li>持久連線的推送機制：<a href="/blog/backend/11-api-design/styles/realtime/realtime-push-mechanisms/" data-link-title="持久連線推送：WebSocket、SSE、long-polling 的承諾差異" data-link-desc="server 推 client 的持久連線機制對消費者承諾什麼：重連誰負責、訊息會不會漏、單向還是雙向；選型看消費者形狀">持久連線推送</a></li>
<li>錯誤與投遞承諾的雙向框架：<a href="/blog/backend/11-api-design/error-bidirectional-contract/" data-link-title="11.11 Status 與錯誤的雙向契約" data-link-desc="status 與錯誤是兩端的合作契約：provider 該讓 consumer 知道什麼、consumer 收到錯誤怎麼判讀與回報、以及單邊設計怎麼把成本外部化給對方">11.11 Status 與錯誤的雙向契約</a></li>
<li>consumer 側的冪等設計：<a href="/blog/backend/11-api-design/api-idempotency-design/" data-link-title="11.8 API 層冪等設計" data-link-desc="idempotency key 誰生成、存多久、replay 回什麼、衝突怎麼回 — 對外冪等契約的條款設計與無標準現況">11.8 API 層冪等設計</a></li>
<li>要可靠送達與重放的事件語意：<a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 訊息佇列</a></li>
<li>各家去重 header 名稱不同只是命名之爭，重送回什麼才是語意之爭：<a href="/blog/backend/11-api-design/idempotency-key-standardization-debate/" data-link-title="Idempotency key 標準化之爭：標準統一得了揭露的形狀、統一不了業務綁定的值" data-link-desc="整合或自建冪等機制時各家條款的實質差異：replay 回首次快照還是最新狀態、保存期是否明文、同 key 並發怎麼處理">Idempotency key 標準化之爭</a></li>
<li>案例原文：<a href="/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">模組十一案例庫</a></li>
</ul>
]]></content:encoded></item></channel></rss>