Skip to content

Bid Protests

A bid protest is the public signal that an award is being challenged. It is the one contestability fact an award record cannot carry on its own: a solicitation can look routine and still be under protest, and an award that drew a protest can be delayed, re-opened, or overturned.

Outcomes are GAO's own published words, passed through and never re-labelled or scored. A protest is a fact about a procurement, not a verdict on a firm.

The feed is on every plan. The per-solicitation lookup, which answers whether the pursuit in front of you is contested right now, is Pro; a caller without Pro gets a 402 pointing at the pricing page, and the feed stays open to them.

The feed needs no filter, so the smallest call that proves it works is one row, shown here trimmed to four of the record's fields:

curl -H "Authorization: Bearer YOUR_API_KEY" \
  "https://govconapi.com/api/v1/protests?limit=1"
{ "b_number": "B-424840.1", "protester": "Bluewater Management Group, LLC",
  "agency": "Department of the Navy : Military Sealift Command", "docket_status": "Open" }

One docket: Bluewater Management Group has protested a Military Sealift Command procurement, and docket_status reads Open, so GAO has not decided it yet.

Endpoints

  • GET /api/v1/protests, the protest feed, filterable by outcome, status, agency, case type, protester, case number and filing date
  • GET /api/v1/protests/{solicitation_number}, Pro, every protest on one solicitation, plus whether any is pending, whether any was sustained, and the soonest date a pending one must be decided

A dismissed protest is not a failed protest

This is the single most misread thing in protest data, and reading outcome at face value will mislead you.

When a protest has merit, the agency will often take voluntary corrective action: it re-opens the evaluation, amends the solicitation, or re-competes the award. Once the agency does that there is nothing left for GAO to decide, so the protest is dismissed as academic. The protester got what they wanted, and the record says Dismissed.

GAO publishes the size of that gap itself. In its fiscal 2025 annual report to Congress, GAO reported a sustain rate of 14% alongside an effectiveness rate of 52%, where "effective" means the protester obtained relief either through a sustained decision or through voluntary agency corrective action. Roughly half of protesters get some form of relief; only about one in seven appears in the record as Sustained.

So, outcome by outcome:

  • Sustained, GAO decided the protest had merit. Unambiguous, and uncommon.
  • Dismissed, ambiguous, and by far the largest group. It covers protests withdrawn, filed late, or outside GAO's jurisdiction, and protests resolved because the agency fixed the problem. A cluster of dismissals on one solicitation is worth a closer look, not a shrug.
  • Denied, GAO considered the merits and did not agree with the protester.
  • Withdrawn, the protester ended it themselves, which can also follow an agency concession.
  • Granted, GAO granted a request rather than a protest. It lands on the entitlement, cost and reconsideration case types, where the thing granted is a claim for protest costs or a request to reconsider a decided case, not an award decision.

case_type matters for the same reason. A reconsideration is a second look at an already-decided case, and an entitlement or cost case is an argument about who pays for the first one, so neither is a fresh challenge to an award. Isolating the fresh ones takes one step of arithmetic, because case_type is a case-insensitive substring include filter with no negation, and the published second-look types are all spelled as Bid Protest plus a colon and a suffix. ?case_type=Bid Protest therefore returns them too. The colon is the part that separates them:

  • fresh challenges plus second looks, ?case_type=Bid Protest
  • second looks only, ?case_type=Bid Protest:, which is the reconsideration, entitlement and cost dockets together
  • fresh challenges only, the first total minus the second, or client-side keep the rows whose case_type is exactly Bid Protest

Dockets whose own page has not been collected yet publish no case type at all, and a handful carry Other: Advisory Opinion, so neither call covers the whole corpus.

The clock does not predict the verdict. GAO's statutory limit is 100 days from filing, 65 under its express option, and almost every decided protest lands inside it. The median is about a month, but that median hides a split in two: cases that end without a merits ruling close in roughly four weeks, while cases GAO decides on the merits run about three months. Denied and Sustained sit at effectively the same elapsed time, so how long a case took tells you nothing about which way it went. Practical use: a protest still Open after roughly a month is probably heading for a merits decision, so plan for the stay to run near the full 100 days. Full distribution: how long protests take.

