Webhook3 tháng 8, 20268 phút đọc

Shopify đã xóa webhook subscription của bạn. Vì sao 5 giây là toàn bộ câu chuyện.

Shopify cho endpoint của bạn năm giây, thử lại tám lần trong bốn giờ, rồi xóa subscription. Không có lỗi nào được ném ra. Đơn hàng cứ thế ngừng đến.

VẪN BỐN BƯỚC ĐÓ, CHỒNG LÊN NHAU, TRƯỚC MỘT BỨC TƯỜNG CỐ ĐỊNHngày suôn sẻ1,2 giâyERP chậm7,1 giây5 giâymọi lượt gửi cùng lúc vượt mốc, nên cả tám lần thử lại đều cạn trong bốn giờcode không hề thay đổi. đến thứ Năm, subscription đã bị xóa.
Handler này không sai chỗ nào. Nó chỉ cần chạy xong trước khi Shopify thôi chờ.

Triệu chứng im lặng đến bất thường. Đơn hàng ngừng xuất hiện trong app của bạn. Log không có lỗi nào, vì chẳng có gì tới được log. Shopify admin không có lượt gửi thất bại nào, vì không còn subscription nào để hiển thị lượt gửi. Shopify đã xóa nó, và trừ khi email cảnh báo đến được một hộp thư có người đọc, tín hiệu đầu tiên sẽ là một người bán hỏi vì sao đơn hàng của hai ngày gần nhất biến mất.

Quy tắc webhook của Shopify khắt khe hơn phần lớn nhà cung cấp khác, và sự khắt khe đó dồn cả vào một con số.

TL;DR

  • Năm giây. Đó là toàn bộ thời hạn phản hồi của bạn, cho mỗi lượt gửi
  • Shopify thử lại (retry) 8 lần trong 4 giờ, và sau 8 lần thất bại liên tiếp, subscription tạo qua Admin API sẽ bị xóa (subscription khai báo trong shopify.app.toml thì không)
  • Với app của bạn, subscription bị xóa trong im lặng. Không có traffic, không có lỗi, vì không còn subscription nào
  • Email cảnh báo và email báo xóa được gửi tới emergency developer email trong tài khoản Partner của bạn, thường là địa chỉ không ai theo dõi
  • Khôi phục nghĩa là tạo lại subscription và lấy bù (backfill) bằng Admin API, vì Shopify không phát lại

Năm giây thực sự đi đâu

GitHub cho bạn mười giây, Stripe không công bố con số nào, còn các nhà cung cấp khác có trong tài liệu tham chiếu các nhà cung cấp webhook của chúng tôi. Shopify chờ phản hồi trong năm giây và coi bất cứ thứ gì chậm hơn là một lượt gửi thất bại.

Năm giây cũng chưa phải mức thấp nhất. Slack cho ba giây, và khắt khe đến mức làm khó bất kỳ ai trước giờ chỉ tích hợp với cửa hàng online. Twilio là ngoại lệ cho thấy các con số này tùy tiện đến đâu: Twilio trao núm vặn cho bạn và để bạn tự đặt timeout, nhưng gần như chẳng ai vặn nó.

Năm giây nghe có vẻ dư dả, cho đến khi bạn ghi ra những gì thực sự diễn ra bên trong một handler (đoạn code xử lý webhook trên server của bạn) điển hình:

BướcThông thườngNgày tệ
Cold start (serverless)200 ms2,5 giây
Xác minh HMAC2 ms2 ms
Tra cứu shop theo domain30 ms400 ms
Ghi đơn hàng + line item80 ms1,2 giây
Đồng bộ sang ERP / 3PL600 mstimeout
Gửi email xác nhận250 ms3 giây
VẪN BỐN BƯỚC ĐÓ, CHỒNG LÊN NHAU, TRƯỚC MỘT BỨC TƯỜNG CỐ ĐỊNHngày suôn sẻ1,2 giâyERP chậm7,1 giây5 giâymọi lượt gửi cùng lúc vượt mốc, nên cả tám lần thử lại đều cạn trong bốn giờcode không hề thay đổi. đến thứ Năm, subscription đã bị xóa.

