开发技巧

接收 Shopify 的 orders/create webhook

接收 Shopify 的 orders/create webhook:用应用的 client secret 校验 X-Shopify-Hmac-SHA256 签名,用 X-Shopify-Webhook-Id 去掉重复投递,先在 5 秒内返回 200,再把事件写进 JSONL 文件。只用 Node.js 内置模块。

BINBHO · 图文教程

接收 Shopify 的 orders/create webhook:用应用的 client secret 校验 X-Shopify-Hmac-SHA256 签名,用 X-Shopify-Webhook-Id 去掉重复投递,先在 5 秒内返回 200,再把事件写进 JSONL 文件。只用 Node.js 内置模块。

先看清成品

本机用 node --test 跑了 4 个测试,全部通过。启动服务后用 curl 模拟了 4 次请求:正确签名返回 ok 200,同一个 ID 再发一次返回 duplicate 200,改动请求体返回 401,缺签名头返回 401。事件文件里只写入了一条记录。没有收到过真实店铺的 webhook。shopify app webhook trigger 在本机报 Could not find a Shopify app configuration file,它需要一个已经配置好的应用项目。

注意:Shopify 的套餐价格、交易费和应用收费以官方价格页和你所在地区的条款为准,本文不列数字。只上架你有权销售的商品和有授权的图片;不要共享店铺员工账号,每个人用自己的员工账号登录。

做完之后你会得到什么?

成果 说明
verify.js HMAC 校验,签名头缺失或长度不对时返回 false,不抛异常
server.js /webhooks 接口,限制 1 MB,去重,先回复再处理
events.jsonl 每个事件一行
测试 签名正确、请求体被改、密钥错误、签名头异常

需要准备哪些环境?

项目 要求 说明
Node.js 18 以上(本机 v22) 没有第三方依赖
Shopify 应用 在 Dev Dashboard 或 Partner 后台创建 client secret 就是签名密钥
公网 HTTPS 地址 Shopify 会校验 SSL 证书 开发时可以用 shopify app dev 提供的隧道

文件结构

28-shopify-C-src/
├── package.json
├── verify.js
├── server.js
└── webhook.test.mjs

第 1 步:签名校验

verify.js:

import crypto from "node:crypto";

// Shopify:X-Shopify-Hmac-SHA256 = base64(HMAC-SHA256(原始请求体, 应用 client secret))
export function verifyShopifyHmac(rawBody, headerValue, secret) {
  if (typeof headerValue !== "string" || headerValue.length === 0) return false;
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest();
  const received = Buffer.from(headerValue, "base64");
  if (received.length !== expected.length) return false;   // timingSafeEqual 长度不同会抛异常
  return crypto.timingSafeEqual(expected, received);
}

官方文档的要求:对原始请求体用 client secret 做 HMAC-SHA256,与签名头 base64 解码后的值比较。如果先用 JSON 解析器处理了请求体,再重新序列化,签名就对不上了。

第 2 步:服务

server.js:

import http from "node:http";
import fs from "node:fs";
import { verifyShopifyHmac } from "./verify.js";

const SECRET = process.env.SHOPIFY_CLIENT_SECRET;
if (!SECRET) { console.error("请设置 SHOPIFY_CLIENT_SECRET"); process.exit(1); }
const PORT = Number(process.env.PORT || 3000);
const MAX = 1024 * 1024;
const seen = new Set();               // 演示用;生产环境换成数据库或 Redis

export const server = http.createServer((req, res) => {
  if (req.method !== "POST" || req.url !== "/webhooks") { res.writeHead(404).end(); return; }
  const chunks = []; let size = 0;
  req.on("data", (c) => { size += c.length; if (size > MAX) req.destroy(); else chunks.push(c); });
  req.on("end", () => {
    const raw = Buffer.concat(chunks);
    if (!verifyShopifyHmac(raw, req.headers["x-shopify-hmac-sha256"], SECRET)) {
      res.writeHead(401).end("invalid hmac"); return;
    }
    const id = req.headers["x-shopify-webhook-id"];
    if (id && seen.has(id)) { res.writeHead(200).end("duplicate"); return; }
    if (id) seen.add(id);
    res.writeHead(200).end("ok");     // 先回 200(Shopify 整个请求 5 秒超时),再慢慢处理
    setImmediate(() => {
      const topic = req.headers["x-shopify-topic"];
      const shop = req.headers["x-shopify-shop-domain"];
      let body; try { body = JSON.parse(raw); } catch { body = null; }
      const line = { at: new Date().toISOString(), id, topic, shop, order: body?.name ?? body?.id ?? null };
      fs.appendFileSync("events.jsonl", JSON.stringify(line) + "\n");
    });
  });
});

if (import.meta.url === `file://${process.argv[1]}`) server.listen(PORT, () => console.log("listening", PORT));

