# cepis TypeScript client for the [public CEP API at cep.is](https://cep.is). Official Correios data (DNE), refreshed every two weeks. Works from JavaScript too. ```ts import { lookup } from "cepis"; const address = await lookup("01001-000"); console.log(address.logradouro, address.localidade, address.uf); // Praça da Sé São Paulo SP ``` ## Installation ```sh bun add cepis # or: npm install cepis ``` Zero runtime dependencies. Requires a runtime with `fetch` and `AbortSignal.timeout`: Node 18+, Bun, Deno, or a browser. Ships ESM and type declarations. ## Usage ### A single lookup `lookup` accepts the CEP masked or unmasked and resolves to an `Address`: ```ts import { lookup } from "cepis"; const a = await lookup("01310-100"); a.logradouro; // "Avenida Paulista" a.localidade; // "São Paulo" a.uf; // "SP" a.ibge; // "3550308" a.cep; // "01310100" (always unmasked) a.formattedCep; // "01310-100" ``` Fields the database does not have for that CEP come back as empty strings, never `null`. The data fields keep the API's own Portuguese names; everything else — methods, types, errors — is English. ### Coordinates About 57% of CEPs have a coordinate. When they do not, `geo` is `null`: ```ts const a = await lookup("01310100"); if (a.geo) { a.geo.lat; // -23.567202 a.geo.lon; // -46.649659 a.geo.raio_m; // 1084 a.geo.precisao; // "logradouro" } ``` **Check `precisao` before computing distance.** There are two kinds of point, and the difference between them is tens of kilometres: | `precisao` | typical radius | what the point is | | ------------ | -------------- | ------------------------------------------------------ | | `logradouro` | 110–140 m | the stretch of street — good for maps and distance | | `localidade` | 7–40 km | the municipality centroid: the CEP _is_ the whole city | `a.geo.isPrecise` is the shorthand for `precisao === "logradouro"`: ```ts const cost = a.geo && a.geo.isPrecise ? shippingFrom(a.geo.lat, a.geo.lon) : shippingFromMunicipality(a.ibge); ``` ### Many CEPs at once Do not loop over `lookup`. Use `lookupMany`, which resolves up to 500 CEPs per request: ```ts import { lookupMany } from "cepis"; const ceps = ["01001000", "99999999", "20040020"]; const result = await lookupMany(ceps); ceps.forEach((requested, i) => { const address = result[i]; console.log(requested, address === null ? "not found" : address.localidade); }); ``` The returned array has **the same length and the same order** as the input. A CEP that does not exist, or is malformed, becomes `null` at its position — it does not vanish, and it does not throw. Arrays longer than 500 are chunked automatically. > **Batches carry no coordinate.** The endpoint behind them is CSV, and the CSV > has no `geo` column. Every `Address` from `lookupMany` has `geo === null`, > including for CEPs that do have one. Need `geo`? Use `lookup`, one at a time. ### API health Useful for two things: checking the API answers, and knowing **which version of the database** is live: ```ts import { health } from "cepis"; const h = await health(); h.ok; // true h.dne; // "26071" identifier of the fortnightly database h.ceps; // 1596168 h.base_gerada_em; // "2026-07-24T03:48:28Z" ``` An API reporting itself degraded does not throw — that arrives in `h.status`. ## Configuration For anything beyond the defaults, build a `Client` and reuse it. It keeps no state between calls: ```ts import { Client } from "cepis"; const client = new Client({ baseUrl: "https://cep.is", // point at another instance timeoutMs: 10_000, // per attempt, request and body read included maxAttempts: 3, // counting the first; 1 disables retrying backoffBaseMs: 500, // base of the exponential wait backoffMaxMs: 8_000, // cap on each wait totalTimeoutMs: undefined, // cap on the whole call, attempts included }); const address = await client.lookup("01001000"); ``` Retries cover 429, 5xx and network failures, with exponential backoff and jitter; 400 and 404 are never retried. The module-level functions (`lookup`, `lookupMany`, `health`) use a shared default client. Need a different configuration? Build your own. ### Pointing at a local instance `baseUrl` is the only option that usually changes between environments. The API repo brings a local instance up with `just up`: ```ts const client = new Client({ baseUrl: "http://localhost:8080" }); ``` From the command line, to confirm quickly that it answers: ```sh bunx cepis --base-url http://localhost:8080 01001000 ``` ## Errors Everything derives from `CepisError`. Below that, the split is by **the decision you have to make**, not by the HTTP status that caused it: | error | when | does retrying help? | | ----------------- | -------------------------------- | ------------------- | | `InvalidCEP` | not 8 digits | no | | `CEPNotFound` | well-formed, not in the database | no | | `RateLimited` | 429 | yes, later | | `ServerError` | 5xx | yes, later | | `NetworkError` | DNS, connection, timeout, TLS | yes, later | | `InvalidResponse` | unintelligible response | probably not | ```ts import { CEPNotFound, CepisError, lookup } from "cepis"; try { const address = await lookup(cepFromForm); } catch (error) { if (error instanceof CEPNotFound) { // ask the user for another CEP } else if (error instanceof CepisError) { // transient failure: reschedule } else { throw error; } } ``` `RateLimited` exposes `retryAfterMs` and `ServerError` exposes `status`. ### When the CEP does not exist `CEPNotFound` may carry a `faixa`: the municipality the CEP _would_ belong to. A failing CEP is rarely random — it is someone who knows the city and guessed the number, and the classic guess is the `-000` that 957 municipalities never received. ```ts import { CEPNotFound, lookup } from "cepis"; try { await lookup("25845000"); } catch (error) { if (error instanceof CEPNotFound && error.faixa) { error.faixa.localidade; // "Areal" error.faixa.uf; // "RJ" error.faixa.ibge; // "3300225" error.faixa.cep_ini; // "25845001" error.faixa.cep_fim; // "25849899" error.faixa.ceps; // 118 error.faixa.label; // "Areal/RJ" } } ``` Three things about it: - **It is still a 404.** `faixa` is context for the error, never an address. Showing "did you mean a CEP in Areal/RJ?" is the point; treating it as a result is a wrong delivery. - **`cep_ini`/`cep_fim` are the CEPs that actually exist there**, not the range the Correios publish. The DNE's own start for Areal is `25845000` — exactly the CEP that just failed. - **The range is sparse.** Areal's 118 CEPs are scattered across the ~4,900 numbers between `25845001` and `25849899`. That is what `ceps` is for: the pair is not a list of valid CEPs, and iterating it yields mostly 404s. When the CEP falls in a band no municipality owns, the API **omits** the field and `error.faixa` is `undefined` — never `null`. A missing or unreadable `faixa` never turns a clean not-found into a parse failure. `InvalidResponse` almost always means something between you and the API is answering instead of it — a captive Wi-Fi portal, a corporate proxy, an HTML error page. Useful as a diagnosis: the problem is not in your code. ## Development Everything runs through `make`, which shells out to `bun`: ```sh make # list the targets make test # bun test make lint # tsc --noEmit + prettier --check, touches nothing make format # apply prettier make build # bundle to dist/ and emit .d.ts make check # lint, then test, then build ``` ## Licence ISC. See [LICENSE](LICENSE).