# US Local Bid Portals Scraper — State, County, City, Schools (`dami_studio/us-local-bid-portals-scraper`) Actor

One row per open solicitation from nine US sub-federal procurement feeds: Los Angeles, New York City, Baton Rouge, Montgomery County MD, Washington DC, Delaware, Illinois CDB, SUNY Construction, and every US school district and library with a live FCC Form 470.

- **URL**: https://apify.com/dami\_studio/us-local-bid-portals-scraper.md
- **Developed by:** [Dami's Studio](https://apify.com/dami_studio) (community)
- **Categories:** Lead generation, Business, Other
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $16.00 / 1,000 solicitation scrapeds

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/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## US Local Bid Portals Scraper — state, county, city and school-district solicitations

SAM.gov covers federal contracts. It does not cover the city that needs a paving contractor, the
county buying security guard services, or the school district that has to replace its network before
the funding year closes. Those live on a few hundred separate portals, most of which have no feed at
all, and the handful that do publish machine-readable data are the ones this Actor reads.

**Nine feeds. One row per open solicitation.** Who is buying, what they want, when it closes, and the
link to bid.

***

### What is actually covered — read this before you buy

This is the honest part, and it is short on purpose. Counts are the number of solicitations that were
genuinely open on **20 September 2026**, the day each feed was measured.

| Feed | Level | State | Open that day |
|---|---|---|---|
| City of Los Angeles (RAMP) | City | CA | 429 |
| City of New York | City | NY | 4 |
| Baton Rouge / East Baton Rouge Parish | City-parish | LA | 9 |
| Montgomery County | County | MD | 17 |
| Washington DC — Office of Contracting and Procurement | District | DC | 33 |
| State of Delaware | State | DE | 43 |
| Illinois Capital Development Board | State | IL | 47 |
| State University Construction Fund | State authority | NY | 3 |
| **US school districts, schools and libraries** — FCC Form 470 | School district | **All 50** | **226** |

That last row is the one most people come for. Every US school district and library that wants
E-Rate funding for internet, network hardware or telecom has to file an FCC Form 470 and then wait
28 days before it can sign with anyone. That waiting period *is* the competitive bidding window, and
it is public. A form whose window is still open is a district actively taking bids. On the day this
was measured there were 226 of them, from Rocklin Unified in California to Sampson County in North
Carolina.

**Now the gaps, stated plainly.**

- **Eight named jurisdictions is eight, not "US local government."** Los Angeles, New York City,
  Baton Rouge, Montgomery County MD, Washington DC, Delaware, the Illinois Capital Development Board
  and the SUNY Construction Fund. If your target city is not on that list, it is not in here. The
  school-district feed is the only nationwide one.
- **Most US local portals publish nothing a machine can read.** They are login-gated vendor portals
  or HTML pages behind a session. Those were checked and left out rather than half-scraped.
- **The school-district feed tells you a district is buying, not every detail of what.** You get the
  requirement description the district wrote, the funding year, the entity number and a link to the
  certified Form 470 PDF. Where the district attached a separate RFP document, the form says so and
  the document lives on the district's own site.
- **New York City's feed is thin.** Four open RFPs the day it was measured, because the city only
  publishes a slice of its pipeline this way.
- **Nothing here is EU, UK or US federal.** If that is what you need, it is a different job and we
  have a separate Actor for it.

***

### What you get

One row per solicitation, the same shape from every feed:

```json
{
  "source": "montgomery-county-md",
  "jurisdiction": "Montgomery County, MD",
  "jurisdictionLevel": "county",
  "state": "MD",
  "solicitationId": "1196087",
  "title": "County-Wide Contract Security Officer Services",
  "summary": null,
  "solicitationType": "RFP",
  "category": null,
  "status": "Active",
  "postedDate": "2026-08-10T00:00:00.000Z",
  "dueDate": "2026-10-28T00:00:00.000Z",
  "daysUntilDue": 38,
  "buyerDepartment": "Police",
  "estimatedValue": null,
  "url": null,
  "documentUrl": null,
  "portalUrl": "https://www.montgomerycountymd.gov/PRO/",
  "scrapedAt": "2026-09-20T09:58:12.104Z"
}
```

`daysUntilDue` is computed at run time, so you can sort by urgency without doing date arithmetic.
Some fields are null on some feeds — a portal that does not publish an estimated value cannot have
one invented for it, so the field stays empty rather than being guessed.

***

### No contact harvesting, and how that is enforced

These are public records, so they name people: the buyer who signed the notice, the procurement
officer's direct line, the district technology co-ordinator's email. **None of that is delivered.**

Two separate steps, because one was not enough:

1. The contact columns are never requested. On the school-district feed that means four whole blocks
   of the source data — `contact_*`, `technical_contact_*`, `authorized_person_*` and the entity's
   own email and phone — plus its latitude and longitude, are left out of the query. They never cross
   the wire.
2. Email addresses and phone numbers are stripped out of the free-text description before the row is
   written. This matters more than it sounds: of 60 live school-district forms checked on
   20 September, **15 had a named person's email typed into the requirement text**. Those become
   `[contact removed]`. The requirement itself is untouched.

If you need the contact, it is on the notice, on the jurisdiction's own portal, where they published
it. Every row carries that link.

***

### Input

Press Start with the form as it comes and you get open solicitations from all nine feeds.

| Field | What it does |
|---|---|
| `sources` | Which feeds to read. All nine by default. |
| `keywords` | Words that must appear in the title, category or buying department. OR'd together, so `["paving","asphalt"]` returns both. Each portal does this filtering itself, so a narrow keyword costs less than a broad one. |
| `states` | Two-letter codes. A feed belonging to a state you did not list is not queried at all, and the school-district feed is narrowed to the states you name. |
| `onlyOpen` | On by default. Off gives you the portal's whole list, closed ones included. |
| `maxItems` | Hard ceiling for the run, 5000 max. Shared across the feeds you picked, so one large city cannot use up the whole allowance. |

```json
{
  "sources": ["erate-f470", "montgomery-county-md", "dc-ocp"],
  "keywords": ["network", "fiber", "wireless"],
  "states": ["TX", "OK", "NM"],
  "onlyOpen": true,
  "maxItems": 300
}
```

***

### A portal that cannot answer says so

An HTTP 200 is not the same as an answer, and this is where bid feeds go wrong. A portal can hand
back a perfectly valid response that is somebody else's data, or an unfiltered list when you asked
for a filter, or a login page wearing a JSON content type.

So every page is checked before a single row is delivered:

- **Is it the right dataset?** The columns have to be the ones that feed is supposed to have. A
  portal that quietly changes shape is skipped, not parsed hopefully.
- **Did it apply the filter?** If you asked for open solicitations and rows come back already closed,
  or you asked for a keyword and the rows do not contain it, that feed is answering a different
  question. Its whole page is dropped.
- **Is it JSON at all?** An HTML body on a JSON endpoint is a refusal, however it is dressed.

When any of those fails you get an uncharged row naming the feed, the reason in plain English, and
the portal's own URL so you can go and look. A dead feed is never a silent zero, and it never costs
you anything.

***

### Pricing

**$16.00 per 1,000 solicitations**, plus a **$0.0005 start fee per run**. One charge per
solicitation delivered, flat on every plan — no volume tiers, no minimum, no subscription.

A solicitation here is a live bid from a named public buyer, with its closing date and the link to
the jurisdiction's own posting. The last full sweep returned 833 of them from 302 distinct buying
entities, 189 of which were independent school districts across 35 states.

**Free every time:** the sample row an empty run returns, the notice when a feed is skipped, the
"nothing matched" row, and any error row. You pay for solicitations, so a run that finds none costs
you the $0.0005 and nothing else.

***

### What this does not do

- It does not cover every US city, county or state. Eight jurisdictions plus the nationwide
  school-district feed, listed above with counts.
- It does not log into vendor portals, create accounts, or accept your portal credentials.
- It does not download bid packets or attachments. It gives you the link the jurisdiction published.
- It does not score, rank or recommend anything. It returns the solicitations; the bid/no-bid call is
  yours.
- It does not return contact names, emails or phone numbers. See above for why and how.
- It does not cover EU, UK or US federal tenders.
- It does not promise a feed will still be there next month. Jurisdictions retire datasets without
  warning; when one does, the run tells you which and keeps going with the rest.

***

### FAQ

**Is there an API key or a login?** No. Every feed here is a public government endpoint that answers
without credentials. Nothing is stored, nothing is signed in.

**How fresh is the data?** As fresh as the jurisdiction makes it. Los Angeles, Montgomery County and
the school-district feed had all been updated the same day they were measured. Delaware and Illinois
were within three days. The feed's own timestamps come through in `postedDate`.

**Why is New York City only showing a handful?** Because that is what the city publishes on this
feed. Four open RFPs on 20 September. It is a real number, not a broken query.

**What does `dueDate` mean on a school-district row?** The close of the FCC's 28-day competitive
bidding window — the first day the district is allowed to sign with a vendor. Up to that date it is
still taking bids. Measured across the newest forms, the gap between certification and that date sits
at a consistent 27 days.

**Can I run it on a schedule?** Yes, and that is the sensible way to use it. Point it at your
keywords and states, run it daily, and sort by `daysUntilDue`.

**Can you add my city?** Only if it publishes a machine-readable feed. Most do not. Send the portal
and it can be checked.

**Why does a row have empty fields?** Because that jurisdiction does not publish that field. Nothing
is filled in with a guess.

# Actor input Schema

## `sources` (type: `array`):

Which procurement feeds to read. Leave every box ticked to check all nine. Each one is a single jurisdiction except the school-district feed, which is nationwide.

## `keywords` (type: `array`):

Words that must appear in the solicitation title, category or buying department. Each portal does this filtering itself, so a narrow keyword costs less than a broad one. Leave empty for everything that is open. Terms are OR'd: "paving", "asphalt" returns both.

## `states` (type: `array`):

Two-letter state codes. A portal that belongs to a state you did not list is not queried at all, and the school-district feed is filtered to the states you name. Leave empty for everywhere.

## `onlyOpen` (type: `boolean`):

On: only solicitations whose closing date is still ahead. Off: the portal's whole list, closed ones included. Leave it on unless you are building a history.

## `maxItems` (type: `integer`):

Hard ceiling on rows for this run, shared across the portals you picked so one big portal cannot use up the whole allowance. Maximum 5000.

## Actor input object example

```json
{
  "sources": [
    "la-city",
    "nyc",
    "baton-rouge",
    "montgomery-county-md",
    "dc-ocp",
    "delaware-state",
    "illinois-cdb",
    "suny-construction",
    "erate-f470"
  ],
  "keywords": [
    "security",
    "software"
  ],
  "states": [],
  "onlyOpen": true,
  "maxItems": 200
}
```

# Actor output Schema

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

Every row in the default dataset: source, jurisdiction, jurisdictionLevel, state, solicitationId, title, summary, solicitationType, category, status, postedDate, dueDate, daysUntilDue, buyerDepartment, estimatedValue, url, documentUrl, portalUrl, scrapedAt. A portal that could not answer, or that ignored a filter, appears as an uncharged row saying which portal and why.

# 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 = {
    "sources": [
        "la-city",
        "nyc",
        "baton-rouge",
        "montgomery-county-md",
        "dc-ocp",
        "delaware-state",
        "illinois-cdb",
        "suny-construction",
        "erate-f470"
    ],
    "keywords": [
        "security",
        "software"
    ],
    "states": [],
    "onlyOpen": true,
    "maxItems": 200
};

// Run the Actor and wait for it to finish
const run = await client.actor("dami_studio/us-local-bid-portals-scraper").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 = {
    "sources": [
        "la-city",
        "nyc",
        "baton-rouge",
        "montgomery-county-md",
        "dc-ocp",
        "delaware-state",
        "illinois-cdb",
        "suny-construction",
        "erate-f470",
    ],
    "keywords": [
        "security",
        "software",
    ],
    "states": [],
    "onlyOpen": True,
    "maxItems": 200,
}

# Run the Actor and wait for it to finish
run = client.actor("dami_studio/us-local-bid-portals-scraper").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 '{
  "sources": [
    "la-city",
    "nyc",
    "baton-rouge",
    "montgomery-county-md",
    "dc-ocp",
    "delaware-state",
    "illinois-cdb",
    "suny-construction",
    "erate-f470"
  ],
  "keywords": [
    "security",
    "software"
  ],
  "states": [],
  "onlyOpen": true,
  "maxItems": 200
}' |
apify call dami_studio/us-local-bid-portals-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,dami_studio/us-local-bid-portals-scraper"
        }
    }
}
```

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/BXc6S4nmBPi161ruY/builds/FbZjb6HSW5EMjcTiP/openapi.json