Read a protest as a fact about a procurement. Protesting is a normal, legal part of federal contracting, and being protested says nothing about the awardee's performance.

Three ways to end up with the wrong set of frozen awards

While a protest is pending it can be actively holding up the award: a timely protest obliges the agency to suspend performance until GAO decides. That makes the pending set the operational one, and each of the three shortcuts below hands you a different wrong version of it.

A null outcome is not a pending protest. Most records carrying no outcome string are decided cases where GAO published no outcome word, not live protests, so the null test overcounts the live set several times over. Pending is docket_status: "Open". Use ?status=Open, or read docket_status. For the current pending and sustained counts, call /api/v1/status.

A large negative days_remaining is a stale docket, not a live stay. due_date is a deadline, not a schedule: many cases end earlier, and the deadline itself can be extended, as it was during the 2025 lapse in appropriations. A small number of cases stay published as open past their deadline, which is the source's own state and is passed through rather than corrected. Read a large negative days_remaining as "the published docket has not moved", and filter on days_remaining >= 0 when you want only cases inside their statutory window.

The newest open dockets have no dates yet, so the date-based sorts and filters miss them. GAO's docket listing publishes the file number, protester, agency, solicitation number and status; the filing date, decision deadline, case type and assigned attorney come from each docket's own page and are collected per docket. On the newest open dockets those four are still null. That puts them out of reach of filed_from and filed_to, last under sort=filed, and last under sort=due as well, which sorts the deadline ascending and drops the missing ones to the end. Reach the newest pending cases with ?status=Open&sort=recent, which leads with open dockets and orders them newest file number first.

A zero result is not proof that nobody protested

count: 0 is a 200, never a 404. It means no protest on that solicitation is visible in this corpus, which is a real and useful answer. It is not a claim that none was ever filed, and the response says so in coverage.note.

This is the GAO forum. A protester may instead go to the Court of Federal Claims, and small-business size or status protests are decided by the SBA. Those are separate venues and are not in this corpus. Protests filed directly with an agency are never public anywhere, from any source. GAO also does not publish a written decision for most dismissals, so a docket row proves a case existed even where no decision document is available.

October 2025 holds no filings and no decisions, and that is the record rather than a gap. GAO was closed for the whole month during the lapse in appropriations: its electronic docketing system was offline, no protests could be filed, and no decisions were issued. Deadlines for pending cases were extended by the length of the closure. The absence in the data is the event.

Grouping on solicitation_number splits one procurement and merges unrelated ones

solicitation_number is passthrough, so a non-null value is not automatically a join key. Some dockets publish none at all, and some publish the literal string N/A, which would collapse unrelated procurements into one group if you grouped on it blindly. Protests also reach back further than our own notice corpus does, so only a minority of protest solicitation numbers also appear in opportunities. The live-pursuit case, a currently-open protest, matches a good deal more often, and that is the case the lookup exists for.

One procurement can carry more than one solicitation-number spelling across GAO's own dockets, a stray RFQ- prefix on some, a dash present or absent, and similar. Matching on the literal string alone would split one procurement into two partial answers, each of which looks complete. The lookup resolves the whole GAO case behind whichever spelling you pass, not just the dockets sharing that literal string, so an individual docket's own solicitation_number in the response can read differently from what you queried. It is also case-insensitive, so 75n98026r00042 and 75N98026R00042 reach the same protest. That is the behaviour that makes a single lookup complete.

Parameters

The feed accepts the following. The per-solicitation lookup takes no query parameters; passing any returns 400.

