Search docs

Jump between documentation pages.

Integrate Shopify with DaloyJS

Shopify is the commerce platform behind millions of stores. This guide uses the community shopify-api-node SDK (maintained by MONEI) to call the Shopify Admin API from a DaloyJS plugin, and shows how to verify Shopify webhooks against the raw request body.

REST is deprecated — prefer GraphQL

Shopify froze the REST Admin API at version 2024-04 and requires new public apps to use the GraphQL Admin API. The REST resources on shopify-api-node (shopify.product.list, shopify.order.create, …) still work against older API versions, but you should write new code against the SDK's shopify.graphql() method. Every example below uses GraphQL.

Pin your apiVersion to a current stable release (Shopify ships a new version every quarter, supports each for 12 months, and lists them on the versioning page). The default in shopify-api-node is the oldest supported stable version, which is usually not what you want.

1. Provision a custom app

  1. In the Shopify admin, open Settings → Apps and sales channels → Develop apps, then create a custom app. The legacy “private apps” flow (API key + password) was removed back in January 2022 — don't use the apiKey / password options on the SDK.
  2. Pick the Admin API access scopes your integration actually needs (for example read_products, write_orders). Stay minimal — you can always grant more later.
  3. Install the app on the store. Copy the Admin API access token (starts with shpat_). This is the only credential the SDK needs.

2. Install

ts
pnpm add shopify-api-node

TypeScript users: the package ships its own typings (under types/ in the repo). No @types/shopify-api-node install needed.

3. Environment variables

ts
# .env
SHOPIFY_SHOP=acme-test.myshopify.com
SHOPIFY_ACCESS_TOKEN=shpat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
SHOPIFY_API_VERSION=2026-01
SHOPIFY_WEBHOOK_SECRET=shpss_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

The webhook secret is shown when you create a webhook subscription (either in the admin or via GraphQL webhookSubscriptionCreate). Update SHOPIFY_API_VERSION to the latest stable on each Shopify release.

4. Plugin

ts
// src/plugins/shopify.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import Shopify from "shopify-api-node";
import type { App } from "@daloyjs/core";

const shopify = new Shopify({
  shopName: process.env.SHOPIFY_SHOP!,
  accessToken: process.env.SHOPIFY_ACCESS_TOKEN!,
  apiVersion: process.env.SHOPIFY_API_VERSION ?? "2026-01",
  // Retry 429s and respect Shopify's GraphQL throttled-cost data.
  // Mutually exclusive with autoLimit — pick one.
  maxRetries: 5,
  timeout: 30_000,
});

// Optional: surface throttle pressure to your metrics layer.
shopify.on("callGraphqlLimits", (limits) => {
  // { actualQueryCost, requestedQueryCost, throttleStatus: { ... } }
  if (limits.throttleStatus.currentlyAvailable < 100) {
    console.warn("[shopify] graphql credits low", limits.throttleStatus);
  }
});

function verifyShopifyHmac(rawBody: Buffer, headerHmac: string | null) {
  if (!headerHmac) return false;
  const digest = createHmac("sha256", process.env.SHOPIFY_WEBHOOK_SECRET!)
    .update(rawBody)
    .digest("base64");
  const a = Buffer.from(digest, "utf8");
  const b = Buffer.from(headerHmac, "utf8");
  return a.length === b.length && timingSafeEqual(a, b);
}

export interface ShopifyClient {
  graphql<TData = unknown, TVars extends Record<string, unknown> = Record<string, unknown>>(
    query: string,
    variables?: TVars,
  ): Promise<TData>;
  verifyWebhook(headers: Headers, rawBody: Buffer): {
    ok: boolean;
    topic: string | null;
    shop: string | null;
    eventId: string | null;
  };
}

export const shopifyPlugin = {
  name: "shopify",
  register(app: App) {
    const client: ShopifyClient = {
      graphql: (query, variables) => shopify.graphql(query, variables) as Promise<never>,
      verifyWebhook(headers, rawBody) {
        return {
          ok: verifyShopifyHmac(rawBody, headers.get("x-shopify-hmac-sha256")),
          topic: headers.get("x-shopify-topic"),
          shop: headers.get("x-shopify-shop-domain"),
          eventId: headers.get("x-shopify-webhook-id"),
        };
      },
    };
    app.decorate("shopify", client);
  },
};

declare module "@daloyjs/core" {
  interface AppState {
    shopify: ShopifyClient;
  }
}

5. Query products with GraphQL

ts
import { z } from "zod";
import { App, secureHeaders } from "@daloyjs/core";
import { shopifyPlugin } from "./plugins/shopify";

const app = new App();
app.use(secureHeaders());
app.register(shopifyPlugin);

