Open source client for ANVISA's official Portal de APIs, specifically the Consultas Externas API: fila de análise, UDI de dispositivos médicos, termos GMDN, nomes técnicos, listas e assuntos de peticionamento.
ANVISA publishes an OpenAPI document for this API, but it is wrong or silent about most of what you need to call it successfully. This repository records what the API actually does and ships a client that encodes it.
| Behavior | Reality |
|---|---|
| User-Agent | Cloudflare answers 403 to curl's default UA, even for the spec URL. Send a descriptive one. |
| Auth | OAuth 2.0 client credentials (Keycloak, realm externo). Token lasts 1740 s (the portal tutorial says 300), no refresh token, one role CONSULTASEXTERNAS_LEITURA. |
| Rate limit | Token bucket, burst 25, refill 1/s, reported only in X-RateLimit-* headers. Keyed by source address, not client id: the unauthenticated portal endpoints on the same gateway drain the same bucket. |
| Pagination | Requests are 1-based (page: 0 → error); responses are Spring Page objects, 0-based. |
| Filters | POST /udi needs at least one filter key; POST /fila/consulta and POST /lista/consulta need filter.subfila (yes, also for listas). |
| Errors | Validation failures come back as HTTP 500 with {status, mensagem, data_hora, mensagem_detalhada}. |
fila/consulta, lista/consulta |
Return the whole subqueue/sublista as an array (40, 35, 71 and 555 rows observed); page/size are ignored. A subfila with nothing queued is an empty-bodied 404, which the client returns as []. |
| Dates | Integer epoch milliseconds. |
| Downloads | The spec says 200 OK with no content and nothing about Accept. Every download answers 500 "Could not find acceptable representation" to Accept: application/json (all but GET /udi/{id}/download); send Accept: */*. Content-Type is always application/vnd.ms-excel even when the bytes are OOXML or a zip. POST /assunto/downloadAssuntoFormulario takes a bare JSON integer body, not the declared PaginationBuilder, and returns the file with no Content-Type and no Content-Disposition. An empty subfila is a 500 here, not the empty 404 fila/consulta gives. |
| Filter keys | Verified live: udi accepts nomeComercial (substring), udiDi (exact), cnpjDetentora, codigoGmdn, nuRegistro; nomeTecnico accepts nomeTecnico (substring) and categoriaProduto; termoGmdn accepts conteudo. POST /assunto/ is broken (500, body not bound). |
| Coverage | The spec had 32 endpoints until 2026-10-01, when ANVISA published 27 more (produtos saúde, certificados de boas práticas, certificados de medicamentos, tabaco; snapshot of 2026-10-06). Unauthenticated probes answer 401 like the wrapped ones, so they are live; the client does not wrap them yet. The empresa nacional/internacional, dossiê and alimentos pages left the portal menu without ever being served. Alimentos and other product families exist as bulk CSV on dados.anvisa.gov.br/dados/CONSULTAS/ (for example TA_CONSULTA_PRODUTOS_IRREGULARES_RESULTADO.CSV, refreshed on weekdays); the alimentos files are republished here as Parquet, see Dados abertos. |
spec/ ANVISA's OpenAPI document (as published, only reformatted) + an OpenAPI Overlay
with the corrections above + the resolved spec. Language-neutral source of truth.
spec/portal/ holds the portal's own doc pages and menu, fetched by snapshot.py.
fixtures/ Real responses recorded from the API, with a manifest. Shared test fixtures.
fixtures/dados/ holds byte-exact samples of the open-data CSVs.
packages/python/ The `anvisa` Python library and CLI.
The overlay follows the OpenAPI Overlay Specification 1.0,
so any overlay tool can apply it. spec/apply_overlay.py is the small applier used here.
Models are generated from the resolved spec; everything else is hand-written and small.
Two sibling repositories build on this one: anvisa-feeds (daily fila snapshots published as
Atom feeds; installs anvisa from PyPI) and anvisa-dash (a DuckDB-WASM search over the
Parquet files published here, see "Dados abertos" below).
uv tool install anvisa # or: pipx install anvisaCredentials come from the portal (login Gov.br → Client ID / Client Secret). Put them in
~/.config/anvisa/credentials.env (chmod 600):
CLIENT_ID=...
CLIENT_SECRET=...
or export ANVISA_CLIENT_ID / ANVISA_CLIENT_SECRET.
anvisa fila areas # Medicamento=1, Dispositivos Médicos=8, ...
anvisa fila grupos 8 # Registros, Alterações, Revalidações, ...
anvisa fila subfilas 285
anvisa fila query 167 # the queue, in order, with protocol numbers
anvisa lista areas && anvisa lista grupos 1 && anvisa lista sublistas 921
anvisa lista query 2141 # same shape as fila query
anvisa udi search --nome cateter --size 5
anvisa udi get 377
anvisa udi get-gmdn 47852
anvisa udi search-gmdn pacing # GMDN terms by text (names are in Portuguese)
anvisa nome-tecnico search --size 50 # nomes técnicos with risk class
anvisa nome-tecnico categorias
anvisa assunto list --busca bioequival # petition subject codes
anvisa assunto get 10013 # documents, forms, legal basis, fees by size
anvisa assunto servicos-associados 13497 # gov.br services behind a serviço code
anvisa --format json fila query 167 | jq lengthDownloads. -o takes a file or a directory (default ., filled in with the name the server
sent) and -o - writes the bytes to stdout; the saved path is printed to stderr:
anvisa fila download 167 -o ./exports # consulta_fila.xlsx, the whole subfila
anvisa lista download 2141
anvisa nome-tecnico download --nome cateter
anvisa assunto download -c 10013 -c 10014 # no --codigo exports all ~2,600 (≈5 MB)
anvisa assunto formulario 8016 # formulários[].id, from `assunto get`
anvisa udi download 377 -o - > udi.xlsx
anvisa udi snapshot 173 -o ./exports # the week's zip, streamed to diskfrom anvisa import Client
with Client() as anvisa: # credentials from the environment or ~/.config/anvisa/credentials.env
for row in anvisa.fila.query(167):
print(row.nuOrdem, row.numeroProcessoFormatado, row.dsAssunto)
page = anvisa.udi.search(nomeComercial="cateter", size=50)
for device in anvisa.udi.iter_search(nomeComercial="cateter"): # every page, throttled
print(device.udiDi, device.nomeComercial)
anvisa.fila.download(167).save("exports/") # consulta_fila.xlsx
anvisa.udi.download_snapshot(173, "exports/") # streamed, never bufferedThe client sends a User-Agent, caches the token and renews it before expiry, mirrors the
gateway's token bucket so a loop never hits 429, sends Accept: */* on every download, and
raises typed exceptions (MissingFilterError, InvalidPageError, MalformedRequestError,
BlockedError, EmptyExportError, NoResultError, and others) instead of a bare 500.
cd packages/python && uv sync --extra dados
uv run pytest # fixture-only, no network
uv run pytest -m live # 6 real requests: 4 to the API (credentials) + the token + a HEAD on dados.anvisa.gov.br
make spec && make models # at the repo root; a non-empty git diff means the overlay drifted
make snapshot # re-download the spec and portal docs; a diff means ANVISA changed themThe Consultas Externas API has no food products. ANVISA publishes them as bulk CSV on
dados.anvisa.gov.br, refreshed on
weekdays, with no CORS headers, so a browser cannot read them; the analysis cycle of every
alimentos petição sits at the root of the same server, and
the measures behind the portal's "Consulta de produtos irregulares", for every area, in
CONSULTAS/EMPRESA_FISCALIZACAO_PRODUTO/; cosméticos and saneantes sit beside alimentos. A
daily workflow here (.github/workflows/dados.yml) converts them to typed, sorted Parquet and
publishes them on GitHub Pages, at https://vasfvitor.github.io/anvisa-api/. Row counts below are from the
day the table was added; the live manifest.json has today's:
| Table | Source | Rows (file of) | One row per |
|---|---|---|---|
alimentos |
TA_CONSULTA_ALIMENTOS.CSV |
66,681 (10-05) | apresentação of a registered or notified food product, sorted by nu_cnpj_empresa, nu_processo |
alimentos_resultado |
TA_CONSULTA_ALIMENTOS_RESULTADO.CSV |
66,941 (10-05) | apresentação detail (embalagem, tabela nutricional, alergênicos), sorted by co_produto, co_seq_apresentacao_produto; join on co_seq_apresentacao_produto, or fetch a whole product with co_produto = alimentos.co_seq_produto |
saneantes |
TA_CONSULTA_SANEANTES.CSV |
144,384 (10-05) | registered or notified saneante (is_registrado), one row per processo; sorted by nu_cnpj_empresa, nu_processo. No detail file. |
peticoes_alimento |
CICLO_ANALISE_PETICOES_ALIMENTO.CSV |
69,188 (10-06) | stage (fila, análise, exigência, finalização…) of an alimentos petição finalized at least once: 20,518 petições since 1998, sorted by num_processo_peticao, num_expediente_peticao, ordem_ocorre_grupo_etapa_asc |
peticoes_alimento_andamento |
CICLO_ANALISE_PETICOES_ANDAMENTO_ALIMENTO.CSV |
1,205 (10-06) | stage of a petição never finalized, what is in análise today (336 petições); same columns minus the two finalization dates, same sort |
produtos_irregulares |
TA_CONSULTA_PRODUTOS_IRREGULARES_RESULTADO.CSV |
79,986 (10-05) | product × ação (suspensão, proibição, recolhimento, apreensão, interdição, inutilização) × atividade of a fiscalização dossiê, every area (Alimento: 8,743 rows, 722 dossiês); sorted by co_tipo_produto (6 = Alimento), nu_cnpj_empresa_investigada, co_seq_dossie_investig_med |
cosmeticos |
TA_CONSULTA_COSMETICOS.CSV |
1,204,980 (10-07) | notified, exempt (ISENTO DE REGISTRO) or registered cosmetic, one row per processo except 11,144 processos listed twice (st_registrado false, then true, same nu_registro); sorted by nu_cnpj_empresa, nu_processo, st_registrado. 24 MB of Parquet, far above the others, so it is also published as search files (busca below). No detail file. |
Two more tables are derived from these on every build (anvisa/dados/derivar.py), so that
"one row per product" and "one row per company" are decided once, here, instead of in every
consumer's SQL. Their manifest entries carry fontes (the tables they were built from) and a
source.url that points at the recipe; they are never carried over, always rebuilt from the
build's own files, and byte-identical for identical inputs (one DuckDB thread, every aggregate
ordered):
| Table | Built from | One row per |
|---|---|---|
produtos |
alimentos, alimentos_resultado, saneantes |
product: alimentos grouped by co_seq_produto (first regularization, apresentação count, the list of registros), saneantes one per nu_expediente; one schema (area, id, nome, marcas, empresa, cnpj, processo, registro, ativo, tipo, categoria, desde, vencimento, apresentacoes, registros), plus what the label declares, from alimentos_resultado across the product's apresentações: gluten and lactose (Sim, Não, Varia), the sorted lists contem (Contém and Contém derivado de), pode_conter and nao_contem (an item some apresentação contains leaves the other two), and alergia_varia; NULL when no apresentação declares anything (39,278 of 50,522 alimentos) and on saneantes. Sorted by cnpj, area, id. Cosméticos are not in it (1.2M rows): use the search files. |
empresas |
alimentos, saneantes, cosmeticos, produtos_irregulares |
14-digit CNPJ that holds a product or was the target of a measure: commonest spelling of the name, products and active products per area and in total (cosméticos by distinct processo), medidas (dossiês against it) and medida_ultima; sorted by cnpj. |
medidas |
produtos_irregulares |
dossiê × produto (the source has one row per dossiê × ação × atividade × produto): area/area_codigo, risco, processo (the measure's), cnpj only when the target is a 14-digit CNPJ, empresa, pessoa (target is a CPF: no number, no name), sorted lists registros, acoes, atividades, primeira/ultima publication, autor_cnpj/autor (who filed); sorted by area_codigo, dossie, produto. 14,092 rows, 0.4 MB on 2026-10-12. |
cosmeticos_por_empresa |
cosmeticos |
the first 100 cosmetics of each CNPJ (ordem 1 to 100: active first, then name, then processo), one row per processo (the unregistered row of a duplicate pair, as empresas counts them), with produtos-like columns (id, nome, empresa, processo, registro, ativo, tipo normalized to Registrado, Notificado, Isento de registro, Descartável, vencimento); totals per company are in empresas. 281,220 rows, 7.6 MB on 2026-10-12; sorted by cnpj, ordem. |
alimentos_por_produto |
alimentos, alimentos_resultado |
the alimentos rows of produtos, same columns and values, plus the petição's situacao_assunto and alegacao (constant within a product) and three timestamps: publicacao and situacao_em (latest among the apresentações), inicio_analise (earliest). A list of foods without the per-apresentação table. 50,522 rows, 2.8 MB on 2026-10-12; sorted by cnpj, id. |
Column names are ANVISA's, lowercased, so the
data dictionary
applies. Timestamps are naive Brasília local time, as ANVISA writes them. What each column
means and what a reader of each table should know is published with the data, in Portuguese:
dicionario.html, generated at build
from dados/descricoes.py and each dataset's notes in the catalog, and the same text in the
manifest (columns[].description, notes). This README keeps what a parser needs; that page
keeps what a consumer needs.
What the files don't tell you (verified on the 2026-10-05 files):
| Behavior | Reality |
|---|---|
| Encoding | Windows-1252, not Latin-1: – ’ “ ” ™ are bytes 0x96, 0x92, 0x93/0x94, 0x99. |
| Quoting | ;-separated, text in "...", but quotes inside a value are not escaped (biscoito tipo "cookies"; a value ending in " is written ...brilhante. ""), and values contain LF and CRLF. DuckDB's strict reader refuses the file, its lenient one merges records, Python's csv splits two. A quote closes a field only before ; or a line break, and (saneantes: AMBIENTE "AS MENINAS" + CRLF CRLF + SPRAY inside one name) a line break can only close the record's last field; read that way, every record of every file has the header's field count. |
| Dates | dd/mm/yyyy HH:MM:SS in the alimentos files, peticoes_alimento_andamento and cosméticos (29/09/2005), mm/dd/yyyy HH:MM:SS in saneantes (06/21/2031), peticoes_alimento and produtos_irregulares. One order per dataset in the catalog, never both: given both, 12/08/2026 would quietly parse as whichever comes first. With one, the wrong order leaves every date with a day above 12 unparsed (65% of them in the open file) and the 20% null guard fails the build. Saneantes expiries run to the year 3033. |
| Booleans | S/N in alimentos (ST_PRODUTO_ATIVO, plus one X), 1/0 in saneantes (IS_REGISTRADO); cosméticos has one of each (ST_SITUACAO_PRODUTO, ST_REGISTRADO). |
DT_VENCIMENTO_REGISTRO |
Month and year, MMYYYY (122029), three rows MM/YYYY. Published as a DATE on the first of the month. Every notificação carries 122029, a placeholder; registros expire 5, 10, 15 or 20 years after DT_REGULARIZACAO. |
| Text | MARCAS and NO_PRODUTO carry HTML entities (L'ANA MED, ", ‐), as do the irregular products' PRODUTO, PRODUTOS_CONCATENADOS and NO_EMPRESA_INVESTIGADA, and cosméticos' NO_PRODUTO (& in capitals, ●; &KISS; is no entity and stays); decoded on the way. Some values end in \xa0\r\n; every value is stripped. ST_PRODUTO_ATIVO is S/N plus one X (NULL in the BOOLEAN). |
| Server | ETag and Last-Modified on every file; If-None-Match answers 304, so an unchanged day costs one empty response per file. TLS verifies with certifi. |
NU_PROCESSO |
Digits only, but not a fixed length: 17 on 36,496 rows (all but 90 of the active ones), 13 on 29,647 old ones, 14 on 400 (the length of a CNPJ), a few shorter. Strip punctuation from user input and compare as text. |
| Registration numbers | NU_REGISTRO_PRODUTO always equals NU_REGISTRO_NOTIFICACAO_PRODUTO when present. For notificações the latter equals NU_PROCESSO; for registros it is the 9-digit registro. NU_REGISTRO is per apresentação: registro + 4-digit suffix (13 digits, like an old processo). |
| Products | Within one CO_SEQ_PRODUTO, processo, CNPJ, situação, name and brands never differ: it is a clean grouping unit (50,474 products, up to 64 apresentações each, p99 = 8). alimentos_resultado.CO_PRODUTO equals it on every joined row. |
| Detail coverage | 94 apresentações have no alimentos_resultado row, all active, regularized 2025-06 to 2026-10: ANVISA's detail export lags new notificações. |
| Petition files | The two are disjoint (no expediente in both): finalized at least once, or never. The finalized file's header starts with # (#NUM_EXPEDIENTE_PETICAO), dropped on read. Neither names the company: join num_processo_peticao to alimentos.nu_processo, which finds 72% of the finalized file's processos, 163 of the 172 open petições of type Petição, and none of the 164 open of type Processo (new registration and evaluation requests: the product does not exist yet). Every open petição also has one Todos row (ordem_ocorre_grupo_etapa_asc = 0, no end date, starting the day its first stage did): leave it out when adding up stage durations. One assunto ends in ​,, an entity whose ; the export turned into ,; kept as written, and cod_assunto_peticao maps one to one to the text. |
| Irregular products | nu_cnpj is who filed the dossiê, not the company acted against: ANVISA itself (03112386000111) on 1,698 of 4,916 dossiês, a marketplace on some, the company itself on others (4,636 of the 8,743 Alimento rows carry the same CNPJ in both columns). The company is nu_cnpj_empresa_investigada, which is not always a CNPJ: 14 digits on 35,029 rows, a CPF's 11 on 2,334, text on most of the rest (DESCONHECIDO, Desconhecido, Não se aplica, NA…), empty on 31,658. Only 1,667 of the 8,693 Alimento rows that name one join alimentos.nu_cnpj_empresa, and only 8 of 1,580 product names match a regularized one: most food measures target products and companies with no registration. registro is empty on every Alimento row; produtos_concatenados is cut at 4,000 characters (use produto); 38 rows equal another once a trailing space is stripped (HARVONI / HARVONI ); dt_publicacao_medida is the dossiê's latest dt_publicacao. |
| Cosméticos | Verified on the 2026-10-07 file. 11,144 processos are listed twice, with the same CNPJ and nu_registro: once with st_registrado false and ds_tipo_peticao ISENTO DE REGISTRO (8,936), REGISTRO (2,207) or DESCARTAVEL (1), once with true; ds_tipo_peticao is empty on exactly the registered rows. Upstream the two sit up to a million records apart; the sort puts them together. Count products with DISTINCT nu_processo. nu_registro holds one literal null. Kept as written: 260 razões sociais that begin with control characters (\x17\x10\x16\x18VITORIA FACE...) and 11 names that were UTF-8 read as Windows-1252 before ANVISA exported them (LOÇÃO). |
| Completeness | ANVISA's own panel (consultas.anvisa.gov.br/#/alimentos/) shows nothing alimentos and alimentos_resultado lack: product page and apresentação page compared by hand on 2026-10-06. The other tables were not compared against anything. |
manifest.json is the entry point (schema_version 1): for each table, its path (relative
to the manifest, under data/<build_id>/), rows, bytes, sha256, column names, types and
descriptions, notes for the reader, sort order, the source file's ETag and ANVISA's load time,
and nulls_added, the count of values per typed column that did not parse (the build fails if a
column loses more than 20%). At the top level, docs names the generated dictionary page and
license says whose the data is (ANVISA's) and the code's (MIT). Data paths are immutable: a new
build gets a new directory and the previous one disappears with the next deploy. A table carried
over from the previous build keeps its file but takes the current code's title, notes and
descriptions.
A table too big to download whole (cosmeticos) also carries a busca key: the same rows
published once more under data/<build_id>/<name>/, re-partitioned into about 3,200 small
Parquet files (most 45 to 75 KB, the largest 2.5 MB) by word of the product name
(palavras/), by CNPJ (empresas/) and by processo or registro number (numeros/), plus
empresas.parquet (CNPJ → razão social) and indice.json, which says which file holds which
range. A search downloads the index once and then one or two files. The key gives the
index's path, size and sha256, the file count and the folder's total bytes:
"busca": {"versao": 1, "indice": "data/<build_id>/cosmeticos/indice.json",
"bytes": 118197, "sha256": "…", "arquivos": 3202, "bytes_total": 240128664}Matching is by word start, so the site must split names exactly as the build did:
apostrophes removed, accents stripped, lowercase, split on anything but [a-z0-9], tokens of
two or more characters that are not stopwords, hyphenated compounds also joined
(ANTI-QUEDA gives anti, queda, antiqueda). fixtures/dados/tokens.json holds 40 cases
that both this package (anvisa.dados.busca.words) and the site test against. The layout,
the lookup and the measurements are in anvisa/dados/busca.py's docstring and in the
anvisa-dash repository.
For a frontend on DuckDB-WASM (what the first one, anvisa-dash, learned on 2026-10-06 with
@duckdb/duckdb-wasm 1.33):
- Fetch
manifest.json(add?t=<now>to bypass the 10-minute Pages cache) and resolve eachpathagainst the manifest URL. A 404 on a data path means a deploy happened mid-session: re-read the manifest. - Download whole files; do not rely on HTTP Range on GitHub Pages. The files are kept at a
few MB each on purpose (
bytesin the manifest);cosmeticos, at 24 MB, has thebuscafiles instead. Range reads worked in Chrome but: Pages answersHEAD+Rangewith 200, which breaks duckdb-wasm'sreliableHeadRequests; Firefox's synchronous XHR withRangefails with a NetworkError; full responses are gzip-encoded and ranged ones are not; and in Chrome a rangedfetchpoisons the cache, so the next plainfetchof the same URL returns a 1-byte body. The cause (found 2026-10-09): Pages gzipsapplication/octet-streamwhen the client accepts gzip and applies the range to the gzipped bytes, and Firefox before 148 does not sendAccept-Encoding: identityon Range requests (bug 1983387), so it gets compressed bytes it cannot decode.fetch()each file, checklength === bytesand that it ends inPAR1(refetch withcache: "reload"otherwise),registerFileBuffer,CREATE VIEW. After that every query costs no network. - The sort orders and 2,048-row groups still matter for anyone reading over HTTP with native
DuckDB (
read_parquet('https://...')): only the first sort key prunes, so CNPJ andco_produtolookups read one row group, a processo lookup scans. - Return dates as
strftime(...)text: Arrow temporal units vary with the apache-arrow version. - To reproduce the Pages code path locally, serve
dist/with something that does CORS (and Range, if you test it); Python'shttp.serverdoes neither.
-- duckdb, anywhere: the path comes from manifest.json
SELECT no_produto, marcas, situacao_registro
FROM 'https://vasfvitor.github.io/anvisa-api/data/<build_id>/alimentos.parquet'
WHERE nu_cnpj_empresa = '40208221000174';
-- what one company has in análise today, on products it already has (see "Petition files")
SELECT num_processo_peticao, desc_assunto_peticao, desc_situacao_atual_peticao,
strftime(data_ini_ocorrencia_grp_etapa, '%Y-%m-%d') AS desde
FROM peticoes_alimento_andamento
WHERE desc_grupo_etapa_ciclo_analise = 'Todos'
AND num_processo_peticao IN (SELECT nu_processo FROM alimentos WHERE nu_cnpj_empresa = ?)
ORDER BY desde;
-- fiscalização measures against one company since 2024 (see "Irregular products")
SELECT ds_acao_fiscalizacao, count(DISTINCT co_seq_dossie_investig_med) AS dossies,
strftime(max(dt_publicacao), '%Y-%m-%d') AS ultima
FROM produtos_irregulares
WHERE nu_cnpj_empresa_investigada = ? AND dt_publicacao >= DATE '2024-01-01'
GROUP BY 1 ORDER BY 2 DESC;The same build runs locally (no credentials; DuckDB comes with the dados extra):
pip install 'anvisa[dados]'
anvisa dados list
anvisa dados build --out dist # dist/manifest.json, index.html, data/<id>/
anvisa dados build --out dist2 --skip-unchanged dist/manifest.json # 304s → {"skipped": true}Covered: the 32 endpoints the spec had before 2026-10-01, as the fila, lista, udi,
nome_tecnico, and assunto domains. That includes the eight file downloads and
servicosAssociados, wrapped on 2026-09-08. From the open data (anvisa.dados), seven tables:
alimentos, alimentos_resultado, saneantes, peticoes_alimento,
peticoes_alimento_andamento, produtos_irregulares and cosmeticos. Not yet covered: the 27 endpoints ANVISA added on 2026-10-01 (saude,
certificado, certificadoMedicamento, tabaco), recorded in spec/ by the drift workflow
and listed in ROADMAP.md. The SNGPC, SAMMED and SNCR APIs (separate services) are
out of scope.
What comes next, and what was ruled out and why, is in ROADMAP.md.
Not affiliated with ANVISA. MIT.