Skip to content
AswincloudPublic

About

Free, open-source, self-hostable shipment tracking for Indian & international couriers — built with Next.js + TypeScript

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

ShipTrack

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.

Why

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.

Quick start

git clone https://github.com/aswin/shiptrack
cd shiptrack
npm install
cp .env.example .env.local
# fill in carrier credentials
npm run dev

Open http://localhost:3000.

API

GET /api/carriers
GET /api/track/{carrier}/{trackingNumber}

Example:

curl http://localhost:3000/api/track/bluedart/1234567890

Response shape: see src/carriers/types.ts.

Carrier credentials

Blue Dart

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.

ST Courier

No credentials required. ST Courier publishes no developer API, so the carrier replicates the two-step flow their own site uses:

  1. POST https://stcourier.com/track/doCheck with form field awb_no, which stores the AWB against a new ci_session and returns only {"code":200,"msg":"Track Shipment"}.
  2. GET https://stcourier.com/track/shipment carrying 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.

The Professional Couriers (TPC)

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.

Amazon Shipping

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

Ekart (Flipkart)

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:

  1. GET https://www.ekartlogistics.com/ekartlogistics-web/shipmenttrack/<ID>, which returns the HTML shell with a <meta name="csrf-token"> and the session / session.sig cookies.
  2. POST https://www.ekartlogistics.com/ekartlogistics-web-routes-api/ekartlogistics-web-proxy/trackings/v2 with {"tracking_ids":"<ID>"}, the token in a csrf-token header 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…).

Delhivery

No credentials required, but one is worth setting. Delhivery is tracked from two sources, best-first:

  1. Account API — used when DELHIVERY_API_TOKEN is set. Returns a full, dated scan history, but only for shipments booked under that account.
  2. 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.com header (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.

Adding a carrier

  1. Create src/carriers/<name>.ts exporting a Carrier (see types.ts).
  2. Register it in src/carriers/registry.ts.
  3. Document any required env vars in .env.example and this README.

That's it — the API route and UI pick it up automatically.

Alerts

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:

  1. 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.
  2. 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.
  3. ADMIN_TOKEN curl. 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.

Guest watch requests

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.

Polling behaviour

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 / remove a watch (curl)

# 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.

Setup

# 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 deploy

ADMIN_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.

Deploy to Cloudflare Workers

This repo is configured for @opennextjs/cloudflare.

npm install
npx wrangler login
npm run deploy

Local Workers preview (runs the actual worker bundle):

npm run preview

CI deploys (Cloudflare Workers Builds)

Both 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.jsonc

Source: deployment with a recent timestamp is a real deploy; version_upload next to a stale active version means the deploy step is failing.

WhatsApp alerts (optional)

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.

How linking works

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

Template

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.

Setup

  1. 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.

  2. 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 messages field.
  3. Meta App Dashboard → App settings → Basic: copy the App secret (WHATSAPP_APP_SECRET). Every webhook call is checked against it.

  4. 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
  5. 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.

License

MIT — see LICENSE.

About

Free, open-source, self-hostable shipment tracking for Indian & international couriers — built with Next.js + TypeScript

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages