# Brazil Government Tenders Search (PNCP) (`jgoia/pncp-lookup`) Actor

Search Brazil's official public-procurement platform (PNCP) for open tenders and published contracting notices: filter by date, UF/municipality, modality, contracting body, keyword, and minimum value. Pay only for matched records.

- **URL**: https://apify.com/jgoia/pncp-lookup.md
- **Developed by:** [Alison Moura](https://apify.com/jgoia) (community)
- **Categories:** Business, Lead generation
- **Stats:** 2 total users, 1 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$20.00 / 1,000 matched tender records

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Brazil Government Tenders Search (PNCP)

Search Brazil's official public-procurement platform — **PNCP** (Portal
Nacional de Contratações Públicas), the national platform every Brazilian
government body (federal, state, municipal — executive, legislative, and
judiciary) is legally required to publish tenders and contracts to under
Lei 14.133/2021 — for open tenders (licitações) and published contracting
notices. Filter by date, state/municipality, contracting modality,
contracting body, keyword, and minimum value, and get back clean,
normalized JSON: contracting body, object/description, modality, estimated
value, key dates, status, and a link back to the official PNCP listing.

### Who this is for

Companies that sell to government and need to monitor open tenders to bid
on, procurement/BD teams doing market research on public spending in a
sector or region, compliance/vendor-vetting tools checking a supplier's
public-contract history, and anyone building on top of Brazilian public
procurement data without wanting to write their own PNCP client, pagination,
and rate-limit handling from scratch.

### What you send in

**Example 1 — "what can I bid on right now"** (mode: `open_for_proposals`):

```json
{
  "mode": "open_for_proposals",
  "closingBefore": "2026-09-15",
  "uf": "SP",
  "modalities": ["Pregão - Eletrônico"],
  "keyword": "informatica",
  "minEstimatedValueBrl": 50000,
  "maxResults": 100
}
```

Returns tenders currently open for proposals in the state of São Paulo,
modality Pregão Eletrônico, closing on or before 2026-09-15, whose object
mentions "informatica" and whose estimated value is at least R$50,000.

**Example 2 — market research** (mode: `published`):

```json
{
  "mode": "published",
  "publishedFrom": "2026-08-01",
  "publishedTo": "2026-08-17",
  "modalities": ["Dispensa", "Inexigibilidade"],
  "contractingBodyCnpj": "46177531000155"
}
```

Returns every tender published by a specific contracting body (by CNPJ)
between two dates, restricted to two modalities.

| Field | Required | Notes |
|---|---|---|
| `mode` | no (default `open_for_proposals`) | `open_for_proposals` or `published` — see "Search modes" below, they map to two different PNCP endpoints with different required fields. |
| `closingBefore` | yes, if `mode = open_for_proposals` | `YYYY-MM-DD`. Proposal deadline cutoff. |
| `publishedFrom` / `publishedTo` | yes, if `mode = published` | `YYYY-MM-DD`. Max 31-day span per run (see below). |
| `modalities` | no | Array of modality names (see the 13 valid values in the input editor). Empty means "all modalities" — cheap in `open_for_proposals` mode (one extra API call skipped, not needed), **expensive** in `published` mode (loops all 13 — see below). |
| `uf` | no | Two-letter Brazilian state code. |
| `municipalityIbgeCode` | no | IBGE municipality code. |
| `contractingBodyCnpj` | no | CNPJ of the contracting body, punctuation optional. |
| `keyword` | no | All space-separated words must (case-insensitively) appear in the tender's object/description. Applied by this Actor, not PNCP (see "Search semantics"). |
| `minEstimatedValueBrl` | no | Minimum estimated value in BRL. Applied by this Actor, not PNCP. |
| `maxResults` | no (default 100, max 500) | Caps matched+charged records per run. |
| `maxConcurrency` | no (default 3, max 5) | Concurrent sub-queries (relevant with multiple modalities). |

### What you get back

One dataset record per matched tender. Example (real data, captured
2026-08-17 — see "Data sources & what was tested" for provenance):

```json
{
  "pncp_id": "46177531000155-1-000394/2025",
  "process_number": "20782/2025",
  "purchase_number": "90147",
  "purchase_year": 2025,
  "purchase_sequential": 394,
  "object": "Registro de preços para aquisição de cortes de carnes bovinas (IQF)",
  "additional_info": "Para as respostas de esclarecimentos e impugnações...",
  "contracting_body": {
    "cnpj": "46177531000155",
    "name": "MUNICIPIO DE PRAIA GRANDE",
    "power": { "code": "N", "description": "Não se aplica" },
    "sphere": { "code": "M", "description": "Municipal" }
  },
  "delegated_body": null,
  "administrative_unit": {
    "name": "PREFEITURA MUNICIPAL DE PRAIA GRANDE - SP",
    "code": "986921",
    "city": "Praia Grande",
    "state": "SP",
    "state_name": "São Paulo",
    "city_ibge_code": "3541000"
  },
  "modality": { "code": 6, "name": "Pregão - Eletrônico" },
  "dispute_mode": { "code": 1, "name": "Aberto" },
  "instrument_type": { "code": 1, "name": "Edital" },
  "legal_basis": {
    "code": 1,
    "name": "Lei 14.133/2021, Art. 28, I",
    "description": "pregão: modalidade de licitação obrigatória para aquisição de bens e serviços comuns"
  },
  "status": { "code": 1, "description": "Divulgada no PNCP" },
  "is_price_registry": false,
  "has_parliamentary_amendment": null,
  "estimated_value_brl": 52100655.6,
  "awarded_value_brl": null,
  "dates": {
    "published_at": "2025-09-09T07:28:44",
    "included_at": "2025-09-09T07:28:44",
    "proposal_opens_at": "2026-08-05T09:00:00",
    "proposal_closes_at": "2026-08-21T09:30:00",
    "updated_at": "2026-08-05T07:00:34"
  },
  "source_system_url": "https://cnetmobile.estaleiro.serpro.gov.br/comprasnet-web/public/landing?destino=acompanhamento-compra&compra=98692105901472025",
  "electronic_process_url": null,
  "pncp_url": "https://pncp.gov.br/app/editais/46177531000155/2025/394",
  "submitted_by_system": "Compras.gov.br",
  "_meta": { "source": "pncp", "search_mode": "open_for_proposals", "retrieved_at": "2026-08-17T14:20:00+00:00" }
}
```

A search that legitimately matches nothing still writes one visible,
**unbilled** record so you can tell "ran fine, found nothing" apart from a
crash:

```json
{ "status": "no_matches", "message": "Search completed successfully but no tenders matched the given filters." }
```

### Search modes

PNCP's own consulta API forces this Actor into two distinct modes because
the two underlying endpoints have genuinely different required parameters
— this isn't an arbitrary design choice, it's a direct consequence of how
the API is built (verified against the live OpenAPI spec,
`https://pncp.gov.br/api/consulta/v3/api-docs`):

- **`open_for_proposals`** → `GET /v1/contratacoes/proposta`. Only
  `dataFinal` (→ `closingBefore`) is required; modality is optional. Best
  for "what can I bid on right now."
- **`published`** → `GET /v1/contratacoes/publicacao`. Requires
  `dataInicial`+`dataFinal` (→ `publishedFrom`/`publishedTo`) **and**
  `codigoModalidadeContratacao` — modality is not optional for this
  endpoint. If you don't specify `modalities`, this Actor queries **all 13
  modalities in turn** to give you full coverage, which is real extra time
  and (bounded by `maxResults`) potentially extra cost. Prefer specifying
  the modalities you actually care about in this mode.

`published` mode is also capped at a 31-day date range per run — a direct
consequence of PNCP's own latency (see "Data freshness & performance"
below): a full-year scan across all 13 modalities, each paginated, would
take a genuinely long time and isn't a good fit for a single Actor run.
Run it repeatedly with narrower windows for a wider historical search.

### Search semantics (what's server-side vs. client-side)