Happy path mất 1,2 giây và trông hoàn toàn ổn. Rồi ERP của bạn có một buổi sáng chậm chạp, mọi lượt gửi cùng lúc vượt năm giây, và bạn đốt sạch cả tám lần thử lại trong bốn giờ. Code của bạn không thay đổi gì. Code của bạn không sai gì. Đến thứ Năm thì subscription đã biến mất.

Có hai yếu tố làm tình hình tệ hơn, và chúng chỉ có ở Shopify.

Flash sale khiến các lỗi dồn vào cùng một lúc. Webhook đến tỷ lệ thuận với số đơn hàng, nên lúc bạn cần orders/create nhất cũng là lúc database bận nhất và thời gian phản hồi tệ nhất. Kiểu lỗi này xảy ra đúng vào lúc bạn ít chịu nổi nó nhất.

Thao tác hàng loạt sinh ra webhook ồ ạt. Một người bán sửa 5.000 sản phẩm qua một app hoặc qua import CSV sẽ sinh ra 5.000 webhook products/update trong một khoảng thời gian ngắn. Lúc này endpoint của bạn bị giới hạn tốc độ bởi chính connection pool của database, và mọi lượt gửi quá năm giây đều bị tính là thất bại của bạn.

Khi bị xóa, mọi thứ trông thế nào

Sau tám lần gửi thất bại trong bốn giờ đó, Shopify ngừng thử lại sự kiện, và sau 8 lần thất bại liên tiếp, subscription tạo qua Admin API sẽ bị xóa, kèm một email cảnh báo gửi tới emergency developer contact của app. Các subscription riêng của app khai báo trong shopify.app.toml thì không bị xóa theo cách này.

Shopify có gửi email cho bạn: một email cảnh báo khi phát hiện lỗi lặp lại, và một email thứ hai khi subscription bị xóa. Cả hai đều gửi tới emergency developer email trong tài khoản Partner của bạn. Trên thực tế, địa chỉ đó được đặt một lần lúc onboarding, thường là địa chỉ cá nhân của một người sau đó đã rời đi, và đó là lý do phổ biến nhất khiến một team bị bất ngờ vì chuyện này.

Hãy kiểm tra địa chỉ đó ngay bây giờ. Việc này mất ba mươi giây và là điều đáng giá nhất trong bài này.

Sau khi bị xóa, webhookSubscriptions không trả về gì cho topic đó, không có lượt gửi nào và cũng không có lỗi nào, vì chẳng còn gì để thất bại. Các sự kiện phát sinh trong lúc subscription vắng mặt cũng không được xếp hàng ở đâu cả. Shopify không phát lại sau khi bạn tạo lại subscription.

Ý sau cùng đó mới là thứ làm chuyện này tốn kém. Khác với một endpoint Stripe bị vô hiệu hóa, khi sự kiện vẫn còn trong API suốt 30 ngày, một subscription Shopify bị xóa nghĩa là các webhook đó đơn giản là chưa bao giờ được gửi. Khôi phục là một tác vụ đối soát với Admin API, không phải phát lại.

Các nền tảng tự host cũng không nhẹ tay hơn: WooCommerce cũng vô hiệu hóa webhook sau hơn năm lần thất bại liên tiếp.

Đưa thời gian phản hồi xuống dưới năm giây

Cấu trúc đáng tin cậy duy nhất là: xác minh, lưu, xác nhận (ack), và làm phần việc thật ở chỗ khác. Công cụ kiểm tra timeout cho thấy hiện giờ handler của bạn vượt năm giây bao xa, và nếu bước chậm là một lệnh gọi mô hình, bài Handler webhook của bạn gọi LLM có cùng cấu trúc đó cho từng nền tảng.

