# Integrate Shopify with DaloyJS

[Shopify](https://www.shopify.com/) is the commerce platform behind millions of stores. This guide uses the community [`shopify-api-node`](https://github.com/MONEI/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](https://shopify.dev/docs/api/admin-rest) and requires new public apps to use the [GraphQL Admin API](https://shopify.dev/docs/api/admin-graphql). 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](https://shopify.dev/docs/api/usage/versioning)). 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](https://shopify.dev/docs/api/usage/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.ts";

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.get(
  "/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(),
        }),
      },
    },
  },
  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 REST `nextPageParameters` helper. Forward the cursor as a query string to your client.

## 6. Receive and verify webhooks

**Diagram: Webhook verification**

Participants: Shopify, DaloyJS route, Your worker

1. **Shopify -> DaloyJS route** (request) - POST /webhooks/shopify - X-Shopify-Hmac-Sha256 over raw body
2. **DaloyJS route -> DaloyJS route** (note) - Compare HMAC-SHA256 with timingSafeEqual - verifyWebhook(headers, rawBody)
3. **DaloyJS route -> Shopify** (response) - 401 when the signature does not match - { error: 'invalid signature' }
4. **DaloyJS route -> Your worker** (async) - Dedupe on X-Shopify-Webhook-Id, then enqueue - ack 200 within ~5s

Hash the raw bytes before JSON.parse, reject bad signatures with 401, dedupe on the webhook id, then ack fast and do the heavy work in a background job.

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 [bounded raw-body helper](/docs/api-reference/security) to get the bytes:

```ts
import { z } from "zod";
import { readBodyLimited } from "@daloyjs/core";

app.post(
  "/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() }) },
    },
  },
  async ({ request, state }) => {
    // Keep webhook reads bounded and preserve the exact bytes Shopify signed.
    const raw = Buffer.from(await readBodyLimited(request, 1_048_576));
    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](https://shopify.dev/docs/api/usage/rate-limits). 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](https://github.com/sindresorhus/got), 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](/docs/adapters). 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`](https://github.com/Shopify/shopify-api-js) 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-node` when you're building a server-side integration for a single store and want a smaller surface.

See also the [payments overview](/docs/payments), [problem+json errors](/docs/errors), and [distributed rate-limit store](/docs/security/rate-limit-redis).

---

Source: https://daloyjs.dev/docs/payments/shopify