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
- 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. - Pick the Admin API access scopes your integration actually needs (for example
read_products, write_orders). Stay minimal — you can always grant more later. - 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
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
# .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
// 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
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:
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:
// 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.