export async function POST(req: Request) {
  const raw = await req.text();               // raw body, required for HMAC
  if (!verifyShopifyHmac(raw, req.headers.get("x-shopify-hmac-sha256"))) {
    return new Response("unauthorized", { status: 401 });
  }

  await queue.publish({                       // durable write, ~10ms
    topic: req.headers.get("x-shopify-topic"),
    shop: req.headers.get("x-shopify-shop-domain"),
    webhookId: req.headers.get("x-shopify-webhook-id"),
    body: raw,
  });

  return new Response(null, { status: 200 }); // well inside 5s
}

Bốn chi tiết cần làm cho chính xác.

Đọc raw body, không phải JSON đã parse. HMAC của Shopify được tính trên đúng từng byte, nên bất kỳ middleware nào parse rồi serialize lại cũng sẽ làm đổi thứ tự key hoặc khoảng trắng, và mọi lần xác minh chữ ký sẽ không qua. Trên App Router của Next.js, gọi await req.text() trước mọi thao tác parse. Xem Xác minh chữ ký webhook của Stripe, GitHub và Shopify để biết chi tiết cho từng nhà cung cấp.

Xác minh trước khi đưa vào hàng đợi, không phải sau. Xác minh mất khoảng 2 ms và là cánh cổng ngăn kẻ tấn công lấp đầy hàng đợi của bạn. Cổng rẻ thì đặt trước.

Chống trùng lặp dựa trên X-Shopify-Webhook-Id. Giá trị này giữ nguyên qua mọi lần thử lại của cùng một sự kiện, nên đó là idempotency key (xử lý nhiều lần vẫn cho cùng một kết quả) đúng. Shopify sẽ thử lại, nên consumer của bạn phải idempotent. Các pattern có trong bài Bạn nhận cùng một webhook hai lần.

Để ý cold start. Trên serverless, một cold start có thể ngốn nửa thời hạn trước khi code của bạn chạy. Giữ dependency graph của route webhook thật nhỏ: import cả ORM lẫn admin SDK vào một handler chỉ cần đẩy byte vào hàng đợi là một chi phí có thật và tránh được.

Khôi phục sau khi bị xóa

Đầu tiên, tạo lại subscription.

mutation {
  webhookSubscriptionCreate(
    topic: ORDERS_CREATE
    webhookSubscription: {
      callbackUrl: "https://your-app.example.com/webhooks/shopify"
      format: JSON
    }
  ) {
    webhookSubscription { id }
    userErrors { field message }
  }
}

Thứ hai, đối soát khoảng trống bằng Admin API. Vì không có gì được phát lại, bạn truy vấn trực tiếp các đối tượng trong khoảng thời gian xảy ra sự cố và xử lý chúng qua đúng code path mà consumer webhook của bạn đang dùng:

{
  orders(first: 250, query: "updated_at:>2026-07-28T00:00:00Z") {
    edges { node { id name updatedAt displayFinancialStatus } }
  }
}

Cho kết quả đó chạy qua consumer idempotent của bạn. Thứ gì đã xử lý rồi sẽ không làm gì cả; thứ gì bị lỡ sẽ được áp dụng. Đây cũng là lúc bạn biết consumer của mình có thật sự idempotent hay không.

Thứ ba, rà soát xem bạn đã mất những topic nào. Liệt kê các subscription hiện có và so với những gì app của bạn cần. Việc xóa diễn ra theo từng subscription, nên hoàn toàn bình thường khi bạn mất orders/create trong khi products/update vẫn còn, và điều đó khiến triệu chứng càng khó hiểu hơn.

Thứ tư, sửa emergency developer email, và thêm giám sát của riêng bạn. Một cảnh báo kiểu “không nhận được orders/create nào trong 6 giờ, trong giờ làm việc” đã có thể bắt được chuyện này ngay từ ngày đầu tiên.