Parameter Type Description
outcome string One of Denied, Dismissed, Sustained, Withdrawn, Granted. Case-insensitive, so sustained works, and any unrecognised value returns 400 naming the valid ones. Sustained is the unambiguous cut, not the full set of protests that achieved something (see the outcome section above).
agency string The awarding agency, up to 200 characters. Common abbreviations resolve to the real departments rather than matching as text: VA, VETERANS, DOD, DEFENSE, ARMY, NAVY, USAF, AIR FORCE, USMC, MARINES, DHS, HOMELAND, HHS, DOJ, DOE, DOT, USDA, DOI, DOC, DOL, HUD, DOS, TREASURY, TREAS, SBA, GSA, DLA, USCG, COAST GUARD, GAO, GPO, EOP. DOD and DEFENSE mean the military rather than the literal department, because GAO files the Army, the Navy, the Air Force, the Marine Corps and the Space Force as top-level departments of their own, so both resolve across all of them and the response echoes the full list in filters_applied.agency_resolved. Anything else is a case-insensitive substring match, which is what you want for sub-agencies (agency=Naval Facilities, agency=Army Contracting Command).
status string Open or Closed, case-insensitive. Open is the live set, the cases still pending before GAO where the award may be under a performance stay. Any other value returns 400. Every record is one or the other, so the two together account for the whole corpus.
case_type string Case-insensitive substring of the published case type, up to 100 characters, e.g. Reconsideration. It includes rather than excludes, and the second-look types are spelled Bid Protest plus a colon and a suffix, so case_type=Bid Protest returns them alongside the fresh challenges. The recipe for each of the three cuts is in the outcome section above.
filed_from date Filed on or after this ISO YYYY-MM-DD date. A malformed date returns 400, never a silently dropped filter. Does not reach dockets whose filing date has not been published yet (see the frozen-awards section above).
filed_to date Filed on or before this ISO YYYY-MM-DD date. Same validation and same caveat as filed_from.
case_number string A GAO case, e.g. B-424433, up to 40 characters. Returns every docket on it (B-424433.1, .2, and so on). Input is normalised, so 424433, b-424433 and B-424433.7 all resolve to the same case. Matched exactly, so wildcards are not honoured.
protester string The firm that filed the protest, up to 200 characters. Case-insensitive substring, so protester=Booz Allen matches Booz Allen Hamilton, Inc.. Use this, not search, for competitor research.
search string Full-text across protester, agency, solicitation number and file number, up to 200 characters. Unquoted input matches records containing all the words; wrap it in double quotes (search="Booz Allen") to match the phrase. Useful when you do not know which field holds the term. Because it spans the agency column too, it is the wrong tool for finding one firm's protests.
fields string Comma-separated response fields, e.g. fields=b_number,outcome,due_date. b_number is always included so rows stay correlatable. An unknown field name returns 400 naming the valid ones. Syntax and behaviour: Response Shaping.
sort string See Ordering below.
limit integer Results per page, default 100.
offset integer Pagination offset, default 0.

Filters combine with AND: ?agency=VA&outcome=Sustained&filed_from=2026-01-01 returns protests that satisfy all three. Whenever an agency abbreviation is resolved, the response echoes exactly what it matched in filters_applied.agency_resolved, so the server's work is always visible:

"filters_applied": { "agency": "VA", "agency_resolved": ["Veterans Affairs"] }

Ordering

sort takes recent (the default), oldest, filed or due. Any other value returns 400.

  • recent, newest decision first, with pending cases at the top because they have no decision date and they are the live ones.
  • oldest, earliest decision first, with pending cases last.
  • filed, newest filing first. Dockets with no published filing date sort last.
  • due, the statutory decision deadline soonest first. Dockets with no deadline sort last, which on status=Open means the newest dockets land on the last page rather than the first. Combined with status=Open it answers which frozen awards resolve next, not which were filed most recently.

Every sort ends on b_number as a tiebreaker, so paging through a run of identical dates is stable and never duplicates or drops a row. The per-solicitation lookup has a fixed order, newest decision first with pending dockets leading, and does not accept sort.

Pagination

Offset-based, through limit and offset. The default page size is 100; the maximum for your plan is in Plans & Limits, and a limit above it returns 402. total is an exact count on every request, never an estimate, because this corpus is small enough to count in full. Page forward on has_next, which reports page fullness: a full page means another may exist, a short or empty page is the end. The per-solicitation lookup is not paginated and returns every docket on the case.

Response

GET /api/v1/protests?outcome=Sustained&limit=1

