Shopify
Forward paid Shopify orders automatically — orders/paid webhook, HMAC verification, SKU mapping, POST /orders — and push tracking back to 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
- 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). Line items whose SKU is not one of your products are skipped by the code below.
- 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. - 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/printURL subscribed toshipment.created(create one in each mode; the secrets differ).
Subscribe to Shopify webhooks
In your app's shopify.app.toml, then shopify app deploy:
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):
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-Idheader. - Duplicates are harmless.
external_id(shopify:<id>) is unique, so a second delivery returns409 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_addressarrives 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 instead of a SKU. - Shipping emails. With
notifyCustomer: trueShopify 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.