AnyHook giúp được gì

Thời hạn năm giây là bài toán của tầng truyền tải, và truyền tải chính là thứ mà một dịch vụ trung chuyển webhook (webhook relay) thay thế.

Trỏ subscription Shopify về in.anyhook.net/you/shopify. AnyHook trả 200 sau khoảng một giây, dù ERP của bạn đang khỏe hay đang cháy. Đồng hồ của Shopify giờ đo câu trả lời đó thay vì server gốc của bạn, nên sự chậm chạp của server gốc không còn kích hoạt bộ đếm ngược tám lần thử lại. Ngoại lệ duy nhất là sự kiện mà AnyHook hoàn toàn không lưu được: khi đó AnyHook trả 503 thay vì 200, và cơ chế thử lại của chính Shopify sẽ tiếp quản.

  • Độ chậm của server gốc được tách khỏi đồng hồ của Shopify. Chúng tôi thử lại tới server của bạn sau các khoảng 5 phút, 30 phút, 3 giờ, 18 giờ, rồi hằng ngày, từ 3 đến 10 lần gửi tùy gói. Với gói Free, lần gửi cuối diễn ra ở phút thứ 35, nên một sự cố ngừng hoạt động dài hơn được bù bằng nhật ký đã lưu và tính năng phát lại (replay)
  • Mọi sự kiện đều được lưu trước khi gửi đi, được mã hóa khi lưu trữ, nên một khoảng trống có thể phát lại từ nhật ký của chúng tôi thay vì phải dựng lại từ Admin API
  • Cảnh báo chuỗi lỗi được gửi ở mốc 1, 5 và 20 lần thất bại liên tiếp, tới một địa chỉ bạn kiểm soát, và tự động tạm dừng ở mốc 20
  • Phát lại bằng một cú bấm cho cả khoảng thời gian server của bạn ngừng hoạt động, và phát lại sự kiện thất bại không bị tính vào hạn mức hằng tháng của bạn, dù vẫn có giới hạn hằng ngày và dùng đến hạn mức compute (tài nguyên tính toán đi kèm gói)

Một public app chạy trên nhiều shop chỉ cần một App AnyHook (một URL nhận cho một nguồn webhook, ví dụ “Stripe production”), không phải mỗi shop một App: trỏ subscription của mọi shop về cùng một URL nhận (inbound URL). Shopify ký webhook của app bằng client secret của app, giống nhau cho mọi shop, nên đó là secret (khóa bí mật dùng để ký) mà AnyHook kiểm tra, còn header X-Shopify-Shop-Domain vẫn cho handler biết shop nào đã gửi từng sự kiện.

Quy tắc năm giây của Shopify là một yêu cầu đối với endpoint mà Shopify gọi tới. Đặt ở đó một endpoint trả lời sau khoảng một giây, và quy tắc đó không còn ràng buộc business logic của bạn.

Điều cần nhớ

Năm giây của Shopify không phải mục tiêu mềm, và cái giá của việc liên tục trượt mốc đó là bị xóa, chứ không phải một endpoint bị tạm dừng mà bạn có thể nhận ra trên dashboard. Rút đường phản hồi xuống còn xác minh, lưu, xác nhận, chống trùng lặp dựa trên X-Shopify-Webhook-Id, và kiểm tra xem emergency developer email trong tài khoản Partner đang trỏ tới địa chỉ nào. Đó là nơi cảnh báo duy nhất bạn nhận được sẽ rơi vào.

Tất cả bài viết3 tháng 8, 2026 · 8 phút

Đừng để mất webhook nữa.

Đổi một URL. Có ngay cơ chế thử lại, nhật ký sự kiện và phát lại bằng một cú bấm. Hoặc xem tận mắt trước: đăng ký, chạy bài diễn tập sự cố 30 giây và xem một sự kiện vẫn còn nguyên khi server của bạn ngừng hoạt động. Không cần có server.