By Chen.lin4 min read690 words

Shopify Webhooks

Backend DevelopmentShopify APIWebhook

主要记录使用 webhooks 时遇到的问题和解决办法

shopify 的 webhooks 可以在商店的特定事件发生的时候对配置的路由位置发送提醒。

例如:

产品更新

库存变化

订单的变化

应用卸载

具体的webhooks 分为很多类型。

配置方法

  1. 后台配置
  2. 通过接口创建
  3. App 安装后可以自动创建

image%20%285%29-4KMrq7LR7mlflCgMRxuA7uEfahLPxM.png

对于定制的app,只是针对于一个商店, 直接后台创建即可。

如果app能够上架商店 那么在app的配置文件中写入的webhooks应该在安装的时候直接自动生成。但是如果没有上架商店 似乎不可以。这里涉及到一个问题是webhooks签名所使用的密钥,如果在后台创建,密钥会直接在页面的下面显示,那么每个商店应该都有各自的密钥,而且这个密钥似乎还不能修改。

image%20%286%29-EdSjRIsNbZb0j0GE42DE1uKmNc0rvF.png

那么当一个App 上架到商店之后 App 如何知道这个密钥呢 查询后得知通过App注册的webhooks将通过App Sercret来签名。

那么这样也可以在App 完成 OAuth 流程后去调用接口创建(graphQL)。只有这样才能让App适应于多个商店。后续的App都应该朝着这个方向发展

必须验签

这是所有的 webhooks 接收方都应该做的事 验签失败 即不能证明是官方发送的请求。

The following code verifies the HMAC signature from Shopify using JavaScript.

