How do I build a Shopify order-tracking page without installing a tracking app?
Use your own custom app's app proxy so a page at your-store.com/apps/track is served by your server: it checks Shopify's signature, looks up the order with the Admin API, and calls the lastscanned API for the tracking numbers. Nothing runs in the theme and your API key never reaches the browser. For alerts, a Shopify Flow workflow watches each new fulfillment with POST /v1/watches, lastscanned sends signed webhooks when a parcel changes, and an hourly job tags orders with no carrier scan in 5 days so Flow can email your team.
- 1. Shopper opens your-store.com/apps/track. Shopify forwards the signed request to your server.
- 2. Your server finds the order with the Admin API and tracks its numbers with GET /v1/track.
- 3. On each fulfillment, Flow (or your server) creates a lastscanned watch with the order id as metadata.
- 4. lastscanned sends signed webhooks on changes; an hourly job tags orders with no scan in 5 days.
- 5. A scheduled Flow workflow finds the tagged orders and alerts your team.
1. Create a custom app with an app proxy
An app proxy forwards requests for a path on your store, such as /apps/track, to a URL you control. Shopify adds shop, logged_in_customer_id, path_prefix, timestamp, and signature to the query string, and if your server answers with Content-Type: application/liquid, Shopify renders the reply inside your theme. This is your own custom app for your own store, made with Shopify CLI, not a tracking app from the App Store.
The app needs the write_app_proxy scope, read_orders to look orders up (read_all_orders for orders older than 60 days), and write_orders if you want the alert tags in step 4. An app can have one proxy. Shopify strips cookies from proxied requests, so identify shoppers by the signed logged_in_customer_id or by order number plus email.
# shopify.app.toml — your own custom app for your own store (created with Shopify CLI)
[access_scopes]
scopes = "read_orders,write_orders,write_app_proxy"
[app_proxy]
url = "https://track.example.com/proxy"
prefix = "apps"
subpath = "track"
# Shoppers open https://your-store.com/apps/track?order=1001&email=…
# Shopify forwards it, signed, to https://track.example.com/proxy2. Serve the tracking page from your server
The handler verifies the signature first: an HMAC-SHA256 of the other query parameters as sorted key=value pairs, joined with no separator, keyed with your app's client secret. Then it confirms the shopper owns the order, using logged_in_customer_id when they are signed in or the order's email when they are not, and sends every tracking number on the order to GET /v1/track?numbers= in one call.
Each result has carrier, status (label_created, in_transit, out_for_delivery, delivered, exception, returned, or unknown), statusDescription, estimatedDelivery, and events newest first, each with a UTC timestamp and the carrier's localTime. A number the carrier has no record of yet comes back with an error instead.
// server.js — Node 20+, Express. Runs on your server, not in the theme.
import crypto from "node:crypto";
import express from "express";
const app = express();
const { SHOPIFY_API_SECRET, SHOPIFY_ADMIN_TOKEN, LASTSCANNED_KEY } = process.env;
// 1. Shopify signs every app proxy request with your app's client secret.
function validProxySignature(query) {
const { signature, ...params } = query;
if (typeof signature !== "string") return false;
const message = Object.keys(params)
.map((key) => `${key}=${[].concat(params[key]).join(",")}`)
.sort()
.join("");
const digest = crypto.createHmac("sha256", SHOPIFY_API_SECRET).update(message).digest("hex");
return digest.length === signature.length &&
crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature));
}
// 2. Find the order with the Admin GraphQL API, and only show it to its owner.
async function findOrder(shop, name, email, customerId) {
const res = await fetch(`https://${shop}/admin/api/2026-10/graphql.json`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Shopify-Access-Token": SHOPIFY_ADMIN_TOKEN },
body: JSON.stringify({
query: `query ($q: String!) {
orders(first: 1, query: $q) {
nodes { id name email customer { id } fulfillments { trackingInfo { number company } } }
}
}`,
variables: { q: `name:${name}` },
}),
});
const order = (await res.json()).data?.orders?.nodes?.[0];
if (!order) return null;
const ownsIt = customerId
? order.customer?.id === `gid://shopify/Customer/${customerId}`
: !!email && order.email?.toLowerCase() === email.toLowerCase();
return ownsIt ? order : null;
}
// 3. Track every number on the order in one lastscanned call (up to 100).
async function track(numbers) {
const res = await fetch(
`https://api.lastscanned.com/v1/track?numbers=${numbers.map(encodeURIComponent).join(",")}`,
{ headers: { Authorization: `Bearer ${LASTSCANNED_KEY}` } },
);
return (await res.json()).results ?? [];
}
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
app.get("/proxy", async (req, res) => {
if (!validProxySignature(req.query)) return res.status(401).send("Bad signature");
const { shop, logged_in_customer_id, order: orderNo, email } = req.query;
const order = orderNo && (await findOrder(shop, `#${String(orderNo).replace(/^#/, "")}`, email, logged_in_customer_id));
// application/liquid makes Shopify render this inside your theme's layout.
res.set("Content-Type", "application/liquid");
if (!order) return res.send(`<h1>Track your order</h1>
<form method="get"><input name="order" placeholder="Order number">
<input name="email" type="email" placeholder="Email"><button>Track</button></form>`);
const numbers = order.fulfillments.flatMap((f) => f.trackingInfo.map((t) => t.number)).filter(Boolean);
const results = numbers.length ? await track(numbers) : [];
res.send(`<h1>Order ${esc(order.name)}</h1>` + results.map((r) => r.error
? `<p>${esc(r.trackingNumber)}: no tracking yet</p>`
: `<h2>${esc(r.carrier)} ${esc(r.trackingNumber)}: ${esc(r.statusDescription || r.status)}</h2>
<ul>${r.events.map((e) => `<li>${esc(e.localTime)} · ${esc(e.description)} · ${esc(e.location)}</li>`).join("")}</ul>`).join(""));
});
app.listen(3000);3. Watch every fulfillment from Shopify Flow
A watch re-checks a number for you: every 15 minutes once it is out for delivery, 30 minutes on an exception, hourly in transit, and every 2 hours before the carrier has scanned it. It ends when the parcel is delivered or returned, after 14 days with no carrier record, or after 90 days. Creating a watch counts as one tracked number and the re-checks are free. Metadata (under 2 KB) comes back on every webhook, so store the Shopify order id there.
Flow's Send HTTP request action is available on Shopify's Grow, Advanced, and Plus plans. Save your lastscanned API key as a Flow secret so it is not visible in the workflow, and add a condition that the fulfillment has tracking info. If you watch a number that is already being watched, lastscanned keeps the existing watch, so Flow's retries do not create duplicates.
Trigger: Fulfillment created
Action: Send HTTP request
Method: POST
URL: https://api.lastscanned.com/v1/watches
Headers: Authorization: Bearer {{secrets.lastscanned_key}}
Content-Type: application/json
Body:
{
"trackingNumbers": [{% for tracking_info in fulfillment.trackingInfo %}"{{ tracking_info.number }}"{% unless forloop.last %},{% endunless %}{% endfor %}],
"metadata": { "orderId": "{{ fulfillment.order.id }}", "orderName": "{{ fulfillment.order.name }}" }
}Same calls from a terminal
Create a free key at app.lastscanned.com and try the watch call with your own numbers. These two are real UPS and FedEx numbers from lastscanned's test set. Set your webhook URL once; the reply includes the whsec_ secret used to sign deliveries.
# The same call Flow makes, from a terminal (one watch per number; re-checks are free)
curl -X POST https://api.lastscanned.com/v1/watches \
-H "Authorization: Bearer $LASTSCANNED_KEY" \
-H "Content-Type: application/json" \
-d '{"trackingNumbers":["1Z000Y770414233027","873970594430"],
"metadata":{"orderId":"gid://shopify/Order/1234567890","orderName":"#1001"}}'
# Point your lastscanned webhooks at your server (returns a whsec_ signing secret)
curl -X PUT https://api.lastscanned.com/v1/webhook \
-H "Authorization: Bearer $LASTSCANNED_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://track.example.com/lastscanned"}'Not on Grow, Advanced, or Plus?
Subscribe the same custom app to the fulfillments/create webhook and create the watch from your server instead. Verify Shopify's X-Shopify-Hmac-Sha256 header (a base64 HMAC-SHA256 of the raw body) before trusting the payload.
// Not on a plan with Flow's Send HTTP request? Subscribe your custom app to fulfillments/create
// (in shopify.app.toml: [[webhooks.subscriptions]] topics = ["fulfillments/create"], uri = "/shopify/fulfillments")
app.post("/shopify/fulfillments", express.raw({ type: "application/json" }), async (req, res) => {
const hmac = crypto.createHmac("sha256", SHOPIFY_API_SECRET).update(req.body).digest("base64");
const given = req.get("X-Shopify-Hmac-Sha256") ?? "";
if (hmac.length !== given.length || !crypto.timingSafeEqual(Buffer.from(hmac), Buffer.from(given))) return res.sendStatus(401);
res.sendStatus(200);
const f = JSON.parse(req.body.toString("utf8"));
if (!f.tracking_numbers?.length) return;
await fetch("https://api.lastscanned.com/v1/watches", {
method: "POST",
headers: { Authorization: `Bearer ${LASTSCANNED_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
trackingNumbers: f.tracking_numbers,
metadata: { orderId: `gid://shopify/Order/${f.order_id}`, orderName: f.name },
}),
});
});4. Receive signed webhooks and tag orders
lastscanned sends tracking.updated when a watched parcel's status, newest event, or estimate changes, and watch.ended when a watch stops. Each request carries Lastscanned-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "t.body">. Answer with any 2xx within 10 seconds; failed deliveries are retried after 1, 5, 30, 120, 360, and 720 minutes, so dedupe on the event id.
Tag the order through the Admin GraphQL tagsAdd mutation. A watch.ended event with reason not_found means the carrier never had a record in 14 days, which is the clearest "label printed, never shipped" signal.
// lastscanned webhooks: verify Lastscanned-Signature (t=<unix>,v1=<hex HMAC-SHA256 of "t.body">)
app.post("/lastscanned", express.raw({ type: "application/json" }), async (req, res) => {
const raw = req.body.toString("utf8");
const parts = Object.fromEntries((req.get("Lastscanned-Signature") ?? "").split(",").map((p) => p.split("=")));
const expected = crypto.createHmac("sha256", process.env.LASTSCANNED_WEBHOOK_SECRET).update(`${parts.t}.${raw}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
if (!fresh || parts.v1?.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))) return res.sendStatus(400);
res.sendStatus(200); // any 2xx within 10 seconds; failed deliveries are retried
const event = JSON.parse(raw); // dedupe on event.id (also sent as Lastscanned-Event-Id)
const { watch, result, reason } = event.data;
if (event.type === "tracking.updated" && result.status === "exception") {
await tagOrder(watch.metadata.orderId, ["tracking-exception"]);
}
if (event.type === "watch.ended" && reason === "not_found") {
await tagOrder(watch.metadata.orderId, ["no-scan-14d"]); // the carrier never had a record
}
});
async function tagOrder(orderId, tags) {
await fetch(`https://${process.env.SHOP}/admin/api/2026-10/graphql.json`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Shopify-Access-Token": SHOPIFY_ADMIN_TOKEN },
body: JSON.stringify({
query: `mutation ($id: ID!, $tags: [String!]!) { tagsAdd(id: $id, tags: $tags) { userErrors { message } } }`,
variables: { id: orderId, tags },
}),
});
}5. Find parcels with no scan in 5 days
Webhooks only fire when something changes, so a parcel stuck at a USPS facility sends nothing. Run a small job every hour that lists your active watches with GET /v1/watches and compares each one's newest event time with now. Pick the threshold that suits your carriers: 3 days for UPS and FedEx Ground, 5 for USPS Ground Advantage, more for cross-border mail.
// stale.js — run hourly (cron, a scheduled function, anything). Webhooks fire on change,
// so silence is something you check for: read every active watch and its latest result.
const DAYS = 5;
let cursor = "";
do {
const res = await fetch(`https://api.lastscanned.com/v1/watches?limit=200${cursor ? `&cursor=${cursor}` : ""}`, {
headers: { Authorization: `Bearer ${process.env.LASTSCANNED_KEY}` },
});
const page = await res.json();
for (const w of page.watches) {
const last = w.result?.events?.[0]?.timestamp ?? w.createdAt; // events are newest first, UTC
const days = (Date.now() - Date.parse(last)) / 86_400_000;
if (days >= DAYS && w.metadata?.orderId) {
await tagOrder(w.metadata.orderId, [`no-scan-${DAYS}d`]); // tagsAdd ignores tags already on the order
}
}
cursor = page.nextCursor;
} while (cursor);6. Alert from Flow
Flow has no trigger for tags being added, so poll for them: a Scheduled time trigger (as often as every 10 minutes) runs Get order data with a tag query, and a For each loop sends the alert and marks the order so it is sent once. Swap Send internal email for Slack, a Klaviyo event, or an order note. Get order data returns up to 100 orders per run.
Trigger: Scheduled time (every hour)
Action: Get order data
Query: tag:"no-scan-5d" AND NOT tag:"no-scan-5d-sent"
For each order:
Action: Send internal email
To: ops@your-store.com
Subject: No carrier scan in 5 days: {{ getOrderDataForeachitem.name }}
Action: Add order tags → no-scan-5d-sentWays to show tracking on Shopify
| Option | How it works | Price | Notes |
|---|---|---|---|
| Shopify's order status page | Built in. Shows the carrier and a link to the carrier's site. | Included with Shopify. | No scan history on your domain and no stuck-parcel alerts. |
| A tracking app from the App Store | Installs into your store and adds its own tracking page and emails. | Varies by app. | Most complete out of the box: branded pages, customer notifications, analytics. |
| Custom app proxy + lastscanned (this guide) | Your server answers /apps/track inside your theme; Flow or your server creates watches. | lastscanned Free: 5 numbers a day. Pro: $4.99/mo for 1,000 numbers, then $0.015 each. | You write and host about 150 lines of code; nothing is added to the theme. |
| Your own subdomain + lastscanned | Host the same page at track.your-store.com and link to it from your store's menu. | Same lastscanned pricing. | No app needed at all, but the page sits outside your theme. |
Start on the free plan (5 tracking numbers a day) while you build. When you go live, Pro is $4.99/mo for 1,000 numbers, with watch re-checks free. Reference: watches and webhooks, tracking API, Shopify and 3PL guide.
FAQ
- Is lastscanned a Shopify app?
- No. lastscanned is a tracking API. You call it from your own server or from Shopify Flow with your API key, so there is nothing to install from the Shopify App Store.
- Does this add JavaScript to my storefront?
- No. The app proxy page is HTML your server returns and Shopify renders as Liquid inside your theme. The lookups, the API key, and the webhooks all stay on your server and in Flow.
- Which carriers does it cover?
- USPS, UPS, FedEx, DHL, and the regional and cross-border carriers lastscanned supports, detected from the number. The same response shape comes back for every carrier, so the page and the stale check do not need per-carrier code.
- What does it cost for a store shipping 1,000 orders a month?
- Watching 1,000 numbers counts as 1,000 tracked numbers on Pro ($4.99/mo), and every re-check of those watches is free. Lookups of the same numbers from the tracking page in the same month are free too. Past 1,000 numbers it is $0.015 each.
- Can I do this without Shopify Plus?
- Yes. The app proxy works on any plan. Only Flow's Send HTTP request action needs Grow, Advanced, or Plus; on other plans, create watches from a fulfillments/create webhook as shown above.
- Why does a tracking number show no results right after fulfillment?
- Carriers often have no record until the parcel's first scan, so a fresh label can return not_found. A watch keeps checking for 14 days and sends watch.ended with reason not_found if the carrier never scans it.