Códigos postales de México desde Node.js
No hay nada que instalar. fetch lleva años en Node, la API es un GET detrás de un token Bearer y el cliente completo cabe en un archivo: lo interesante es cuáles fallas conviertes en valores y cuáles dejas pasar.
Node 22.23 · Express 5.2 · ejecutado el 2026-08-17
El cliente completo
Un módulo, sin dependencias. Lee la llave y la URL base del entorno para que nada sensible quede en el archivo que subes al repositorio.
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;
}
}
Ahí hay tres decisiones deliberadas, y cada una es un bug en otro lado si te vas por el camino contrario.
-
«No encontrado» es un valor; todo lo demás es una excepción.
Un código postal del que nadie ha oído hablar es un resultado normal de una consulta; una llave equivocada o una cuota agotada no lo son. Devolver null para lo primero y lanzar para lo demás hace que el código que llama se lea como la decisión que está tomando.
-
fetch no trae timeout propio.
Sin AbortSignal.timeout, una conexión colgada mantiene tu petición abierta mientras viva el socket. Cinco segundos son generosos para una sola consulta.
-
El encabezado Accept no es opcional.
Pide sin él una ruta que no existe y te entregan una página de error en HTML. res.json() lanza entonces un error de parseo y el código de estado real nunca llega a tu handler.
Qué regresa cuando no funciona
Vale la pena reconocer cinco respuestas a mano. Todo lo demás es un 5xx y hay que reintentarlo, no interpretarlo.
Sin llave, o con una llave que no es válida
{
"error": "Unauthenticated",
"message": "A valid API key is required. Pass it as: Authorization: Bearer {token}"
}
Un código real de cinco dígitos que no está en el catálogo
{
"message": "Postal code '09999' not found."
}
Algo que ni siquiera son cinco dígitos
{
"message": "The route v1/postal-codes/abcde could not be found."
}
Los dos últimos son 404 y no son el mismo evento. El endpoint solo acepta cinco dígitos, así que cualquier otra cosa nunca le llega: el router la rechaza antes. Revisa tú mismo la forma antes de llamar y nunca vas a ver el segundo.
Más de sesenta solicitudes en un minuto
{
"message": "Too many requests"
}
Se acabó la cuota del mes
{
"message": "Monthly API quota exceeded"
}
Los dos son 429 y quieren cosas opuestas de ti. El límite por minuto manda Retry-After y ningún encabezado de cuota, y esperar es la respuesta correcta. La cuota manda X-RateLimit-Remaining: 0 y ningún Retry-After, y no hay espera que ayude antes del primero del mes. Lee los encabezados y no el mensaje: el código de estado por sí solo no te dice en cuál de los dos estás.
Una ruta, para que la llave nunca salga del servidor
Si un formulario en el navegador necesita estos datos, le pregunta a tu servidor y tu servidor nos pregunta a nosotros. Cualquier otra cosa mete una llave que se te cobra a ti dentro de un bundle de JavaScript que cualquiera puede leer.
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);
El encabezado Cache-Control es la parte más barata de toda la integración. Los datos postales cambian a lo mucho una vez al mes, así que un día en el navegador y en cualquier CDN que tengas enfrente quita la mayor parte del tráfico repetido antes de que llegue a esta ruta.
Vale más vigilar X-RateLimit-Remaining desde aquí que desde un panel. Llega en cada respuesta exitosa, y una advertencia en tus propios logs al umbral que tú elijas es la forma de enterarte de que se te está acabando antes que tus clientes.
Antes de publicarlo
Tres cosas con las que la gente tropieza
-
Nunca conviertas un código postal a número.
Number("06600") es 6600, que es un código con pinta de válido en un lugar completamente distinto. Un JSON.parse sobre un payload que lo guardó como número ya perdió el cero antes de que tu código lo vea.
-
Pasa la señal de la petición hacia abajo.
El cliente recibe una señal para que quien llama pueda cancelar. Un navegador que ya se fue a otra página o una petición que ya expiró son trabajo que de otro modo sigues pagando, en solicitudes y en tiempo.
-
Quédate con el id del asentamiento, no con el nombre de la colonia.
El id sobrevive a una actualización del catálogo; un nombre es una cadena que se puede volver a escribir distinto. Guarda el id en el pedido y resuelve el nombre cuando lo muestres.