Shopify
快速开始
使用 Shopify 蓝图为已有的 Flue 项目添加已验证的 webhook 入口和应用自有的 Admin GraphQL 行为。在终端或你选择的编码智能体中运行以下命令:
flue add channel shopify
概述
该蓝图会安装 @flue/shopify 和官方轻量级
@shopify/admin-api-client,在源码根目录创建 channels/shopify.ts 模块,
其中包含命名的 channel 导出和项目自有的 client 导出,并修改所选的
订单智能体以绑定生成的 Admin GraphQL 工具。当项目需要 Admin 客户端的
声明专用 Buffer 类型时,还会添加 @types/node。
import { createAdminApiClient } from '@shopify/admin-api-client';
import { createShopifyChannel } from '@flue/shopify';
import { dispatch, useModel } from '@flue/runtime';
import { Orders } from '../agents/orders.ts';
const SHOP_DOMAIN = process.env.SHOPIFY_SHOP_DOMAIN!;
export const client = createAdminApiClient({
storeDomain: SHOP_DOMAIN,
apiVersion: '2026-04',
accessToken: process.env.SHOPIFY_ADMIN_ACCESS_TOKEN!,
});
export const channel = createShopifyChannel({
clientSecret: process.env.SHOPIFY_CLIENT_SECRET!,
previousClientSecret: process.env.SHOPIFY_PREVIOUS_CLIENT_SECRET || undefined,
async webhook({ c, payload }) {
const shopDomain = c.req.header('x-shopify-shop-domain');
if (shopDomain !== SHOP_DOMAIN) {
return c.json({ error: 'Unexpected Shopify shop.' }, 403);
}
if (c.req.header('x-shopify-topic') !== 'orders/create') return;
const order = parseOrderCreatedPayload(payload);
if (!order) return c.json({ error: 'Unsupported orders/create payload.' }, 400);
await dispatch(Orders, {
id: orderInstanceId(shopDomain, order.id),
message: {
kind: 'signal',
type: 'shopify.orders/create',
body: `Shopify order ${order.name} created.`,
attributes: { shopDomain, orderId: order.id, orderName: order.name },
},
});
},
});简略版示例省略了生成的 payload 解析器、订单实例辅助函数和 Admin GraphQL 工具。配置完成后,orders/create 交付会继续处理绑定到该受信任店铺和订单的智能体实例,且工具可以检索该订单,而不会让模型选择店铺、令牌或订单 ID。相同的已验证 Fetch 路径在 Node 和 Cloudflare Workers 上均可运行(使用 Flue 的 nodejs_compat 设置)。
挂载渠道
渠道仅在 app.ts 挂载它的地方提供 HTTP 路由。挂载模块的命名 channel 导出:
import { channel as shopify } from './channels/shopify.ts';
app.route('/channels/shopify', shopify.route());channel.route() 是一个纯路由器工厂,相对于挂载路径提供渠道声明的路由。本指南中的 webhook 路径假定使用常规的 /channels/shopify 挂载路径;不同的挂载路径会相应调整。分发目标智能体模块携带 'use agent' 指令——该指令会注册它,因此仅用于分发的智能体不需要自己的 HTTP 挂载。
配置
| 变量 | 用途 |
|---|---|
SHOPIFY_CLIENT_SECRET | 必需 — 验证入站请求体。 |
SHOPIFY_PREVIOUS_CLIENT_SECRET | 可选 — 在密钥轮换重叠期间验证交付。 |
SHOPIFY_ADMIN_ACCESS_TOKEN | 必需 — 验证出站 Admin GraphQL 请求。 |
SHOPIFY_SHOP_DOMAIN | 必需 — 将客户端和入站租户检查绑定到一个店铺。 |
它会安装 @flue/shopify 和官方轻量级 @shopify/admin-api-client@1.1.2。蓝图创建一个渠道模块,包含命名的 channel 和项目自有的 client 导出。
使用以下 URL 配置 JSON webhook 订阅:
https://example.com/channels/shopify/webhook
入站客户端密钥和出站 Admin 访问令牌是不同的凭据。
Admin 客户端的公共声明通过 @shopify/graphql-client 包含了对 Buffer 的引用。当项目尚未提供时,添加兼容的 @types/node 开发依赖。这是一个仅类型的依赖,不会向 Worker 添加 Node 运行时代码。
渠道模块
import { type ClientResponse, createAdminApiClient } from '@shopify/admin-api-client';
import { createShopifyChannel, type JsonValue } from '@flue/shopify';
import { defineTool, dispatch, useModel } from '@flue/runtime';
import { Orders } from '../agents/orders.ts';
const SHOP_DOMAIN = process.env.SHOPIFY_SHOP_DOMAIN!;
const ADMIN_API_VERSION = '2026-04';
const ORDER_INSTANCE_PREFIX = 'shopify-order:';
export function createShopifyClient(customFetchApi: typeof fetch = globalThis.fetch) {
return createAdminApiClient({
storeDomain: SHOP_DOMAIN,
apiVersion: ADMIN_API_VERSION,
accessToken: process.env.SHOPIFY_ADMIN_ACCESS_TOKEN!,
customFetchApi,
});
}
export const client = createShopifyClient();
export const channel = createShopifyChannel({
clientSecret: process.env.SHOPIFY_CLIENT_SECRET!,
previousClientSecret: process.env.SHOPIFY_PREVIOUS_CLIENT_SECRET || undefined,
// 路径:/channels/shopify/webhook
async webhook({ c, payload }) {
// Shopify 的 HMAC 对请求体进行验证,而不是对这些头进行验证,这些头
// 是从通过 `c` 的已验证请求中读取的。这个比较是租户一致性检查,
// 本身不是授权。
const shopDomain = c.req.header('x-shopify-shop-domain');
if (shopDomain !== SHOP_DOMAIN) {
return c.json({ error: 'Unexpected Shopify shop.' }, 403);
}
switch (c.req.header('x-shopify-topic')) {
case 'orders/create': {
const order = parseOrderCreatedPayload(payload);
if (!order) {
return c.json({ error: 'Unsupported orders/create payload.' }, 400);
}
const webhookId = c.req.header('x-shopify-webhook-id');
const eventId = c.req.header('x-shopify-event-id');
await dispatch(Orders, {
id: orderInstanceId(shopDomain, order.id),
message: {
kind: 'signal',
type: 'shopify.orders/create',
body: `Shopify order ${order.name} created.`,
attributes: {
shopDomain,
orderId: order.id,
orderName: order.name,
...(webhookId === undefined ? {} : { webhookId }),
...(eventId === undefined ? {} : { eventId }),
},
},
});
return;
}
default:
return;
}
},
});
const ORDER_QUERY = `#graphql
query BoundOrder($id: ID!) {
order(id: $id) {
id
name
displayFinancialStatus
displayFulfillmentStatus
totalPriceSet {
shopMoney {
amount
currencyCode
}
}
}
}
`;
interface ShopifyOrderQuery {
order: {
id: string;
name: string;
displayFinancialStatus: string | null;
displayFulfillmentStatus: string;
totalPriceSet: {
shopMoney: {
amount: string;
currencyCode: string;
};
};
} | null;
}
export function retrieveOrder(orderId: string) {
return defineTool({
name: 'retrieve_shopify_order',
description: '检索已绑定到此智能体的 Shopify 订单。',
async run() {
const result: ClientResponse<ShopifyOrderQuery> = await client.request(ORDER_QUERY, {
variables: { id: `gid://shopify/Order/${orderId}` },
});
if (result.errors) throw new Error('Shopify Admin API request failed.');
if (!result.data?.order) throw new Error('Shopify order was not found.');
return { output: result.data.order };
},
});
}
function parseOrderCreatedPayload(payload: JsonValue): { id: string; name: string } | undefined {
if (!isRecord(payload) || !isOrderId(payload.id)) return undefined;
if (typeof payload.name !== 'string' || payload.name.length === 0) {
return undefined;
}
return { id: String(payload.id), name: payload.name };
}
function isOrderId(value: unknown): value is string | number {
if (typeof value === 'string') return /^[1-9]\d*$/.test(value);
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
}
function isRecord(value: JsonValue): value is Record<string, JsonValue> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function orderInstanceId(shopDomain: string, orderId: string): string {
if (!shopDomain || !orderId) {
throw new TypeError('Shopify shop domain and order id must be non-empty.');
}
return `${ORDER_INSTANCE_PREFIX}${encodeURIComponent(shopDomain)}:${encodeURIComponent(orderId)}`;
}
export function orderRefFromInstanceId(id: string): {
shopDomain: string;
orderId: string;
} {
if (!id.startsWith(ORDER_INSTANCE_PREFIX)) {
throw new TypeError('Expected a local Shopify order instance id.');
}
const encoded = id.slice(ORDER_INSTANCE_PREFIX.length);
const separator = encoded.indexOf(':');
if (separator < 1) {
throw new TypeError('Expected a local Shopify order instance id.');
}
let shopDomain: string;
let orderId: string;
try {
shopDomain = decodeURIComponent(encoded.slice(0, separator));
orderId = decodeURIComponent(encoded.slice(separator + 1));
} catch {
throw new TypeError('Expected a local Shopify order instance id.');
}
if (!shopDomain || !orderId) {
throw new TypeError('Expected a local Shopify order instance id.');
}
return { shopDomain, orderId };
}客户端绑定一个受信任的店铺域名、访问令牌和显式的 Admin API 版本。该工具不接受来自模型的任何目标地址。多店铺应用应从自身已认证的状态解析安装凭据,而不是从 webhook 头或工具输入中选择。
示例从 orders/create 验证 id 和 name。使用 Shopify 的 includeFields 订阅选项时保留这些字段,或定义另一个已验证的应用标识。
Shopify 订单 ID 可能超出 JavaScript 的安全整数范围。验证器接受正十进制字符串和正安全整数,然后立即用 String(id) 规范化值。它永远不会通过 Number 强制转换不安全的十进制字符串。
绑定工具
'use agent';
import { type AgentProps, useModel, useTool } from '@flue/runtime';
import { orderRefFromInstanceId, retrieveOrder } from '../channels/shopify.ts';
export function Orders({ id }: AgentProps) {
useModel('anthropic/claude-haiku-4-5');
const { shopDomain, orderId } = orderRefFromInstanceId(id);
if (shopDomain !== process.env.SHOPIFY_SHOP_DOMAIN) {
throw new TypeError('Unexpected Shopify shop.');
}
useTool(retrieveOrder(orderId));
return 'Review the newly created Shopify order and summarize any fulfillment or payment follow-up.';
}本地 shopify-order: ID 包含店铺和订单标识,因为 Shopify 没有通用的线程概念来派生实例 ID。它仍然是一个标识符,而不是授权能力。对直接智能体路由应用常规访问控制。
验证和事件格式
Shopify 对精确请求体计算 base64 HMAC-SHA256。@flue/shopify 在解码或解析 JSON 之前验证这些字节。第一方渠道仅支持 JSON 订阅;XML 会收到 415 响应。
回调接收 { c, payload, rawBody }:Hono 上下文、已解析的 JSON payload 和精确已验证的 rawBody。交付元数据通过 c 从提供商原生头中读取:
c.req.header('x-shopify-topic')、'x-shopify-shop-domain'、'x-shopify-api-version'和'x-shopify-webhook-id';- 可选的
'x-shopify-event-id'、'x-shopify-triggered-at'和'x-shopify-sub-topic'。
渠道仅验证请求体签名;它不整理类型化的头对象、不要求任何头的存在,也不读取非标准的 X-Shopify-Name 头。缺少元数据头的交付仍然会到达回调,应用从 c 读取并验证它消费的头。
主题保持提供商原生字符串,如 orders/create。未来的已验证主题会到达回调而不是被拒绝,因为已安装的包不识别封闭的主题联合。
@flue/shopify 使用 lossless-json 解析 payload。适合 JavaScript 安全整数范围的数字字面量保持为数字。不安全的数字字面量,包括 64 位 Shopify 标识符,保留其精确的十进制拼写为字符串,而不是被四舍五入。验证每个主题使用的字段,并在标识符可能以任一形式出现时接受 string | number。
HMAC 仅覆盖请求体,不覆盖 Shopify 的交付头。将 shopDomain、版本、主题和交付元数据视为提供商提供的路由上下文,而不是独立签名的授权声明。Shopify 也没有记录签名时间戳或 webhook 重放窗口。
在应用密钥轮换重叠期间使用 previousClientSecret:
createShopifyChannel({
clientSecret: process.env.SHOPIFY_CLIENT_SECRET!,
previousClientSecret: process.env.SHOPIFY_PREVIOUS_CLIENT_SECRET || undefined,
webhook({ c, payload }) {
// ...
},
});
在应用的轮换窗口结束后移除之前的密钥。
响应、重试和排序
不返回任何内容会产生一个空的 200 响应。兼容 JSON 的值会变成 JSON 响应。普通的 Hono 或 Fetch Response 会原样传递。非 2xx 响应会让 Shopify 进行重试。
Shopify 允许 5 秒来完成整个交付。渠道不会用计时器强制执行截止时间,因为将 JavaScript 回调与计时器竞赛无法取消它:超时的工作会继续运行,并可能在失败响应之后完成。及时提交持久性工作——分发并返回——而不是在响应之前执行慢操作,并在 webhook 响应路径之外调度长时间运行的处理。抛出的回调会传播到 Hono 的错误处理程序。
Shopify 在 4 小时内重试失败的 HTTPS 交付 8 次。交付可能被复制或乱序到达。在应用自有的持久存储中使用 c.req.header('x-shopify-webhook-id') 进行交付去重,依靠幂等性而不是超时来保持重试安全。可选的 c.req.header('x-shopify-event-id') 关联由同一商家操作引起的单独交付;它不替代 webhook ID。
渠道不注册订阅、不持久化交付 ID、不恢复排序、不管理安装令牌,也不推断对话或资源键。
合规主题
应用商店应用必须处理:
customers/data_request;customers/redact;shop/redact。
这些主题使用相同的 /channels/shopify/webhook 路由和验证路径。它们所需的业务操作仍然是应用自有的。shop/redact 可能在卸载后到达,因此 webhook 验证不能依赖实时 Admin API 令牌。
Cloudflare Workers
来自 @shopify/admin-api-client@1.1.2 的直接 Web Crypto 验证器和普通 Fetch 请求路径可在 Node 和 workerd 中运行,使用 Flue 所需的 nodejs_compat 配置。这是此处显示的客户端操作的证据,而不是对完整的 @shopify/shopify-api SDK 或 Admin 客户端中每个辅助函数的全面保证。
Cloudflare 项目可以通过 process.env 或类型化的 Worker 绑定来初始化密钥。针对其 Worker 目标测试应用使用的确切 GraphQL 操作。
创建原始合成 webhook 体和本地生成的 HMAC。覆盖有效和篡改的精确字节、当前和以前的密钥、省略可选元数据头的交付、安全和不安全的数字标识符、未知主题、格式错误的 JSON、体限制和处理程序结果。在 Node 和 workerd 中测试 createShopifyClient(fakeFetch),使用拒绝意外主机和路径的假传输。任何测试都不应注册 webhook 或联系 Shopify。