Free, open-source shipment tracking for Indian and international couriers. Built with Next.js + TypeScript.
Currently supports:
- Blue Dart (India)
- Delhivery (India)
- ST Courier (India)
- The Professional Couriers (India)
- Amazon Shipping (India)
- Ekart (India — Flipkart's courier)
- Shiprocket (couriers shipped via Shiprocket)
More carriers coming — contributions welcome.
Most courier tracking lives behind paid SaaS aggregators. ShipTrack is a tiny, self-hostable alternative: bring your own carrier credentials and run it on Vercel, Cloudflare, or your own box.
git clone https://github.com/aswin/shiptrack
cd shiptrack
npm install
cp .env.example .env.local
# fill in carrier credentials
npm run devOpen http://localhost:3000.
GET /api/carriers
GET /api/track/{carrier}/{trackingNumber}
Example:
curl http://localhost:3000/api/track/bluedart/1234567890Response shape: see src/carriers/types.ts.
No credentials required. The carrier scrapes the public tracking page at
https://www.bluedart.com/trackdartresultthirdparty, which renders all
shipment data server-side.
Blue Dart's commercial tracking API is gated behind customer credentials
issued by a Blue Dart account manager; a DHL Developer Portal app on its
own is not sufficient. If you have such credentials and want to switch to
the official API, the prior version of src/carriers/bluedart.ts in git
history shows the request shape.
Polling every 15–30 minutes per shipment is plenty.
No credentials required. ST Courier publishes no developer API, so the carrier replicates the two-step flow their own site uses:
POST https://stcourier.com/track/doCheckwith form fieldawb_no, which stores the AWB against a newci_sessionand returns only{"code":200,"msg":"Track Shipment"}.GET https://stcourier.com/track/shipmentcarrying that cookie, which renders the summary table and scan timeline server-side.
The AWB lives in the session rather than the URL, so step 2 without the cookie from step 1 just returns the empty search form. Not-found is signalled by the absence of the "Status of AWB No." heading — the page still returns HTTP 200.
AWBs are numeric and capped at 11 digits by their form. ST Courier exposes no
expected-delivery date, so estimatedDelivery is always unset.
No credentials required. TPC's website tracker sits behind an image captcha, but the plain-text feed their mobile app reads does not:
GET https://www.tpcindia.com/TPCWebService/TrackMobDe.ashx?podno=<CONSIGNMENT>
It returns one block per scan (newest first) with a DD/MM/YYYY date, Time,
City, WayNo and Activity line. An unknown or malformed number still
answers HTTP 200 with just the Forwarding Details : header, which the carrier
reports as not_found.
The feed has no summary status, origin, destination or expected-delivery date,
so status is derived from the newest activity text and estimatedDelivery
is always unset. TPC often records the same scan twice under different
WayNo bag references; those duplicates are collapsed.
TPC also exposes /TPCWebService/Track.ashx?client=&podno=&tpcpwd=, gated by
a corporate login issued by a TPC branch. Its response format is undocumented
and it is not used.
No credentials required. Amazon Shipping's public tracker at
https://track.amazon.in/ calls:
GET https://track.amazon.in/api/tracker/<TRACKING_ID>
progressTracker and eventHistory come back as JSON strings nested inside
the JSON body. An unknown ID is still HTTP 200, with
errorCode: TRACKING_ID_NOT_FOUND inside progressTracker.errors.
This is the Amazon Shipping (India) courier ID — typically 10–18 digits — not an Amazon.in order number. The Selling Partner / Amazon Shipping v2 API is not used; it needs seller credentials and only covers shipments you purchased.
Expected delivery is taken from summary.metadata.expectedDeliveryDate when
present. Destination is omitted on the anonymous tracker (addresses are null
until the recipient signs in).
No credentials required. Ekart's public tracking page is a React SPA that reads one JSON endpoint guarded by a CSRF check, so the carrier replays the browser's two steps:
GET https://www.ekartlogistics.com/ekartlogistics-web/shipmenttrack/<ID>, which returns the HTML shell with a<meta name="csrf-token">and thesession/session.sigcookies.POST https://www.ekartlogistics.com/ekartlogistics-web-routes-api/ekartlogistics-web-proxy/trackings/v2with{"tracking_ids":"<ID>"}, the token in acsrf-tokenheader and both cookies.
The reply is keyed by tracking ID and carries source/destination city, an
expected-delivery epoch and the scan list. An unknown ID is HTTP 200 with {}.
Scans are kept in Ekart's own (oldest-first) order rather than sorted by time,
because their timestamps are not always monotonic.
IDs are typically four letters and ten digits (FMPP…, FMPC…, NAHC…).
No credentials required, but one is worth setting. Delhivery is tracked from two sources, best-first:
- Account API — used when
DELHIVERY_API_TOKENis set. Returns a full, dated scan history, but only for shipments booked under that account. - Public feed — used for everything else, including when no token is set.
This is the endpoint delhivery.com's own tracking page calls, and it needs no
credentials; the only gate is an
Origin: https://www.delhivery.comheader (without it:401 ERROR: Invalid Origin).
The public feed carries the current status and the expected delivery date, but
its per-scan scanDate / scanDateTime fields come back empty — only top-level
status.statusDateTime is reliably dated, so that always becomes the newest
event. Undated scans are still surfaced for their location and remark.
Setting the token therefore buys richer history for your own shipments; without it every AWB still resolves. A rejected or rate-limited token falls through to the public feed rather than failing the request.
Waybills are numeric. The public feed accepts 11-14 digits and answers 400 to
anything shorter or containing letters, which this carrier reports as
not_found.
- Create
src/carriers/<name>.tsexporting aCarrier(seetypes.ts). - Register it in
src/carriers/registry.ts. - Document any required env vars in
.env.exampleand this README.
That's it — the API route and UI pick it up automatically.
Anyone can paste a tracking number and see the current status without an account. A watch — the thing that emails you when the status changes — is registered one of three ways:
- Signed in. The "Notify me on changes" box on the home page and the dashboard's "Watch an AWB". A watch pointed at your own account address is active immediately; one pointed at anybody else's has to be confirmed by that recipient first.
- Signed out. Wherever a lookup comes back "not found" — a shared
/track/{carrier}/{number}link, or the home page's own search — the result is a form rather than a dead end: enter an email and we'll alert you when the number appears. See Guest watch requests. ADMIN_TOKENcurl. The operator's own flow, below.
When a watch is active, a scheduled worker polls every 15 minutes, diffs the latest event, and emails the configured address via Resend on any change. Every alert email has a one-click unsubscribe link.
A number the carrier doesn't know is usually one it hasn't ingested yet (freshly booked shipments take hours to appear), so the not-found state is a form rather than a dead end. Requiring an account there loses the person who followed a shared link, so it doesn't. A found shipment still routes signed-out visitors to signup — the guest path exists for the case where there's nothing to show them.
The form offers two channels, one watch each.
Email. Nobody has proved they own the address they typed, so a guest
request is always double opt-in: the watch is created pending, a confirmation
link goes out, and the poller ignores it until that link is clicked.
WhatsApp (shown only when WhatsApp alerts are
configured). No number is typed. The visitor taps a wa.me link that opens
WhatsApp on our business number with VERIFY <code> pre-filled; the inbound
message carries their number and is itself the opt-in, so the watch activates
the moment it arrives — no email, no link. The page polls the watch and flips
to "Watching" on its own. Such rows have email = '' and their own phone.
They get a message when the shipment first appears (whatever the status) and
then on milestones only; STOP from that number cancels all its guest watches.
A number may claim at most MAX_GUEST_WATCHES_PER_PHONE_PER_DAY (5) a day,
and one watch per shipment; both are enforced when the message arrives, since
that is when the number is known. Unclaimed requests are swept away once the
code's 15-minute TTL passes.
Three limits bound what the email form can be made to send (all in
src/lib/db.ts):
- a repeat request for the same address + shipment returns the existing watch instead of mailing the link again;
MAX_GUEST_WATCHES_PER_EMAIL_PER_DAY(5) requests per address per day;MAX_GUEST_WATCHES_PER_HOUR(30) guest requests site-wide per hour, which caps an address-enumeration run at the cost of asking a real visitor to come back later during a flood.
Guest watches also always take the default 15-minute poll interval — the
request body's pollIntervalSeconds is ignored for them.
Because these watches have no owning account (user_id IS NULL), no user
dashboard shows them. The admin dashboard lists them under Guest watch
requests, newest first, with whether each one has been confirmed; the same
data is at GET /api/admin/watches. Watches registered through ADMIN_TOKEN
are ownerless too, so they appear in that list as well.
Notifier registry (src/notifiers/) is pluggable — email (Resend) is implemented; webhook / sms / slack / telegram are registered stubs for later.
The poller (workers/poller) runs every 15 minutes and fetches up to 50 due
watches per tick, with at most 5 concurrent fetches per carrier so a burst of
Blue Dart watches can't get the scraper rate-limited.
A watch whose poll fails (carrier error, upstream outage, AWB the carrier
doesn't know yet) backs off exponentially: its interval is multiplied by
2^poll_failures, capped at 96x — one fetch a day at the 15-minute minimum.
The counter resets on the next successful poll.
That doubling is wrong for the one watch it hits hardest: the pre-watch
registered because the carrier didn't know the number yet. Every poll fails
until the shipment is ingested, so plain backoff reaches 8h within half a day
and the 24h cap after about 32 hours — "we'll tell you when it appears"
becomes "some time tomorrow". So while a watch has never produced a scan and
is under UNSCANNED_FAST_WINDOW_SECONDS (48h) old, its effective interval is
clamped to UNSCANNED_MAX_INTERVAL_SECONDS (1h), or to its own interval if
the owner picked something slower. Past that window the normal cap takes over:
a number still missing after two days is likelier mistyped than early.
A watch that has never produced a single scan 30 days after it was confirmed
is retired (marked cancelled) and its owner is emailed once. This needs
migration 0013_poll_failures.sql.
Both rules live in listDueWatches() in src/lib/db.ts, which the poller
worker bundles — so changing them needs a poller deploy, not just a web
one.
# Add
curl -X POST https://your-app.workers.dev/api/watches \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"email": "you@example.com",
"carrier": "bluedart",
"trackingNumber": "1234567890",
"label": "Mom’s package"
}'
# Cancel — open the unsubscribe link from any alert email, OR:
wrangler d1 execute shiptrack --remote \
--command "UPDATE watches SET status='cancelled' WHERE id='<watch-id>'"A bearer token that doesn't match ADMIN_TOKEN is rejected rather than
quietly downgraded to a guest request, so a typo'd token fails loudly instead
of silently creating a watch that waits on a confirmation email.
# 1. Create the D1 database
npx wrangler d1 create shiptrack
# Copy the `database_id` into BOTH wrangler.jsonc and wrangler.poller.jsonc
# 2. Apply schema (local + remote)
npm run db:migrate
npm run db:migrate:remote
# 3. Set secrets on the web worker
for k in ADMIN_TOKEN TOKEN_SECRET RESEND_API_KEY RESEND_FROM APP_URL; do
npx wrangler secret put "$k"
done
# 4. Set the relevant secrets on the poller (no ADMIN_TOKEN needed there)
for k in TOKEN_SECRET RESEND_API_KEY RESEND_FROM APP_URL; do
npx wrangler secret put "$k" -c wrangler.poller.jsonc
done
# 5. Deploy both
npm run deployADMIN_TOKEN and TOKEN_SECRET should each be a random 32-byte hex string (openssl rand -hex 32). RESEND_FROM must be a verified sender in your Resend account.
This repo is configured for @opennextjs/cloudflare.
npm install
npx wrangler login
npm run deployLocal Workers preview (runs the actual worker bundle):
npm run previewBoth workers are also built by Cloudflare Workers Builds on every push. Each worker has its own build configuration, and the deploy is the trigger's job, not the build command's:
| worker | build command | deploy command (main) |
version command (other branches) |
|---|---|---|---|
shiptrack (web) |
npm run deploy:web |
npx wrangler deploy |
npx wrangler versions upload |
shiptrack-poller |
npm install |
npm run deploy:poller |
npx wrangler versions upload |
deploy:web therefore only builds — despite the name, which is fixed by the
existing trigger config. Do not make it deploy: it runs on every branch, so it
would ship unreviewed branch code straight to production. npm run deploy is
the manual full deploy (web + poller).
Every wrangler invocation in these fields must go through npm or npx.
Wrangler is a devDependency, and Cloudflare runs these commands with /bin/sh,
which does not have node_modules/.bin on PATH — a bare wrangler deploy
fails with /bin/sh: 1: wrangler: not found.
The poller's command must also keep -c wrangler.poller.jsonc, which is why
its row says npm run deploy:poller rather than the web worker's
npx wrangler deploy. Without the flag wrangler falls back to the default
wrangler.jsonc, whose main is the web app's .open-next/worker.js, and the
poller build — whose build command is only npm install — never produces that
bundle:
✘ [ERROR] The entry-point file at ".open-next/worker.js" was not found.
Copying the web row into the poller's config gets you exactly that error.
That failure mode is quiet and expensive. The version command still succeeds, so
versions keep accumulating in the dashboard while the active deployment stays
frozen at whatever last promoted successfully. The poller ran a bundle that was
a week stale this way: newly registered carriers were live on the site but
unknown to the poller, which then silently stamped last_polled_at and left
last_known_status null, so watches sat on "awaiting first scan" and never
alerted. To check what is actually serving traffic, rather than merely uploaded:
npx wrangler deployments status -c wrangler.poller.jsoncSource: deployment with a recent timestamp is a real deploy; version_upload
next to a stale active version means the deploy step is failing.
Signed-in users can opt in to WhatsApp messages on the milestones that matter — picked up, out for delivery, delivered, exception, returned — alongside the email alerts, which still cover every scan. Messages go out through Meta's WhatsApp Cloud API using a pre-approved utility template.
Meta requires the recipient's opt-in before a business may message them, and
authentication (OTP) templates are only available to Meta-verified businesses.
So instead of us sending a code, the user sends us one: Settings hands them
a six-digit code and a wa.me link that opens WhatsApp on our business number
with VERIFY <code> pre-filled. They tap send. Meta's webhook delivers the
message with the sender's number, and that number is bound to their account.
Nothing to type, nothing to mistype, and the proof of ownership is the message
itself. The same webhook honours STOP / START and logs replies to alerts.
Fallback for desktops. With an approved Authentication-category template
(WHATSAPP_OTP_TEMPLATE_NAME, e.g. shiptrack_verify) Settings also offers
"enter your number instead": a one-time code is sent to the typed number and
typed back. Same end state. It is the fallback rather than the default because
it costs a message per attempt and sends to a number nobody has proved they
hold yet — so it is signed-in only, hashed at rest, 5 attempts, 10-minute TTL
(matching the template's footer), and limited to 5 sends a day per account and
per number. Bare 10-digit numbers starting 6–9 are read as Indian (+91).
Create a Utility template in WhatsApp Manager with five positional parameters, in this order: recipient name, shipment, location, time, status. The one this repo was built against:
Hi {{1}}, There is an update on your shipment {{2}}. 📍 {{3}} 🕐 {{4}} {{5}}
If you think any tracking detail is incorrect, please reply to this message.
Set WHATSAPP_TEMPLATE_NAME / WHATSAPP_TEMPLATE_LANG to its exact name and
language code. Until Meta approves it, sends fail with template_unavailable
and are logged; nothing else breaks.
-
Meta App Dashboard → WhatsApp → API Setup: note the Phone number ID and create a permanent System User access token with
whatsapp_business_messaging. Put the app in Live mode — in Development mode Meta only delivers to a short allow-list of test numbers. -
Meta App Dashboard → WhatsApp → Configuration → Webhook:
- Callback URL:
https://<your-app>/api/whatsapp/webhook - Verify token: any random string (this becomes
WHATSAPP_WEBHOOK_VERIFY_TOKEN) - Click Verify and save, then subscribe to the
messagesfield.
- Callback URL:
-
Meta App Dashboard → App settings → Basic: copy the App secret (
WHATSAPP_APP_SECRET). Every webhook call is checked against it. -
Set the secrets. The web worker needs all of them; the poller only sends:
for k in WHATSAPP_PHONE_NUMBER_ID WHATSAPP_ACCESS_TOKEN WHATSAPP_BUSINESS_NUMBER \ WHATSAPP_TEMPLATE_NAME WHATSAPP_TEMPLATE_LANG \ WHATSAPP_APP_SECRET WHATSAPP_WEBHOOK_VERIFY_TOKEN; do npx wrangler secret put "$k" done for k in WHATSAPP_PHONE_NUMBER_ID WHATSAPP_ACCESS_TOKEN WHATSAPP_TEMPLATE_NAME WHATSAPP_TEMPLATE_LANG; do npx wrangler secret put "$k" -c wrangler.poller.jsonc done
-
Apply
migrations/0014_whatsapp.sql(npm run db:migrate:remote).
With the secrets unset the Settings section is hidden and the poller never attempts a send, so this is safe to deploy before Meta has approved anything.
Guest watches with an email never get WhatsApp — there is no account to link a number to. Signed-out visitors can instead pick WhatsApp as the channel on the "not found yet" form; see Guest watch requests.
MIT — see LICENSE.