跳转到内容

输入关键词开始搜索文档。

Shopify

最后更新 查看 Markdown 源文件@flue/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。

src/channels/shopify.ts(简略版)
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 导出:

src/app.ts
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 运行时代码。

渠道模块

src/channels/shopify.ts
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 强制转换不安全的十进制字符串。

绑定工具

src/agents/orders.ts
'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。

参见 @flue/shopify README。