PNCP's consulta API has **no full-text search and no minimum-value query
parameter on any endpoint** — verified against the complete OpenAPI spec,
not assumed. The only server-side filters it exposes are date range,
UF, municipality (IBGE code), contracting-body CNPJ, administrative unit
code, and modality.

So `keyword` and `minEstimatedValueBrl` are applied **by this Actor**,
after fetching each page from PNCP:

- `keyword`: case-insensitive, "all words must appear" match against the
  tender's `objetoCompra` (object/description) field. Accent-naive but not
  accent-*normalizing* — "saude" will not match "saúde" in the source text
  (Portuguese text on PNCP is not consistently accented either, so there's
  no single normalization that's reliably correct; matching the words
  exactly as typed against the exact text PNCP stores is the honest,
  predictable behavior).
- `minEstimatedValueBrl`: numeric `>=` comparison against
  `valorTotalEstimado`. Tenders with no published estimated value are
  excluded when this filter is set (there's no principled way to know if
  an unpublished value would have cleared the bar).

Because these are client-side, this Actor scans PNCP pages until it finds
enough matches or hits an internal scan ceiling (10x your `maxResults`,
capped at 1000 raw records per sub-query) — **not** an index of all PNCP
data. A very narrow keyword against a very broad, common search (e.g. all
Pregão Eletrônico tenders nationwide) may not find every real match if
they're spread past the scan ceiling; narrowing by `uf`, modality, or
date range first makes the keyword filter more reliable.

### Pricing

Pay-per-event: **you are charged only for tender records that actually
match your search filters and are returned** (event `tender-record`,
**$0.02/record** — see "Pricing rationale" below). A search that
legitimately matches zero tenders is not charged; it still writes one
visible summary record so you can confirm the search ran correctly.

#### Pricing rationale

This product's shape is a *search*, not a *resolve-by-ID* lookup (unlike
this developer's [`cnpj-lookup`](https://apify.com/jgoia/cnpj-lookup)
Actor, priced at $0.01/resolved record) — there's no natural "not found"
outcome to distinguish, so the charging rule is simply: charge for every
record actually delivered to you, don't charge when a search finds
nothing to deliver.

Priced at 2x `cnpj-lookup`'s rate for two concrete reasons, not just "feels
richer":

1. **Higher operational cost per record.** PNCP's own API is measurably
   slower than the community APIs `cnpj-lookup` wraps (12-20 seconds per
   page observed live, vs. sub-second for `cnpj-lookup`'s sources) and has
   a real, empirically-hit rate limit with no published number to plan
   around, so this Actor must move more conservatively. `published` mode
   frequently needs multiple sequential API calls (one per modality) to
   answer a single search. All of that is real compute/wall-clock cost
   this Actor absorbs per delivered record that a single fast per-ID
   lookup doesn't have.
2. **Higher buyer value per record.** A resolved CNPJ is a static company
   profile; a matched tender is an actionable, time-sensitive bid
   opportunity (or a real signal for market research) tied to a
   government contract that can be worth anywhere from thousands to
   millions of reais — the value delivered per record is plausibly higher
   even before accounting for cost.

### Data freshness & performance

Data comes directly from PNCP's live consulta API on every run — not a
cached or periodically-refreshed copy — so it's as current as PNCP itself
(tenders are legally required to be published there, generally close to
real time by the contracting body).

**PNCP's own API is slow**: 12-20 seconds per page was observed
consistently during this Actor's development (2026-08-17), independent of
load — this is the platform's own infrastructure speed, not something
this Actor controls or can improve. A `published`-mode search spanning
several modalities will take real wall-clock minutes, by design. Plan
`maxResults` and date ranges accordingly, and prefer narrower, targeted
searches (specific UF/modality/CNPJ) over broad nationwide scans.

### Rate limits & fair use

PNCP enforces a real, empirically-observed rate limit (confirmed live,
2026-08-17 — sending unpaced sequential requests reliably triggered an
HTTP 429 after roughly half a dozen requests). No numeric requests/second
figure is published anywhere found (the OpenAPI spec, the public FAQ, or a
search for the integration manual) — this Actor's pacing (a self-imposed
minimum interval between requests, plus exponential backoff and retry on
429/5xx) is a deliberately conservative, empirically-informed default, not
a number taken from documentation. It can be tuned later if real usage
shows PNCP tolerates more.

