# Shopify > Forward paid Shopify orders automatically — orders/paid webhook, HMAC verification, SKU mapping, POST /orders — and push tracking back to Shopify. Source: https://swaglists.com/docs/shopify This recipe connects a Shopify store with a small server you run (any Node.js host). When a customer pays, Shopify calls your server; your server forwards the order to us; when we ship, we call your server and it marks the order fulfilled in Shopify with tracking. ```text Shopify ── orders/paid webhook ──► your server ── POST /orders ─────────► Swaglists Shopify ◄── fulfillmentCreate ──── your server ◄── shipment.created webhook ──┘ ``` ## Before you start 1. **Products in Shopify with our SKUs.** Export your products as a Shopify CSV (**Products → Export → Shopify CSV** in our dashboard) and import it in Shopify — the variant SKUs are ours. Already have Shopify products? Set their variant SKUs to your SKUs here (see [Products & SKUs](https://swaglists.com/docs/products.md#skus)). Line items whose SKU is not one of your products are skipped by the code below. 2. **A Shopify app** for your store. Create it in the Shopify **Dev Dashboard** (or with the Shopify CLI), install it on your store, and give it these access scopes: `read_orders`, `read_merchant_managed_fulfillment_orders`, `write_merchant_managed_fulfillment_orders`. Note its **client id** and **client secret** — the secret also signs Shopify's webhooks. 3. **Our keys and webhook endpoint.** A live key (`sk_live_…`), a test key for Shopify test orders (`sk_test_…`), and a webhook endpoint pointing at your server's `/webhooks/print` URL subscribed to `shipment.created` (create one in each mode; the secrets differ). ## Subscribe to Shopify webhooks In your app's `shopify.app.toml`, then `shopify app deploy`: ```toml title="shopify.app.toml" [webhooks] api_version = "2026-10" [[webhooks.subscriptions]] topics = ["orders/paid", "orders/cancelled"] uri = "https://your-server.example.com/webhooks/shopify" ``` ## The server One file, Node.js 20+ (`npm install express standardwebhooks`): ```js title="server.mjs" import crypto from "node:crypto"; import express from "express"; import { Webhook } from "standardwebhooks"; const { SHOPIFY_SHOP, // "your-store.myshopify.com" SHOPIFY_CLIENT_ID, SHOPIFY_CLIENT_SECRET, // also the HMAC key of Shopify webhooks PRINT_LIVE_KEY, // sk_live_… PRINT_TEST_KEY, // sk_test_… (Shopify test orders go to test mode) PRINT_LIVE_WEBHOOK_SECRET, // whsec_… of your live endpoint PRINT_TEST_WEBHOOK_SECRET, // whsec_… of your test endpoint } = process.env; const API = "https://swaglists.com/api/v1"; const SHIPPING = { Standard: "standard", Express: "express", Priority: "priority", Economy: "economy" }; // your Shopify rate names → our methods const CARRIERS = { usps: "USPS", ups: "UPS", fedex: "FedEx", dhl: "DHL Express", canada_post: "Canada Post", royal_mail: "Royal Mail", australia_post: "Australia Post" }; const app = express(); /* ---------- helpers ---------- */ // Shopify ids exceed JavaScript's safe integers: always take them from the string GraphQL id. const gidTail = (gid) => String(gid).split("/").pop(); function shopifyHmacValid(rawBody, header) { if (!header) return false; const expected = crypto.createHmac("sha256", SHOPIFY_CLIENT_SECRET).update(rawBody).digest(); const received = Buffer.from(header, "base64"); return received.length === expected.length && crypto.timingSafeEqual(received, expected); } async function printApi(key, method, path, body, idempotencyKey) { const res = await fetch(API + path, { method, headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}), }, body: body === undefined ? undefined : JSON.stringify(body), }); const json = await res.json(); if (!res.ok) throw Object.assign(new Error(json.error.message), { status: res.status, error: json.error }); return json; } let token = { value: null, expiresAt: 0 }; async function shopifyToken() { // Client credentials grant (app and store in the same Shopify organization); tokens last 24 hours. if (token.value && Date.now() < token.expiresAt) return token.value; const res = await fetch(`https://${SHOPIFY_SHOP}/admin/oauth/access_token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "client_credentials", client_id: SHOPIFY_CLIENT_ID, client_secret: SHOPIFY_CLIENT_SECRET }), }); const json = await res.json(); token = { value: json.access_token, expiresAt: Date.now() + (json.expires_in - 300) * 1000 }; return token.value; } async function shopifyGraphql(query, variables) { const res = await fetch(`https://${SHOPIFY_SHOP}/admin/api/2026-10/graphql.json`, { method: "POST", headers: { "X-Shopify-Access-Token": await shopifyToken(), "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }), }); const json = await res.json(); if (json.errors) throw new Error(JSON.stringify(json.errors)); return json.data; } /* ---------- 1. Shopify → us: forward paid orders, cancel canceled ones ---------- */ app.post("/webhooks/shopify", express.raw({ type: "*/*" }), (req, res) => { if (!shopifyHmacValid(req.body, req.get("x-shopify-hmac-sha256"))) return res.sendStatus(401); res.sendStatus(200); // Shopify waits at most 5 s — acknowledge, then work (use a real queue in production) const topic = req.get("x-shopify-topic"); const order = JSON.parse(req.body.toString("utf8")); handleShopifyOrder(topic, order).catch((err) => console.error(`Shopify ${topic} ${gidTail(order.admin_graphql_api_id)} failed:`, err.error ?? err)); }); async function handleShopifyOrder(topic, order) { const orderId = gidTail(order.admin_graphql_api_id); const key = order.test ? PRINT_TEST_KEY : PRINT_LIVE_KEY; const externalId = `shopify:${orderId}`; if (topic === "orders/cancelled") { try { await printApi(key, "POST", `/orders/ext:${externalId}/cancel`, { reason: order.cancel_reason || "Canceled in Shopify" }, `cancel-${externalId}`); } catch (err) { if (err.status !== 404) throw err; // 404: never forwarded, nothing to cancel } return; } if (topic !== "orders/paid" || order.cancelled_at) return; const address = order.shipping_address; // null for orders without shipping let items = order.line_items .filter((li) => li.requires_shipping && li.sku && li.fulfillable_quantity > 0) .map((li) => ({ sku: li.sku, quantity: li.fulfillable_quantity, external_line_id: gidTail(li.admin_graphql_api_id) })); if (!address) return; const body = (lineItems) => ({ external_id: externalId, recipient: { name: address.name || `${address.first_name ?? ""} ${address.last_name ?? ""}`.trim(), company: address.company || null, address1: address.address1, address2: address.address2 || null, city: address.city, state_code: address.province_code || null, postal_code: address.zip || null, country_code: address.country_code, phone: address.phone || order.phone || null, email: order.email || order.contact_email || null, }, shipping_method: SHIPPING[order.shipping_lines?.[0]?.title] ?? "standard", items: lineItems, packing_slip: { customer_order_number: order.name }, // "#1001" metadata: { source: "shopify", shopify_order_id: orderId }, }); // Line items that are not our products (other SKUs) are dropped and the order is retried without them. for (let attempt = 1; items.length > 0; attempt++) { try { const created = await printApi(key, "POST", "/orders", body(items), `shopify-order-${orderId}-${attempt}`); console.log(`Shopify order ${order.name} → ${created.id} (${created.status})`); return; } catch (err) { const e = err.error ?? {}; if (e.code === "duplicate_external_id") return; // already forwarded const unknown = e.code === "resource_missing" && /^items\[(\d+)\]\.sku$/.exec(e.param ?? ""); if (!unknown) throw err; items = items.filter((_, i) => i !== Number(unknown[1])); } } } /* ---------- 2. Us → Shopify: push tracking when a parcel ships ---------- */ const verifiers = { live: new Webhook(PRINT_LIVE_WEBHOOK_SECRET), test: new Webhook(PRINT_TEST_WEBHOOK_SECRET) }; app.post("/webhooks/print", express.raw({ type: "*/*" }), (req, res) => { const raw = req.body.toString("utf8"); let event; for (const wh of Object.values(verifiers)) { try { event = wh.verify(raw, req.headers); break; } catch { /* try the other mode's secret */ } } if (!event) return res.sendStatus(400); res.sendStatus(204); if (event.type === "shipment.created") { pushTracking(event.data.object, event.livemode).catch((err) => console.error(`tracking for ${event.data.object.id} failed:`, err)); } }); async function pushTracking(shipment, livemode) { const order = await printApi(livemode ? PRINT_LIVE_KEY : PRINT_TEST_KEY, "GET", `/orders/${shipment.order_id}`); if (!order.external_id?.startsWith("shopify:")) return; // Shopify line item gid → quantity in this parcel const parcel = new Map(); for (const part of shipment.items) { const item = order.items.find((i) => i.id === part.order_item_id); if (item?.external_line_id) parcel.set(`gid://shopify/LineItem/${item.external_line_id}`, part.quantity); } const data = await shopifyGraphql( `query ($id: ID!) { order(id: $id) { fulfillmentOrders(first: 20) { nodes { id status lineItems(first: 100) { nodes { id remainingQuantity lineItem { id } } } } } } }`, { id: `gid://shopify/Order/${order.external_id.slice("shopify:".length)}` }, ); // One call per location: if your store fulfills from several locations, group by location first. const lineItemsByFulfillmentOrder = []; for (const fo of data.order.fulfillmentOrders.nodes) { if (fo.status !== "OPEN" && fo.status !== "IN_PROGRESS") continue; const fulfillmentOrderLineItems = fo.lineItems.nodes .filter((li) => parcel.has(li.lineItem.id) && li.remainingQuantity > 0) .map((li) => ({ id: li.id, quantity: Math.min(li.remainingQuantity, parcel.get(li.lineItem.id)) })); if (fulfillmentOrderLineItems.length) lineItemsByFulfillmentOrder.push({ fulfillmentOrderId: fo.id, fulfillmentOrderLineItems }); } if (!lineItemsByFulfillmentOrder.length) return; // already fulfilled (duplicate delivery) const result = await shopifyGraphql( `mutation ($f: FulfillmentInput!) { fulfillmentCreate(fulfillment: $f) { fulfillment { id status } userErrors { field message } } }`, { f: { notifyCustomer: true, // Shopify emails the customer; turn off our shipment emails in that case trackingInfo: { company: CARRIERS[shipment.carrier] ?? shipment.carrier, number: shipment.tracking_number, url: shipment.tracking_url }, lineItemsByFulfillmentOrder, }, }, ); if (result.fulfillmentCreate.userErrors.length) throw new Error(JSON.stringify(result.fulfillmentCreate.userErrors)); } app.listen(process.env.PORT ?? 3000); ``` ## Field mapping | Our field | Shopify `orders/paid` payload | | --- | --- | | `external_id` | `shopify:` + the numeric tail of `admin_graphql_api_id` | | `recipient.name` | `shipping_address.name` (or `first_name` + `last_name`) | | `recipient.company`, `address1`, `address2`, `city` | `shipping_address.company`, `address1`, `address2`, `city` | | `recipient.state_code` | `shipping_address.province_code` | | `recipient.postal_code` | `shipping_address.zip` | | `recipient.country_code` | `shipping_address.country_code` | | `recipient.phone` | `shipping_address.phone` or `phone` | | `recipient.email` | `email` or `contact_email` | | `items[].sku` | `line_items[].sku` | | `items[].quantity` | `line_items[].fulfillable_quantity` (reflects edits and removals) | | `items[].external_line_id` | numeric tail of `line_items[].admin_graphql_api_id` | | `shipping_method` | map `shipping_lines[0].title` (your rate names) | | `packing_slip.customer_order_number` | `name` (e.g. `#1001`) | ## Things to know - **Respond fast.** Shopify gives your endpoint 5 seconds and retries 8 times over 4 hours. Acknowledge, then process — in production through a queue (BullMQ, SQS, a database table) so a crash never loses an order. Deduplicate on the `X-Shopify-Webhook-Id` header. - **Duplicates are harmless.** `external_id` (`shopify:`) is unique, so a second delivery returns `409 duplicate_external_id`, which the code treats as done. - **Test orders.** Orders paid with Shopify's test gateway have `test: true`; the code sends them to test mode. - **Customer data.** If `shipping_address` arrives without a name or address, your app lacks access to protected customer data — enable it for your app in the Dev Dashboard. - **Personalization.** Customer inputs from product page fields arrive in `line_items[].properties`; render the artwork from them and send the item with [inline designs](https://swaglists.com/docs/personalized-orders.md) instead of a SKU. - **Shipping emails.** With `notifyCustomer: true` Shopify sends the shipping confirmation; turn off **Email customers when their order ships** in our Settings → Orders to avoid two emails. - **Order edits** after payment are not forwarded by this code; edit or cancel the order here (before production) if needed.