要点:

  • 官方文档写明:连接超时 1 秒,整个请求超时 5 秒;返回 200 以外的状态码(包括 3XX)都算失败,会在 4 小时内重试 8 次。所以耗时的处理放到回复之后。
  • 同一主题有多个订阅时,每次投递的 X-Shopify-Webhook-Id 不同,但 X-Shopify-Event-Id 相同。这里按投递去重。
  • seen 存在内存里,服务重启后就清空了,生产环境要换成持久化存储。

第 3 步:测试

webhook.test.mjs:

import test from "node:test";
import assert from "node:assert";
import crypto from "node:crypto";
import { verifyShopifyHmac } from "./verify.js";

const secret = "test_secret";
const body = Buffer.from(JSON.stringify({ id: 1, name: "#1001" }));
const sign = (b, s = secret) => crypto.createHmac("sha256", s).update(b).digest("base64");

test("正确签名通过", () => assert.ok(verifyShopifyHmac(body, sign(body), secret)));
test("改动请求体后失败", () => assert.ok(!verifyShopifyHmac(Buffer.from(body + " "), sign(body), secret)));
test("错误密钥失败", () => assert.ok(!verifyShopifyHmac(body, sign(body, "other"), secret)));
test("缺少或长度不对的签名头不抛异常", () => {
  assert.strictEqual(verifyShopifyHmac(body, undefined, secret), false);
  assert.strictEqual(verifyShopifyHmac(body, "abc", secret), false);
});
npm test      # pass 4, fail 0

第 4 步:本地模拟投递

SHOPIFY_CLIENT_SECRET=test_secret PORT=3999 node server.js &
B='{"id":820982911946154500,"name":"#9999"}'
S=$(printf %s "$B" | openssl dgst -sha256 -hmac test_secret -binary | base64)
curl -X POST localhost:3999/webhooks -H "X-Shopify-Hmac-SHA256: $S" \
  -H "X-Shopify-Webhook-Id: w-1" -H "X-Shopify-Topic: orders/create" \
  -H "X-Shopify-Shop-Domain: demo.myshopify.com" --data-binary "$B"

本机 4 次请求的结果:

ok 200            # 正确签名
duplicate 200     # 同一个 Webhook-Id 再发一次
invalid hmac 401  # 请求体末尾多了一个空格
invalid hmac 401  # 没有签名头

events.jsonl:

{"at":"2026-10-21T07:04:59.843Z","id":"w-1","topic":"orders/create","shop":"demo.myshopify.com","order":"#9999"}

第 5 步:接入真实店铺(本机未执行)

  1. 把服务部署到 HTTPS 地址,把 SHOPIFY_CLIENT_SECRET 设成应用的 client secret(放在环境变量或密钥管理里,不要提交进代码库)。
  2. 在应用配置里订阅 orders/create,地址填 https://你的域名/webhooks。订阅订单类主题需要相应的权限范围,具体看官方 webhook 参考文档。
  3. 在应用项目目录里运行 shopify app webhook trigger 发送测试事件,参数以 CLI 文档为准。

调试示例

照抄官方示例,遇到没有签名头的请求时进程报错

官方的手动校验示例直接调用 timingSafeEqual(Buffer.from(计算值,'base64'), Buffer.from(签名头,'base64'))。本机试了两种异常输入:

A: ERR_INVALID_ARG_TYPE                  # 签名头不存在(undefined)
B: ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH   # 签名头长度不对

两种都会直接抛异常。没有 try/catch 的话,接口会返回 500,而不是 401。verify.js 先检查签名头是不是非空字符串、长度是否一致,再做比较,测试 4 专门覆盖了这种情况。

常见问题

现象 原因 处理
签名总是对不上 用的是解析后的 JSON,不是原始请求体 签名要基于原始字节计算
刚换完 client secret 就失败 官方说明新密钥最长要一小时才生效 过渡期间新旧密钥都试一下
订阅被自动删除 连续 8 次失败,且订阅是通过 Admin API 创建的 修好后重新订阅,并补拉漏掉的数据
订单 ID 末尾几位变了 JS 的 Number 精度不够 用 admin_graphql_api_id 或按字符串处理

模板规则

  • 先校验签名,再处理任何内容。
  • 先回复 200,再慢慢处理。
  • 按 Webhook-Id 做幂等处理,并定期调用 API 对账,补上漏掉的事件。
  • 密钥只放在环境变量里。

项目资源

用途 地址
Webhook 校验(HMAC、去重、超时) https://shopify.dev/docs/apps/build/webhooks/subscribe/https
CLI:app webhook trigger https://shopify.dev/docs/api/shopify-cli/app/app-webhook-trigger
Node.js crypto.timingSafeEqual https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b
Shopify 服务条款 https://www.shopify.com/legal/terms

webhook 接收端只要做好三件事:签名校验、快速回复、幂等处理。签名头缺失这种边界情况最容易漏掉,写进测试就不会再出问题。