# cepis Lua client for the [public CEP API at cep.is](https://cep.is). Official Correios data (DNE), refreshed every two weeks. ```lua local cepis = require("cepis") local address = cepis.lookup("01001-000") print(address.logradouro, address.localidade, address.uf) -- Praça da Sé São Paulo SP ``` ## Installation ```sh luarocks install cepis ``` Targets **Lua 5.4**. It also runs unmodified on 5.1, 5.2, 5.3 and LuaJIT — nothing here needs anything newer, so the compatibility was free. > The rockspec points at `git+https://git.cep.is/lua.git` with tag `v0.1.0`. > That repository is not published yet, so `luarocks install cepis` will not > resolve until it is. Until then, `luarocks make cepis-0.1.0-1.rockspec` from > a clone works. Three rocks are needed, because Lua's standard library ships neither an HTTP client nor a JSON parser: | rock | why | | --- | --- | | `luasocket` | the HTTP client, plus `gettime`/`sleep` for backoff | | `luasec` | TLS, so the default `https://cep.is` works | | `dkjson` | JSON, and pure Lua, so no C toolchain is needed to install it | CSV is parsed in-tree — a real reader, not `split(",")`, but too small to justify a fourth dependency. `luasec` is loaded lazily, only when `base_url` is `https://`. Pointing at a local instance over plain HTTP therefore needs just `luasocket` and `dkjson`. ## Usage ### A single lookup `lookup` accepts the CEP masked or unmasked: ```lua local address, err = cepis.lookup("01310-100") address.logradouro -- "Avenida Paulista" address.localidade -- "São Paulo" address.uf -- "SP" address.ibge -- "3550308" address.cep -- "01310100" (always unmasked) address:formatted_cep() -- "01310-100" ``` Fields the database does not have for that CEP come back as empty strings, never `nil`. `address.raw` holds the decoded response untouched, including a field the API grew after this release. Expected failures are returned, never thrown: ```lua local address, err = cepis.lookup(cep_from_form) if not address then print(err.kind, err.message) end ``` ### Coordinates About 57% of CEPs have a coordinate. When they do not, `geo` is `nil`: ```lua if address.geo then print(address.geo.lat, address.geo.lon) -- -23.567202 -46.649659 print(address.geo.raio_m) -- 1084 print(address.geo.precisao) -- "logradouro" end ``` **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 | `address.geo:is_precise()` is the shorthand for `precisao == "logradouro"`: ```lua local cost if address.geo and address.geo:is_precise() then cost = shipping_from(address.geo.lat, address.geo.lon) else cost = shipping_from_municipality(address.ibge) end ``` ### Many CEPs at once Do not loop over `lookup`. Use `lookup_many`, which resolves up to 500 CEPs per request: ```lua local ceps = { "01001000", "99999999", "20040020" } local result, err = cepis.lookup_many(ceps) for index, address in ipairs(result) do if address then print(ceps[index], address.localidade) else print(ceps[index], "not found") end end ``` The result has **the same length and the same order** as the input. **A CEP that does not exist, or is malformed, is `false` at its position — not `nil`.** That is not cosmetic. A `nil` inside a Lua sequence truncates it at the first hole, so `#result` and `ipairs` would stop at the first missing CEP and silently drop every address after it. `false` keeps the list dense, and `if address then` still reads as the natural test. Lists 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 `lookup_many` has `geo == nil`, > 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: ```lua local health, err = cepis.health() health:ok() -- true health.dne -- "26071" identifier of the fortnightly database health.ceps -- 1596168 health.geo -- 894489 health.base_gerada_em -- "2026-07-24T03:48:28Z" ``` An API reporting itself degraded is not a failure — that arrives in `health.status`. ## Configuration For anything beyond the defaults, build a client and reuse it. It keeps no state between calls: ```lua local client = cepis.new({ base_url = "https://cep.is", -- point at another instance timeout = 10, -- seconds, per socket operation max_attempts = 3, -- counting the first; 1 disables retrying backoff_base = 0.5, backoff_max = 8, total_timeout = 15, -- cap on the whole call user_agent = "myapp/1.0", }) local address, err = client:lookup("01001000") local result = client:lookup_many({ "01001000", "20040020" }) local health = client:health() ``` `timeout` bounds a single socket operation — the connect, and each read — not the whole call. A call may make several attempts with waits between them; the cap on all of that is `total_timeout`. Retries cover 429, 5xx and network failures, with exponential backoff and full jitter; 400 and 404 are never retried. The module-level functions (`cepis.lookup`, etc.) share a default client, reachable as `cepis.default()`. It applies process-wide, other libraries included. Need a different configuration? Build your own. ### Pointing at a local instance `base_url` is the only setting that usually changes between environments. The API repo brings a local instance up with `just up`: ```lua local client = cepis.new({ base_url = "http://localhost:8080" }) ``` ## Errors Every expected failure is returned as `nil, err`. `error()` is raised only for programmer mistakes — a bad option table, a wrong argument type. An error is a table carrying `kind`, `message`, and whichever of `cep`, `status` and `retry_after` apply. `tostring(err)` yields the message. The kinds split by **the decision you have to make**, not by the HTTP status that caused it: | `err.kind` | when | does retrying help? | | --- | --- | --- | | `cepis.INVALID_CEP` | not 8 digits | no | | `cepis.NOT_FOUND` | well-formed, not in the database | no | | `cepis.RATE_LIMITED` | 429; see `err.retry_after` | yes, later | | `cepis.SERVER_ERROR` | 5xx; see `err.status` | yes, later | | `cepis.NETWORK_ERROR` | DNS, connection, timeout, TLS | yes, later | | `cepis.INVALID_RESPONSE` | unintelligible response | probably not | ```lua local address, err = cepis.lookup(cep_from_form) if not address then if err.kind == cepis.NOT_FOUND then -- ask the user for another CEP elseif err:is_retriable() then -- transient failure: reschedule retry_in(err.retry_after or 60) end end ``` The kinds are plain strings, so `err.kind == "not_found"` works too. `cepis.is_error(value)` tells one of this library's errors apart from anything else a `nil, err` boundary might hand you. `INVALID_RESPONSE` 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`: ```sh make # list the targets make test # run the suite make lint # luacheck when installed, else luac -p; touches nothing make format # stylua, when installed (writes) make build # byte-compile every module into build/ make check # lint, then test, then build ``` The suite stands up a real HTTP server on loopback and points the client at it through `base_url`; nothing is stubbed and nothing touches the network. Run it on another interpreter with `make test LUA=luajit`. ## Licence ISC. See [LICENSE](LICENSE).