How I Built a Free Package Tracking Notifier with Cloudflare Workers

working on the front end to build fantastic user interfaces and deliver a top notch user experience.
Search for a command to run...

working on the front end to build fantastic user interfaces and deliver a top notch user experience.
This is such a clever use of Cloudflare Workers and KV. I love how lightweight and efficient the setup is especially the part where it compares snapshots and only triggers IFTTT on changes. Perfect for tracking without constant refreshes. Thanks for sharing the full flow
A Simpler Way to Handle Themes

introduction ( part-1 )

What are the duotone icons ? We all know what a traditional single color web icon font is, but a duotone font is a completely different type of font that has two contrasting colors instead of one color. Fontawesome recently introduced their duotone i...

I recently ordered Starlink. It shipped via DHL, and then got forwarded to a local courier that had no push notifications. I didn’t want to keep refreshing their tracking page, so I built a free notifier with Cloudflare Workers, KV, and IFTTT. This post explains the approach and gives a step-by-step guide you can follow.
A Cloudflare Worker fetches the courier’s tracking API (JSON).
It compares the latest response with the last one stored in KV.
If there’s a change, it posts a webhook to IFTTT (which sends me a push/email).
A scheduler (cron) runs the Worker every 30 minutes.
export default {
async fetch(request, env, ctx) {
// respond immediately to avoid cron timeouts
ctx.waitUntil(handleTracking(env));
return new Response(JSON.stringify({ status: "Queued" }), {
headers: { "Content-Type": "application/json" },
});
},
};
async function handleTracking(env) {
const API_URL = env.TRACKING_API_URL; // store in wrangler.toml/env
const IFTTT_WEBHOOK_URL = env.IFTTT_WEBHOOK_URL; // stored as a secret
try {
// fetch with an explicit timeout
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
const response = await fetch(API_URL, { signal: controller.signal });
clearTimeout(timer);
if (!response.ok) throw new Error(`API error: ${response.status}`);
const current = await response.json();
// read previous snapshot
const previousRaw = await env.TRACKING_CACHE.get("previous_response");
const previous = previousRaw ? JSON.parse(previousRaw) : null;
// format timeline into readable text (limit to latest 5 entries to keep push short)
const timeline = Array.isArray(current.timeline) ? current.timeline : [];
const latest = timeline.slice(-5);
const timelineText = latest
.map(
(item, i) =>
`${i + 1}. [${item.date_time}] ${item.message} at ${item.location} (Status: ${item.status})`
)
.join("\n");
// only notify if changed (or if nothing stored yet)
const changed = !previous || JSON.stringify(previous) !== JSON.stringify(current);
if (changed) {
await fetch(IFTTT_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ update: timelineText }),
});
console.log("Change detected → IFTTT notified");
}
// persist latest snapshot
await env.TRACKING_CACHE.put("previous_response", JSON.stringify(current));
} catch (err) {
console.error("handleTracking error:", err);
}
}
A Cloudflare account (Workers + KV enabled on the free tier)
Node.js + npm installed locally
An IFTTT account with the Webhooks service enabled
A courier tracking API URL (replace with yours)
# install wrangler globally (or use npx wrangler)
npm i -g wrangler
# create a new worker
wrangler init tracking-notifier
cd tracking-notifier
Wrangler v3 syntax uses spaces, not colons.
wrangler kv namespace create TRACKING_CACHE
Copy the returned id. If you use environments (dev/prod), also create a preview namespace:
wrangler kv namespace create TRACKING_CACHE --preview
Then add to wrangler.toml:
name = "tracking-notifier"
main = "src/index.js"
compatibility_date = "2024-01-01"
kv_namespaces = [
{ binding = "TRACKING_CACHE", id = "YOUR_NAMESPACE_ID" }
]
# Optional: built-in Cloudflare cron (every 30 minutes)
triggers = { crons = ["*/30 * * * *"] }
[vars]
TRACKING_API_URL = "https://<>.lk:3001/api/tracking/<number>"
If you prefer environment sections:
[env.production]
kv_namespaces = [
{ binding = "TRACKING_CACHE", id = "PROD_NAMESPACE_ID" }
]
[env.preview]
kv_namespaces = [
{ binding = "TRACKING_CACHE", id = "PREVIEW_NAMESPACE_ID" }
]
Create the IFTTT webhook URL like:
https://maker.ifttt.com/trigger/ship/json/with/key/<REPLACE_WITH_YOUR_KEY>
Save it as a Worker secret:
wrangler secret put IFTTT_WEBHOOK_URL
# paste your full webhook URL when prompted
Now the code can read env.IFTTT_WEBHOOK_URL.
In IFTTT, create a new Applet.
If This → choose Webhooks, event name: ship.
Then That → choose Notifications (or Email/Telegram/Slack).
In the message body, include {{update}} to display the timeline text.
Go to the Webhooks Documentation page in IFTTT to confirm your key and test.
Manual test (optional):
curl -X POST "$IFTTT_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d '{"update":"IFTTT test from curl"}'
Place the JavaScript from “Final Worker Script” into src/index.js.
Make sure the imports/structure match your wrangler.toml (the main field).
Local dev (uses a local preview KV binding):
wrangler dev
Open the local URL shown in the terminal to trigger the fetch once.
Deploy:
wrangler deploy
Tail logs (great for debugging):
wrangler tail
Option A — Cloudflare Cron Triggers (recommended)
We already added:
triggers = { crons = ["*/30 * * * *"] }
After deploy, Cloudflare runs your Worker every 30 minutes automatically.
Option B — External Cron (cron-job.org)
Create a cron job that hits your Worker URL every 30 minutes.
Use method GET (or POST) and a timeout ≥ 10–15 seconds.
Since our Worker responds immediately and does the work in the background (ctx.waitUntil), cron timeouts are unlikely.
IFTTT not firing? Ensure you send {"update":"...text..."} in the body and your event name matches (/trigger/ship/...).
cron-job.org timeout? We respond immediately and process in the background; verify you deployed that ctx.waitUntil version.
Large notifications truncated? Limit timeline to the last few entries (as in the sample).
KV not updating? Check your kv_namespaces binding name matches your code (TRACKING_CACHE).
API errors or slow courier API? Increase the timeout slightly or add retry logic.

Now I get a neat push/email every time the local courier posts an update, even though they don’t offer native notifications. The stack is free, reliable, and easy to adapt for any other API you want to watch.
Happy automating! 🚀📦