Call contract search directly from a script or server instead of through the web app. Requires a Pro subscription.
Beta
Getting a token
- Go to your profile → API Access.
- Click Generate token.
- 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"import os
import requests
token = os.environ["CONTRACTRADAR_TOKEN"]
params = {"q": "electrical wiring", "state": "TX", "pageSize": 10}
# repeat a key for more than one value: params["state"] = ["TX", "NM"]
r = requests.get(
"https://contractradar.io/api/v1/posts",
headers={"Authorization": f"Bearer {token}"},
params=params,
)
r.raise_for_status()
items = r.json()["items"]// Node 18+ ESM (.mjs, or "type": "module"), server-side only — never ship this token to a browser.
const token = process.env.CONTRACTRADAR_TOKEN;
if (!token) throw new Error("CONTRACTRADAR_TOKEN is not set");
const params = new URLSearchParams({ q: "electrical wiring", state: "TX", pageSize: "10" });
// params.append("state", "NM") to add another state
const res = await fetch(`https://contractradar.io/api/v1/posts?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
const { items } = await res.json();using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
var token = Environment.GetEnvironmentVariable("CONTRACTRADAR_TOKEN")
?? throw new InvalidOperationException("CONTRACTRADAR_TOKEN is not set");
var query = "q=electrical+wiring&state=TX&pageSize=10";
// repeat state=XX&state=YY to filter on more than one
using var client = new HttpClient { BaseAddress = new Uri("https://contractradar.io") };
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var page = await client.GetFromJsonAsync<JsonElement>($"/api/v1/posts?{query}");
var items = page.GetProperty("items");Query parameters
All optional.
| Parameter | Type | Notes |
|---|---|---|
q | string | Keyword. Matched semantically unless mode says otherwise. Truncated to 200 characters; 400 above 2000. |
mode | string | semantic (default) or literal. See Matching mode below. |
state | string, repeatable | Two-letter code, e.g. state=TX&state=NM. |
page | integer | Default 1. Clamped to a minimum of 1. |
pageSize | integer | Default 20. Clamped to 1–100. |
postType | string | Contract or Subcontract. |
buyerType | string, repeatable | Who 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. |
coverageType | string, repeatable | Deprecated 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. |
sort | string | relevance, posted_desc, posted_asc, deadline_asc, deadline_desc, name_asc, or name_desc. relevance needs q. |
postedAfter / postedBefore / deadlineAfter / deadlineBefore | date | yyyy-MM-dd, or the literal today. Nothing else. |
updatedAfter / updatedBefore | date | yyyy-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. |
tag | string, repeatable | A Key:Value pair, e.g. tag=Set-Aside:8A. Repeat it to filter on several values. |
minScore / maxScore | double, 0–1 | Band 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.
| Field | Notes |
|---|---|
id | The ContractRadar post ID. |
canonicalPostId | The 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. |
externalId | The 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. |
title | Contract title. |
agency | Posting agency, or null. |
placeOfPerformanceState | Two-letter state code, or null. |
postedDate | ISO 8601 UTC. |
responseDeadline | ISO 8601 UTC, or null when the source gives none. |
postType | Contract or Subcontract. |
contractSource | The source pipeline, e.g. SamGov. |
contractSourceShortLabel / contractSourceFullLabel / contractSourceAbbreviation | Display forms of contractSource — short label, longer display label, and a ≤3-char badge code. |
contractSourcePortalName | The portal’s brand name, e.g. SAM.gov. |
description | Free text, or null. |
link | The original posting, or a portal listing page — see linksToListingPageOnly. |
linksToListingPageOnly | True when the portal has no per-solicitation deep link, so link points at a search page. |
tags | Pipeline-specific { key, value } pairs — set-asides, commodity codes, and similar. |
similarityScore | Raw 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. |
buyerType | Who 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. |
totalCount | Total matching results, or the cap when totalIsCapped is true. |
totalPages | Total pages at the requested pageSize. |
page / pageSize | The resolved (clamped) values used for this response. |
totalIsCapped | True 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.txtimport os
import requests
token = os.environ["CONTRACTRADAR_TOKEN"]
# stream=True — a browse export can be 10,000 rows of description text (~20 MB).
with requests.get(
"https://contractradar.io/api/v1/posts/export",
headers={"Authorization": f"Bearer {token}"},
params={"state": "TX", "buyerType": "City"},
stream=True,
) as r:
r.raise_for_status()
with open("contracts.csv", "wb") as f:
for chunk in r.iter_content(chunk_size=65536):
f.write(chunk)
rows = r.headers["X-Export-Row-Count"]
if "X-Export-Truncated" in r.headers:
print(f"Capped at {rows} rows — narrow the search to get the rest.")// Node 18+ ESM, server-side only — never ship this token to a browser.
import { writeFile } from "node:fs/promises";
const token = process.env.CONTRACTRADAR_TOKEN;
if (!token) throw new Error("CONTRACTRADAR_TOKEN is not set");
const params = new URLSearchParams({ state: "TX", buyerType: "City" });
const res = await fetch(`https://contractradar.io/api/v1/posts/export?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Export failed: ${res.status}`);
await writeFile("contracts.csv", Buffer.from(await res.arrayBuffer()));
const rows = res.headers.get("X-Export-Row-Count");
if (res.headers.get("X-Export-Truncated")) {
console.warn(`Capped at ${rows} rows — narrow the search to get the rest.`);
}using System.Net.Http.Headers;
var token = Environment.GetEnvironmentVariable("CONTRACTRADAR_TOKEN")
?? throw new InvalidOperationException("CONTRACTRADAR_TOKEN is not set");
using var client = new HttpClient { BaseAddress = new Uri("https://contractradar.io") };
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// ResponseHeadersRead so the headers are available before the whole CSV is buffered.
using var res = await client.GetAsync(
"/api/v1/posts/export?state=TX&buyerType=City",
HttpCompletionOption.ResponseHeadersRead);
res.EnsureSuccessStatusCode();
await using (var file = File.Create("contracts.csv"))
await res.Content.CopyToAsync(file);
var rows = res.Headers.GetValues("X-Export-Row-Count").First();
if (res.Headers.Contains("X-Export-Truncated"))
Console.WriteLine($"Capped at {rows} rows — narrow the search to get the rest.");How many rows you get depends on whether you sent a keyword, and the difference is not a policy we set:
| Request | Rows | Why |
|---|---|---|
With q | up to 200 | A 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,000 | With 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:
| Header | Notes |
|---|---|
X-Export-Row-Count | Data 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-Truncated | true 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, ContractRadarPostLinkDates 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
=, +, - 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
| Status | Meaning |
|---|---|
400 | A 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. |
401 | The token is missing, malformed, or revoked. |
403 | Only from /posts/export: the subscription behind the token is no longer active. Search itself keeps working — see below. |
429 | Rate 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.