State & Local — Beta¶
State, local and education (SLED) procurement: solicitations that never appear on SAM.gov because they were never federal. For field definitions, see the State & Local Data Dictionary.
Beta — coverage is partial and grows by jurisdiction
There is no national SLED feed. Every jurisdiction publishes on its own portal, in its own vocabulary, on its own schedule, and Tango reads them one at a time. A thin result for a state is at least as likely to be a portal we do not read yet as a quiet market, so call /api/sled/opportunities/coverage/ before treating a per-state count as market size. Field availability varies by state in ways the federal endpoints never taught you to expect — description is absent on every row in several states, and solicitation_number is null wherever the portal publishes none.
Fields and filters may still be added; nothing documented here will be removed without a version bump. The raw(*) expansion is explicitly unstable at any tier (see Shaping).
Authentication: SLED endpoints require authentication (API key or OAuth2). Unauthenticated requests receive HTTP 401. Every filter, every endpoint and the full-text search are available on every plan including Free; three shape leaves — attachments(extracted_text), revisions(changes) and raw(*) — require Small or above.
attachments(extracted_text) needs API version 4.25.1 or later; GET /api/version reports what you are calling.
Endpoints¶
GET /api/sled/opportunities/— state, local and education solicitationsGET /api/sled/opportunities/{opportunity_id}/— one solicitationGET /api/sled/opportunities/{opportunity_id}/revisions/— that solicitation's observed amendment historyGET /api/sled/opportunities/coverage/— what this dataset actually covers, per stateGET /api/sled/forecasts/— planned state procurementsGET /api/sled/forecasts/{forecast_id}/— one forecast
Detail routes are keyed by a Tango-minted UUID (opportunity_id / forecast_id) and the path segment must be a valid RFC 4122 UUID — anything else is a 404, not a 500.
SLED is its own namespace rather than a domain on /api/opportunities/, and deliberately so: the state schema shares almost no columns with a SAM notice. There is no NAICS or PSC on most rows, no set-aside, no office, no notice type, and no entry in the federal agency hierarchy. Folding these rows into the federal endpoint would degrade the federal shape and drag state solicitations into every existing federal filter, search and alert. /api/opportunities/ returns no SLED rows, and /api/sled/opportunities/ returns no federal ones.
Two things to read before your first query¶
A list request with no liveness filter returns open solicitations only¶
Only about a fifth of the corpus is open at any time, and a state portal drops a closed solicitation rather than restating it as closed. So the list endpoint defaults to status=open.
- To page the whole corpus, pass an explicit
status=(oractive=) filter. status=unknown— standing vendor rosters and dateless RFIs, which have no deadline to have passed — is hidden by that default. Reach it withstatus=open|unknown.- A detail request returns the solicitation whatever its status. The default lives on the list action only, so a closed solicitation still resolves on its own URL.
status is Tango's answer, not the portal's¶
status is one of open, closed, awarded, cancelled or unknown, derived from the portal's own word, the response deadline and the clock — and refreshed every fifteen minutes whether or not the feed moved. status_reason says which input decided it (deadline_future, deadline_past, no_deadline, source_terminal, delisted), and status_computed_at says when.
A solicitation the portal stops listing before its deadline reads closed with status_reason=delisted. delisted_at is when a complete crawl of that portal first found it gone; it is null on anything never delisted. The portal's own terminal word (awarded, cancelled) still outranks a delisting, and a delisting outranks the deadline. If the portal lists the solicitation again, delisted_at clears and status is derived from the deadline as before. delisted_at needs API version 4.25.3 or later.
The portal's own status word is a different and much weaker fact: it is frozen at last capture, and most of what the feeds call open already has a deadline in the past. That word is served as source_status so you can see the disagreement, but do not filter liveness on it. The portal's full raw status vocabulary lives behind the Small-and-above raw(*) expansion.
Forecasts carry no status at all — see Forecasts.
Coverage¶
GET /api/sled/opportunities/coverage/ takes no parameters and returns one cached rollup (counts below are illustrative — call the endpoint for live ones):
{
"generated_at": "2026-09-10T14:00:00Z",
"totals": {
"opportunities": 46479,
"forecasts": 1452,
"jurisdictions_covered": 51,
"by_status": {"open": 9207, "closed": 35102, "awarded": 812, "cancelled": 604, "unknown": 754}
},
"states": [
{
"state": "AL",
"total_count": 812,
"by_status": {"open": 131, "closed": 651, "awarded": 12, "cancelled": 4, "unknown": 14},
"jurisdictions": ["local", "state"],
"last_change_observed_at": "2026-09-10T06:12:44Z"
}
]
}
Every state row carries all five status buckets whether or not they have rows, so a total and two buckets never invite you to subtract. last_change_observed_at is the most recent scrape that saw any solicitation in that state — a stale value there means we have not read the portal lately, not that the market is quiet.
The payload is counts and nothing else, by design. The caveats that make a particular state's number readable belong here in the documentation, where they can be corrected; prose shipped in a data payload cannot be kept true.
Read it before concluding that a scheme-specific category filter is broken (see Category codes) or that a state is empty.
Solicitations¶
GET /api/sled/opportunities/
One row is one advertised solicitation, arbitrated across every scrape that observed it, keyed by a stable opportunity_id.
Filtering¶
Identifier filters accept | (or the word OR) for OR. Date filters require YYYY-MM-DD; invalid dates or inverted ranges return 400 (see Date filters). search and agency must be at least 2 characters.
| Param | What it does |
|---|---|
state | Two-letter state or territory code. The primary partition. Multi-value: use \| for OR. |
jurisdiction | Level of government: state, local or education. unknown reaches the aggregator rows that cannot tell state from local. Multi-value. |
status | open, closed, awarded, cancelled or unknown. Absent on a list request, this defaults to open. Multi-value. |
active | Boolean sugar for federal-shaped callers: active=true is status=open, active=false is its complement (so it includes unknown). |
agency | Substring match on the buyer's raw text. There is no code resolution behind this — state agencies have no entry in the federal organization tree. Min 2 characters. |
solicitation_number | The number a human would quote (exact, case-insensitive). Null on roughly a third of the corpus, where the portal publishes none. Multi-value. |
solicitation_type | rfp, ifb, rfq, rfi, itb, sole_source, grant or other. null (the portal states no type) is a distinct answer from other (a type the vocabulary does not recognize). Multi-value. |
has_documents | Boolean. Whether the solicitation advertises at least one document. |
revision_kind | The kind of the most recent substantive revision: deadline_change, status_change, documents_added, documents_removed, documents_replaced, title_change or content_change. Multi-value. |
naics | Exact match within the naics category scheme. Thin on purpose — see Category codes. Multi-value. |
nigp | Exact match within the nigp scheme (the most widely tagged of the four). Multi-value. |
unspsc | Exact match within the unspsc scheme. Multi-value. |
category | Exact match within the text scheme, where the code is the portal's own human label. Multi-value. |
category_code | Matches a code under any scheme, including the untagged strings a pre-migration row still carries. The escape hatch when a scheme-specific filter returns less than you expected. Multi-value. |
posted_after, posted_before | Posted-date range (YYYY-MM-DD). posted_date is null on a sixth of the corpus. |
response_deadline_after, response_deadline_before | Response-deadline range. |
first_seen_after, first_seen_before | When Tango first observed the solicitation. This is the polling primitive: everything new since your last call. |
change_seen_after | When Tango observed the last substantive change. A scrape date, not an amendment date — see Revisions. |
modified_after, modified_before | When the Tango row last changed. |
platform, native_id, external_id | Support filters for reproducing a specific record with us. They appear in no response shape and their values are not a contract — quote solicitation_number to a human. external_id accepts at most 500 values. |
search | Ranked full-text search — see Search. Min 2 characters. |
verbose | Boolean. verbose=true adds description and contact(*) to the default list shape without costing a join. |
No enum filter is a strict choice list: an unrecognized value returns an empty page rather than a 400, because the live vocabularies are wider than any list transcribed into a doc.
Category codes¶
category_codes is an array of {scheme, code} objects over nigp, unspsc, naics, text and unknown, and the scheme tagging is mid-migration. Roughly half of all entries corpus-wide are still bare pre-migration strings carrying no scheme, which read as unknown.
The practical consequence: ?naics= matches only the entries that are scheme-tagged NAICS, which is a very small share of the corpus. That is a fact about the tagging, not about coverage. ?category_code=541620 matches that code under any scheme and the untagged strings, and is what you want unless you specifically need scheme precision.
Nothing here resolves against the federal NAICS or PSC tables. A state's NIGP commodity code is not a PSC and there is no crosswalk in this dataset.
Ordering¶
ordering= allowlist: rank, response_deadline, posted_date, first_seen_at, last_seen_at, last_change_seen_at, modified.
rank requires a non-empty search — passing ordering=rank without one returns 400. Under search= with no explicit ordering, results sort by relevance. response_deadline, posted_date and last_change_seen_at sort nulls last in both directions, so a page of results is never headed by rows with no date.
Search¶
?search= is a ranked full-text query over title, agency, identifiers, category labels and description, weighted in that order, widened by the solicitations whose attachment text matched.
Three properties worth knowing:
- A title match outranks a body match, and a document-only match ranks below both.
descriptionis null on about a quarter of open rows and absent entirely in several states, so ranking body matches first would bury the states that publish well. snippetis the passage that matched. Undersearch=, a row that matched on its description carries asnippetfield with the matching passage rather than the whole body — which is the answer to "why is this row here" on a resource whose descriptions run to six figures of characters. A row that matched on title or agency alone carries nosnippet; that is the honest answer rather than the opening sentence of its description.- Document matching returns ids, never text — and that is not a tier gate. When a solicitation's attached PDF matches, the solicitation appears in your results. You get no snippet, no highlight and no fragment of the document: you learn that a document matched, never what it said. Because the response carries none of the text, searching it is ungated and available on every plan including Free — there is no tier check anywhere on this path.
Reading a document's body is a separate, paid thing, and it is not part of search.
attachments(extracted_text)serves the extracted text on Small and above — see Attachments. Searching that text stays ungated because a search response gives none of it away; buying the body buys the body, not better search.So there are two independent questions. Did a document match? — every plan, through
search=, as ids. What does the document say? — Small and above, throughattachments(extracted_text), one record at a time. Every plan also gets enough metadata to judge a document without reading it:size_bytes,char_count,pagesandextraction_status.Document matching fails open: when it is unavailable, the metadata search answers on its own — you get fewer rows, never an error. Treat a document match as a bonus signal rather than something to depend on for completeness.
Shaping¶
See Response shaping for the syntax. Pass show_shapes=true on any request to have the API report the shape tree your plan can reach.
- List default:
opportunity_id,solicitation_number,solicitation_type,title,organization(*),status,status_reason,delisted_at,posted_date,response_deadline,bid_opening_date,source_url,has_documents,category_codes,first_seen_at,last_seen_at,last_change_seen_at,meta(*) ?verbose=truelist default: the list fields plusdescriptionandcontact(*)- Detail default: the list fields plus
description,solicitation_type_source,status_computed_at,response_deadline_original,bid_opening_raw,contact(*),attachments(*),revisions(*)
description is detail-only on purpose: its median is around 550 characters and its tail runs past 120,000, so twenty-five of them is not a list page. Reach it on a list by naming it, by ?verbose=true, or by snippet under search=.
Expansions:
| Expand | Leaves | Notes |
|---|---|---|
organization(*) | state, level, agency | The buyer. Not the federal 7-key office payload — there is no code resolution, and no organization_id to join on. |
contact(*) | name, email, phone | The buyer contact as the portal publishes it. |
meta(*) | attachment_count, revision_count, last_revision_kind, last_change_source_declared | Denormalized counts; costs no join. |
attachments(*) | 13 leaves — see Attachments | Document metadata and extraction stats. The wildcard never carries extracted_text — name it explicitly. |
revisions(*) | observed_at, sequence, kind, changed_fields, changes, source_declared | The observed amendment history, minus enrichment rows. changes requires Small or above. |
attachments(extracted_text) | the document's extracted body | Small or above, and opt-in only — see Attachments. |
raw(*) | wildcard over the platform's own keys | The platform's unparsed payload. Small or above, and explicitly unstable — its shape varies by platform and is not a contract at any tier. |
Below Small, naming raw(*), revisions(changes) or attachments(extracted_text) gets the node withheld and listed in meta.upgrade_hints; under a bare revisions(*) the changes key is simply absent, with no hint.
meta.attachment_count can be lower than the length of attachments(*), and that is not a bug. Some portals auto-generate a cover sheet alongside the real documents; it is a file, not a document the agency wrote, so it is excluded from the count and from has_documents but still listed — flagged is_generated_summary: true. attachment_count answers "does this record hold its solicitation package"; the collection answers "what files exist". A record whose only file is a generated sheet holds no package.
Attachments¶
attachments(*) leaves — available on every plan: name, extension, mime_type, checksum, size_bytes, char_count, word_count, pages, extraction_status, download_status, is_contested, is_generated_summary, first_seen_at.
One further leaf, extracted_text, requires Small or above and is not included by attachments(*) — see below.
There is no download URL on this endpoint. The free metadata is enough to decide whether a document is worth fetching:
size_bytesandchar_countonly mean something as a pair. Three megabytes that yielded no characters is a scan awaiting OCR; three megabytes that yielded 40,000 characters is a specification. Neither number says that alone.extraction_statusiscompleted,emptyorfailed.emptyis extraction succeeding with zero characters — almost always a scanned image, which is a different problem from one that threw.is_contested: truemeans the stored object's checksum disagrees with what this record advertised — another solicitation's bytes under the same key. A contested attachment is served as a name and an existence, never as content.is_generated_summary: trueis the auto-generated cover sheet described above.
extracted_text — the document body¶
GET /api/sled/opportunities/{opportunity_id}/?shape=opportunity_id,attachments(name,size_bytes,extracted_text)
Requires a Small plan or above, and API version 4.25.1 or later. Three rules govern it:
- You have to name it.
attachments(*)does not carry the body, and neither default shape includes it. Addextracted_textto the expand when you want it, and leave it off when you are just listing what documents exist — a record's documents can run to megabytes of text. - The key is absent, not null, whenever the text is not being served to you: below Small (where it is withheld and listed in
meta.upgrade_hints), on a contested document, or where we hold no text for that document. Read it with a default rather than assuming the key is present. - A contested document never returns text, at any plan. The file we retrieved for it does not match what the solicitation listed, so we cannot vouch that its text belongs to this record.
is_contestedis why the body is missing.
Two entries with the same checksum are the same document under two filenames, and return the same body.
This is a per-record read, not a search feature: a ?search= response never carries text at any plan (see Search). char_count reports whether there was text to get, whether or not this response carries it — which is what lets you decide on a Free plan whether the body is worth buying.
Revisions¶
GET /api/sled/opportunities/{opportunity_id}/revisions/
A revision is a diff between two consecutive arbitrated states of one solicitation, stamped with the scrape that saw it. No state portal emits amendment notices, so this is observation rather than publication, and three limits follow from that:
kindis Tango's inference from the diff —deadline_change,status_change,documents_added,documents_removed,documents_replaced,title_change,content_change— on about 95% of revisions.source_declaredistrueonly on the minority where a portal's own amendment marker moved in the same emission.- Resolution is the crawl cadence, not the amendment. A portal restates one page in place and publishes no amendment date, so
observed_atis when we saw the change, not when the agency made it.change_seen_afteron the parent endpoint filters on the same timestamp and inherits the same caveat. - History starts when we started reading that jurisdiction, not when the solicitation was posted. For the most recently added sources that is days, not months.
This route serves enrichment rows, which the revisions(*) expansion does not: an enrichment is Tango filling in coverage it did not have — a description it had not yet read, say — rather than the agency amending anything. It is excluded from revision_count and from the expansion for that reason, but it is real provenance, and ?kind=enrichment shows only those.
Filtering¶
| Param | What it does |
|---|---|
kind | Revision kind, plus enrichment. Multi-value: use \| for OR. |
source_declared | Boolean. Whether the portal's own amendment marker moved at this emission. |
observed_after, observed_before | Observation-date range (YYYY-MM-DD). |
Results are ordered by sequence (1-based within the record) and paginated like every other list.
changes — the per-field before and after — requires Small or above. changed_fields names what moved at every plan, so the Free answer is still a usable amendment log.
Forecasts¶
GET /api/sled/forecasts/
A forecast is a state's own advance notice of something it intends to buy. Maryland is the only state publishing one today; /api/sled/opportunities/coverage/ reports the forecast total alongside the solicitation coverage.
Forecasts carry no liveness at all. There is no deadline to have passed, so a status column would stamp unknown on every row forever. Currency is your call from estimated_advertisement_date — which is the start of the published quarter, not a posting date. estimated_advertisement_raw keeps the portal's own words ("Q3 (Jan.-March 2027)"), and a large share of rows publish no quarter at all.
Filtering¶
search and agency must be at least 2 characters; date filters require YYYY-MM-DD.
| Param | What it does |
|---|---|
state | Two-letter state code. Multi-value: use \| for OR. |
agency | Substring match on the buyer's raw text. Min 2 characters. |
procurement_category | The portal's own category word, passed through verbatim. Multi-value. |
procurement_method | The portal's own method word, passed through verbatim. Multi-value. |
contract_number | The number the state expects to award under, where it publishes one in advance. Multi-value. |
incumbent_name | Substring match on the incumbent vendor's name as published. Not resolved to a Tango entity. Multi-value. |
advertisement_after, advertisement_before | Estimated-advertisement-date range. Remember the date is a quarter start. |
first_seen_after, first_seen_before | When Tango first observed the forecast. |
modified_after, modified_before | When the Tango row last changed. |
search | Ranked full-text search over title, agency and description. Min 2 characters. |
Ordering¶
ordering= allowlist: rank, estimated_advertisement_date, first_seen_at, last_seen_at, modified. rank requires a non-empty search. estimated_advertisement_date sorts nulls last.
Shaping¶
- List default:
forecast_id,state,agency,title,description,organization(*),estimated_advertisement_date,estimated_advertisement_raw,estimated_value(*),procurement_category,procurement_method,contract_term,contract_number,incumbent_name,mbe_dbe_goal,source_url,first_seen_at,last_seen_at - Detail default: the list fields plus
contact(*),delivery_location,has_documents
| Expand | Leaves | Notes |
|---|---|---|
organization(*) | state, level, agency | Narrower than a solicitation's — the forecast feed carries no sub-state jurisdiction. |
contact(*) | name, email, phone | As published. |
estimated_value(*) | min, max, raw | Parsed from the portal's free-text award band, with the band preserved as raw. A band naming one number is a floor, so max is null — never read a missing max as an unbounded ceiling. |
raw(*) | wildcard | Small or above, and unstable. |
Pagination¶
Standard page-number pagination on every list, including the nested revisions route:
page(default 1)limit(default 25, max 100)
Responses carry count / next / previous / results. List responses are cached for up to 15 minutes and the cache is scoped by plan, so a Free caller never receives a Small caller's raw(*).
Alerts¶
sled_opportunity is an alertable query type — alerts.sled_opportunity.match, using the same filters documented above. See the webhooks guide.
Unlike exclusions and DIBBS, a SLED solicitation closing does fire an alert. status is a stored column Tango recomputes every fifteen minutes, so a closure is a real change to the record rather than a query-time derivation. sled_forecast is not alertable (no deadline, so no transition); revisions and attachments are not alertable on their own — subscribe to the solicitation and filter on change_seen_after or revision_kind.
What this dataset is not¶
- Not federal, and not joinable to it. No UEI, no PIID, no NAICS/PSC crosswalk, no agency-hierarchy key.
organization(*)here is three strings, not the 7-key federal office payload. - Not a complete picture of any state. See Coverage.
- Not a document repository. Attachment metadata and extraction stats are served; bodies and download URLs are not.
- Not award data.
status=awardedrecords that a portal declared an award; there is no awardee, value or contract record behind it.
Examples¶
# Open solicitations in Texas closing this month
GET /api/sled/opportunities/?state=TX&response_deadline_before=2026-10-01
# Local and education buyers with documents attached
GET /api/sled/opportunities/?jurisdiction=local|education&has_documents=true
# Everything new since your last poll, including dateless standing rosters
GET /api/sled/opportunities/?first_seen_after=2026-09-01&status=open|unknown
# Full-text search, with the matching passage on each row
GET /api/sled/opportunities/?search=environmental%20mitigation&shape=opportunity_id,title,state,snippet,response_deadline
# A category code under any scheme, since scheme tagging is partial
GET /api/sled/opportunities/?category_code=541620
# One solicitation, whatever its status, with its documents
GET /api/sled/opportunities/550e8400-e29b-41d4-a716-446655440000/?shape=opportunity_id,title,status,attachments(*)
# What changed on it, and when we saw it change
GET /api/sled/opportunities/550e8400-e29b-41d4-a716-446655440000/revisions/?kind=deadline_change
# Coverage, before trusting any per-state count
GET /api/sled/opportunities/coverage/
# Maryland forecasts advertising after this quarter
GET /api/sled/forecasts/?state=MD&advertisement_after=2026-10-01