const ProductsQuery = /* GraphQL */ `
  query Products($first: Int!, $after: String) {
    products(first: $first, after: $after) {
      pageInfo { hasNextPage endCursor }
      edges {
        node {
          id
          title
          handle
          status
          priceRangeV2 {
            minVariantPrice { amount currencyCode }
          }
        }
      }
    }
  }
`;

app.route({
  method: "GET",
  path: "/catalog/products",
  operationId: "listShopifyProducts",
  request: {
    query: z.object({
      first: z.coerce.number().int().min(1).max(50).default(20),
      after: z.string().optional(),
    }),
  },
  responses: {
    200: {
      description: "Page of products",
      body: z.object({
        products: z.array(z.object({
          id: z.string(),
          title: z.string(),
          handle: z.string(),
          status: z.string(),
          price: z.object({ amount: z.string(), currency: z.string() }),
        })),
        nextCursor: z.string().nullable(),
      }),
    },
  },
  handler: async ({ query, state }) => {
    const data = await state.shopify.graphql<{
      products: {
        pageInfo: { hasNextPage: boolean; endCursor: string | null };
        edges: Array<{ node: {
          id: string; title: string; handle: string; status: string;
          priceRangeV2: { minVariantPrice: { amount: string; currencyCode: string } };
        } }>;
      };
    }>(ProductsQuery, { first: query.first, after: query.after ?? null });

    return {
      status: 200,
      body: {
        products: data.products.edges.map((e) => ({
          id: e.node.id,
          title: e.node.title,
          handle: e.node.handle,
          status: e.node.status,
          price: {
            amount: e.node.priceRangeV2.minVariantPrice.amount,
            currency: e.node.priceRangeV2.minVariantPrice.currencyCode,
          },
        })),
        nextCursor: data.products.pageInfo.hasNextPage ? data.products.pageInfo.endCursor : null,
      },
    };
  },
});

GraphQL pagination uses opaque cursors (endCursor) rather than the RESTnextPageParameters helper. Forward the cursor as a query string to your client.

6. Receive and verify webhooks

Shopify signs every webhook with X-Shopify-Hmac-Sha256 over the raw body. Skip JSON parsing until the signature matches, and dedupe on X-Shopify-Webhook-Id so retries don't double-process. Use the raw-body helper to get the bytes:

ts
import { z } from "zod";
import { readRawBody } from "@daloyjs/core/raw";

app.route({
  method: "POST",
  path: "/webhooks/shopify",
  operationId: "shopifyWebhook",
  // No body schema — we hash bytes before parsing.
  responses: {
    200: { description: "ack", body: z.object({ ok: z.literal(true) }) },
    401: { description: "bad signature", body: z.object({ error: z.string() }) },
  },
  handler: async ({ request, state }) => {
    const raw = await readRawBody(request);
    const result = state.shopify.verifyWebhook(request.headers, raw);
    if (!result.ok) {
      return { status: 401, body: { error: "invalid signature" } };
    }
    // Dedupe before any side effect.
    if (result.eventId && (await seen(result.eventId))) {
      return { status: 200, body: { ok: true as const } };
    }

    const payload = JSON.parse(raw.toString("utf8"));
    switch (result.topic) {
      case "orders/create":
        await onOrderCreated(payload);
        break;
      case "orders/paid":
        await onOrderPaid(payload);
        break;
      case "app/uninstalled":
        await onAppUninstalled(result.shop, payload);
        break;
      // ...
    }

    return { status: 200, body: { ok: true as const } };
  },
});

Shopify expects a 2xx within ~5 seconds, retries with exponential back-off for up to 48 hours, and disables the subscription after 19 consecutive failures. Do the heavy work in a background job and ack fast.

Rate limits

The GraphQL Admin API uses a calculated cost & leaky-bucket model. Use the maxRetries option (above) so the SDK respects the throttled-cost information that comes back on 429 responses. autoLimit only works for the REST API and only inside a single Node process — skip it for GraphQL or multi-instance deployments and rely on retries instead.

Runtimes

shopify-api-node is built on got v11, which depends on Node's HTTPS module. It runs fine on Node, Bun, AWS Lambda, and any long-running container, but it is not drop-in compatible with Cloudflare Workers or Vercel Edge. On those runtimes, call the Admin GraphQL endpoint directly with fetch:

ts
// edge-friendly fallback
const res = await fetch(
  `https://${shop}/admin/api/${apiVersion}/graphql.json`,
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Shopify-Access-Token": accessToken,
    },
    body: JSON.stringify({ query, variables }),
  },
);

Alternatives

Shopify also publishes the official @shopify/shopify-api library, which adds OAuth for public/embedded apps and a built-in webhook registry. Reach for it when you're building a Shopify App Store listing; reach for shopify-api-nodewhen you're building a server-side integration for a single store and want a smaller surface.

See also the payments overview, problem+json errors, and distributed rate-limit store.