Skip to content

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

  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). 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:

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-Id header.
  • Duplicates are harmless. external_id (shopify:<id>) 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 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.