# Directory Structure ``` ├── .DS_Store ├── .gitignore ├── Dockerfile ├── jest.config.js ├── LICENSE ├── package-lock.json ├── package.json ├── README.md ├── smithery.yaml ├── src │ ├── __tests__ │ │ └── ShopifyClient.test.ts │ ├── .DS_Store │ ├── index.ts │ └── ShopifyClient │ ├── ShopifyClient.ts │ └── ShopifyClientPort.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` .env node_modules build dist ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # Shopify MCP Server MCP Server for Shopify API, enabling interaction with store data through GraphQL API. This server provides tools for managing products, customers, orders, and more. <a href="https://glama.ai/mcp/servers/bemvhpy885"><img width="380" height="200" src="https://glama.ai/mcp/servers/bemvhpy885/badge" alt="Shopify Server MCP server" /></a> ## Features * **Product Management**: Search and retrieve product information * **Customer Management**: Load customer data and manage customer tags * **Order Management**: Advanced order querying and filtering * **GraphQL Integration**: Direct integration with Shopify's GraphQL Admin API * **Comprehensive Error Handling**: Clear error messages for API and authentication issues ## Tools 1. `get-products` * Get all products or search by title * Inputs: * `searchTitle` (optional string): Filter products by title * `limit` (number): Maximum number of products to return * Returns: Formatted product details including title, description, handle, and variants 2. `get-products-by-collection` * Get products from a specific collection * Inputs: * `collectionId` (string): ID of the collection to get products from * `limit` (optional number, default: 10): Maximum number of products to return * Returns: Formatted product details from the specified collection 3. `get-products-by-ids` * Get products by their IDs * Inputs: * `productIds` (array of strings): Array of product IDs to retrieve * Returns: Formatted product details for the specified products 4. `update-product-price` * Update product prices for its ID * Inputs: * `productId` (string): ID of the product to update * `price` (string): New price for the product * Returns: Response of the update 5. `get-variants-by-ids` * Get product variants by their IDs * Inputs: * `variantIds` (array of strings): Array of variant IDs to retrieve * Returns: Detailed variant information including product details 6. `get-customers` * Get shopify customers with pagination support * Inputs: * `limit` (optional number): Maximum number of customers to return * `next` (optional string): Next page cursor * Returns: Customer data in JSON format 7. `tag-customer` * Add tags to a customer * Inputs: * `customerId` (string): Customer ID to tag * `tags` (array of strings): Tags to add to the customer * Returns: Success or failure message 8. `get-orders` * Get orders with advanced filtering and sorting * Inputs: * `first` (optional number): Limit of orders to return * `after` (optional string): Next page cursor * `query` (optional string): Filter orders using query syntax * `sortKey` (optional enum): Field to sort by ('PROCESSED_AT', 'TOTAL_PRICE', 'ID', 'CREATED_AT', 'UPDATED_AT', 'ORDER_NUMBER') * `reverse` (optional boolean): Reverse sort order * Returns: Formatted order details 9. `get-order` * Get a single order by ID * Inputs: * `orderId` (string): ID of the order to retrieve * Returns: Detailed order information 10. `create-discount` * Create a basic discount code * Inputs: * `title` (string): Title of the discount * `code` (string): Discount code that customers will enter * `valueType` (enum): Type of discount ('percentage' or 'fixed_amount') * `value` (number): Discount value (percentage as decimal or fixed amount) * `startsAt` (string): Start date in ISO format * `endsAt` (optional string): Optional end date in ISO format * `appliesOncePerCustomer` (boolean): Whether discount can be used only once per customer * Returns: Created discount details 11. `create-draft-order` * Create a draft order * Inputs: * `lineItems` (array): Array of items with variantId and quantity * `email` (string): Customer email * `shippingAddress` (object): Shipping address details * `note` (optional string): Optional note for the order * Returns: Created draft order details 12. `complete-draft-order` * Complete a draft order * Inputs: * `draftOrderId` (string): ID of the draft order to complete * `variantId` (string): ID of the variant in the draft order * Returns: Completed order details 13. `get-collections` * Get all collections * Inputs: * `limit` (optional number, default: 10): Maximum number of collections to return * `name` (optional string): Filter collections by name * Returns: Collection details 14. `get-shop` * Get shop details * Inputs: None * Returns: Basic shop information 15. `get-shop-details` * Get extended shop details including shipping countries * Inputs: None * Returns: Extended shop information including shipping countries 16. `manage-webhook` * Subscribe, find, or unsubscribe webhooks * Inputs: * `action` (enum): Action to perform ('subscribe', 'find', 'unsubscribe') * `callbackUrl` (string): Webhook callback URL * `topic` (enum): Webhook topic to subscribe to * `webhookId` (optional string): Webhook ID (required for unsubscribe) * Returns: Webhook details or success message ## Setup ### Shopify Access Token To use this MCP server, you'll need to create a custom app in your Shopify store: 1. From your Shopify admin, go to **Settings** > **Apps and sales channels** 2. Click **Develop apps** (you may need to enable developer preview first) 3. Click **Create an app** 4. Set a name for your app (e.g., "Shopify MCP Server") 5. Click **Configure Admin API scopes** 6. Select the following scopes: * `read_products`, `write_products` * `read_customers`, `write_customers` * `read_orders`, `write_orders` 7. Click **Save** 8. Click **Install app** 9. Click **Install** to give the app access to your store data 10. After installation, you'll see your **Admin API access token** 11. Copy this token - you'll need it for configuration Note: Store your access token securely. It provides access to your store data and should never be shared or committed to version control. More details on how to create a Shopify app can be found [here](https://help.shopify.com/en/manual/apps/app-types/custom-apps). ### Usage with Claude Desktop Add to your `claude_desktop_config.json`: ```json { "mcpServers": { "shopify": { "command": "npx", "args": ["-y", "shopify-mcp-server"], "env": { "SHOPIFY_ACCESS_TOKEN": "<YOUR_ACCESS_TOKEN>", "MYSHOPIFY_DOMAIN": "<YOUR_SHOP>.myshopify.com" } } } } ``` ## Development 1. Clone the repository 2. Install dependencies: ```bash npm install ``` 3. Create a `.env` file: ``` SHOPIFY_ACCESS_TOKEN=your_access_token MYSHOPIFY_DOMAIN=your-store.myshopify.com ``` 4. Build the project: ```bash npm run build ``` 5. Run tests: ```bash npm test ``` ## Dependencies - @modelcontextprotocol/sdk - MCP protocol implementation - graphql-request - GraphQL client for Shopify API - zod - Runtime type validation ## Contributing Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTING.md) first. ## License MIT ## Community - [MCP GitHub Discussions](https://github.com/modelcontextprotocol/servers/discussions) - [Report Issues](https://github.com/your-username/shopify-mcp-server/issues) --- Built with ❤️ using the [Model Context Protocol](https://modelcontextprotocol.io) ``` -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- ```javascript export default { preset: 'ts-jest', testEnvironment: 'node', extensionsToTreatAsEsm: ['.ts'], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', }, transform: { '^.+\\.tsx?$': ['ts-jest', { useESM: true, }], }, }; ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile FROM node:22.12-alpine AS builder # Must be entire project because `prepare` script is run during `npm install` and requires all files. COPY src /app COPY tsconfig.json /tsconfig.json COPY package.json /package.json COPY package-lock.json /package-lock.json WORKDIR /app ENV NODE_ENV=production RUN npm ci --ignore-scripts --omit-dev ENTRYPOINT ["node", "dist/index.js"] ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "module": "Nodenext", "moduleResolution": "Nodenext", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["src/**/*"], "exclude": ["node_modules", "src/__tests__"] } ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml # Smithery configuration file: https://smithery.ai/docs/deployments build: dockerBuildPath: ../../ startCommand: configSchema: # JSON Schema defining the configuration options for the MCP. type: object required: - shopifyAccessToken - shopifyDomain properties: shopifyAccessToken: type: string description: The personal access token for accessing the Shopify API. shopifyDomain: type: string description: The domain of the Shopify store. commandFunction: # A function that produces the CLI command to start the MCP on stdio. |- (config) => ({ command: 'node', args: ['dist/index.js'], env: { SHOPIFY_ACCESS_TOKEN: config.shopifyAccessToken, MYSHOPIFY_DOMAIN: config.shopifyDomain } }) type: stdio ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "shopify-mcp-server", "version": "1.0.1", "main": "index.js", "scripts": { "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "build": "tsc && node -e \"require('fs').chmodSync('dist/index.js', '755')\"" }, "keywords": [], "author": "Amir Bengherbi", "license": "MIT", "description": "MCP Server for Shopify API, enabling interaction with store data through GraphQL API.", "dependencies": { "@modelcontextprotocol/sdk": "^1.4.1", "graphql-request": "^7.1.2", "zod": "^3.24.1" }, "devDependencies": { "@types/jest": "^29.5.14", "@types/node": "^22.10.10", "dotenv": "^16.4.7", "jest": "^29.7.0", "ts-jest": "^29.2.5", "typescript": "^5.7.3" }, "type": "module", "files": [ "dist" ], "bin": { "shopify-mcp-server": "./dist/index.js" } } ``` -------------------------------------------------------------------------------- /src/__tests__/ShopifyClient.test.ts: -------------------------------------------------------------------------------- ```typescript import { config } from "dotenv"; import { ShopifyClient } from "../ShopifyClient/ShopifyClient.js"; import { CreateBasicDiscountCodeInput, CreateDraftOrderPayload, ShopifyWebhookTopic, } from "../ShopifyClient/ShopifyClientPort.js"; // Load environment variables from .env file config(); const SHOPIFY_ACCESS_TOKEN = process.env.SHOPIFY_ACCESS_TOKEN; const MYSHOPIFY_DOMAIN = process.env.MYSHOPIFY_DOMAIN; if (!SHOPIFY_ACCESS_TOKEN || !MYSHOPIFY_DOMAIN) { throw new Error( "SHOPIFY_ACCESS_TOKEN and MYSHOPIFY_DOMAIN must be set in .env file" ); } describe("ShopifyClient", () => { let client: ShopifyClient; beforeEach(() => { client = new ShopifyClient(); }); describe("Products", () => { it("should load products", async () => { const products = await client.loadProducts( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, "*", 100 ); expect(products).toBeDefined(); expect(products.products).toBeDefined(); expect(products.currencyCode).toBeDefined(); }); it("should load products by collection id", async () => { // load collections to get a valid collection id const collections = await client.loadCollections( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, { limit: 1 } ); const collectionId = collections.collections[0]?.id.toString(); expect(collectionId).toBeDefined(); const products = await client.loadProductsByCollectionId( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, collectionId, 10 ); expect(products).toBeDefined(); expect(products.products).toBeDefined(); expect(products.currencyCode).toBeDefined(); }); it("should load products by ids", async () => { // load products to get a valid product id const allProducts = await client.loadProducts( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, "*", 100 ); const productIds = allProducts.products.map((product) => product.id.toString() ); const products = await client.loadProductsByIds( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, productIds ); expect(products).toBeDefined(); expect(products.products).toBeDefined(); expect(products.currencyCode).toBeDefined(); }); it("should load variants by ids", async () => { // load products to get a valid product id const allProducts = await client.loadProducts( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, "*", 100 ); const variantIds = allProducts.products.flatMap((product) => product.variants.edges.map((variant) => variant.node.id.toString()) ); const variants = await client.loadVariantsByIds( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, variantIds ); expect(variants).toBeDefined(); expect(variants.variants).toBeDefined(); expect(variants.currencyCode).toBeDefined(); }); }); describe("Customers", () => { it("should load customers", async () => { const customers = await client.loadCustomers( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, 100 ); expect(customers).toBeDefined(); expect(customers.customers).toBeDefined(); }); it("should tag customer", async () => { // load customers to get a valid customer id const customers = await client.loadCustomers( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, 100 ); const customerId = customers.customers[0]?.id?.toString(); expect(customerId).toBeDefined(); if (!customerId) { throw new Error("No customer id found"); } const tagged = await client.tagCustomer( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, ["test"], customerId ); expect(tagged).toBe(true); }); }); describe("Orders", () => { it("should load orders", async () => { const orders = await client.loadOrders( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, { first: 100, } ); expect(orders).toBeDefined(); expect(orders.orders).toBeDefined(); expect(orders.pageInfo).toBeDefined(); }); it("should load single order", async () => { // load orders to get a valid order id const orders = await client.loadOrders( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, { first: 100, } ); const orderId = orders.orders[0]?.id?.toString(); expect(orderId).toBeDefined(); if (!orderId) { throw new Error("No order id found"); } const order = await client.loadOrder( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, { orderId: client.getIdFromGid(orderId) } ); expect(order).toBeDefined(); expect(order.id).toBeDefined(); }); }); describe("Discounts", () => { it("should create and delete basic discount code", async () => { const discountInput: CreateBasicDiscountCodeInput = { title: "Test Discount", code: "TEST123", startsAt: new Date().toISOString(), valueType: "percentage", value: 0.1, includeCollectionIds: [], excludeCollectionIds: [], appliesOncePerCustomer: true, combinesWith: { productDiscounts: true, orderDiscounts: true, shippingDiscounts: true, }, }; const discount = await client.createBasicDiscountCode( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, discountInput ); expect(discount).toBeDefined(); expect(discount.id).toBeDefined(); expect(discount.code).toBe(discountInput.code); await client.deleteBasicDiscountCode( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, discount.id ); }); }); describe("Draft Orders", () => { it("should create and complete draft order", async () => { // load products to get a valid variant id const allProducts = await client.loadProducts( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, null, 100 ); const variantIds = allProducts.products.flatMap((product) => product.variants.edges.map((variant) => variant.node.id.toString()) ); const variantId = variantIds[0]; expect(variantId).toBeDefined(); if (!variantId) { throw new Error("No variant id found"); } const draftOrderData: CreateDraftOrderPayload = { lineItems: [ { variantId, quantity: 1, }, ], email: "[email protected]", shippingAddress: { address1: "123 Test St", city: "Test City", province: "Test Province", country: "Test Country", zip: "12345", firstName: "Test", lastName: "User", countryCode: "US", }, billingAddress: { address1: "123 Test St", city: "Test City", province: "Test Province", country: "Test Country", zip: "12345", firstName: "Test", lastName: "User", countryCode: "US", }, tags: "test", note: "Test draft order", }; const draftOrder = await client.createDraftOrder( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, draftOrderData ); expect(draftOrder).toBeDefined(); expect(draftOrder.draftOrderId).toBeDefined(); const completedOrder = await client.completeDraftOrder( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, draftOrder.draftOrderId, draftOrderData.lineItems[0].variantId ); expect(completedOrder).toBeDefined(); expect(completedOrder.orderId).toBeDefined(); }); }); describe("Collections", () => { it("should load collections", async () => { const collections = await client.loadCollections( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, { limit: 10 } ); expect(collections).toBeDefined(); expect(collections.collections).toBeDefined(); }); }); describe("Shop", () => { it("should load shop", async () => { const shop = await client.loadShop( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN ); expect(shop).toBeDefined(); expect(shop.shop).toBeDefined(); }); it("should load shop details", async () => { const shopDetails = await client.loadShopDetail( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN ); expect(shopDetails).toBeDefined(); expect(shopDetails.data).toBeDefined(); }); }); describe("Webhooks", () => { it("should manage webhooks", async () => { const callbackUrl = "https://example.com/webhook"; const topic = ShopifyWebhookTopic.ORDERS_UPDATED; const webhook = await client.subscribeWebhook( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, callbackUrl, topic ); expect(webhook).toBeDefined(); expect(webhook.id).toBeDefined(); const foundWebhook = await client.findWebhookByTopicAndCallbackUrl( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, callbackUrl, topic ); expect(foundWebhook).toBeDefined(); expect(foundWebhook?.id).toBe(webhook.id); if (!foundWebhook?.id) { throw new Error("No webhook id found"); } const webhookId = foundWebhook.id; await client.unsubscribeWebhook( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, webhookId ); const deletedWebhook = await client.findWebhookByTopicAndCallbackUrl( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, callbackUrl, topic ); expect(deletedWebhook).toBeNull(); }); }); describe("Utility Methods", () => { it("should get ID from GID", () => { const gid = "gid://shopify/Product/123456789"; const id = client.getIdFromGid(gid); expect(id).toBe("123456789"); }); }); }); ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { ShopifyClient } from "./ShopifyClient/ShopifyClient.js"; import { CustomError, ProductNode, ShopifyOrderGraphql, CreateBasicDiscountCodeInput, CreateDraftOrderPayload, ShopifyWebhookTopic, } from "./ShopifyClient/ShopifyClientPort.js"; const server = new McpServer({ name: "shopify-tools", version: "1.0.1", }); const SHOPIFY_ACCESS_TOKEN = process.env.SHOPIFY_ACCESS_TOKEN; if (!SHOPIFY_ACCESS_TOKEN) { console.error("Error: SHOPIFY_ACCESS_TOKEN environment variable is required"); process.exit(1); } const MYSHOPIFY_DOMAIN = process.env.MYSHOPIFY_DOMAIN; if (!MYSHOPIFY_DOMAIN) { console.error("Error: MYSHOPIFY_DOMAIN environment variable is required"); process.exit(1); } function formatProduct(product: ProductNode): string { return ` Product: ${product.title} id: ${product.id} description: ${product.description} handle: ${product.handle} variants: ${product.variants.edges .map( (variant) => `variant.title: ${variant.node.title} variant.id: ${variant.node.id} variant.price: ${variant.node.price} variant.sku: ${variant.node.sku} variant.inventoryPolicy: ${variant.node.inventoryPolicy} ` ) .join(", ")} `; } function formatOrder(order: ShopifyOrderGraphql): string { return ` Order: ${order.name} (${order.id}) Created At: ${order.createdAt} Status: ${order.displayFinancialStatus || "N/A"} Email: ${order.email || "N/A"} Phone: ${order.phone || "N/A"} Total Price: ${order.totalPriceSet.shopMoney.amount} ${ order.totalPriceSet.shopMoney.currencyCode } Customer: ${ order.customer ? ` ID: ${order.customer.id} Email: ${order.customer.email}` : "No customer information" } Shipping Address: ${ order.shippingAddress ? ` Province: ${order.shippingAddress.provinceCode || "N/A"} Country: ${order.shippingAddress.countryCode}` : "No shipping address" } Line Items: ${ order.lineItems.nodes.length > 0 ? order.lineItems.nodes .map( (item) => ` Title: ${item.title} Quantity: ${item.quantity} Price: ${item.originalTotalSet.shopMoney.amount} ${ item.originalTotalSet.shopMoney.currencyCode } Variant: ${ item.variant ? ` Title: ${item.variant.title} SKU: ${item.variant.sku || "N/A"} Price: ${item.variant.price}` : "No variant information" }` ) .join("\n") : "No items" } `; } // Products Tools server.tool( "get-products", "Get all products or search by title", { searchTitle: z .string() .optional() .describe("Search title, if missing, will return all products"), limit: z.number().describe("Maximum number of products to return"), }, async ({ searchTitle, limit }) => { const client = new ShopifyClient(); try { const products = await client.loadProducts( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, searchTitle ?? null, limit ); const formattedProducts = products.products.map(formatProduct); return { content: [{ type: "text", text: formattedProducts.join("\n") }], }; } catch (error) { return handleError("Failed to retrieve products data", error); } } ); server.tool( "get-products-by-collection", "Get products from a specific collection", { collectionId: z .string() .describe("ID of the collection to get products from"), limit: z .number() .optional() .default(10) .describe("Maximum number of products to return"), }, async ({ collectionId, limit }) => { const client = new ShopifyClient(); try { const products = await client.loadProductsByCollectionId( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, collectionId, limit ); const formattedProducts = products.products.map(formatProduct); return { content: [{ type: "text", text: formattedProducts.join("\n") }], }; } catch (error) { return handleError("Failed to retrieve products from collection", error); } } ); server.tool( "get-products-by-ids", "Get products by their IDs", { productIds: z .array(z.string()) .describe("Array of product IDs to retrieve"), }, async ({ productIds }) => { const client = new ShopifyClient(); try { const products = await client.loadProductsByIds( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, productIds ); const formattedProducts = products.products.map(formatProduct); return { content: [{ type: "text", text: formattedProducts.join("\n") }], }; } catch (error) { return handleError("Failed to retrieve products by IDs", error); } } ); server.tool( "update-product-price", "Update the price of a product by its ID for all variants", { productId: z.string() .describe("ID of the product to update"), price: z.string() .describe("Price of the product to update to"), }, async ({ productId, price }) => { const client = new ShopifyClient(); try { const response = await client.updateProductPrice( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, productId, price ); return { content: [{ type: "text", text: JSON.stringify(response, null, 2) }], }; } catch (error) { return handleError("Failed to update product price", error); } } ); server.tool( "get-variants-by-ids", "Get product variants by their IDs", { variantIds: z .array(z.string()) .describe("Array of variant IDs to retrieve"), }, async ({ variantIds }) => { const client = new ShopifyClient(); try { const variants = await client.loadVariantsByIds( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, variantIds ); return { content: [{ type: "text", text: JSON.stringify(variants, null, 2) }], }; } catch (error) { return handleError("Failed to retrieve variants", error); } } ); // Customer Tools server.tool( "get-customers", "Get shopify customers with pagination support", { limit: z.number().optional().describe("Limit of customers to return"), next: z.string().optional().describe("Next page cursor"), }, async ({ limit, next }) => { const client = new ShopifyClient(); try { const response = await client.loadCustomers( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, limit, next ); return { content: [{ type: "text", text: JSON.stringify(response, null, 2) }], }; } catch (error) { return handleError("Failed to retrieve customers data", error); } } ); server.tool( "tag-customer", "Add tags to a customer", { customerId: z.string().describe("Customer ID to tag"), tags: z.array(z.string()).describe("Tags to add to the customer"), }, async ({ customerId, tags }) => { const client = new ShopifyClient(); try { const success = await client.tagCustomer( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, tags, customerId ); return { content: [ { type: "text", text: success ? "Successfully tagged customer" : "Failed to tag customer", }, ], }; } catch (error) { return handleError("Failed to tag customer", error); } } ); // Order Tools server.tool( "get-orders", "Get shopify orders with advanced filtering and sorting", { first: z.number().optional().describe("Limit of orders to return"), after: z.string().optional().describe("Next page cursor"), query: z.string().optional().describe("Filter orders using query syntax"), sortKey: z .enum([ "PROCESSED_AT", "TOTAL_PRICE", "ID", "CREATED_AT", "UPDATED_AT", "ORDER_NUMBER", ]) .optional() .describe("Field to sort by"), reverse: z.boolean().optional().describe("Reverse sort order"), }, async ({ first, after, query, sortKey, reverse }) => { const client = new ShopifyClient(); try { const response = await client.loadOrders( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, { first, after, query, sortKey, reverse, } ); const formattedOrders = response.orders.map(formatOrder); return { content: [{ type: "text", text: formattedOrders.join("\n---\n") }], }; } catch (error) { return handleError("Failed to retrieve orders data", error); } } ); server.tool( "get-order", "Get a single order by ID", { orderId: z.string().describe("ID of the order to retrieve"), }, async ({ orderId }) => { const client = new ShopifyClient(); try { const order = await client.loadOrder( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, { orderId } ); return { content: [{ type: "text", text: JSON.stringify(order, null, 2) }], }; } catch (error) { return handleError("Failed to retrieve order", error); } } ); // Discount Tools server.tool( "create-discount", "Create a basic discount code", { title: z.string().describe("Title of the discount"), code: z.string().describe("Discount code that customers will enter"), valueType: z .enum(["percentage", "fixed_amount"]) .describe("Type of discount"), value: z .number() .describe("Discount value (percentage as decimal or fixed amount)"), startsAt: z.string().describe("Start date in ISO format"), endsAt: z.string().optional().describe("Optional end date in ISO format"), appliesOncePerCustomer: z .boolean() .describe("Whether discount can be used only once per customer"), }, async ({ title, code, valueType, value, startsAt, endsAt, appliesOncePerCustomer, }) => { const client = new ShopifyClient(); try { const discountInput: CreateBasicDiscountCodeInput = { title, code, valueType, value, startsAt, endsAt, includeCollectionIds: [], excludeCollectionIds: [], appliesOncePerCustomer, combinesWith: { productDiscounts: true, orderDiscounts: true, shippingDiscounts: true, }, }; const discount = await client.createBasicDiscountCode( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, discountInput ); return { content: [{ type: "text", text: JSON.stringify(discount, null, 2) }], }; } catch (error) { return handleError("Failed to create discount", error); } } ); // Draft Order Tools server.tool( "create-draft-order", "Create a draft order", { lineItems: z .array( z.object({ variantId: z.string(), quantity: z.number(), }) ) .describe("Line items to add to the order"), email: z.string().email().describe("Customer email"), shippingAddress: z .object({ address1: z.string(), city: z.string(), province: z.string(), country: z.string(), zip: z.string(), firstName: z.string(), lastName: z.string(), countryCode: z.string(), }) .describe("Shipping address details"), note: z.string().optional().describe("Optional note for the order"), }, async ({ lineItems, email, shippingAddress, note }) => { const client = new ShopifyClient(); try { const draftOrderData: CreateDraftOrderPayload = { lineItems, email, shippingAddress, billingAddress: shippingAddress, // Using same address for billing tags: "draft", note: note || "", }; const draftOrder = await client.createDraftOrder( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, draftOrderData ); return { content: [{ type: "text", text: JSON.stringify(draftOrder, null, 2) }], }; } catch (error) { return handleError("Failed to create draft order", error); } } ); server.tool( "complete-draft-order", "Complete a draft order", { draftOrderId: z.string().describe("ID of the draft order to complete"), variantId: z.string().describe("ID of the variant in the draft order"), }, async ({ draftOrderId, variantId }) => { const client = new ShopifyClient(); try { const completedOrder = await client.completeDraftOrder( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, draftOrderId, variantId ); return { content: [ { type: "text", text: JSON.stringify(completedOrder, null, 2) }, ], }; } catch (error) { return handleError("Failed to complete draft order", error); } } ); // Collection Tools server.tool( "get-collections", "Get all collections", { limit: z .number() .optional() .default(10) .describe("Maximum number of collections to return"), name: z.string().optional().describe("Filter collections by name"), }, async ({ limit, name }) => { const client = new ShopifyClient(); try { const collections = await client.loadCollections( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, { limit, name } ); return { content: [{ type: "text", text: JSON.stringify(collections, null, 2) }], }; } catch (error) { return handleError("Failed to retrieve collections", error); } } ); // Shop Tools server.tool("get-shop", "Get shop details", {}, async () => { const client = new ShopifyClient(); try { const shop = await client.loadShop(SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN); return { content: [{ type: "text", text: JSON.stringify(shop, null, 2) }], }; } catch (error) { return handleError("Failed to retrieve shop details", error); } }); server.tool( "get-shop-details", "Get extended shop details including shipping countries", {}, async () => { const client = new ShopifyClient(); try { const shopDetails = await client.loadShopDetail( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN ); return { content: [{ type: "text", text: JSON.stringify(shopDetails, null, 2) }], }; } catch (error) { return handleError("Failed to retrieve extended shop details", error); } } ); // Webhook Tools server.tool( "manage-webhook", "Subscribe, find, or unsubscribe webhooks", { action: z .enum(["subscribe", "find", "unsubscribe"]) .describe("Action to perform with webhook"), callbackUrl: z.string().url().describe("Webhook callback URL"), topic: z .nativeEnum(ShopifyWebhookTopic) .describe("Webhook topic to subscribe to"), webhookId: z .string() .optional() .describe("Webhook ID (required for unsubscribe)"), }, async ({ action, callbackUrl, topic, webhookId }) => { const client = new ShopifyClient(); try { switch (action) { case "subscribe": { const webhook = await client.subscribeWebhook( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, callbackUrl, topic ); return { content: [{ type: "text", text: JSON.stringify(webhook, null, 2) }], }; } case "find": { const webhook = await client.findWebhookByTopicAndCallbackUrl( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, callbackUrl, topic ); return { content: [{ type: "text", text: JSON.stringify(webhook, null, 2) }], }; } case "unsubscribe": { if (!webhookId) { throw new Error("webhookId is required for unsubscribe action"); } await client.unsubscribeWebhook( SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN, webhookId ); return { content: [ { type: "text", text: "Webhook unsubscribed successfully" }, ], }; } } } catch (error) { return handleError("Failed to manage webhook", error); } } ); // Utility function to handle errors function handleError( defaultMessage: string, error: unknown ): { content: { type: "text"; text: string }[]; isError: boolean; } { let errorMessage = defaultMessage; if (error instanceof CustomError) { errorMessage = `${defaultMessage}: ${error.message}`; } return { content: [{ type: "text", text: errorMessage }], isError: true, }; } async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("Shopify MCP Server running on stdio"); } main().catch((error) => { console.error("Fatal error in main():", error); process.exit(1); }); ``` -------------------------------------------------------------------------------- /src/ShopifyClient/ShopifyClientPort.ts: -------------------------------------------------------------------------------- ```typescript export type Nullable<T> = T | null; export type ISODate = string; export type Maybe<T> = T | null | undefined; export type CreateDiscountCodeResponse = { id: string; priceRuleId: string; code: string; usageCount: number; }; export enum ShopifyWebhookTopicGraphql { ORDERS_UPDATED = "ORDERS_UPDATED", } export enum ShopifyWebhookTopic { ORDERS_UPDATED = "orders/updated", } export type ShopifyWebhook = { id: string; callbackUrl: string; topic: ShopifyWebhookTopic; }; export type ShopifyPriceRule = { id: number; value_type: string; value: string; customer_selection: string; target_type: string; target_selection: string; allocation_method: string; allocation_limit: number | null; once_per_customer: boolean; usage_limit: number | null; starts_at: string; ends_at: string | null; created_at: string; updated_at: string; entitled_product_ids: number[]; entitled_variant_ids: number[]; entitled_collection_ids: number[]; entitled_country_ids: number[]; prerequisite_product_ids: number[]; prerequisite_variant_ids: number[]; prerequisite_collection_ids: number[]; prerequisite_saved_search_ids: number[]; prerequisite_customer_ids: number[]; prerequisite_subtotal_range: { greater_than_or_equal_to: string; } | null; prerequisite_quantity_range: { greater_than_or_equal_to: number; } | null; prerequisite_shipping_price_range: { less_than_or_equal_to: string; } | null; prerequisite_to_entitlement_quantity_ratio: { prerequisite_quantity: number; entitled_quantity: number; } | null; title: string; admin_graphql_api_id: string; }; export type ShopifyCreatePriceRuleResponse = { price_rule: ShopifyPriceRule; }; export type ShopifyDiscountCode = { id: number; price_rule_id: number; code: string; usage_count: number; created_at: string; updated_at: string; }; export type ShopifyCreateDiscountCodeResponse = { discount_code: ShopifyDiscountCode; }; export type CreatePriceRuleInput = { title: string; targetType: "LINE_ITEM" | "SHIPPING_LINE"; allocationMethod: "ACROSS" | "EACH"; valueType: "fixed_amount" | "percentage"; value: string; entitledCollectionIds: string[]; usageLimit?: number; startsAt: ISODate; endsAt?: ISODate; }; export type CreateBasicDiscountCodeInput = { title: string; code: string; startsAt: ISODate; endsAt?: ISODate; valueType: string; value: number; usageLimit?: number; includeCollectionIds: string[]; excludeCollectionIds: string[]; appliesOncePerCustomer: boolean; combinesWith: { productDiscounts: boolean; orderDiscounts: boolean; shippingDiscounts: boolean; }; }; export type CreateBasicDiscountCodeResponse = { id: string; code: string; }; export type BasicDiscountCodeResponse = { data: { discountCodeBasicCreate: { codeDiscountNode: { id: string; codeDiscount: { title: string; codes: { nodes: Array<{ code: string; }>; }; startsAt: string; endsAt: string; customerSelection: { allCustomers: boolean; }; customerGets: { appliesOnOneTimePurchase: boolean; appliesOnSubscription: boolean; value: { percentage?: number; amount?: { amount: number; currencyCode: string; }; }; items: { allItems: boolean; }; }; appliesOncePerCustomer: boolean; recurringCycleLimit: number; }; }; userErrors: Array<{ field: string[]; code: string; message: string; }>; }; }; }; export type CreatePriceRuleResponse = { id: string; }; export type UpdateProductPriceResponse ={ success: boolean; errors?: Array<{field: string; message: string}>; product?: { id: string; variants: { edges: Array<{ node: { price: string; }; }>; }; }; } type DiscountCode = { code: string | null; amount: string | null; type: string | null; }; export type ShopifyCustomer = { id?: number; email?: string; first_name?: string; last_name?: string; phone?: string; orders_count?: number; email_marketing_consent?: { state?: "subscribed" | "not_subscribed" | null; opt_in_level?: "single_opt_in" | "confirmed_opt_in" | "unknown" | null; consent_updated_at?: string; }; sms_marketing_consent?: { state?: string; opt_in_level?: string | null; consent_updated_at?: string; consent_collected_from?: string; }; tags?: string; currency?: string; default_address?: { first_name?: string | null; last_name?: string | null; company?: string | null; address1?: string | null; address2?: string | null; city?: string | null; province?: string | null; country?: string | null; zip?: string | null; phone?: string | null; name?: string | null; province_code?: string | null; country_code?: string | null; country_name?: string | null; }; }; export type LoadCustomersResponse = { customers: Array<ShopifyCustomer>; next?: string | undefined; }; export type ShopifyOrder = { id: string; createdAt: string; currencyCode: string; discountApplications: { nodes: Array<{ code: string | null; value: { amount: string | null; percentage: number | null; }; __typename: string; }>; }; displayFinancialStatus: string | null; name: string; totalPriceSet: { shopMoney: { amount: string; currencyCode: string }; presentmentMoney: { amount: string; currencyCode: string }; }; totalShippingPriceSet: { shopMoney: { amount: string; currencyCode: string }; presentmentMoney: { amount: string; currencyCode: string }; }; customer?: { id: string; email: string; firstName: string; lastName: string; phone: string; }; }; export type ShopifyOrdersResponse = { data: { orders: { edges: Array<{ node: ShopifyOrder; }>; pageInfo: { hasNextPage: boolean; endCursor: string; }; }; }; }; export function isShopifyOrder( shopifyOrder: any ): shopifyOrder is ShopifyOrder { return ( shopifyOrder && "id" in shopifyOrder && "createdAt" in shopifyOrder && "currencyCode" in shopifyOrder && "discountApplications" in shopifyOrder && "displayFinancialStatus" in shopifyOrder && "name" in shopifyOrder && "totalPriceSet" in shopifyOrder && "totalShippingPriceSet" in shopifyOrder ); } // Shopify webhook payload is the same type as the order // We expose the same type for having an easier to read and consistent API across all webshop clients export type ShopifyOrderWebhookPayload = ShopifyOrder; export function isShopifyOrderWebhookPayload( webhookPayload: any ): webhookPayload is ShopifyOrderWebhookPayload { return isShopifyOrder(webhookPayload); } export type ShopifyCollectionsQueryParams = { sinceId?: string; // Retrieve all orders after the specified ID name?: string; limit: number; }; export type ShopifyCollection = { id: number; handle: string; title: string; updated_at: string; body_html: Nullable<string>; published_at: string; sort_order: string; template_suffix?: Nullable<string>; published_scope: string; image?: { src: string; alt: string; }; }; export type ShopifySmartCollectionsResponse = { smart_collections: ShopifyCollection[]; }; export type ShopifyCustomCollectionsResponse = { custom_collections: ShopifyCollection[]; }; export type LoadCollectionsResponse = { collections: ShopifyCollection[]; next?: string; }; export type ShopifyShop = { id: string; name: string; domain: string; myshopify_domain: string; currency: string; enabled_presentment_currencies: string[]; address1: string; created_at: string; updated_at: string; }; export type LoadStorefrontsResponse = { shop: ShopifyShop; }; export type ShopifyQueryParams = { query?: string; // Custom query string for advanced filtering sortKey?: | "PROCESSED_AT" | "TOTAL_PRICE" | "ID" | "CREATED_AT" | "UPDATED_AT" | "ORDER_NUMBER"; reverse?: boolean; before?: string; after?: string; // Keeping these for backwards compatibility, but they should be used in query string sinceId?: string; updatedAtMin?: string; createdAtMin?: string; financialStatus?: | "AUTHORIZED" | "PENDING" | "PAID" | "PARTIALLY_PAID" | "REFUNDED" | "VOIDED" | "PARTIALLY_REFUNDED" | "ANY" | "UNPAID"; ids?: string[]; status?: "OPEN" | "CLOSED" | "CANCELLED" | "ANY"; limit?: number; }; export type ShippingZone = { id: string; name: string; countries: Array<{ id: string; name: string; code: string; }>; }; export type ShopifyLoadOrderQueryParams = { orderId: string; fields?: string[]; }; export type ProductImage = { src: string; height: number; width: number; }; export type ProductOption = { id: string; name: string; values: string[]; }; export type SelectedProductOption = { name: string; value: string; }; export type ProductVariant = { id: string; title: string; price: string; sku: string; availableForSale: boolean; image: Nullable<ProductImage>; inventoryPolicy: "CONTINUE" | "DENY"; selectedOptions: SelectedProductOption[]; }; export type ShopResponse = { data: { shop: { shipsToCountries: string[]; }; }; }; export type MarketResponse = { data: { market: { name: string; enabled: string; regions: { nodes: { name: string; code: string; }; }; }; }; }; export type GetPriceRuleInput = { query?: string }; export type GetPriceRuleResponse = { priceRules: { nodes: [ { id: string; title: string; status: string; } ]; }; }; export type ProductVariantWithProductDetails = ProductVariant & { product: { id: string; title: string; description: string; images: { edges: { node: ProductImage; }[]; }; }; }; export type ProductNode = { id: string; handle: string; title: string; description: string; publishedAt: string; updatedAt: string; options: ProductOption[]; images: { edges: { node: ProductImage; }[]; }; variants: { edges: { node: ProductVariant; }[]; }; }; export type LoadProductsResponse = { currencyCode: string; products: ProductNode[]; next?: string; }; export type LoadProductsByIdsResponse = { currencyCode: string; products: ProductNode[]; }; export type LoadVariantsByIdResponse = { currencyCode: string; variants: ProductVariantWithProductDetails[]; }; export type CreateDraftOrderPayload = { lineItems: Array<{ variantId: string; quantity: number; appliedDiscount?: { title: string; value: number; valueType: "FIXED_AMOUNT" | "PERCENTAGE"; }; }>; shippingAddress: { address1: string; address2?: string; countryCode: string; firstName: string; lastName: string; zip: string; city: string; country: string; province?: string; provinceCode?: string; phone?: string; }; billingAddress: { address1: string; address2?: string; countryCode: string; firstName: string; lastName: string; zip: string; city: string; country: string; province?: string; provinceCode?: string; phone?: string; }; email: string; tags: string; note: string; }; export type DraftOrderResponse = { draftOrderId: string; draftOrderName: string; }; export type CompleteDraftOrderResponse = { draftOrderId: string; draftOrderName: string; orderId: string; }; function serializeError(err: any): any { if (Array.isArray(err)) { return err.map((item) => serializeError(item)); } else if (typeof err === "object" && err !== null) { const result: Record<string, any> = {}; Object.getOwnPropertyNames(err).forEach((key) => { result[key] = serializeError(err[key]); }); return result; } return err; } type InnerError = | Error | Error[] | string | string[] | Record<string, any> | undefined; export interface CustomErrorPayload { customCode?: string; message?: string; innerError?: InnerError; /** * Used to add custom data that will be logged */ contextData?: any; } export class CustomError extends Error { public code: string; public innerError: InnerError; public contextData: any; constructor(message: string, code: string, payload: CustomErrorPayload = {}) { super(message); this.code = payload.customCode ? `${code}.${payload.customCode}` : code; if (payload.message) this.message = message; this.innerError = payload.innerError; this.contextData = payload.contextData; this.name = this.constructor.name; } toJSON(): unknown { return { message: this.message, innerError: serializeError(this.innerError), name: this.name, code: this.code, contextData: this.contextData, }; } static is<E extends typeof CustomError & { code: string }>( error: any, ErrorClass: E ): error is InstanceType<E> { return "code" in error && error.code === ErrorClass.code; } } export class ShopifyClientErrorBase extends CustomError { // eslint-disable-next-line @typescript-eslint/explicit-function-return-type static make(message: string, code: string) { return class extends ShopifyClientErrorBase { static code = code; constructor(payload?: CustomErrorPayload) { super(message, code, payload); } }; } } export class ShopifyCastObjError extends ShopifyClientErrorBase.make( "Error occurred on Shopify cast object", "SHOPIFY_CLIENT.SHOPIFY_CAST_ERROR" ) {} export class ShopifyAuthorizationError extends ShopifyClientErrorBase.make( "Shopify authorization error", "SHOPIFY_CLIENT.AUTHORIZATION_ERROR" ) {} export class ShopifyRequestError extends ShopifyClientErrorBase.make( "Shopify request error", "SHOPIFY_CLIENT.REQUEST_ERROR" ) {} export class ShopifyInputError extends ShopifyClientErrorBase.make( "Shopify input error", "SHOPIFY_CLIENT.INPUT_ERROR" ) {} export class ShopifyRateLimitingError extends ShopifyClientErrorBase.make( "Shopify rate limiting error", "SHOPIFY_CLIENT.RATE_LIMITING_ERROR" ) {} export class ShopifyServerInfrastructureError extends ShopifyClientErrorBase.make( "Shopify server or infrastructure error", "SHOPIFY_CLIENT.SERVER_INFRASTRUCTURE_ERROR" ) {} export class ShopifyPaymentError extends ShopifyClientErrorBase.make( "Shopify payment error", "SHOPIFY_CLIENT.PAYMENT_ERROR" ) {} export class GeneralShopifyClientError extends ShopifyClientErrorBase.make( "Error occurred on Shopify API client", "SHOPIFY_CLIENT.SHOPIFY_CLIENT_ERROR" ) {} export class ShopifyWebShopNotFoundError extends ShopifyClientErrorBase.make( "The Shopify webshop not found", "SHOPIFY_CLIENT.WEBSHOP_CONNECTION_NOT_FOUND" ) {} export class ShopifyProductVariantNotFoundError extends ShopifyClientErrorBase.make( "The Shopify product variant not found", "SHOPIFY_CLIENT.PRODUCT_VARIANT_NOT_FOUND" ) {} export class ShopifyProductVariantNotAvailableForSaleError extends ShopifyClientErrorBase.make( "The Shopify product variant is not available for sale", "SHOPIFY_CLIENT.PRODUCT_VARIANT_NOT_AVAILABLE_FOR_SALE" ) {} export class InvalidShopifyCurrencyError extends ShopifyClientErrorBase.make( "The Shopify currency is invalid", "SHOPIFY_CLIENT.INVALID_CURRENCY" ) {} export class ShopifyWebhookNotFoundError extends ShopifyClientErrorBase.make( "The Shopify webhook not found", "SHOPIFY_CLIENT.WEBHOOK_NOT_FOUND" ) {} export class ShopifyWebhookAlreadyExistsError extends ShopifyClientErrorBase.make( "The Shopify webhook already exists", "SHOPIFY_CLIENT.WEBHOOK_ALREADY_EXISTS" ) {} export function getHttpShopifyError( error: any, statusCode: number, contextData?: Record<string, any> ): ShopifyClientErrorBase { switch (statusCode) { case 401: case 403: case 423: case 430: return new ShopifyAuthorizationError({ innerError: error, contextData }); case 400: case 405: case 406: case 414: case 415: case 783: return new ShopifyRequestError({ innerError: error, contextData }); case 404: case 409: case 422: return new ShopifyInputError({ innerError: error, contextData }); case 429: return new ShopifyRateLimitingError({ innerError: error, contextData }); case 500: case 501: case 502: case 503: case 504: case 530: case 540: return new ShopifyServerInfrastructureError({ innerError: error, contextData, }); case 402: return new ShopifyPaymentError({ innerError: error, contextData }); default: return new GeneralShopifyClientError({ innerError: error, contextData, }); } } export function getGraphqlShopifyUserError( errors: any[], contextData?: Record<string, any> ): ShopifyClientErrorBase { const hasErrorWithMessage = (messages: string[]): boolean => errors.some((error) => messages.includes(error.message)); if (hasErrorWithMessage(["Product variant not found."])) { return new ShopifyProductVariantNotFoundError({ innerError: errors, contextData, }); } if (hasErrorWithMessage(["Webhook subscription does not exist"])) { return new ShopifyWebhookNotFoundError({ innerError: errors, contextData, }); } if (hasErrorWithMessage(["Address for this topic has already been taken"])) { return new ShopifyWebhookAlreadyExistsError({ innerError: errors, contextData, }); } return new GeneralShopifyClientError({ innerError: errors, contextData, }); } export function getGraphqlShopifyError( errors: any[], statusCode: number, contextData?: Record<string, any> ): ShopifyClientErrorBase { const hasErrorWithCode = (codes: string[]): boolean => errors.some((error) => codes.includes(error.extensions?.code)); switch (statusCode) { case 403: case 423: return new ShopifyAuthorizationError({ innerError: errors, contextData, }); case 400: return new ShopifyRequestError({ innerError: errors, contextData, }); case 404: return new ShopifyInputError({ innerError: errors, contextData, }); case 500: case 501: case 502: case 503: case 504: case 530: case 540: return new ShopifyServerInfrastructureError({ innerError: errors, contextData, }); case 402: return new ShopifyPaymentError({ innerError: errors, contextData, }); default: if (hasErrorWithCode(["UNAUTHORIZED", "ACCESS_DENIED", "FORBIDDEN"])) { return new ShopifyAuthorizationError({ innerError: errors, contextData, }); } if (hasErrorWithCode(["UNPROCESSABLE"])) { return new ShopifyInputError({ innerError: errors, contextData, }); } if (hasErrorWithCode(["THROTTLED"])) { return new ShopifyRateLimitingError({ innerError: errors, contextData, }); } if (hasErrorWithCode(["INTERNAL_SERVER_ERROR"])) { return new ShopifyServerInfrastructureError({ innerError: errors, contextData, }); } return new GeneralShopifyClientError({ innerError: errors, contextData, }); } } export type ShopifyOrderGraphql = { id: string; name: string; createdAt: string; displayFinancialStatus: string; email: string; phone: string | null; totalPriceSet: { shopMoney: { amount: string; currencyCode: string }; presentmentMoney: { amount: string; currencyCode: string }; }; customer: { id: string; email: string; } | null; shippingAddress: { provinceCode: string | null; countryCode: string; } | null; lineItems: { nodes: Array<{ id: string; title: string; quantity: number; originalTotalSet: { shopMoney: { amount: string; currencyCode: string }; }; variant: { id: string; title: string; sku: string | null; price: string; } | null; }>; }; }; export type ShopifyOrdersGraphqlQueryParams = { first?: number; after?: string; query?: string; sortKey?: | "PROCESSED_AT" | "TOTAL_PRICE" | "ID" | "CREATED_AT" | "UPDATED_AT" | "ORDER_NUMBER"; reverse?: boolean; }; export type ShopifyOrdersGraphqlResponse = { orders: ShopifyOrderGraphql[]; pageInfo: { hasNextPage: boolean; endCursor: string | null; }; }; export interface ShopifyClientPort { createPriceRule( accessToken: string, shop: string, priceRuleInput: CreatePriceRuleInput ): Promise<CreatePriceRuleResponse>; createDiscountCode( accessToken: string, shop: string, code: string, priceRuleId: string ): Promise<CreateDiscountCodeResponse>; deletePriceRule( accessToken: string, shop: string, priceRuleId: string ): Promise<void>; deleteDiscountCode( accessToken: string, shop: string, priceRuleId: string, discountCodeId: string ): Promise<void>; createBasicDiscountCode( accessToken: string, shop: string, discountInput: CreateBasicDiscountCodeInput ): Promise<CreateBasicDiscountCodeResponse>; deleteBasicDiscountCode( accessToken: string, shop: string, discountCodeId: string ): Promise<void>; loadOrders( accessToken: string, shop: string, queryParams: ShopifyOrdersGraphqlQueryParams ): Promise<ShopifyOrdersGraphqlResponse>; loadOrder( accessToken: string, myshopifyDomain: string, queryParams: ShopifyLoadOrderQueryParams ): Promise<ShopifyOrder>; subscribeWebhook( accessToken: string, myshopifyDomain: string, callbackUrl: string, topic: ShopifyWebhookTopic ): Promise<ShopifyWebhook>; unsubscribeWebhook( accessToken: string, myshopifyDomain: string, webhookId: string ): Promise<void>; findWebhookByTopicAndCallbackUrl( accessToken: string, myshopifyDomain: string, callbackUrl: string, topic: ShopifyWebhookTopic ): Promise<ShopifyWebhook | null>; loadCollections( accessToken: string, myshopifyDomain: string, queryParams: ShopifyQueryParams, next?: string ): Promise<LoadCollectionsResponse>; loadShop( accessToken: string, myshopifyDomain: string ): Promise<LoadStorefrontsResponse>; loadCustomers( accessToken: string, myshopifyDomain: string, limit?: number, next?: string ): Promise<LoadCustomersResponse>; tagCustomer( accessToken: string, myshopifyDomain: string, tags: string[], customerId: string ): Promise<boolean>; loadProducts( accessToken: string, myshopifyDomain: string, searchTitle: string | null, limit?: number, afterCursor?: string ): Promise<LoadProductsResponse>; loadProductsByCollectionId( accessToken: string, myshopifyDomain: string, collectionId: string, limit?: number, afterCursor?: string ): Promise<LoadProductsResponse>; loadProductsByIds( accessToken: string, shop: string, productIds: string[] ): Promise<LoadProductsByIdsResponse>; updateProductPrice( accessToken: string, shop: string, productId: string, price: string ): Promise<UpdateProductPriceResponse>; loadVariantsByIds( accessToken: string, shop: string, variantIds: string[] ): Promise<LoadVariantsByIdResponse>; createDraftOrder( accessToken: string, shop: string, draftOrderData: CreateDraftOrderPayload, idempotencyKey: string ): Promise<DraftOrderResponse>; completeDraftOrder( accessToken: string, shop: string, draftOrderId: string, variantId: string ): Promise<CompleteDraftOrderResponse>; getIdFromGid(gid: string): string; loadShopDetail(accessToken: string, shop: string): Promise<ShopResponse>; } ``` -------------------------------------------------------------------------------- /src/ShopifyClient/ShopifyClient.ts: -------------------------------------------------------------------------------- ```typescript import { CompleteDraftOrderResponse, CreateBasicDiscountCodeInput, CreateBasicDiscountCodeResponse, BasicDiscountCodeResponse, CreateDiscountCodeResponse, CreateDraftOrderPayload, CreatePriceRuleInput, CreatePriceRuleResponse, DraftOrderResponse, GeneralShopifyClientError, GetPriceRuleInput, GetPriceRuleResponse, LoadCollectionsResponse, LoadCustomersResponse, LoadProductsResponse, LoadStorefrontsResponse, LoadVariantsByIdResponse, ProductNode, ProductVariantWithProductDetails, ShopResponse, ShopifyAuthorizationError, ShopifyClientErrorBase, ShopifyCollection, ShopifyCollectionsQueryParams, ShopifyCustomCollectionsResponse, ShopifyInputError, ShopifyLoadOrderQueryParams, ShopifyOrder, ShopifyPaymentError, ShopifyProductVariantNotAvailableForSaleError, ShopifyProductVariantNotFoundError, ShopifyRequestError, ShopifySmartCollectionsResponse, ShopifyWebhook, getGraphqlShopifyError, getGraphqlShopifyUserError, getHttpShopifyError, ShopifyWebhookTopic, ShopifyWebhookTopicGraphql, ShopifyClientPort, UpdateProductPriceResponse, CustomError, Maybe, ShopifyOrdersGraphqlQueryParams, ShopifyOrdersGraphqlResponse, ShopifyOrderGraphql, } from "./ShopifyClientPort.js"; import { gql } from "graphql-request"; const productImagesFragment = gql` src height width `; const productVariantsFragment = gql` id title price sku image { ${productImagesFragment} } availableForSale inventoryPolicy selectedOptions { name value } `; const productFragment = gql` id handle title description publishedAt updatedAt options { id name values } images(first: 20) { edges { node { ${productImagesFragment} } } } variants(first: 250) { edges { node { ${productVariantsFragment} } } } `; export class ShopifyClient implements ShopifyClientPort { private readonly logger = console; private SHOPIFY_API_VERSION = "2024-04"; static getShopifyOrdersNextPage(link: Maybe<string>): string | undefined { if (!link) return; if (!link.includes("next")) return; if (link.includes("next") && link.includes("previous")) { return link .split('rel="previous"')[1] .split("page_info=")[1] .split('>; rel="next"')[0]; } return link.split("page_info=")[1].split('>; rel="next"')[0]; } async shopifyHTTPRequest<T>({ method, url, accessToken, params, data, }: { method: "GET" | "POST" | "DELETE" | "PUT"; url: string; accessToken: string; params?: Record<string, any>; data?: Record<string, any>; }): Promise<{ data: T; headers: Headers }> { try { // Add query parameters to URL if they exist if (params) { const queryParams = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value !== undefined) { queryParams.append(key, String(value)); } }); url = `${url}${url.includes("?") ? "&" : "?"}${queryParams.toString()}`; } const response = await fetch(url, { method, headers: { "X-Shopify-Access-Token": accessToken, ...(data ? { "Content-Type": "application/json" } : {}), }, ...(data ? { body: JSON.stringify(data) } : {}), }); if (!response.ok) { const responseData = await response .json() .catch(() => response.statusText); const responseError = responseData.error ?? responseData.errors ?? responseData ?? response.status; throw getHttpShopifyError(responseError, response.status, { url, params, method, data: responseData, }); } const responseData = await response.json(); return { data: responseData, headers: response.headers, }; } catch (error: any) { let shopifyError: ShopifyClientErrorBase; if (error instanceof ShopifyClientErrorBase) { shopifyError = error; } else { shopifyError = new GeneralShopifyClientError({ innerError: error, contextData: { url, params, method, }, }); } if ( shopifyError instanceof ShopifyRequestError || shopifyError instanceof GeneralShopifyClientError ) { this.logger.error(shopifyError); } else if ( shopifyError instanceof ShopifyInputError || shopifyError instanceof ShopifyAuthorizationError || shopifyError instanceof ShopifyPaymentError ) { this.logger.debug(shopifyError); } else { this.logger.warn(shopifyError); } throw shopifyError; } } async shopifyGraphqlRequest<T>({ url, accessToken, query, variables, }: { url: string; accessToken: string; query: string; variables?: Record<string, any>; }): Promise<{ data: T; headers: Headers }> { try { const response = await fetch(url, { method: "POST", headers: { "X-Shopify-Access-Token": accessToken, "Content-Type": "application/json", }, body: JSON.stringify({ query, variables }), }); const responseData = await response.json(); if (!response.ok || responseData?.errors) { const error = new Error("Shopify GraphQL Error"); throw Object.assign(error, { response: { data: responseData, status: response.status }, }); } return { data: responseData, headers: response.headers, }; } catch (error: any) { let shopifyError: ShopifyClientErrorBase; if (error.response) { const responseError = error.response.data.error ?? error.response.data.errors ?? error.response.data ?? error.response.status; shopifyError = getGraphqlShopifyError( responseError, error.response.status, { url, query, variables, data: error.response.data, } ); } else { shopifyError = new GeneralShopifyClientError({ innerError: error, contextData: { url, query, variables, }, }); } if ( shopifyError instanceof ShopifyRequestError || shopifyError instanceof GeneralShopifyClientError ) { this.logger.error(shopifyError); } else if ( shopifyError instanceof ShopifyInputError || shopifyError instanceof ShopifyAuthorizationError || shopifyError instanceof ShopifyPaymentError ) { this.logger.debug(shopifyError); } else { this.logger.warn(shopifyError); } throw shopifyError; } } private async getMyShopifyDomain( accessToken: string, shop: string ): Promise<string> { // POST requests are getting converted into GET on custom domain, so we need to retrieve the myshopify domain from the shop object const loadedShop = await this.loadShop(accessToken, shop); return loadedShop.shop.myshopify_domain; } async checkSubscriptionEligibility( accessToken: string, myshopifyDomain: string ): Promise<boolean> { const graphqlQuery = gql` query CheckSubscriptionEligibility { shop { features { eligibleForSubscriptions sellsSubscriptions } } } `; const res = await this.shopifyGraphqlRequest<{ data: { shop: { features: { eligibleForSubscriptions: boolean; sellsSubscriptions: boolean; }; }; }; }>({ url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, accessToken, query: graphqlQuery, }); return ( res.data.data.shop.features.eligibleForSubscriptions && res.data.data.shop.features.sellsSubscriptions ); } async createBasicDiscountCode( accessToken: string, shop: string, discountInput: CreateBasicDiscountCodeInput ): Promise<CreateBasicDiscountCodeResponse> { if (discountInput.valueType === "percentage") { if (discountInput.value < 0 || discountInput.value > 1) { throw new CustomError( "Invalid input: percentage value must be between 0 and 1", "InvalidInputError", { contextData: { discountInput, shop, }, } ); } } if (discountInput.valueType === "fixed_amount") { if (discountInput.value <= 0) { throw new CustomError( "Invalid input: fixed_amount value must be greater than 0", "InvalidInputError", { contextData: { discountInput, shop, }, } ); } } const myShopifyDomain = await this.getMyShopifyDomain(accessToken, shop); const isEligibleForSubscription = await this.checkSubscriptionEligibility( accessToken, myShopifyDomain ); const graphqlQuery = this.graphqlQueryPreparationForCreateBasicDiscountCode(); const variables = this.prepareBasicDiscountCodeVariable( discountInput, isEligibleForSubscription ); const res = await this.shopifyGraphqlRequest<BasicDiscountCodeResponse>({ url: `https://${myShopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, accessToken, query: graphqlQuery, variables, }); const id = res.data.data.discountCodeBasicCreate.codeDiscountNode.id; const codeDiscount = res.data.data.discountCodeBasicCreate.codeDiscountNode.codeDiscount.codes .nodes[0]; const userErrors = res.data.data.discountCodeBasicCreate.userErrors; if (userErrors.length > 0) { throw getGraphqlShopifyUserError(userErrors, { shop, discountInput, }); } return { id, code: codeDiscount.code, }; } private graphqlQueryPreparationForCreateBasicDiscountCode(): string { return gql` mutation discountCodeBasicCreate( $basicCodeDiscount: DiscountCodeBasicInput! ) { discountCodeBasicCreate(basicCodeDiscount: $basicCodeDiscount) { codeDiscountNode { id codeDiscount { ... on DiscountCodeBasic { title codes(first: 10) { nodes { code } } startsAt endsAt customerSelection { ... on DiscountCustomerAll { allCustomers } } customerGets { appliesOnOneTimePurchase appliesOnSubscription value { ... on DiscountPercentage { percentage } ... on DiscountAmount { amount { amount currencyCode } appliesOnEachItem } } items { ... on AllDiscountItems { allItems } } } appliesOncePerCustomer } } } userErrors { field code message } } } `; } private prepareBasicDiscountCodeVariable( discountInput: CreateBasicDiscountCodeInput, isEligibleForSubscription: boolean ): any { return { basicCodeDiscount: { title: discountInput.title, code: discountInput.code, startsAt: discountInput.startsAt, endsAt: discountInput.endsAt, customerSelection: { all: true, }, customerGets: { appliesOnOneTimePurchase: isEligibleForSubscription ? true : undefined, appliesOnSubscription: isEligibleForSubscription ? true : undefined, value: { percentage: discountInput.valueType === "percentage" ? discountInput.value : undefined, discountAmount: discountInput.valueType === "fixed_amount" ? { amount: discountInput.value, appliesOnEachItem: false, } : undefined, }, items: { all: discountInput.excludeCollectionIds.length === 0 && discountInput.includeCollectionIds.length === 0, collections: discountInput.includeCollectionIds.length || discountInput.excludeCollectionIds.length ? { add: discountInput.includeCollectionIds.map( (id) => `gid://shopify/Collection/${id}` ), remove: discountInput.excludeCollectionIds.map( (id) => `gid://shopify/Collection/${id}` ), } : undefined, }, }, appliesOncePerCustomer: discountInput.appliesOncePerCustomer, recurringCycleLimit: isEligibleForSubscription ? discountInput.valueType === "fixed_amount" ? 1 : null : undefined, usageLimit: discountInput.usageLimit, combinesWith: { productDiscounts: discountInput.combinesWith.productDiscounts, orderDiscounts: discountInput.combinesWith.orderDiscounts, shippingDiscounts: discountInput.combinesWith.shippingDiscounts, }, }, }; } async createPriceRule( accessToken: string, shop: string, priceRuleInput: CreatePriceRuleInput ): Promise<CreatePriceRuleResponse> { const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); const graphqlQuery = gql` mutation priceRuleCreate($priceRule: PriceRuleInput!) { priceRuleCreate(priceRule: $priceRule) { priceRule { id } priceRuleDiscountCode { id code } priceRuleUserErrors { field message } userErrors { field message } } } `; const res = await this.shopifyGraphqlRequest<{ data: { priceRuleCreate: { priceRule: { id: string; }; priceRuleUserErrors: Array<{ field: string[]; message: string; }>; userErrors: Array<{ field: string[]; message: string; }>; }; }; }>({ url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, accessToken, query: graphqlQuery, variables: { priceRule: { title: priceRuleInput.title, allocationMethod: priceRuleInput.allocationMethod, target: priceRuleInput.targetType, value: priceRuleInput.valueType === "fixed_amount" ? { fixedAmountValue: priceRuleInput.value } : { percentageValue: parseFloat(priceRuleInput.value) }, validityPeriod: { start: priceRuleInput.startsAt, end: priceRuleInput.endsAt, }, usageLimit: priceRuleInput.usageLimit, customerSelection: { forAllCustomers: true, }, itemEntitlements: { collectionIds: priceRuleInput.entitledCollectionIds.map( (id) => `gid://shopify/Collection/${id}` ), targetAllLineItems: priceRuleInput.entitledCollectionIds.length === 0, }, combinesWith: { productDiscounts: true, orderDiscounts: false, shippingDiscounts: true, }, }, }, }); const priceRule = res.data.data.priceRuleCreate.priceRule; const userErrors = res.data.data.priceRuleCreate.userErrors; if (userErrors.length > 0) { throw getGraphqlShopifyUserError(userErrors, { shop, priceRuleInput, }); } return { id: priceRule.id, }; } async createDiscountCode( accessToken: string, shop: string, code: string, priceRuleId: string ): Promise<CreateDiscountCodeResponse> { const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); const graphqlQuery = gql` mutation priceRuleDiscountCodeCreate($priceRuleId: ID!, $code: String!) { priceRuleDiscountCodeCreate(priceRuleId: $priceRuleId, code: $code) { priceRuleUserErrors { field message code } priceRule { id title } priceRuleDiscountCode { id code usageCount } } } `; const res = await this.shopifyGraphqlRequest<{ data: { priceRuleDiscountCodeCreate: { priceRuleUserErrors: Array<{ field: string[]; message: string; code: string; }>; priceRule: { id: string; title: string; }; priceRuleDiscountCode: { id: string; code: string; usageCount: number; }; }; }; }>({ url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, accessToken, query: graphqlQuery, variables: { priceRuleId, code, }, }); const discountCode = res.data.data.priceRuleDiscountCodeCreate.priceRuleDiscountCode; const userErrors = res.data.data.priceRuleDiscountCodeCreate.priceRuleUserErrors; if (userErrors.length > 0) { throw getGraphqlShopifyUserError(userErrors, { shop, code, priceRuleId, }); } return { id: priceRuleId, priceRuleId: priceRuleId, code: discountCode.code, usageCount: discountCode.usageCount, }; } async deleteBasicDiscountCode( accessToken: string, shop: string, discountCodeId: string ): Promise<void> { const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); const graphqlQuery = gql` mutation discountCodeDelete($id: ID!) { discountCodeDelete(id: $id) { deletedCodeDiscountId userErrors { field code message } } } `; const res = await this.shopifyGraphqlRequest<{ data: { discountCodeDelete: { deletedCodeDiscountId: string; userErrors: Array<{ field: string[]; code: string; message: string; }>; }; }; }>({ url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, accessToken, query: graphqlQuery, variables: { id: discountCodeId, }, }); const userErrors = res.data.data.discountCodeDelete.userErrors; if (userErrors.length > 0) { throw getGraphqlShopifyUserError(userErrors, { shop, discountCodeId, }); } } async deletePriceRule( accessToken: string, shop: string, priceRuleId: string ): Promise<void> { const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); await this.shopifyHTTPRequest({ method: "DELETE", url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/price_rules/${priceRuleId}.json`, accessToken, }); } async deleteDiscountCode( accessToken: string, shop: string, priceRuleId: string, discountCodeId: string ): Promise<void> { const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); await this.shopifyHTTPRequest({ method: "DELETE", url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/price_rules/${priceRuleId}/discount_codes/${discountCodeId}.json`, accessToken, }); } async loadOrders( accessToken: string, shop: string, queryParams: ShopifyOrdersGraphqlQueryParams ): Promise<ShopifyOrdersGraphqlResponse> { const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); const graphqlQuery = gql` query getOrdersDetailed( $first: Int $after: String $query: String $sortKey: OrderSortKeys $reverse: Boolean ) { orders( first: $first after: $after query: $query sortKey: $sortKey reverse: $reverse ) { nodes { id name createdAt displayFinancialStatus email phone totalPriceSet { shopMoney { amount currencyCode } presentmentMoney { amount currencyCode } } customer { id email } shippingAddress { provinceCode countryCode } lineItems(first: 50) { nodes { id title quantity originalTotalSet { shopMoney { amount currencyCode } } variant { id title sku price } } } } pageInfo { hasNextPage endCursor } } } `; const variables = { first: queryParams.first || 50, after: queryParams.after, query: queryParams.query, sortKey: queryParams.sortKey, reverse: queryParams.reverse, }; const res = await this.shopifyGraphqlRequest<{ data: { orders: { nodes: ShopifyOrderGraphql[]; pageInfo: { hasNextPage: boolean; endCursor: string | null; }; }; }; }>({ url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, accessToken, query: graphqlQuery, variables, }); return { orders: res.data.data.orders.nodes, pageInfo: res.data.data.orders.pageInfo, }; } async loadOrder( accessToken: string, shop: string, queryParams: ShopifyLoadOrderQueryParams ): Promise<ShopifyOrder> { const res = await this.shopifyHTTPRequest<{ order: ShopifyOrder }>({ method: "GET", url: `https://${shop}/admin/api/${this.SHOPIFY_API_VERSION}/orders/${queryParams.orderId}.json`, accessToken, params: { fields: this.getOrdersFields(queryParams.fields), }, }); return res.data.order; } async loadCollections( accessToken: string, shop: string, queryParams: ShopifyCollectionsQueryParams, next?: string ): Promise<LoadCollectionsResponse> { const nextList = next?.split(","); const customNext = nextList?.[0]; const smartNext = nextList?.[1]; let customCollections: ShopifyCollection[] = []; let customCollectionsNextPage; let smartCollections: ShopifyCollection[] = []; let smartCollectionsNextPage; if (customNext !== "undefined") { const customRes = await this.shopifyHTTPRequest<ShopifyCustomCollectionsResponse>({ method: "GET", url: `https://${shop}/admin/api/${this.SHOPIFY_API_VERSION}/custom_collections.json`, accessToken, params: { limit: queryParams.limit, page_info: customNext, title: customNext ? undefined : queryParams.name, since_id: customNext ? undefined : queryParams.sinceId, }, }); customCollections = customRes.data?.custom_collections || []; customCollectionsNextPage = ShopifyClient.getShopifyOrdersNextPage( customRes.headers?.get("link") ); } if (smartNext !== "undefined") { const smartRes = await this.shopifyHTTPRequest<ShopifySmartCollectionsResponse>({ method: "GET", url: `https://${shop}/admin/api/${this.SHOPIFY_API_VERSION}/smart_collections.json`, accessToken, params: { limit: queryParams.limit, page_info: smartNext, title: smartNext ? undefined : queryParams.name, since_id: smartNext ? undefined : queryParams.sinceId, }, }); smartCollections = smartRes.data?.smart_collections || []; smartCollectionsNextPage = ShopifyClient.getShopifyOrdersNextPage( smartRes.headers?.get("link") ); } const collections = [...customCollections, ...smartCollections]; if (customCollectionsNextPage || smartCollectionsNextPage) { next = `${customCollectionsNextPage},${smartCollectionsNextPage}`; } else { next = undefined; } return { collections, next }; } async loadShop( accessToken: string, shop: string ): Promise<LoadStorefrontsResponse> { const res = await this.shopifyHTTPRequest<LoadStorefrontsResponse>({ method: "GET", url: `https://${shop}/admin/api/${this.SHOPIFY_API_VERSION}/shop.json`, accessToken, }); return res.data; } async loadShopDetail( accessToken: string, shop: string ): Promise<ShopResponse> { const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); const graphqlQuery = gql` { shop { shipsToCountries } } `; const res = await this.shopifyGraphqlRequest<ShopResponse>({ url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, accessToken, query: graphqlQuery, }); return res.data; } async loadMarkets(accessToken: string, shop: string): Promise<ShopResponse> { const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); const graphqlQuery = gql` { markets(first: 100) { nodes { name enabled regions { nodes { name ... on MarketRegionCountry { code __typename } } } } } } `; const res = await this.shopifyGraphqlRequest<ShopResponse>({ url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, accessToken, query: graphqlQuery, }); return res.data; } async loadProductsByCollectionId( accessToken: string, shop: string, collectionId: string, limit: number = 10, afterCursor?: string ): Promise<LoadProductsResponse> { const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); const graphqlQuery = gql` { shop { currencyCode } collection(id: "gid://shopify/Collection/${collectionId}") { products( first: ${limit}${afterCursor ? `, after: "${afterCursor}"` : ""} ) { edges { node { ${productFragment} } } pageInfo { hasNextPage endCursor } } } } `; const res = await this.shopifyGraphqlRequest<{ data: { shop: { currencyCode: string; }; collection: { products: { edges: Array<{ node: ProductNode; }>; pageInfo: { hasNextPage: boolean; endCursor: string; }; }; }; }; }>({ url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, accessToken, query: graphqlQuery, }); const data = res.data.data; const edges = data.collection.products.edges; const products = edges.map((edge) => edge.node); const pageInfo = data.collection.products.pageInfo; const next = pageInfo.hasNextPage ? pageInfo.endCursor : undefined; const currencyCode = data.shop.currencyCode; return { products, next, currencyCode }; } async loadProducts( accessToken: string, myshopifyDomain: string, searchTitle: string | null, limit: number = 10, afterCursor?: string ): Promise<LoadProductsResponse> { const titleFilter = searchTitle ? `title:*${searchTitle}*` : ""; const graphqlQuery = gql` { shop { currencyCode } products(first: ${limit}, query: "${titleFilter}"${ afterCursor ? `, after: "${afterCursor}"` : "" }) { edges { node { ${productFragment} } } pageInfo { hasNextPage endCursor } } } `; const res = await this.shopifyGraphqlRequest<{ data: { shop: { currencyCode: string; }; products: { edges: Array<{ node: ProductNode; }>; pageInfo: { hasNextPage: boolean; endCursor: string; }; }; }; }>({ url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, accessToken, query: graphqlQuery, }); const data = res.data.data; const edges = data.products.edges; const products = edges.map((edge) => edge.node); const pageInfo = data.products.pageInfo; const next = pageInfo.hasNextPage ? pageInfo.endCursor : undefined; const currencyCode = data.shop.currencyCode; return { products, next, currencyCode }; } async loadVariantsByIds( accessToken: string, shop: string, variantIds: string[] ): Promise<LoadVariantsByIdResponse> { const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); const graphqlQuery = gql` { shop { currencyCode } nodes(ids: ${JSON.stringify(variantIds)}) { __typename ... on ProductVariant { ${productVariantsFragment} product { id title description images(first: 20) { edges { node { ${productImagesFragment} } } } } } } } `; const res = await this.shopifyGraphqlRequest<{ data: { shop: { currencyCode: string; }; nodes: Array< | ({ __typename: string; } & ProductVariantWithProductDetails) | null >; }; }>({ url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, accessToken, query: graphqlQuery, }); const variants = res.data.data.nodes.filter( ( node ): node is { __typename: string; } & ProductVariantWithProductDetails => node?.__typename === "ProductVariant" ); const currencyCode = res.data.data.shop.currencyCode; return { variants, currencyCode }; } async createDraftOrder( accessToken: string, myshopifyDomain: string, draftOrderData: CreateDraftOrderPayload ): Promise<DraftOrderResponse> { const graphqlQuery = gql` mutation draftOrderCreate($input: DraftOrderInput!) { draftOrderCreate(input: $input) { draftOrder { id name } userErrors { field message } } } `; const res = await this.shopifyGraphqlRequest<{ data: { draftOrderCreate: { draftOrder: { id: string; name: string; }; userErrors: Array<{ field: string[]; message: string; }>; }; }; }>({ url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, accessToken, query: graphqlQuery, variables: { input: draftOrderData, }, }); const draftOrder = res.data.data.draftOrderCreate.draftOrder; const userErrors = res.data.data.draftOrderCreate.userErrors; if (userErrors.length > 0) { throw getGraphqlShopifyUserError(userErrors, { myshopifyDomain, draftOrderData, }); } return { draftOrderId: draftOrder.id, draftOrderName: draftOrder.name, }; } async completeDraftOrder( accessToken: string, shop: string, draftOrderId: string, variantId: string ): Promise<CompleteDraftOrderResponse> { // First, load the variant to check if it's available for sale const variantResult = await this.loadVariantsByIds(accessToken, shop, [ variantId, ]); if (!variantResult.variants || variantResult.variants.length === 0) { throw new ShopifyProductVariantNotFoundError({ contextData: { shop, variantId, }, }); } const variant = variantResult.variants[0]; if (!variant.availableForSale) { throw new ShopifyProductVariantNotAvailableForSaleError({ contextData: { shop, variantId, }, }); } const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); const graphqlQuery = gql` mutation draftOrderComplete($id: ID!) { draftOrderComplete(id: $id) { draftOrder { id name order { id } } userErrors { field message } } } `; const res = await this.shopifyGraphqlRequest<{ data: { draftOrderComplete: { draftOrder: { id: string; name: string; order: { id: string; }; }; userErrors: Array<{ field: string[]; message: string; }>; }; }; }>({ url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, accessToken, query: graphqlQuery, variables: { id: draftOrderId, }, }); const draftOrder = res.data.data.draftOrderComplete.draftOrder; const order = draftOrder.order; const userErrors = res.data.data.draftOrderComplete.userErrors; if (userErrors && userErrors.length > 0) { throw getGraphqlShopifyUserError(userErrors, { shop, draftOrderId, variantId, }); } return { draftOrderId: draftOrder.id, orderId: order.id, draftOrderName: draftOrder.name, }; } async loadProductsByIds( accessToken: string, shop: string, productIds: string[] ): Promise<LoadProductsResponse> { const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); const graphqlQuery = gql` { shop { currencyCode } nodes(ids: ${JSON.stringify(productIds)}) { __typename ... on Product { ${productFragment} } } } `; const res = await this.shopifyGraphqlRequest<{ data: { shop: { currencyCode: string; }; nodes: Array< | ({ __typename: string; } & ProductNode) | null >; }; }>({ url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, accessToken, query: graphqlQuery, }); const data = res.data.data; const products = data.nodes.filter( ( node ): node is { __typename: string; } & ProductNode => node?.__typename === "Product" ); const currencyCode = data.shop.currencyCode; return { products, currencyCode }; } async updateProductPrice( accessToken: string, shop: string, productId: string, price: string ): Promise<UpdateProductPriceResponse> { const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); const graphqlQuery = gql` mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id priceRangeV2 { minVariantPrice { amount currencyCode } maxVariantPrice { amount currencyCode } } variants(first: 100) { edges { node { id price } } } } userErrors { field message } } } `; const variables = { input: { id: productId, variants: { price: price } } }; const res = await this.shopifyGraphqlRequest<{ data: { productUpdate: { product: { id: string; priceRangeV2: { minVariantPrice: {amount: string; currencyCode: string}; maxVariantPrice: {amount: string; currencyCode: string}; }; variants: { edges: Array<{ node: { id: string; price: string; }; }>; }; }; userErrors: Array<{field: string; message: string}>; }; }; }>({ url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, accessToken, query: graphqlQuery, variables }); const data = res.data.data; if (data.productUpdate.userErrors.length > 0) { return { success: false, errors: data.productUpdate.userErrors }; } return { success: true, product: data.productUpdate.product }; } async loadCustomers( accessToken: string, shop: string, limit?: number, next?: string ): Promise<LoadCustomersResponse> { const res = await this.shopifyHTTPRequest<{ customers: any[] }>({ method: "GET", url: `https://${shop}/admin/api/${this.SHOPIFY_API_VERSION}/customers.json`, accessToken, params: { limit: limit ?? 250, page_info: next, fields: ["id", "email", "tags"].join(","), }, }); const customers = res.data.customers; const nextPageInfo = ShopifyClient.getShopifyOrdersNextPage( res.headers.get("link") ); return { customers, next: nextPageInfo }; } async tagCustomer( accessToken: string, shop: string, tags: string[], externalCustomerId: string ): Promise<boolean> { const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); const graphqlQuery = gql` mutation tagsAdd($id: ID!, $tags: [String!]!) { tagsAdd(id: $id, tags: $tags) { userErrors { field message } node { id } } } `; const res = await this.shopifyGraphqlRequest<{ data: { tagsAdd: { userErrors: Array<{ field: string[]; message: string; }>; node: { id: string; }; }; }; }>({ url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, accessToken, query: graphqlQuery, variables: { id: `gid://shopify/Customer/${externalCustomerId}`, tags, }, }); const userErrors = res.data.data.tagsAdd.userErrors; if (userErrors.length > 0) { const errorMessages = userErrors.map((error) => error.message).join(", "); throw new Error(errorMessages); } return true; } async subscribeWebhook( accessToken: string, shop: string, callbackUrl: string, topic: ShopifyWebhookTopic ): Promise<ShopifyWebhook> { const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); const graphqlQuery = gql` mutation webhookSubscriptionCreate( $topic: WebhookSubscriptionTopic! $webhookSubscription: WebhookSubscriptionInput! ) { webhookSubscriptionCreate( topic: $topic webhookSubscription: $webhookSubscription ) { webhookSubscription { id topic endpoint { __typename ... on WebhookHttpEndpoint { callbackUrl } } } userErrors { field message } } } `; const res = await this.shopifyGraphqlRequest<{ data: { webhookSubscriptionCreate: { webhookSubscription: { id: string; topic: ShopifyWebhookTopicGraphql; endpoint: { callbackUrl: string; }; }; userErrors: Array<{ field: string[]; message: string; }>; }; }; }>({ url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, accessToken, query: graphqlQuery, variables: { topic: this.mapTopicToGraphqlTopic(topic), webhookSubscription: { callbackUrl, }, }, }); const webhookSubscription = res.data.data.webhookSubscriptionCreate.webhookSubscription; const userErrors = res.data.data.webhookSubscriptionCreate.userErrors; if (userErrors.length > 0) { throw getGraphqlShopifyUserError(userErrors, { shop, topic, callbackUrl: callbackUrl, }); } return { id: webhookSubscription.id, topic: this.mapGraphqlTopicToTopic(webhookSubscription.topic), callbackUrl: webhookSubscription.endpoint.callbackUrl, }; } async findWebhookByTopicAndCallbackUrl( accessToken: string, shop: string, callbackUrl: string, topic: ShopifyWebhookTopic ): Promise<ShopifyWebhook | null> { const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); const graphqlQuery = gql` query webhookSubscriptions( $topics: [WebhookSubscriptionTopic!] $callbackUrl: URL! ) { webhookSubscriptions( first: 10 topics: $topics callbackUrl: $callbackUrl ) { edges { node { id topic endpoint { __typename ... on WebhookHttpEndpoint { callbackUrl } } } } } } `; const res = await this.shopifyGraphqlRequest<{ data: { webhookSubscriptions: { edges: { node: { id: string; topic: ShopifyWebhookTopicGraphql; endpoint: { callbackUrl: string; }; }; }[]; }; }; }>({ url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, accessToken, query: graphqlQuery, variables: { topics: [this.mapTopicToGraphqlTopic(topic)], callbackUrl, }, }); const webhookSubscriptions = res.data.data.webhookSubscriptions.edges; if (webhookSubscriptions.length === 0) { return null; } const webhookSubscription = webhookSubscriptions[0].node; return { id: webhookSubscription.id, topic: this.mapGraphqlTopicToTopic(webhookSubscription.topic), callbackUrl: webhookSubscription.endpoint.callbackUrl, }; } async unsubscribeWebhook( accessToken: string, shop: string, webhookId: string ): Promise<void> { const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); const graphqlQuery = gql` mutation webhookSubscriptionDelete($id: ID!) { webhookSubscriptionDelete(id: $id) { userErrors { field message } deletedWebhookSubscriptionId } } `; const res = await this.shopifyGraphqlRequest<{ data: { webhookSubscriptionDelete: { deletedWebhookSubscriptionId: string; userErrors: Array<{ field: string[]; message: string; }>; }; }; }>({ url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, accessToken, query: graphqlQuery, variables: { id: webhookId, }, }); const userErrors = res.data.data.webhookSubscriptionDelete.userErrors; if (userErrors.length > 0) { throw getGraphqlShopifyUserError(userErrors, { shop, webhookId, }); } } private getOrdersFields(fields?: string[]): string { const defaultFields = [ "id", "order_number", "total_price", "discount_codes", "currency", "financial_status", "total_shipping_price_set", "created_at", "customer", "email", ]; if (!fields) return defaultFields.join(","); return [...defaultFields, ...fields].join(","); } private getIds(ids?: string[]): string | undefined { if (!ids) return; return ids.join(","); } public getIdFromGid(gid: string): string { const id = gid.split("/").pop(); if (!id) { throw new Error("Invalid GID"); } return id; } async getPriceRule( accessToken: string, shop: string, priceRuleInput: GetPriceRuleInput ): Promise<GetPriceRuleResponse> { const myShopifyDomain = await this.getMyShopifyDomain(accessToken, shop); const graphqlQuery = gql` query priceRules(first:250,$query: String) { priceRules(query: $query) { nodes { id title status } } } `; const res = await this.shopifyGraphqlRequest<{ data: GetPriceRuleResponse; }>({ url: `https://${myShopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, accessToken, query: graphqlQuery, variables: priceRuleInput, }); return res.data.data; } private mapGraphqlTopicToTopic( topic: ShopifyWebhookTopicGraphql ): ShopifyWebhookTopic { switch (topic) { case ShopifyWebhookTopicGraphql.ORDERS_UPDATED: return ShopifyWebhookTopic.ORDERS_UPDATED; } } private mapTopicToGraphqlTopic( topic: ShopifyWebhookTopic ): ShopifyWebhookTopicGraphql { switch (topic) { case ShopifyWebhookTopic.ORDERS_UPDATED: return ShopifyWebhookTopicGraphql.ORDERS_UPDATED; } } } ```