← DocsPro feature

How to use the ContractRadar API

Call contract search directly from a script or server instead of through the web app. Requires a Pro subscription.

Beta

The response shape may change without notice — there is no versioning promise yet. Tell us what you’re building at hi@contractradar.io; that decides whether this becomes a stable API.

Getting a token

  1. Go to your profile → API Access.
  2. Click Generate token.
  3. Copy it — it’s shown once and can’t be retrieved again.

The token reads search as you. Treat it like a password: keep it on a server or in a secrets store, never in a browser or a public repo. If one was ever sent over plain http://, regenerate it.

Making a request

Send the token as a bearer token against GET /api/v1/posts:

curl -H "Authorization: Bearer crk_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
  "https://contractradar.io/api/v1/posts?q=electrical+wiring&state=TX&pageSize=10"

Query parameters

All optional.

ParameterTypeNotes
qstringKeyword. Matched semantically unless mode says otherwise. Truncated to 200 characters; 400 above 2000.
modestringsemantic (default) or literal. See Matching mode below.
statestring, repeatableTwo-letter code, e.g. state=TX&state=NM.
pageintegerDefault 1. Clamped to a minimum of 1.
pageSizeintegerDefault 20. Clamped to 1–100.
postTypestringContract or Subcontract.
buyerTypestring, repeatableWho is buying: Federal, State, City (cities, towns, townships, boroughs), County, Education (school districts, colleges, universities), SpecialDistrict (water, sewer, housing, transit, airport and library authorities and districts), or Other (nonprofits and companies that post on a public portal). Values are case-insensitive; repeat the parameter to match any of several.
coverageTypestring, repeatableDeprecated alias for buyerType, kept so existing scripts keep working. It accepts only Federal, State, City and County and is merged with buyerType. Any other value, including the retired Regional, is ignored.
sortstringrelevance, posted_desc, posted_asc, deadline_asc, deadline_desc, name_asc, or name_desc. relevance needs q.
postedAfter / postedBefore / deadlineAfter / deadlineBeforedateyyyy-MM-dd, or the literal today. Nothing else.
updatedAfter / updatedBeforedateyyyy-MM-dd, or the literal today. Filters on when we created or last changed the post — poll on this, not postedAfter, whose date comes from the source and is often older than the day we ingested it. Title, deadline, posted date, description, contacts, agency and tags bump it; link, state and externalId do not.
tagstring, repeatableA Key:Value pair, e.g. tag=Set-Aside:8A. Repeat it to filter on several values.
minScore / maxScoredouble, 0–1Band on similarityScore (below), the raw cosine similarity between q and the result — higher is closer. Only meaningful with q; ignored (not rejected) without one, since a browse has nothing to score. An absent minScore defaults to 0.35, not 0 — so a maxScore below 0.35 sent without an explicit minScore returns nothing; send minScore=0 explicitly to search the full range down to 0. An absent maxScore defaults to 1.0. minScore above maxScore, or either outside 0–1 or non-finite, is a 400.

Only sort, mode, the date parameters, and minScore/maxScore reject a bad value with a 400. An unrecognised postType, buyerType (or coverageType), state or tag is silently ignored — the filter just doesn’t apply, so a typo looks like too many results rather than an error.

Matching mode

By default q is matched semantically: we embed your query and rank contracts by meaning, so q=snow removal also returns “winter road maintenance services”.

mode=literal turns that off and matches literally instead: case-insensitive substring, against the contract’s title or description — and nothing else. Not tags, not agency, not externalId. To filter on a tag, use the tag parameter, which matches exactly. No embedding model is called, so results are entirely determined by the characters you send.

Prefer it when you already know the exact string: a solicitation number, a statute or programme name, an agency’s in-house term of art, a specific material or model. Prefer the default when you are describing a kind of work rather than quoting a phrase — mode=literal with q=snow removal will miss every contract that calls it something else.

q must contain at least 3 consecutive letters or digits in mode=literal — so hv, a b and ab% are all rejected, while abc, a bcd and W912DR-24-R-0043 are fine. A pattern without such a run cannot use the index behind this mode, so it is rejected rather than served slowly. Unlike the default, q in mode=literal is never truncated to 200 characters — a literal match has to be exactly what you sent, so a query that would be silently shortened is rejected instead.

