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
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.
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.tsimport { 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; };}// Call with the root app, not through app.register(): a decoration made// inside a registered plugin is visible only to that plugin's own routes.export function shopifyPlugin(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; }}
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
Webhook verification
ShopifyDaloyJS routeYour worker
01requestShopifyDaloyJS routePOST /webhooks/shopifyX-Shopify-Hmac-Sha256 over raw body
02noteDaloyJS routeDaloyJS routeCompare HMAC-SHA256 with timingSafeEqualverifyWebhook(headers, rawBody)
03responseDaloyJS routeShopify401 when the signature does not match{ error: 'invalid signature' }
04asyncDaloyJS routeYour workerDedupe on X-Shopify-Webhook-Id, then enqueueack 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 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. 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. On those runtimes, call the Admin GraphQL endpoint directly with fetch:
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-node when you're building a server-side integration for a single store and want a smaller surface.