构建商品同步应用
让外部系统与店铺商品保持同步:安装时先拉取现有商品,之后通过 webhook 实时接收变更,再用 Admin API 拉取完整商品数据并持久化。
本指南涵盖
- 订阅商品 webhook(
products/create、products/update、products/delete) - 安装时全量拉取店铺现有商品
- 校验 webhook 签名并识别来源店铺
- 通过 Admin API 拉取完整商品详情并持久化
适用场景
当外部系统(如 ERP、选品工具、数据分析平台)需要维护一份店铺商品的实时镜像时,使用本方案。
商品同步的工作原理
完整的同步分两个阶段:
- 安装阶段(一次性):OAuth 之后,注册商品 webhook 并全量拉取店铺现有商品。webhook 只推送订阅之后发生的变更,不做全量,已存在的商品永远不会进来。
- 运行阶段(持续):之后每次新建、修改、删除都会以 webhook 推送给你,你再拉取完整商品并持久化。
流程图如下:

应用把同步到的商品持久化,并可以展示出来:

本指南全程使用 API 版本 2026-01 与 Node.js。下面的函数都是普通辅助函数,每一步都标注了在哪里调用。
前置需求
- 一个已跑通 OAuth 与 token 存储的公有应用——请先完成开发应用。下面的调用都会用到你为每个店铺保存的
access_token。 - OAuth 授权时已获得
product访问权限。
第一步:订阅商品 webhook
在哪运行:在 OAuth 安装回调里,保存好该店铺 token 之后。
为每个商品事件各注册一个订阅。请求体把订阅信息包在 webhook 对象里:
POST https://<shop>/openapi/2026-01/webhooks
Access-Token: <access_token>
Content-Type: application/json
{
"webhook": {
"topic": "products/create",
"address": "https://your-app.example.com/webhook/products-create"
}
}
const axios = require('axios');
const TOPICS = ['products/create', 'products/update', 'products/delete'];
// 为一个店铺的每个商品事件各注册一个订阅。
// 在安装回调里调用一次,传入该店铺的 access token。
async function registerProductWebhooks(shop, accessToken) {
for (const topic of TOPICS) {
// 每个事件路由到各自的 path:products/create -> /webhook/products-create
const address = `https://your-app.example.com/webhook/${topic.replace('/', '-')}`;
await axios.post(
`https://${shop}/openapi/2026-01/webhooks`,
{ webhook: { topic, address } }, // 2026-01 要求包一层 webhook
{ headers: { 'Access-Token': accessToken } }
);
}
}
注册订阅时有几点要注意:
address必须是公网 HTTPS URL。 本地开发时,用内网穿透工具(如 ngrok)把本地服务暴露到公网。- 请求体结构因 API 版本而异。 从
2026-01(以及2025-06)起,请求体必须把订阅包在webhook对象里(如上);2022-01用扁平的{ "topic", "address" }。详见 Webhook 概述。 - 对同一 topic 和 address 重复注册会产生重复订阅。 如果每次安装都注册,先用
GET /openapi/2026-01/webhooks查出已有订阅,删掉旧的products/*再注册。
第二步:初始全量同步
在哪运行:在同一个安装回调里,紧接 registerProductWebhooks 之后。
用列表接口拉取当前全部商品。该接口用游标分页(cursor 加 per_page,上限 250):
// 拉取一个店铺的全部现有商品,逐个交给你自己的 upsert。
// upsertProduct 是你的数据存储函数(见第五步)。
async function backfillProducts(shop, accessToken, upsertProduct) {
let cursor = '';
for (;;) {
const params = { per_page: 250 }; // 250 是单页最大条数
if (cursor) params.cursor = cursor; // 第一页不带 cursor
const { data } = await axios.get(
`https://${shop}/openapi/2026-01/products`,
{ headers: { 'Access-Token': accessToken }, params }
);
// 结果嵌在 data 下:读 data.data.products / data.data.cursor
const products = data.data.products || [];
for (const product of products) {
await upsertProduct(shop, product.id, product);
}
cursor = data.data.cursor || '';
// 某页为空或不再返回 cursor 时停止
if (!products.length || !cursor) break;
}
}
第三步:接收并校验 webhook
在哪运行:作为应用里的一个公网 HTTP 路由——它是所有运行期变更的入口。
信任请求体之前,先对原始请求体校验签名,再从请求头读取来源店铺和事件类型:
const express = require('express');
const crypto = require('crypto');
const app = express();
// 对「原始请求体」校验 webhook 签名。
function verifyWebhook(rawBody, hmacHeader, clientSecret) {
const digest = crypto
.createHmac('sha256', clientSecret)
.update(rawBody)
.digest('base64');
return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(hmacHeader));
}
// 每个事件一个路由。express.raw() 保留原始字节,签名校验需要它——
// 这个路由上不要用 JSON 解析器。
app.post('/webhook/:event', express.raw({ type: '*/*' }), async (req, res) => {
const hmac = req.get('X-Shoplazza-Hmac-Sha256');
if (!verifyWebhook(req.body, hmac, CLIENT_SECRET)) {
return res.sendStatus(401); // 校验不过一律拒绝
}
// 从请求头识别来源店铺和事件,而不是从 URL 或 body。
const shop = req.get('X-Shoplazza-Shop-Domain');
const topic = req.get('X-Shoplazza-Topic');
const dedupId = req.get('X-Shoplazza-Deduplication-ID');
const { product } = JSON.parse(req.body.toString('utf8'));
// 幂等:同一事件可能被投递多次——已处理过就跳过。
if (await alreadyProcessed(dedupId)) {
return res.sendStatus(200);
}
// ... 处理事件:见第四步(拉取)和第五步(持久化)...
res.sendStatus(200);
});
必须对未解析的原始请求体校验。如果先经过 JSON 解析(例如全局 express.json()),字节会改变,校验必然失败。只在 webhook 路由上用 express.raw()。
每个 webhook 请求都带有标识用的请求头,你不需要在 URL 里编码任何信息:
X-Shoplazza-Shop-Domain——事件来自哪个店铺,用它查出该店铺的 tokenX-Shoplazza-Topic——事件名,如products/createX-Shoplazza-Deduplication-ID——事件唯一 ID。同一事件可能被投递多次,已处理过的 ID 应跳过。
payload 把商品包在 product 里,其中只有 id 一定有用:
{ "product": { "id": "0510dace-34da-4555-a896-495e0cb1ed6e" } }
完整 payload 见 Products create 参考。
第四步:拉取完整商品详情
在哪运行:在 webhook 处理函数内部,处理 create 和 update 事件时。
webhook payload 只是快照。用商品 id 拉取权威详情:
// 用 id 拉取权威的商品详情。
async function fetchProduct(shop, accessToken, productId) {
const { data } = await axios.get(
`https://${shop}/openapi/2026-01/products/${productId}`,
{ headers: { 'Access-Token': accessToken } }
);
// 商品嵌在 data 下——读 data.data.product,不要取顶层,否则字段是空的。
return data.data.product;
}
第五步:持久化到数据存储
在哪运行:在同一个 webhook 处理函数里,替换第三步里的占位注释。
查出该店铺保存的 token,再以 product.id 为键做 upsert 或删除,并记下事件 ID 以便跳过重复投递。getToken、upsertProduct、deleteProduct、alreadyProcessed、markProcessed 都是你自己的存储层函数(token 存储来自前置,商品表与去重表是你自己的):
const accessToken = await getToken(shop); // 安装时为该店铺保存的 token
if (topic === 'products/delete') {
await deleteProduct(shop, product.id); // delete 的 payload 带 id
} else {
// create/update:拉取完整商品,再以 id 为键 upsert
const full = await fetchProduct(shop, accessToken, product.id);
await upsertProduct(shop, product.id, full);
}
await markProcessed(dedupId); // 记下来,下次重复投递就会被跳过
res.sendStatus(200);
alreadyProcessed 和 markProcessed 是你自己的存储函数,用来记录已处理的 X-Shoplazza-Deduplication-ID——同一事件可能被投递多次。只有持久化成功后才返回 2xx,非 2xx 响应会被重试。