Every other parameter behaves identically in both modes, and a literal search costs the same one search against your daily limit. minScore/maxScore are accepted but ignored: a literal match has nothing to score, since it either contains what you typed or it doesn’t. sort=relevance (the default) has the same caveat — there is no similarity to rank by, so it returns matches newest-first, the same as an unranked browse.

One difference to plan for: a literal search works over at most the 1,000 newest matching contracts (by posted date). A broad term returns totalCount: 1000 with totalIsCapped: true — the same way a semantic search reports its candidate cap — and sort orders those 1,000, so sort=deadline_asc means “soonest deadline among the newest 1,000 matches”. Pages past totalPages are empty. If a term is capped, narrow it with more words or filters rather than paging deeper.

Every *Before bound resolves to 00:00:00Z on the named date and matches up to and including that instant, so updatedBefore=2026-09-20 returns nothing later than midnight on the 20th. To include a whole day, use the next date.

Response shape

{
  "items": [
    {
      "id": "8f14e45f-ceea-467e-bd3e-8f1f4c0e1234",
      "externalId": "SAM-2026-00123",
      "title": "Electrical wiring upgrade — Building 4",
      "agency": "General Services Administration",
      "placeOfPerformanceState": "TX",
      "postedDate": "2026-09-10T00:00:00Z",
      "responseDeadline": "2026-10-01T00:00:00Z",
      "postType": "Contract",
      "contractSource": "SamGov",
      "contractSourceShortLabel": "Fed",
      "contractSourceFullLabel": "Federal Contract",
      "contractSourceAbbreviation": "FED",
      "contractSourcePortalName": "SAM.gov",
      "description": "Upgrade of electrical distribution systems...",
      "link": "https://sam.gov/opp/abc123/view",
      "linksToListingPageOnly": false,
      "tags": [{ "key": "Set-Aside", "value": "WOSB" }],
      "canonicalPostId": "8f14e45f-ceea-467e-bd3e-8f1f4c0e1234",
      "similarityScore": 0.47,
      "buyerType": "Federal"
    }
  ],
  "totalCount": 42,
  "totalPages": 3,
  "page": 1,
  "pageSize": 20,
  "totalIsCapped": false
}

tags is always an array, never null. postType and contractSource serialize as strings, not integers.

FieldNotes
idThe ContractRadar post ID.
canonicalPostIdThe post ID that represents this solicitation. Where a portal publishes amendments or re-posts as separate notices, each is its own post and they share one canonicalPostId (the first notice posted); otherwise it equals id. Group results by it to see each solicitation once.
externalIdThe source portal’s own ID for the posting — a bid number, event ID, or whatever that portal uses. Unique per contractSource and stable across syncs, so it is the key to de-duplicate against. It is usually not the solicitation number; where a portal publishes one, it appears as a Solicitation # tag.
titleContract title.
agencyPosting agency, or null.
placeOfPerformanceStateTwo-letter state code, or null.
postedDateISO 8601 UTC.
responseDeadlineISO 8601 UTC, or null when the source gives none.
postTypeContract or Subcontract.
contractSourceThe source pipeline, e.g. SamGov.
contractSourceShortLabel / contractSourceFullLabel / contractSourceAbbreviationDisplay forms of contractSource — short label, longer display label, and a ≤3-char badge code.
contractSourcePortalNameThe portal’s brand name, e.g. SAM.gov.
descriptionFree text, or null.
linkThe original posting, or a portal listing page — see linksToListingPageOnly.
linksToListingPageOnlyTrue when the portal has no per-solicitation deep link, so link points at a search page.
tagsPipeline-specific { key, value } pairs — set-asides, commodity codes, and similar.
similarityScoreRaw cosine similarity to q, 0–1, or null on a browse (no q), a mode=literal result (nothing to score — it either contains what you typed or it doesn’t), or a literal-keyword-fallback result.
buyerTypeWho is buying — Federal, State, City, County, Education, SpecialDistrict or Other — the same values the buyerType filter takes. It describes the posting’s own buyer, not the portal: a school district’s bid is Education even on a city portal. Use it rather than contractSource to tell kinds of buyer apart.
totalCountTotal matching results, or the cap when totalIsCapped is true.
totalPagesTotal pages at the requested pageSize.
page / pageSizeThe resolved (clamped) values used for this response.
totalIsCappedTrue when a keyword search or a mode=literal search hit its candidate cap (200 or 1,000 respectively), so totalCount is that cap.

