Skip to content

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

401 Unauthenticated
{
  "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

404 Not found
{
  "message": "Postal code '09999' not found."
}

Something that is not five digits at all

404 No such route
{
  "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

429 Rate limited
{
  "message": "Too many requests"
}

The monthly allowance is gone

429 Quota exceeded
{
  "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.

Clone it, export a key, run it.

100 requests a month, free, no card. The client above needs one of them to prove it works.

curl -s https://api.postalkit.mx/v1/postal-codes/06600 -H "Authorization: Bearer YOUR_API_KEY"