---
description: Receive verified Shopify webhooks and use a shop-bound Admin GraphQL client from application-owned tools.
title: Shopify | Flue
image: https://flueframework.com/docs/og4.jpg
---

# Shopify

AI-generated, awaiting review [ View as Markdown](https://flueframework.com/docs/ecosystem/channels/shopify/index.md) [  @flue/shopify ](https://www.npmjs.com/package/@flue/shopify) 

## Quickstart

Add verified Shopify webhook ingress and application-owned Admin GraphQL behavior to an existing Flue project with the [Shopify](https://shopify.dev) blueprint. Run the following command in your terminal or coding agent of choice:

```
flue add channel shopify
```

## Overview

The blueprint installs `@flue/shopify` and the official lightweight`@shopify/admin-api-client`, creates a source-root `channels/shopify.ts` module with named `channel` and project-owned `client` exports, and modifies the selected orders agent to bind a generated Admin GraphQL tool. It also adds`@types/node` when the project needs the Admin client’s declaration-only`Buffer` type.

```
import { createAdminApiClient } from '@shopify/admin-api-client';
import { createShopifyChannel } from '@flue/shopify';
import { dispatch } 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),
      input: { type: 'shopify.orders.create', orderId: order.id, orderName: order.name },
    });
  },
});
```

The abridged example omits the generated payload parser, order-instance helpers, and Admin GraphQL tool. Once configured, an `orders/create` delivery continues the agent instance bound to that trusted shop and order, and the tool can retrieve that order without letting the model choose a shop, token, or order id. The same verified Fetch path runs on Node and Cloudflare Workers with Flue’s`nodejs_compat` setting.

## Configure

| Variable                          | Purpose                                                                |
| --------------------------------- | ---------------------------------------------------------------------- |
| SHOPIFY\_CLIENT\_SECRET           | **Required** — Verifies inbound request bodies.                        |
| SHOPIFY\_PREVIOUS\_CLIENT\_SECRET | **Optional** — Verifies deliveries during a secret-rotation overlap.   |
| SHOPIFY\_ADMIN\_ACCESS\_TOKEN     | **Required** — Authenticates outbound Admin GraphQL requests.          |
| SHOPIFY\_SHOP\_DOMAIN             | **Required** — Binds the client and inbound tenancy check to one shop. |

It installs `@flue/shopify` and the official lightweight`@shopify/admin-api-client@1.1.2`. The blueprint creates a channel module with named `channel` and project-owned `client` exports.

Configure a JSON webhook subscription with this URL:

```
https://example.com/channels/shopify/webhook
```

The inbound client secret and outbound Admin access token are separate credentials.

The Admin client’s public declarations include a `Buffer` reference through`@shopify/graphql-client`. Add a compatible `@types/node` development dependency when the project does not already provide one. It is a type-only requirement and does not add Node runtime code to a Worker.

## Channel module

```
import { type ClientResponse, createAdminApiClient } from '@shopify/admin-api-client';
import { createShopifyChannel, type JsonValue } from '@flue/shopify';
import { defineTool, dispatch } 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,

  // Path: /channels/shopify/webhook
  async webhook({ c, payload }) {
    // Shopify's HMAC authenticates the body, not these headers, which are
    // read from the verified request through `c`. This comparison is a
    // tenancy consistency check, not authorization by itself.
    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);
        }

        await dispatch(orders, {
          id: orderInstanceId(shopDomain, order.id),
          input: {
            type: 'shopify.orders.create',
            deliveryId: c.req.header('x-shopify-webhook-id'),
            eventId: c.req.header('x-shopify-event-id'),
            shopDomain,
            apiVersion: c.req.header('x-shopify-api-version'),
            orderId: order.id,
            orderName: order.name,
            triggeredAt: c.req.header('x-shopify-triggered-at'),
          },
        });
        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: 'Retrieve the Shopify order already bound to this agent.',
    parameters: {
      type: 'object',
      properties: {},
      additionalProperties: false,
    },
    async execute() {
      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 JSON.stringify(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 };
}
```

The client binds one trusted shop domain, access token, and explicit Admin API version. The tool accepts no destination from the model. A multi-shop application should resolve installation credentials from its own authenticated state instead of selecting them from webhook headers or tool input.

The example validates `id` and `name` from `orders/create`. Preserve those fields when using Shopify’s `includeFields` subscription option, or define another validated application identity.

Shopify order ids can exceed JavaScript’s safe integer range. The guard accepts positive decimal strings and positive safe integers, then immediately normalizes the value with `String(id)`. It never coerces an unsafe decimal string through `Number`.

## Bind the tool

```
import { createAgent } from '@flue/runtime';
import { orderRefFromInstanceId, retrieveOrder } from '../channels/shopify.ts';

export default createAgent(({ id }) => {
  const { shopDomain, orderId } = orderRefFromInstanceId(id);
  if (shopDomain !== process.env.SHOPIFY_SHOP_DOMAIN) {
    throw new TypeError('Unexpected Shopify shop.');
  }
  return {
    model: 'anthropic/claude-haiku-4-5',
    tools: [retrieveOrder(orderId)],
  };
});
```

The local `shopify-order:` id includes shop and order identity because Shopify has no universal conversation key. It is still an identifier, not an authorization capability. Apply normal access control to direct agent routes.

## Verification and event shape

Shopify computes base64 HMAC-SHA256 over the exact request body.`@flue/shopify` verifies those bytes before decoding or parsing JSON. The first-party channel supports JSON subscriptions only; XML receives `415`.

The callback receives `{ c, payload, rawBody }`: the Hono context, the parsed JSON `payload`, and the exact verified `rawBody`. Delivery metadata is read from the provider’s native headers through `c`:

* `c.req.header('x-shopify-topic')`, `'x-shopify-shop-domain'`,`'x-shopify-api-version'`, and `'x-shopify-webhook-id'`;
* optional `'x-shopify-event-id'`, `'x-shopify-triggered-at'`, and`'x-shopify-sub-topic'`.

The channel verifies the body signature only; it does not curate a typed header object, require any header’s presence, or read the non-standard `X-Shopify-Name`header. A delivery missing a metadata header still reaches the callback, where the application reads and validates the headers it consumes from `c`.

Topics remain provider-native strings such as `orders/create`. Future verified topics reach the callback instead of being rejected because the installed package does not recognize a closed topic union.

`@flue/shopify` parses the payload with `lossless-json`. Numeric literals that fit JavaScript’s safe integer range remain numbers. Unsafe numeric literals, including 64-bit Shopify identifiers, retain their exact decimal spelling as strings instead of being rounded. Validate the fields used by each topic and accept `string | number` where an identifier may appear in either form.

The HMAC covers only the body, not Shopify’s delivery headers. Treat`shopDomain`, version, topic, and delivery metadata as provider-supplied routing context, not an independently signed authorization claim. Shopify also documents no signed timestamp or webhook replay window.

Use `previousClientSecret` during an app-secret rotation overlap:

```
createShopifyChannel({
  clientSecret: process.env.SHOPIFY_CLIENT_SECRET!,
  previousClientSecret: process.env.SHOPIFY_PREVIOUS_CLIENT_SECRET || undefined,
  webhook({ c, payload }) {
    // ...
  },
});
```

Remove the previous secret after the application’s rotation window.

## Responses, retries, and ordering

Returning nothing produces an empty `200`. A JSON-compatible value becomes a JSON response. A normal Hono or Fetch `Response` passes through unchanged. Non-2xx responses ask Shopify to retry.

Shopify allows five seconds for the complete delivery. The channel does not enforce a deadline with a timer, because racing a JavaScript callback against a timer cannot cancel it: the timed-out work keeps running and may complete after the failure response. Admit durable work promptly — dispatch and return — rather than performing slow operations before responding, and schedule long-running processing outside the webhook response path. A thrown callback propagates to Hono’s error handler.

Shopify retries failed HTTPS deliveries eight times over four hours. Deliveries can be duplicated or arrive out of order. Use`c.req.header('x-shopify-webhook-id')` in application-owned durable storage for delivery deduplication, relying on idempotency rather than a timeout to keep retries safe. Optional `c.req.header('x-shopify-event-id')` correlates separate deliveries caused by the same merchant action; it does not replace the webhook id.

The channel does not register subscriptions, persist delivery ids, restore ordering, manage installation tokens, or infer a conversation or resource key.

## Compliance topics

App Store apps must process:

* `customers/data_request`;
* `customers/redact`;
* `shop/redact`.

These topics use the same `/channels/shopify/webhook` route and verification path. Their required business actions remain application-owned.`shop/redact` can arrive after uninstall, so webhook verification must not depend on a live Admin API token.

## Cloudflare Workers

The direct Web Crypto verifier and the ordinary Fetch request path from`@shopify/admin-api-client@1.1.2` execute in Node and workerd with Flue’s required `nodejs_compat` configuration. This is evidence for the client operation shown here, not a blanket guarantee for the full`@shopify/shopify-api` SDK or every helper in the Admin client.

Cloudflare projects may initialize secrets through `process.env` or typed Worker bindings. Test the exact GraphQL operations used by the application against its Worker target.

Create original synthetic webhook bodies and locally generated HMACs. Cover valid and tampered exact bytes, current and previous secrets, deliveries that omit optional metadata headers, safe and unsafe numeric identifiers, unknown topics, malformed JSON, body limits, and handler results. Test`createShopifyClient(fakeFetch)` in Node and workerd with a fake transport that rejects unexpected hosts and paths. No test should register a webhook or contact Shopify.

See the [@flue/shopify API reference](https://flueframework.com/docs/api/shopify-channel/).

## Docs Navigation

Current page: [Shopify](https://flueframework.com/docs/ecosystem/channels/shopify/)

### Sections

* [Guide](https://flueframework.com/docs/getting-started/quickstart/)
* [Reference](https://flueframework.com/docs/api/agent-api/)
* [CLI](https://flueframework.com/docs/cli/overview/)
* [SDK](https://flueframework.com/docs/sdk/overview/)
* [Ecosystem](https://flueframework.com/docs/ecosystem/)

* [  Overview ](https://flueframework.com/docs/ecosystem/)

### Channels

* [ Discord ](https://flueframework.com/docs/ecosystem/channels/discord/)
* [ Facebook ](https://flueframework.com/docs/ecosystem/channels/messenger/)
* [ GitHub ](https://flueframework.com/docs/ecosystem/channels/github/)
* [ Google Chat ](https://flueframework.com/docs/ecosystem/channels/google-chat/)
* [ Intercom ](https://flueframework.com/docs/ecosystem/channels/intercom/)
* [ Linear ](https://flueframework.com/docs/ecosystem/channels/linear/)
* [ Microsoft Teams ](https://flueframework.com/docs/ecosystem/channels/teams/)
* [ Notion ](https://flueframework.com/docs/ecosystem/channels/notion/)
* [ Resend ](https://flueframework.com/docs/ecosystem/channels/resend/)
* [ Salesforce ](https://flueframework.com/docs/ecosystem/channels/salesforce-marketing-cloud/)
* [ Shopify ](https://flueframework.com/docs/ecosystem/channels/shopify/)
* [ Slack ](https://flueframework.com/docs/ecosystem/channels/slack/)
* [ Stripe ](https://flueframework.com/docs/ecosystem/channels/stripe/)
* [ Telegram ](https://flueframework.com/docs/ecosystem/channels/telegram/)
* [ Twilio ](https://flueframework.com/docs/ecosystem/channels/twilio/)
* [ WhatsApp ](https://flueframework.com/docs/ecosystem/channels/whatsapp/)
* [ Zendesk ](https://flueframework.com/docs/ecosystem/channels/zendesk/)

### Sandboxes

* [ boxd ](https://flueframework.com/docs/ecosystem/sandboxes/boxd/)
* [ Cloudflare Shell ](https://flueframework.com/docs/ecosystem/sandboxes/cloudflare-shell/)
* [ Cloudflare Sandbox ](https://flueframework.com/docs/ecosystem/sandboxes/cloudflare/)
* [ Daytona ](https://flueframework.com/docs/ecosystem/sandboxes/daytona/)
* [ E2B ](https://flueframework.com/docs/ecosystem/sandboxes/e2b/)
* [ exe.dev ](https://flueframework.com/docs/ecosystem/sandboxes/exedev/)
* [ islo ](https://flueframework.com/docs/ecosystem/sandboxes/islo/)
* [ Mirage ](https://flueframework.com/docs/ecosystem/sandboxes/mirage/)
* [ Modal ](https://flueframework.com/docs/ecosystem/sandboxes/modal/)
* [ Vercel Sandbox ](https://flueframework.com/docs/ecosystem/sandboxes/vercel/)

### Deploy

* [ AWS ](https://flueframework.com/docs/ecosystem/deploy/aws/)
* [ Cloudflare ](https://flueframework.com/docs/ecosystem/deploy/cloudflare/)
* [ Docker ](https://flueframework.com/docs/ecosystem/deploy/docker/)
* [ Fly.io ](https://flueframework.com/docs/ecosystem/deploy/fly/)
* [ GitHub Actions ](https://flueframework.com/docs/ecosystem/deploy/github-actions/)
* [ GitLab CI/CD ](https://flueframework.com/docs/ecosystem/deploy/gitlab-ci/)
* [ Node.js ](https://flueframework.com/docs/ecosystem/deploy/node/)
* [ Railway ](https://flueframework.com/docs/ecosystem/deploy/railway/)
* [ Render ](https://flueframework.com/docs/ecosystem/deploy/render/)
* [ SST ](https://flueframework.com/docs/ecosystem/deploy/sst/)

### Databases

* [ libSQL ](https://flueframework.com/docs/ecosystem/databases/libsql/)
* [ MongoDB ](https://flueframework.com/docs/ecosystem/databases/mongodb/)
* [ MySQL ](https://flueframework.com/docs/ecosystem/databases/mysql/)
* [ Postgres ](https://flueframework.com/docs/ecosystem/databases/postgres/)
* [ Redis ](https://flueframework.com/docs/ecosystem/databases/redis/)
* [ Supabase ](https://flueframework.com/docs/ecosystem/databases/supabase/)
* [ Turso ](https://flueframework.com/docs/ecosystem/databases/turso/)
* [ Valkey ](https://flueframework.com/docs/ecosystem/databases/valkey/)

### Tooling

* [ Braintrust ](https://flueframework.com/docs/ecosystem/tooling/braintrust/)
* [ OpenTelemetry ](https://flueframework.com/docs/ecosystem/tooling/opentelemetry/)
* [ Sentry ](https://flueframework.com/docs/ecosystem/tooling/sentry/)