function verifyShopifyHmac(body, hmacHeader) { if (!hmacHeader) return false; // 空 header,直接返回 false const secret = process.env.SHOPIFY_WEBHOOK_SECRET; const digest = crypto .createHmac("sha256", secret) .update(body, "utf8") .digest("base64"); try { return crypto.timingSafeEqual( Buffer.from(digest, "utf8"), Buffer.from(hmacHeader, "utf8") ); } catch (err) { // 避免因长度不一致导致抛出异常 return false; } }

忽略重复 - 幂等性设计

shopify 会多次发送相同的 webhooks 虽然这是同一个事件导致的 比如某个订单的产生 但是发来两次提醒,如果此时向数据库插入两次订单的信息 那么将造成错误的重复。所以官方文档 要求webhooks的处理必须是幂等的

如果不是幂等的 可以通过利用头部信息来检测重复的 webhook 事件 X-Shopify-Event-Id 会标识同一事件。

shopify 这样做的目的保证 webhook 一定被接收到

必须且快速响应

✅即使失败也必须响应 webhooks 未收到回复( 非 2xx 响应 )将会不断重试 多次失败后shopify可能会移除掉该webhooks.

✅对于重复的 webhooks 如果处理不是幂等的 在上一步中通过 事件id 过滤 此时也是要返回200响应的

✅快速响应:Shopify 会等待最多 5 秒,如果超时会认为失败,也会重试。

  1. 验签失败 401
  2. 重复 webhook 200 建议附带表示重复的日志信息
  3. 正确处理 200

颗粒度问题 - filter 过滤

颗粒度问题是由下面的原因导致的:

  1. Webhook 是无状态的:不告知「哪个字段变了」,只给你变更后的最新快照。
  2. 事件定义颗粒度粗:如 orders/updated
  3. 顺序未必可靠( 使用多个范围重叠的 webhooks 时 )

有些 webhooks 的事件触发很宽泛,甚至产生了一个 webhooks 包含了另一个 (Order Update 包含了 Order Cancel)

Order Update,任何关于订单的修改 例如修改 note tags 任何变化都会触发 。而在一个线上的环境中,如果用户安装了大量的辅助类的App, 这些 app 可能会在短时间内不停的修改order的一些数据,这些都会不断的触发 Order Update。 如果不加过滤,影响性能不说,如果没有做好幂等性设计,后果很严重,,,

【这里插入本人写的bug 因为通过webhook接收订单的修改信息 去第三方系统 用户的ERP创建订单 但是更新操作我直接在ERP上取消再创建 实际上订单的商品行修改是很罕见的 取消再创建也没问题 在测试环境因为环境很干净,所以看不出来 测试也没测出来 但是当上线之后 ERP上被疯狂修改再创建】

我这里涉及到第三方ERP信息的创建 其实整个 webhook 的处理就不是幂等的了

还有一点:Order Update 包含了 Order Cancel。 经过我测试,当订单被取消的时候 Update webhook 可能会先一步发过来 。当两个 webhooks 同时使用的时候,这也可能会产生问题。

对于 Order Update 来说 也许我们只关心其中特定的变化 这个时候可能就会用到官方文档中的Webhooks Filter 例如:

[[webhooks.subscriptions]] topics = ["products/update"] uri = "https://example.com/webhooks" filter = "variants.price:>=10.00"

这样可以过滤出 价格大于10的 还有是否存在的检测 具体看官方文档

但是 这样的方式也是有局限的 因为这并不具备记忆的能力

例如我要检测订单中用户的 shipping address 变化的情况。Shopify 没有专门的 Webhook 事件用于监听“订单中收货地址的修改” 那么就只能选择 order update , 而 **Webhook 是无状态的,**并不会告知是 adress 发生了变化

因此解决办法就只有保存 webhooks payload 的部分数据作为副本,每次进行比较。对于订单数据我使用 redis 保存 设定过期时间合理即可

这里再记录下我踩的小坑:

问题是:当我保存我关心的数据作为副本数据时,在每次对比数据是否变化的逻辑中,需要先从 redis 中取出数据,那么取出数据的时候如果出现找不到这个数据,应该认为此次 webhook 是涉及到我关心得变化了 还是没有呢?理论上来说,是不应该出现这种情况的 对于order update 来说,我应该是在订单产生的时候就保存了第一份副本,所以未找到副本数据 这是不对的 理应不对此次 webhook 进行处理。此时贸然处理可能导致业务逻辑错误或数据污染

如果副本缺失,说明状态不一致,有可能是:

  • 系统 bug(比如忘了在 orders/paidorders/create 时保存副本)
  • Redis 数据被清掉(例如重启、过期、或 key 设置错误)
  • 功能上线时没做副本补全

我的问题出现在第三点,而且我在没做补全的基础上:对于没找到副本的时候,进行了处理。

redis 的数据设置了90天过期 应该不会出现订单90天还在更新吧。

The following JavaScript code filters order update webhooks where the shipping_address or line_items fields have changed.

/** * After order paid, save key fields to redis for webhook order update filter * @param {object} newOrder */ export async function orderFilterInit(newOrder) { const orderId = newOrder.id; const redisKey = `order:${orderId}`; const oldRaw = await redis.get(redisKey); if (!oldRaw) { await redis.set(redisKey, JSON.stringify(extractSnapshot(newOrder)), "EX", 90 * 24 * 60 * 60); } } /** * Order Update Webhooks Filter * @param {object} newOrder * @returns {Promise<boolean>} */ export async function orderUpdateFilter(newOrder) { const orderId = newOrder.id; const redisKey = `order:${orderId}`; const oldRaw = await redis.get(redisKey); if (!oldRaw) { await redis.set(redisKey, JSON.stringify(extractSnapshot(newOrder)), "EX", 90 * 24 * 60 * 60); return false; } const old = JSON.parse(oldRaw); const current = extractSnapshot(newOrder); const changed = JSON.stringify(current) !== JSON.stringify(old); if (changed) { await redis.set(redisKey, JSON.stringify(current), "EX", 90 * 24 * 60 * 60); } return changed; } /** * extract key field for compare(shipping_address、line_items sku / quantity) * @param {object} order */ function extractSnapshot(order) { return { shipping_address: normalizeAddress(order.shipping_address), line_items: (order.line_items || []).map(item => ({ sku: item.sku, quantity: item.current_quantity, })).sort((a, b) => a.sku.localeCompare(b.sku)) // keep sort correct }; } function normalizeAddress(addr = {}) { if (!addr || typeof addr !== 'object') return {}; return Object.keys(addr) .sort() // sort fields .reduce((sorted, key) => { sorted[key] = addr[key]; return sorted; }, {}); }

多个 webhooks 的接口设计

多个webhook请求的组织方式有两种主要方案:

1. 单一接口方案

  • 所有webhook请求使用同一个接口路径,如 /webhook
  • 在webhook服务中通过topic参数进行分流,引导至不同的处理逻辑
  • 优点:接口管理简单,便于统一验证和处理 可以统一进行合法性验证 可以统一进行可能需要的重复性验证
  • 缺点:可能需要额外的逻辑来区分不同类型的webhook

实际上官方的 shopify php template 中就是这样实现的。通过一个接口路径 /api/webhooks 接收所有的webhook请求, 进行统一的验证后再获取已经注册过的 webhook handler, 通过每个 webhook 的 handler 进行处理。显然如果webhook的数量非常多,如此可以节省一部分逻辑。(当然你可以将验证合法性的逻辑单独抽出来形成一个bean注入)这本质上是一个何时进行分流的问题,过早分流可能增加一些公共的处理。

Route::post('/api/webhooks', function (Request $request) { try { $topic = $request->header(HttpHeaders::X_SHOPIFY_TOPIC, ''); $response = Registry::process($request->header(), $request->getContent()); if (!$response->isSuccess()) { Log::error("Failed to process '$topic' webhook: {$response->getErrorMessage()}"); return response()->json(['message' => "Failed to process '$topic' webhook"], 500); } } catch (InvalidWebhookException $e) { Log::error("Got invalid webhook request for topic '$topic': {$e->getMessage()}"); return response()->json(['message' => "Got invalid webhook request for topic '$topic'"], 401); } catch (\Exception $e) { Log::error("Got an exception when handling '$topic' webhook: {$e->getMessage()}"); return response()->json(['message' => "Got an exception when handling '$topic' webhook"], 500); } }); /** * Processes a triggered webhook, calling the appropriate handler. * * @param array $rawHeaders The raw HTTP headers for the request * @param string $rawBody The raw body of the HTTP request * * @return ProcessResponse * * @throws \Shopify\Exception\InvalidWebhookException * @throws \Shopify\Exception\MissingWebhookHandlerException */ public static function process(array $rawHeaders, string $rawBody): ProcessResponse { if (empty($rawBody)) { throw new InvalidWebhookException("No body was received when processing webhook"); } $headers = self::parseProcessHeaders($rawHeaders); $topic = $headers->get(HttpHeaders::X_SHOPIFY_TOPIC); $shop = $headers->get(HttpHeaders::X_SHOPIFY_DOMAIN); $hmac = $headers->get(HttpHeaders::X_SHOPIFY_HMAC); self::validateProcessHmac($rawBody, $hmac); $body = json_decode($rawBody, true); $topic = self::convertTopic($topic); $handler = self::getHandler($topic); if (!$handler) { throw new MissingWebhookHandlerException("No handler was registered for topic '$topic'"); } try { $handler->handle($topic, $shop, $body); $response = new ProcessResponse(true); } catch (Exception $error) { $response = new ProcessResponse(false, $error->getMessage()); } return $response; }

2. 多接口方案

  • 为每个webhook类型创建独立的接口路径,如 /orderCreate, /inventoryLevelUpdate
  • 每个接口直接对应到各自的处理服务
  • 优点:接口职责清晰,便于单独维护和扩展
  • 缺点:接口数量增多,可能需要更多的配置和管理

选择哪种方案取决于具体需求,如webhook的数量、复杂度、安全要求等。单一接口方案适合简单系统,多接口方案则更适合复杂、大规模的应用。