# cepis Elixir client for the [public CEP API at cep.is](https://cep.is). Official Correios data (DNE), refreshed every two weeks. ```elixir {:ok, address} = Cepis.lookup("01001-000") {address.logradouro, address.localidade, address.uf} # {"Praça da Sé", "São Paulo", "SP"} ``` ## Installation ```elixir def deps do [{:cepis, git: "https://git.cep.is/elixir.git", tag: "v0.1.0"}] end ``` No dependencies. Requires Elixir 1.14 or newer and Erlang/OTP 27 or newer — HTTP is `:httpc` and JSON is the `:json` module, both from the standard distribution. ## Usage Every function returns `{:ok, result}` or `{:error, error}`, and every one has a bang variant that returns the result directly and raises instead. ### A single lookup `lookup/2` accepts the CEP masked or unmasked and returns a `Cepis.Address`: ```elixir {:ok, a} = Cepis.lookup("01310-100") a.logradouro # "Avenida Paulista" a.localidade # "São Paulo" a.uf # "SP" a.ibge # "3550308" a.cep # "01310100" (always unmasked) Cepis.Address.formatted_cep(a) # "01310-100" ``` Field names are the API's own, in Portuguese. Fields the database does not have for that CEP come back as empty strings, never `nil`. ### Coordinates About 57% of CEPs have a coordinate. When they do not, `geo` is `nil`: ```elixir {:ok, a} = Cepis.lookup("01310100") if a.geo do a.geo.lat # -23.567202 a.geo.lon # -46.649659 a.geo.raio_m # 1084 a.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 | `Cepis.Geo.precise?/1` is the shorthand for `precisao == "logradouro"`: ```elixir if a.geo && Cepis.Geo.precise?(a.geo) do shipping_from(a.geo.lat, a.geo.lon) else shipping_from_municipality(a.ibge) end ``` ### Many CEPs at once Do not loop over `lookup/2`. Use `lookup_many/2`, which resolves up to 500 CEPs per request: ```elixir ceps = ["01001000", "99999999", "20040020"] {:ok, addresses} = Cepis.lookup_many(ceps) for {requested, address} <- Enum.zip(ceps, addresses) do case address do nil -> IO.puts("#{requested}: not found") a -> IO.puts("#{requested}: #{a.localidade}") end end ``` The returned list has **the same length and the same order** as the input. A CEP that does not exist, or is malformed, becomes `nil` at its position — it does not vanish, and it does not fail the call. 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 `Cepis.Address` from `lookup_many/2` has > `geo: nil`, including for CEPs that do have one. Need `geo`? Use `lookup/2`, > one at a time. ### API health Useful for two things: checking the API answers, and knowing **which version of the database** is live: ```elixir {:ok, h} = Cepis.health() Cepis.Health.ok?(h) # 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 is not a failure — that arrives in `h.status`. ## Configuration The last argument of every call is the configuration: a keyword list, or a `Cepis.Client` built once with `Cepis.Client.new/1` and reused. It is plain data with no process behind it, so it is safe to share across processes. ```elixir client = Cepis.Client.new( base_url: "https://cep.is", # point at another instance timeout: 10_000, # milliseconds for one attempt max_attempts: 3, # counting the first; 1 disables retrying backoff_base: 500, # base of the exponential wait backoff_max: 8_000, # cap on each wait total_timeout: nil # cap on the whole call, attempts included ) {:ok, address} = Cepis.lookup("01001000", client) ``` All durations are in milliseconds. Retries cover 429, 5xx and network failures, with exponential backoff and jitter; 400 and 404 are never retried. See `Cepis.Client` for the details of each option. ### Pointing at a local instance `base_url` is the only option that usually changes between environments. The API repo brings a local instance up with `just up`: ```elixir Cepis.lookup("01001000", base_url: "http://localhost:8080") ``` ## Errors Matching on `{:error, error}` is the one handler that catches everything. 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? | | --- | --- | --- | | `Cepis.InvalidCEPError` | not 8 digits | no | | `Cepis.NotFoundError` | well-formed, not in the database | no | | `Cepis.RateLimitError` | 429 | yes, later | | `Cepis.ServerError` | 5xx | yes, later | | `Cepis.NetworkError` | DNS, connection, timeout, TLS | yes, later | | `Cepis.InvalidResponseError` | unintelligible response | probably not | ```elixir case Cepis.lookup(cep_from_form) do {:ok, address} -> address {:error, %Cepis.NotFoundError{}} -> ask_for_another_cep() {:error, error} -> reschedule(error) end ``` `Cepis.Error.retriable?/1` answers the same question in code. `Cepis.RateLimitError` exposes `retry_after` in milliseconds, ready for `Process.send_after/3`. `Cepis.InvalidResponseError` 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 `mix`: ```sh make # list the targets make test # run the ExUnit suite make lint # mix format --check-formatted, touches nothing make format # apply mix format make build # compile with warnings as errors make check # lint, then test, then build ``` ## Licence ISC. See [LICENSE](LICENSE).