Mexican postal codes from Node.js
There is nothing to install. fetch has been in Node for years, the API is one GET behind a Bearer token, and the whole client is one file — the interesting part is which failures you turn into values and which you let through.
Node 22.23 · Express 5.2 · run on 2026-08-17
The whole client
One module, no dependencies. Read the request key and base URL from the environment so nothing sensitive is in the file you commit.
postalkit.js
const BASE = process.env.POSTALKIT_BASE ?? "https://api.postalkit.mx";
const KEY = process.env.POSTALKIT_KEY;
export class PostalKitError extends Error {
constructor(status, body) {
super(body?.message ?? `PostalKit responded ${status}`);
this.name = "PostalKitError";
this.status = status;
}
}
async function call(path, signal) {
const res = await fetch(`${BASE}${path}`, {
headers: { Authorization: `Bearer ${KEY}`, Accept: "application/json" },
signal: signal ?? AbortSignal.timeout(5000),
});
const body = await res.json().catch(() => null);
if (!res.ok) {
throw new PostalKitError(res.status, body);
}
return { body, remaining: Number(res.headers.get("X-RateLimit-Remaining")) };
}
export async function postalCode(code, signal) {
try {
const { body, remaining } = await call(`/v1/postal-codes/${code}`, signal);
return { data: body.data, remaining };
} catch (error) {
if (error instanceof PostalKitError && error.status === 404) {
return { data: null, remaining: null };
}
throw error;
}
}
Three deliberate decisions in there, and each one is a bug somewhere else if you go the other way.
-
“Not found” is a value, everything else is an exception.
A postal code nobody has heard of is an ordinary outcome of a lookup; a wrong key or an exhausted quota is not. Returning null for the first and throwing for the rest means the calling code reads like the decision it is making.
-
fetch has no timeout of its own.
Without AbortSignal.timeout, a connection that hangs holds your request open for as long as the socket lives. Five seconds is generous for a single lookup.
-
The Accept header is not optional.
Ask for a route that does not exist without it and you are handed an HTML error page. res.json() then throws a parse error and the real status code never reaches your handler.
What comes back when it does not work
Five responses are worth recognising by hand. Everything else is a 5xx and should be retried rather than interpreted.
No key, or a key that is not valid
{
"error": "Unauthenticated",
"message": "A valid API key is required. Pass it as: Authorization: Bearer {token}"
}
A real five-digit code that is not in the catalog
{
"message": "Postal code '09999' not found."
}
Something that is not five digits at all
{
"message": "The route v1/postal-codes/abcde could not be found."
}
Those last two are both 404s and they are not the same event. The endpoint only accepts five digits, so anything else never reaches it — the router turns it away first. Check the shape yourself before you call and you will never see the second one.
More than sixty requests in a minute
{
"message": "Too many requests"
}
The monthly allowance is gone
{
"message": "Monthly API quota exceeded"
}
Both are 429 and they want opposite things from you. The burst limit sends Retry-After and no rate-limit headers, and waiting is the correct response. The quota sends X-RateLimit-Remaining: 0 and no Retry-After, and no amount of waiting will help before the first of the month. Read the headers rather than the message — the status code alone cannot tell you which one you are in.
One route, so the key never leaves the server
If a form in a browser needs this data, it asks your server and your server asks us. Anything else puts a key that bills to you inside a JavaScript bundle that anyone can read.
server.js
import express from "express";
import { postalCode } from "./postalkit.js";
const app = express();
app.get("/api/postal-codes/:code", async (req, res) => {
if (!/^\d{5}$/.test(req.params.code)) {
return res.status(422).json({ message: "A postal code is exactly five digits." });
}
try {
const { data, remaining } = await postalCode(req.params.code);
if (data === null) {
return res.status(404).json({ message: "No such postal code." });
}
if (remaining !== null && remaining < 500) {
console.warn(`PostalKit quota running low: ${remaining} left this month.`);
}
res.set("Cache-Control", "public, max-age=86400");
return res.json({ data });
} catch (error) {
console.error(error);
return res.status(502).json({ message: "Postal code lookup is unavailable." });
}
});
app.listen(3000);
The Cache-Control header is the cheapest part of the whole integration. Postal code data changes monthly at most, so a day at the browser and at any CDN in front of you removes most of the repeat traffic before it ever reaches this route.
X-RateLimit-Remaining is worth watching from here rather than from a dashboard. It arrives on every successful response, and a warning in your own logs at a threshold you choose is how you find out you are running out before your customers do.
Before you ship it
Three things that catch people out
-
Never coerce a postal code to a number.
Number("06600") is 6600, which is a valid-looking code somewhere else entirely. JSON.parse on a payload that stored it as a number has already lost the zero before your code sees it.
-
Pass the request signal through.
The client takes a signal so a caller can cancel. A browser that navigated away or a request that already timed out is work you are still paying for otherwise, in requests as well as in time.
-
Keep the settlement id, not the colonia name.
The id survives a catalog refresh; a name is a string that can be re-spelled. Store the id on the order and resolve the name when you display it.