{
  "data": [
    {
      "b_number": "B-424575.2",
      "protester": "Battelle Memorial Institute",
      "agency": "Independent Government Entities : National Science Foundation",
      "solicitation_number": "49100425R0006",
      "outcome": "Sustained",
      "filed_date": "2026-07-23",
      "decision_date": "2026-09-10",
      "due_date": "2026-11-02",
      "days_to_decision": 49,
      "days_remaining": null,
      "case_type": "Bid Protest",
      "gao_attorney": "Todd C. Culliton",
      "decision_url": "https://www.gao.gov/docket/b-424575.2",
      "docket_status": "Closed"
    }
  ],
  "pagination": { "limit": 1, "offset": 0, "total": 553, "has_next": true },
  "filters_applied": { "sort": "recent", "outcome": "Sustained" },
  "coverage": { "since": "2019-10-01", "through": "2026-09-18", "protests": 14142,
                "basis": "GAO bid-protest docket decisions" },
  "_sources": ["gao_bid_protests"]
}

days_remaining is computed at request time against today's date, so every value printed on this page is the one that sample call returned and yours will read differently.

The lookup returns the same record shape under protests, wrapped in a verdict about the solicitation:

{
  "solicitation_number": "W31P4Q-25-R-0080",
  "count": 4,
  "any_open": true,
  "any_sustained": false,
  "earliest_open_due_date": "2026-12-24",
  "protests": [ { "b_number": "B-423870.4", "...": "same fields as above" } ],
  "coverage": { "since": "2019-10-01", "through": "2026-09-18", "protests": 14142,
                "basis": "GAO bid-protest docket decisions",
                "note": "no protests found means none visible in our corpus for this solicitation, not proof none were filed" },
  "_sources": ["gao_bid_protests"]
}
Field Type Meaning Source
b_number string GAO's file number, e.g. B-424487.1. The part before the dot is the case; the suffix distinguishes dockets filed separately against the same procurement, so one solicitation often carries several, and a heavily contested one can carry hundreds. GAO
protester string The firm or individual that filed, as published. GAO
agency string The awarding agency as published, usually Department : sub-agency. GAO
solicitation_number string or null The procurement the protest concerns, and the join key into opportunities and award records. null when GAO published none, and occasionally the literal N/A. At the top level of the lookup it is instead the value you asked for, echoed back with surrounding whitespace stripped, so it can read differently from the dockets' own values underneath it. GAO
outcome string or null Published outcome, one of Denied, Dismissed, Sustained, Withdrawn, Granted. null while pending, and also on decided cases where GAO published no outcome word. GAO
filed_date date or null Date the protest was filed. null where GAO has not published it. GAO
decision_date date or null Date the protest was decided. null while pending. GAO
due_date date or null The date GAO must decide by, set by statute at 100 days from filing and rolled forward to the next business day when day 100 lands on a weekend or a holiday, so a gap of 101, 102 or 103 days from filed_date is ordinary rather than an error. The express option runs 65 days, and a deadline extended by a government closure runs longer still. While the docket is Open this is the expected date the automatic stay lifts and performance can resume. GAO
days_to_decision integer or null Derived, calendar days from filed_date to decision_date. null unless both are present, never 0 as a stand-in. Derived
days_remaining integer or null Derived, calendar days from today to due_date. Populated only while the docket is Open, because a decided case has spent its statutory clock. Can be negative. Derived
case_type string or null As published: Bid Protest, Bid Protest: Reconsideration, Bid Protest: Entitlement, Bid Protest: Cost or Other: Advisory Opinion. null on dockets whose own page has not been collected yet. GAO
gao_attorney string or null The GAO attorney assigned to the case, as published. GAO
decision_url string Link to the public docket entry. GAO
docket_status string or null Open (still pending, so outcome and decision_date are null) or Closed. GAO publishes this word on its docket listing, which is where every Open row comes from. The historical corpus is read from GAO's decision listing instead, which carries no status field, so on most rows the value is derived as Closed from GAO's own outcome or decision date. Never upgraded to Open without evidence. GAO / Derived
data array The protest records, one object per docket, each shaped as the rows above. Feed only. GAO
pagination object {limit, offset, total, has_next}. total is an exact count of the match set, and has_next reports page fullness rather than a look ahead. Feed only. Derived
filters_applied object The filters the server actually used, including agency_resolved when an abbreviation was expanded. fields is not echoed, because it reshapes the response rather than narrowing the result set. Derived
coverage object {since, through, protests, basis}, read from the data on every request rather than fixed. since and through are the oldest and newest decision dates held, protests the total record count, basis the source. The lookup adds note, stating that absence is not proof none was filed. Derived
_sources array Always ["gao_bid_protests"] here. Shape and meaning: Response metadata. Derived
count integer Lookup only. How many dockets we hold on the solicitation's GAO case, not how many distinct cases. One case routinely carries several dockets, so this is the row count of protests, and Example 1 below shows why the two numbers differ. Derived
any_open boolean Lookup only. Whether any of them is still pending, which is the flag that says the award may be under a stay. Derived
any_sustained boolean Lookup only. Whether any of them was sustained. Derived
earliest_open_due_date date or null Lookup only. The soonest date a pending protest here must be decided by, and so the earliest the award can be expected to come unfrozen. null when nothing is pending. Derived
protests array Lookup only. Every docket on the solicitation's GAO case, newest decision first with pending dockets leading, each shaped as the rows above. GAO