### Data source, legal basis & terms of use

**Source**: PNCP (`pncp.gov.br`), operated by Brazil's Ministério da
Gestão e da Inovação em Serviços Públicos (MGI). PNCP's existence and its
open-data obligation are not just policy — they're a direct legal
requirement:

- **Lei nº 14.133/2021** (Brazil's Public Procurement Law), Art. 174,
  §4º, requires PNCP to adopt the **"formato de dados abertos"** (open
  data format) and observe **Lei nº 12.527/2011** (the Lei de Acesso à
  Informação / Access to Information Law).
- **Decreto nº 8.777/2016**, which defines and governs "dados abertos"
  (open data) for the federal executive branch, defines the term
  explicitly as: *"dados acessíveis ao público, representados em meio
  digital, estruturados em formato aberto, processáveis por máquina,
  referenciados na internet e disponibilizados sob licença aberta que
  permita sua livre utilização, consumo ou cruzamento, limitando-se a
  creditar a autoria ou a fonte"* — data under an open license permitting
  free use, consumption, or cross-referencing, limited only to crediting
  authorship/source. The same decree states as a governing principle
  **"permissão irrestrita de reuso das bases de dados publicadas em
  formato aberto"** — unrestricted permission to reuse databases
  published in open format.

This is a stronger, more direct legal basis than a typical API terms-of-
service document (which is what `cnpj-lookup`'s source vetting relied on,
see ADR-005) — commercial reuse isn't merely *not prohibited*, it's the
explicit statutory purpose of the open-data format PNCP is legally
required to use. No PNCP-specific terms-of-service page, FAQ entry, or
manual section found during this build (searched: the public FAQ, the
integration manual search results, and a public-interest technical report
on PNCP's API) contradicts or narrows this — nothing found restricts
commercial use, resale, or bulk automated querying of consulta data.

**Access is public and free, confirmed three independent ways** (see
`pncp_lookup/client.py` module docstring for the full detail): the live
OpenAPI spec defines no security requirement on any consulta operation;
PNCP's own FAQ states plainly that access is free/universal and needs no
login; and every endpoint this Actor calls was confirmed live to work with
zero authentication. The JWT-token authentication documented elsewhere in
PNCP's integration manuals is for government bodies *submitting* data to
PNCP — an entirely separate write API this Actor never touches.

### What was actually tested vs. not

**Tested and passing (57/57 tests, `pytest`, Python 3.12 venv — see
`cnpj-lookup/README.md` for why 3.12 not the VPS's default 3.10):**

- `pncp_lookup/utils.py` — date parsing/validation, UF validation (all 27
  real Brazilian state codes), CNPJ-filter cleaning, keyword matching.
- `pncp_lookup/modalities.py` — the modality name↔code table (see below
  for how it was verified).
- `pncp_lookup/normalize.py` — mapping tested against **real** API
  responses (not hand-written fixtures) captured live 2026-08-17 for real,
  currently-open tenders (`tests/fixtures/proposta_sample.json`,
  `tests/fixtures/publicacao_sample.json`).
- `pncp_lookup/client.py` — pagination, retry/backoff, and both of the
  real non-obvious response shapes discovered live: HTTP 204 with a
  genuinely empty body for zero matches, and HTTP 429 returning an HTML
  error page instead of JSON. Tested with `httpx.MockTransport` (no real
  network calls in the automated test suite).
- `pncp_lookup/main.py` — query planning/validation logic (`_plan_queries`,
  `_coerce_modalities`): correct endpoint selection per mode, correct
  required-field enforcement per mode, the 31-day range cap, modality
  resolution/dedup.
- `apify-cli validate-schema` passes for both `input_schema.json` and
  `dataset_schema.json`.
- Ruff lint: clean, zero warnings.

**Verified live against the real PNCP API (not mocked) during this
build**, 2026-08-17:

- The full OpenAPI spec (`/api/consulta/v3/api-docs`) fetched and parsed
  directly — all 12 consulta endpoints, their parameters, and their
  response schemas came from this, not documentation guesses.
- All 13 modality codes (1-13) individually confirmed by querying
  `/v1/contratacoes/publicacao` with each code and reading back the real
  `modalidadeNome` PNCP returned — not taken from memory or a secondary
  source, since no domain-table endpoint exists in the API.
- All 4 `poderId` values (E/L/J/N) and all 4 `esferaId` values (F/E/M/N)
  confirmed by sampling ~80 real live records across federal, state, and
  municipal contracting bodies and reading the pattern off named,
  recognizable entities (e.g. "MINISTERIO DA SAUDE" → poderId E, esferaId
  F; "SECRETARIA DE ESTADO DA SAUDE" (SP) → poderId E, esferaId E;
  "MUNICIPIO DE CAMPINAS" → poderId N, esferaId M).
- The public PNCP listing URL format
  (`https://pncp.gov.br/app/editais/{cnpj}/{ano}/{sequencial}`) confirmed
  to return HTTP 200 for a real tender.
- The HTTP 204-empty-body (zero matches) and HTTP 429-HTML-body (rate
  limited) response shapes — both discovered live, neither documented
  anywhere found, and both are now specifically handled and unit tested
  (see `client.py`).
- Both consulta endpoints this Actor uses returned correct real data with
  zero authentication headers sent.

**Attempted but NOT cleanly confirmed — full Actor run against the live
API, honestly reported:** a real end-to-end run was attempted via direct
`python -m pncp_lookup` invocation against the live PNCP API (UF=SP,
modality=Pregão Eletrônico, `maxResults: 5`). By that point in the build
session, this developer had already made ~30+ live requests directly
against PNCP within roughly 30 minutes (fetching the OpenAPI spec,
individually verifying all 13 modality codes, sampling ~80 records for the
`poderId`/`esferaId` mapping, capturing fixtures, and deliberately probing
the rate limiter). The full-Actor run started returning `httpx.ReadTimeout`
on every request; a bare `curl` against the same endpoint, run completely
outside this Actor's code, reproduced the identical timeout, including
after a 3-minute cooldown and retry. That rules out a bug in this Actor's
own code as the cause (the same failure happens with zero Python/httpx
involved) and points to this VPS's IP being temporarily throttled/blocked
by PNCP's infrastructure after the session's own request volume — the
mechanism wasn't independently confirmed (no response body/header
distinguishes it from a generic outage), but a self-inflicted, temporary,
IP-scoped block is the best-supported explanation given the earlier ~30
successful requests immediately followed by 100% failures including from
outside this Actor entirely.

This was still useful: it caught a real bug (see below) and confirms the
Actor's failure handling is correct under a real, sustained upstream
outage — the run logged the error per sub-query, did **not** crash, wrote
a "0 matched" summary, and exited cleanly (`exit_code: 0`), which is
exactly the intended graceful-degradation behavior. What it did **not**
confirm is a full successful run that actually returns and charges for
real matched tender data end-to-end — that step is still outstanding and
should be the first thing re-tried (from a fresh IP/session, with no
preceding burst of requests) before or immediately after deploying to
Apify's own infrastructure, which will be a different egress IP than this
VPS regardless.

**A real bug was found and fixed during this attempt**: the first attempt
crashed the whole Actor with an unhandled `httpx.ReadTimeout` traceback —
`client.py`'s retry loop caught `httpx.TimeoutException`/`httpx.HTTPError`
internally but re-raised the *raw* exception after exhausting retries
instead of wrapping it in one of this module's own exception types, so it
propagated straight past `main.py`'s `except PncpRateLimitedError` /
`except PncpRequestError` handlers uncaught. Fixed by wrapping both
`httpx.TimeoutException` and `httpx.HTTPError` into `PncpRequestError`
inside `fetch_page` (see `client.py`), with two dedicated regression tests
added (`tests/test_client.py::TestNetworkErrorsAreWrapped`) simulating a
raw `httpx.ReadTimeout`/`httpx.ConnectError` to confirm they now surface
as a caught, per-sub-query error rather than an Actor crash. Re-ran the
full suite after the fix: 57/57 passing.

**Not tested / could not verify locally:**

- **A full Actor run that actually returns real matched tender data
  end-to-end.** This is the single biggest gap in this build's testing,
  stated plainly: every individual piece (spec, modality codes,
  poder/esfera mapping, normalization against real fixtures, all client
  response-shape handling) was verified live and independently, and the
  orchestration logic is unit tested, but they were never all exercised
  together in one successful live run that produces output records,
  because of the timeout situation described above. This should be the
  first thing re-verified — either by retrying from this VPS later (a
  fresh session, no preceding request burst) or via the live
  `apify-cli call` smoke test during deployment (Apify's platform egress
  IP will differ from this VPS's regardless).
- Real pay-per-event **charging** behavior: outside a real Apify platform
  run, `Actor.push_data(..., charged_event_name=...)` correctly no-ops
  with a log line noting the Actor isn't running under pay-per-event
  pricing (same behavior documented in `cnpj-lookup/README.md`). The
  charging code path is exercised on every match; actual billing can only
  be confirmed after deploying and monetizing on the real platform.
- **Docker build**: this VPS has no Docker installed (no root/sudo
  available). The Dockerfile follows Apify's official Python Actor
  template exactly (same one `cnpj-lookup` uses, already verified to
  build correctly on Apify's own infrastructure — see `cnpj-lookup`
  ADR-006), with only the package name changed (`pncp_lookup` instead of
  `cnpj_lookup`). Apify's build pipeline will build this Dockerfile on
  `apify push` / Console upload — not done yet, see Deployment.
- Behavior under Apify's real platform network conditions/egress (vs.
  this VPS) — no reason to expect a difference (both are generic outbound
  HTTPS), but not independently confirmed.
- No real paid usage, no Apify Store listing yet — this Actor has not
  been deployed anywhere outside this repo and this VPS.

### Deployment

Not yet deployed. Follow the exact same process already verified working
for this developer's first Actor — see
`products/cnpj-lookup/README.md` "Deployment" for the full step-by-step
(Apify signup, `apify-cli login --token`, `npx apify-cli push`, live
`apify-cli call` smoke test, then finishing monetization/publishing via
the Apify REST API once the account-level payout/public-profile
prerequisites are met — those are one-time, already satisfied by the
owner for the `cnpj-lookup` Actor's account, so this Actor should only
need the push + monetize + publish steps, not the account setup again).

**Important — do this before monetizing/publishing, not after**: the live
`apify-cli call` smoke test step is not just a formality for this Actor
the way it was for `cnpj-lookup` (which had already proven the "resolve a
real CNPJ" path live before deploying). PNCP's own build never got a
clean confirmed successful run with real returned data (see "What was
actually tested vs. not" above) — so this smoke test is the first real
chance to confirm the whole pipeline works end-to-end, and should be
treated as a genuine test, not a rubber stamp: run it with a real,
specific filter (e.g. the exact input from this README's Example 1) and
check the output looks like the documented example shape before flipping
`isPublic: true` or setting pricing live.

Once that's confirmed, set pricing from `.actor/pay_per_event.json`
(`tender-record` event at $0.02) via the same `pricingInfos` API call
pattern already verified working for `cnpj-lookup`, and set
`isPublic: true` + appropriate Store categories.

### For the Aurora project (internal)

Built per ADR-008 (second of two candidate Actors approved that ADR;
DataJud, the other candidate, was found infeasible — see ADR-009 — due to
CNJ's terms of use explicitly banning commercial resale). PNCP's own legal
and terms-of-use situation is materially different and stronger: see "Data
source, legal basis & terms of use" above — the open-data obligation is a
direct statutory requirement (Lei 14.133/2021 + Decreto 8.777/2016), not
merely an absence-of-prohibition read of a third-party API's terms page.

Real, unplanned discoveries during this build worth flagging for future
Aurora work on any PNCP-adjacent or similarly slow/rate-limited government
API: (1) always fetch the raw OpenAPI spec directly
(`/api/consulta/v3/api-docs` here) rather than relying on the Swagger UI
page, which is a client-rendered SPA with no static content reachable by
a simple fetch; (2) government API response shapes can defy REST norms in
ways worth testing for explicitly rather than assuming — PNCP's 204-empty-
body-means-zero-results and 429-returns-HTML-not-JSON were both real
surprises that would have caused silent bugs (a crash on `.json()`, or
treating a rate-limit page as valid data) if not specifically probed for
and handled; (3) real-world response latency (12-20s/request here) can be
a bigger practical constraint on product design than rate limits or
auth — it directly shaped this Actor's mode split, date-range cap, and
scan-ceiling design.

# Actor input Schema

## `mode` (type: `string`):

"open\_for\_proposals": tenders currently accepting proposals, closing on or before "closingBefore" (best for "what can I bid on right now"). "published": tenders published within a date range regardless of current status (best for market research / tracking all activity). PNCP's API requires a different endpoint - and different required fields - for each mode.

## `closingBefore` (type: `string`):

Date in YYYY-MM-DD format. Required when "mode" is "open\_for\_proposals". Returns tenders currently open for proposals whose submission deadline is on or before this date.

## `publishedFrom` (type: `string`):

Date in YYYY-MM-DD format. Required when "mode" is "published". Limited to a 31-day window per run (see README).

## `publishedTo` (type: `string`):

Date in YYYY-MM-DD format. Required when "mode" is "published".

## `modalities` (type: `array`):

Optional filter on contracting modality. In "open\_for\_proposals" mode, leave empty for all modalities (one API call). In "published" mode, leaving this empty means this Actor queries all 13 modalities in turn (PNCP's API requires a modality per call for that endpoint) - real extra time/cost, see README.

## `uf` (type: `string`):

Optional. Two-letter Brazilian state code (e.g. "SP", "RJ", "DF") to restrict results to a single state.

## `municipalityIbgeCode` (type: `string`):

Optional. IBGE municipality code (e.g. "3550308" for Sao Paulo city) to restrict results to a single municipality.

## `contractingBodyCnpj` (type: `string`):

Optional. Restrict results to tenders from a specific contracting body's CNPJ (punctuation optional).

## `keyword` (type: `string`):

Optional. Every space-separated word must appear (case-insensitive) in the tender's "objeto" (object/description) text. Applied by this Actor after fetching results - PNCP's own API has no full-text-search parameter (see README "Search semantics").

## `minEstimatedValueBrl` (type: `integer`):

Optional. Only include tenders with an estimated value (valorTotalEstimado) at or above this amount. Applied by this Actor after fetching results - PNCP's API has no min-value parameter. Tenders with no published estimated value are excluded when this filter is set.

## `maxResults` (type: `integer`):

Maximum number of matched tenders to return (and be charged for) in this run. Kept modest by default to keep run time and cost predictable given PNCP's own slow response times (commonly 12-20 seconds per page, out of this Actor's control). Max 500.

## `maxConcurrency` (type: `integer`):

How many modality sub-queries to run concurrently (relevant mainly in "published" mode with multiple modalities). All requests still share one conservative rate limiter underneath, so this mostly affects pipelining, not raw request rate. Max 5.

## Actor input object example

```json
{
  "mode": "open_for_proposals",
  "modalities": [],
  "maxResults": 100,
  "maxConcurrency": 3
}
```

# Actor output Schema

## `results` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {};

// Run the Actor and wait for it to finish
const run = await client.actor("jgoia/pncp-lookup").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {}

# Run the Actor and wait for it to finish
run = client.actor("jgoia/pncp-lookup").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{}' |
apify call jgoia/pncp-lookup --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,jgoia/pncp-lookup"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/ZBccrgmiPQAdgSxdF/builds/eEUXejfaT04CdRV9z/openapi.json
