Engineering
How to Integrate a Package Tracking API with Node.js and TypeScript
A production-minded Node.js and TypeScript tutorial for tracking packages, validating responses, handling retries, protecting API keys, and moving from polling to webhooks.
A tracking integration can be demonstrated in one fetch call. A production integration needs a little more discipline: secrets must stay on the server, responses need validation, timeouts must be bounded, transient errors need controlled retries, and carrier data should be stored without coupling your application to one provider's vocabulary.
The example below uses Node.js, TypeScript, the built-in fetch API, and Zod. The same architecture works in a Next.js route handler, an Express service, a background worker, or a serverless function.
Keep the API key on the server
Never call a paid tracking API directly from browser code with a secret key. A browser bundle, network inspector, or public source map can expose it. Put the tracking call behind your own authenticated server endpoint and read the key from an environment variable.
Create separate keys for local development, staging, and production. That separation makes revocation safer and lets usage dashboards reveal which environment is generating traffic.
const TRACE_API_URL = "https://api.traceapi.dev";
const TRACE_API_KEY = process.env.TRACE_API_KEY;
if (!TRACE_API_KEY) {
throw new Error("TRACE_API_KEY is not configured");
}Validate the response at the boundary
Third-party responses are external input, even when the provider is reliable. Validate once at the network boundary, then let the rest of your application use an inferred TypeScript type. This catches contract drift before an undefined field reaches a customer-facing page.
Keep the schema focused on fields your product actually needs. Passthrough fields can be added later without forcing a full rewrite.
import { z } from "zod";
const TrackingSchema = z.object({
tracking_number: z.string(),
carrier: z.object({
code: z.string(),
name: z.string(),
type: z.string(),
}),
status: z.enum([
"pending", "in_transit", "out_for_delivery",
"customs", "delivered", "exception",
]),
estimated_delivery: z.string().nullable(),
events: z.array(z.object({
status: z.string(),
description: z.string(),
location: z.string().nullable(),
timestamp: z.string(),
})),
cached: z.boolean(),
request_id: z.string(),
});
export type Tracking = z.infer<typeof TrackingSchema>;Use a timeout and classify errors
A carrier lookup can be slower than an ordinary database request. Give it a deliberate timeout and distinguish authentication, quota, rate-limit, upstream, and validation failures. Your application can then respond correctly: alert on an invalid key, stop at a monthly quota, retry a 503, and show a temporary message to the customer.
Retry only idempotent lookups and use exponential backoff with jitter. One immediate retry storm across thousands of shipments will make an upstream incident worse.
export async function trackPackage(trackingNumber: string): Promise<Tracking> {
const response = await fetch(TRACE_API_URL + "/v1/track", {
method: "POST",
headers: {
Authorization: "Bearer " + TRACE_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ tracking_number: trackingNumber }),
signal: AbortSignal.timeout(30_000),
});
const body = await response.json();
if (!response.ok) {
throw new Error(body?.error?.code || "tracking_request_failed");
}
return TrackingSchema.parse(body);
}Store a snapshot and append events
Keep the current status on the shipment row for fast reads, and store carrier events separately with a uniqueness key based on tracking number, timestamp, and description. This avoids duplicating the entire history on every poll while preserving a useful audit trail.
Record the provider request ID and last-checked timestamp. Those two fields dramatically shorten support investigations when a customer reports stale or surprising data.
Choose a polling schedule based on shipment state
Do not poll every shipment every minute. Pending shipments might need a check every few hours, active shipments every 30 to 90 minutes, out-for-delivery parcels more frequently, and delivered shipments no further polling. Add random jitter so jobs do not all fire on the hour.
Respect the cached flag and provider rate-limit headers. If a fresh response is already cached, a rapid repeat call may be useful for your UI but should not be treated as new carrier evidence.
Move important changes to webhooks
Polling is useful for discovery and reconciliation; webhooks are better for customer-facing changes. Subscribe to delivered and exception events, verify the signature, process each event idempotently, and retain a slower reconciliation job for anything a webhook misses.
This hybrid design gives you timely notifications without creating a polling bill that grows directly with page refreshes. It also keeps the integration resilient when your webhook endpoint is briefly unavailable.
Test failure paths before launch
Test an invalid key, malformed tracking number, monthly quota error, per-minute rate limit, upstream timeout, duplicate webhook, and delivered shipment. Confirm that none of these cases expose a secret or turn into an unhelpful generic 500.
A successful demo proves the happy path. A production launch depends on the seven failure paths around it. Trace keeps the request surface deliberately small so those cases can be exercised before customer volume arrives.
Build tracking into your product
Create a Trace account, generate an API key, and test package tracking from the dashboard.