Exporting to CSV

GET /api/v1/posts/export returns the whole result set as a CSV file rather than a page of JSON. It takes exactly the same parameters as the search above, and ignores page and pageSize. The response is text/csv with a Content-Disposition filename, not JSON.

curl -D headers.txt -o contracts.csv \
  -H "Authorization: Bearer crk_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
  "https://contractradar.io/api/v1/posts/export?state=TX&buyerType=City"

# Was it capped? The header is present only when it was.
grep -i x-export-truncated headers.txt

How many rows you get depends on whether you sent a keyword, and the difference is not a policy we set:

RequestRowsWhy
With qup to 200A keyword search ranks by meaning against a bounded candidate set, so 200 is every result it has — the same cap that makes totalIsCapped true on the search endpoint. Asking for more cannot produce more. (Rarely you may receive more than 200: if semantic ranking is temporarily unavailable, a keyword export falls back to literal matching and the 10,000 cap below, exactly as search does.)
Filters only (no q)up to 10,000With no keyword there is no ranking and no candidate set — the filters run over the whole corpus, so this is a genuine bulk export.

So to pull a large set, express it as filters (state, buyerType, postType, tag, and the date ranges) rather than as a keyword.

Two response headers tell you what you got:

HeaderNotes
X-Export-Row-CountData rows in the file, not counting the header row. Always present. Computed before the file is streamed, so treat it as the expected count rather than a guarantee: compare it against the rows you actually parsed, and retry if they differ. A short file with a dropped connection is how a mid-stream failure reaches you — the status line has already been sent by then, so it will still say 200.
X-Export-Truncatedtrue when the cap bound the result, so there are more matching contracts than the file holds. Sent only then — check for the header’s presence, not for a false value, which is never sent.

Columns, in order. They are the fields of a search result, flattened — so a CSV row and a JSON item carry the same information, with no contact details in either.

Title, Agency, PostedDate, ResponseDeadline, PlaceOfPerformanceState,
PostType, BuyerType, SourceShortLabel, SourceFullLabel, ExternalId,
Description, Link, LinksToListingPageOnly, Tags, ContractRadarPostLink

Dates are ISO 8601 UTC (yyyy-MM-ddTHH:mm:ssZ); a missing value is an empty cell. Tags is flattened to one cell as Key: Value; Key: Value — read the JSON endpoint if you need them structured, since tag values routinely contain commas. ContractRadarPostLink is a link to the contract on ContractRadar, which is worth having because Link points at a portal search page rather than the contract itself when LinksToListingPageOnly is true.

Opening the file

Cells that begin =, +, - or @ are prefixed with a single quote, so a scraped title is never executed as a formula by a spreadsheet. Strip that leading quote if you are parsing the file rather than reading it.

New columns are appended at the end, never inserted, so a script that reads by position keeps working. Prefer reading by header name anyway. The one exception so far: the CoverageType column became BuyerType in the same position, and now holds the posting’s own buyer type (Education, SpecialDistrict and so on) rather than the portal’s level of government.

Errors

StatusMeaning
400A parameter failed validation — a bad date, an unrecognised sort or mode, q over 2000 characters, or (in mode=literal only) a q without a 3-character letter/digit run, or a genuinely too-broad literal term that timed out. The response body names the problem.
401The token is missing, malformed, or revoked.
403Only from /posts/export: the subscription behind the token is no longer active. Search itself keeps working — see below.
429Rate limited. Honour the Retry-After header (seconds). Search and export have separate budgets; the export’s is the tighter of the two, since one request can be a whole result set. mode=literal can also return a 429 of its own under heavy concurrent load on that mode specifically — its body names the reason and it carries no Retry-After; a short pause before retrying is enough.

If your subscription lapses the token keeps working for search, falling back to the free daily search quota; the CSV export is Pro-only and starts returning 403. Generate, regenerate, or revoke the token from your profile.

Ready to try it?

Create a free account and start searching government contracts, or jump straight to the app.