days_to_decision, days_remaining and the lookup's four verdict fields are computed per request rather than stored, because days_remaining changes every day and a stored copy would guarantee a stale column. Both day counts return null rather than 0 when an input is missing, since 0 is a real and different answer meaning "decided the day it was filed" or "due today".

coverage.through is the newest decision date, so it naturally sits behind today whenever the newest records are pending dockets with no decision yet. For freshness of the feed itself, along with the live corpus total and the pending and sustained counts, call /api/v1/status.

Errors

Code Trigger
400 An outcome, status or sort outside its enum; a filed_from or filed_to that is not an ISO YYYY-MM-DD date; an unknown field name in fields; any query parameter at all on the per-solicitation lookup, which accepts none; a solicitation path segment longer than 100 characters, or one that is only whitespace (/api/v1/protests/%20). A bare trailing slash is not that case: /api/v1/protests/ redirects to the feed and answers 200.
402 The per-solicitation lookup on a plan without Pro. The feed stays available on every plan. Also a limit above your plan's page-size cap.
404 A solicitation number containing / cannot be addressed as a path segment. Find those protests through the feed with ?search= instead.
422 limit below 1, a negative offset, or a text filter longer than its cap (agency and protester and search 200 characters, case_type 100, case_number 40).

Shared codes, including 401 and 429: Error Codes.

Examples

Responses below are trimmed to the fields each sentence reads; the full shape is in Response above.

Recent sustained protests.

curl -H "Authorization: Bearer YOUR_API_KEY" \
  "https://govconapi.com/api/v1/protests?outcome=Sustained&limit=2"
[
  { "b_number": "B-424575.2", "protester": "Battelle Memorial Institute",
    "solicitation_number": "49100425R0006", "outcome": "Sustained",
    "filed_date": "2026-07-23", "decision_date": "2026-09-10", "days_to_decision": 49 },
  { "b_number": "B-424575.1", "protester": "Battelle Memorial Institute",
    "solicitation_number": "49100425R0006", "outcome": "Sustained",
    "filed_date": "2026-06-12", "decision_date": "2026-09-10", "days_to_decision": 90 }
]

Two dockets on one case, both sustained and both decided on 2026-09-10, but days_to_decision reads 49 and 90 because each docket's clock starts at its own filing. Count sustained cases on b_number before the dot, not sustained rows, or a protester who filed twice counts twice.

Pending protests in deadline order. status=Open with sort=due answers "which frozen awards resolve next".

curl -H "Authorization: Bearer YOUR_API_KEY" \
  "https://govconapi.com/api/v1/protests?status=Open&sort=due&limit=2"
[
  { "b_number": "B-423860.1", "agency": "Department of the Treasury : Bureau of the Fiscal Service",
    "filed_date": "2025-08-27", "due_date": "2025-12-05", "days_remaining": -289,
    "docket_status": "Open" },
  { "b_number": "B-424054.1", "agency": "Department of Homeland Security : United States Customs and Border Protection",
    "filed_date": "2025-11-13", "due_date": "2026-02-23", "days_remaining": -209,
    "docket_status": "Open" }
]

Ascending due_date puts the stalest dockets first by construction, so the leading rows carry a large negative days_remaining: these are cases the public docket still lists as open long past their deadline, not stays that are still running. Add a days_remaining >= 0 test client-side to get the genuinely live queue.

One firm's protest record, and the filter that looks like it but is not.

curl -H "Authorization: Bearer YOUR_API_KEY" \
  "https://govconapi.com/api/v1/protests?protester=Booz%20Allen&limit=1"
{ "pagination": { "total": 68 },
  "data": [ { "b_number": "B-424806.1", "protester": "Booz Allen Hamilton, Inc.",
              "agency": "Department of Defense : Defense Intelligence Agency",
              "outcome": null, "docket_status": "Open" } ] }

68 dockets filed by the firm, the newest still pending. Running the same question through search=Navy instead returns more than a thousand rows in which the agency is the Navy and the protester is somebody else entirely, while protester=Navy correctly returns 0. Scope competitor research to protester.

Is this pursuit contested right now?

curl -H "Authorization: Bearer YOUR_API_KEY" \
  "https://govconapi.com/api/v1/protests/W31P4Q-25-R-0080"
{ "solicitation_number": "W31P4Q-25-R-0080", "count": 4,
  "any_open": true, "any_sustained": false, "earliest_open_due_date": "2026-12-24",
  "protests": [
    { "b_number": "B-423870.4", "outcome": null, "docket_status": "Open",
      "protester": "Oasis Systems, LLC, a wholly owned subsidiary of Astrion Group, LLC",
      "filed_date": "2026-09-15", "due_date": "2026-12-24", "days_remaining": 95 },
    { "b_number": "B-423870.3", "protester": "Oasis Systems, LLC",
      "outcome": "Dismissed", "decision_date": "2025-12-15" },
    { "b_number": "B-423870.2", "protester": "Oasis Systems, LLC",
      "outcome": "Dismissed", "decision_date": "2025-12-15" },
    { "b_number": "B-423870.1", "protester": "Oasis Systems, LLC",
      "outcome": "Dismissed", "decision_date": "2025-12-15" }
  ] }

any_open: true with earliest_open_due_date: 2026-12-24 is the operational answer: this award is challenged again and cannot be relied on before late December. The three dismissals underneath it are the pattern from the outcome section, one protester dismissed three times on this procurement and back for a fourth. Read protester across the four dockets rather than grouping on the string: GAO publishes the newest as Oasis Systems, LLC, a wholly owned subsidiary of Astrion Group, LLC and the older three as Oasis Systems, LLC, so a corporate transaction between filings is enough to split one firm into two names. any_sustained: false is not a clean bill here, it just means GAO has not yet ruled for them.

One case, two spellings of the same solicitation.

curl -H "Authorization: Bearer YOUR_API_KEY" \
  "https://govconapi.com/api/v1/protests/9232022"
{ "solicitation_number": "9232022", "count": 16, "any_open": false, "any_sustained": false,
  "protests": [ { "b_number": "B-421560.16", "protester": "ManTech Advanced Systems International, Inc.",
                  "solicitation_number": "RFQ-09232022", "outcome": "Denied",
                  "decision_date": "2025-05-08" } ] }

Querying 9232022 returns all 16 dockets of case B-421560 even though this docket's own solicitation_number reads RFQ-09232022, and querying RFQ-09232022 returns the identical 16. count: 16 is those 16 dockets on the single case, not 16 separate protests of a procurement. Matching on the literal string alone would have split one procurement into two partial answers, each of which looks complete.

  • Recompete Watchlist, when you want contestability on a contract coming up for recompete rather than on a single solicitation; a protest on its solicitation is a direct signal.
  • Search opportunities, where the solicitation_number you feed into the lookup comes from. Go there first if you are screening a notice you have not yet pursued.
  • NAICS competition, for market-level contestedness when the question is about a whole market rather than one award.
  • Federal bid protests, the background guide covering the automatic stay, protest timing distributions and how this corpus was checked.

A REST API and MCP server for U.S. federal procurement data.

Get a free key, emailed instantly, or follow the quickstart.

Found something wrong, missing or confusing on this page?

Press Ctrl + Enter to send a note. Highlight text first and it travels with your note.