Skip to content

Contract appeals

Contract appeals are the decisions of the two boards of contract appeals, exposed at /api/contract_appeals/. For field definitions, see the Contract Appeals Data Dictionary.

A contract appeal is a dispute under a contract that already exists. A contractor and the government disagree over a claim, a termination, delay, or how much is owed; the contracting officer issues a final decision; the contractor appeals it to a board under the Contract Disputes Act. Two boards hear them:

  • CBCA — the Civilian Board of Contract Appeals, which hears appeals from most civilian agencies. Coverage runs from 2007 to the present.
  • ASBCA — the Armed Services Board of Contract Appeals, which hears appeals from the Department of Defense, NASA and the CIA. Coverage runs from 2010 to the present.

About 8,200 decisions are available across the two boards, and the dataset is updated daily.

Contract appeals are not bid protests

A bid protest challenges an award or a solicitation before or just after award — who should have won. A contract appeal challenges a contracting officer's final decision under a contract already in hand — what is owed under it. They are different proceedings, in different forums, usually with different parties, and Tango keeps them in separate resources.

Bid protests (GAO, the U.S. Court of Federal Claims, and SBA OHA) live at /api/protests/. Nothing appears in both resources.

Authentication: Contract appeal endpoints require authentication (API key or OAuth2). Unauthenticated requests receive HTTP 401.

Plans: every endpoint, every filter and the full-text search are available on every plan, including Free. One field — decision_text, the extracted body of the decision — requires an Enterprise plan. Searching that text is free; reading it is not. See decision_text.

Endpoints

  • GET /api/contract_appeals/ — list, with filtering, search, ordering and pagination
  • GET /api/contract_appeals/{uuid}/ — one decision

The detail path segment is the decision's uuid, and it must be a valid RFC 4122 UUID — anything else is a 404. The uuid is stable: it survives a rebuild of the dataset, so it is safe to store alongside your own records.

One row is one decision as the board lists it

Each board publishes its decisions as a listing, and one row here is one document on that listing.

Three consequences are worth knowing before your first query:

  • A consolidated appeal carries several dockets. docket_numbers is an array, not a string. One decision resolving four consolidated appeals lists all four.
  • A decision the board stops listing is kept, not deleted. listed goes to false and the row stays, so a decision you retrieved last month still resolves on its uuid. Filter ?listed=true if you want only what the boards currently publish.
  • Not every field is populated on every row, and the gaps follow the boards' own history. ASBCA's listing gained a docket-number column in 2014, so docket_numbers is empty on most ASBCA decisions before then. CBCA's listing gained a decision-type column in 2015, so decision_type is null on CBCA decisions before then — and it is null on every ASBCA decision, because that board publishes no type at all.

Filtering

Identifier and classification 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 appellant must be at least 2 characters.

Param What it does
board cbca or asbca. Case-insensitive. Multi-value: use \| for OR.
docket Exact match on one of the decision's docket numbers — see Matching a docket. Multi-value: use \| for OR.
appellant Case-insensitive substring match on the appellant's name. Min 2 characters. Multi-value: use \| for OR.
judge Case-insensitive exact match on the judge as the board's listing names them. Multi-value: use \| for OR.
decision_type CBCA only: Decision, Dismissal, Order, Full Board Order, or the listing's own wording where it matches none of those. Multi-value: use \| for OR.
decision_date_after, decision_date_before Decision-date range (YYYY-MM-DD).
listed Boolean. false returns decisions the board's newest listing no longer carries.
document_id Exact match on the board's document identifier. Useful for pinning one document when several listing rows link it.
search Ranked full-text search over the appellant and the decision text — see Search. Min 2 characters.

An unrecognized query parameter on the list endpoint returns 400 with a did-you-mean suggestion rather than being silently ignored, so a mistyped filter never returns a full, unfiltered page.

Matching a docket

?docket= matches one docket exactly, against any element of the decision's docket_numbers array.

Dockets are stored without the board prefix, as the board writes them minus its own name: 59116, 3288-R, 7092-C(6682, 6765, 6767). You do not have to strip anything yourself — a leading CBCA or ASBCA, and No. or Nos., are ignored, so a docket can be pasted straight out of a citation:

GET /api/contract_appeals/?docket=59116
GET /api/contract_appeals/?docket=ASBCA%20No.%2059116
GET /api/contract_appeals/?docket=CBCA%203288-R

All three of those are the same query, and suffix letters are matched case-insensitively (3288-r finds 3288-R).

Two things this filter is not:

  • It is not a prefix or substring search. ?docket=5911 does not find 59116. A quantum or remand docket can name its source dockets in parentheses — 7092-C(6682, 6765, 6767) — and that whole string is one docket, so quoting 6682 alone does not reach it.
  • The comma is not a separator. Because a docket can contain commas, | (or OR) is the only way to ask for several: ?docket=59116|59117.

?search= is a ranked full-text query over the appellant name and the full text of the decision, and it is available on every plan including Free.

Wrap a value in double quotes for a phrase: ?search="differing site conditions". Without quotes, the terms are matched individually.

Under search= with no explicit ordering, results come back by relevance. ordering=rank asks for that explicitly and requires a non-empty search — passing it alone returns 400.

Searching the decision text is free; reading it is not. A search response tells you which decisions matched and carries no fragment of the body, so there is nothing to gate. Reading the body is a separate, Enterprise-only field — see decision_text.

Search reaches the decisions whose text we hold. text_status and text_char_count report, on every plan, whether a given decision has extracted text behind it, so you can tell a decision with no text from one whose text simply did not match.

Ordering

ordering= allowlist:

  • decision_date — the default is -decision_date (newest first)
  • appellant
  • first_listed_at — when Tango first observed the decision on a board's listing, which is the field to poll on
  • rank — relevance; requires a non-empty search, and is the implicit default under one

decision_date and appellant sort empties last in both directions, so a page is never headed by undated rows. Results are tie-broken deterministically, so paging through a large result set never repeats or skips a decision.

Pagination

Standard page-number pagination:

  • page (default 1)
  • limit (default 25, max 100)

Responses carry count / next / previous / results.

Shaping

Contract appeals are a flat resource: every field is a leaf, and there are no expandable relations (so no * expansions). See Response shaping for the syntax.

  • List default: uuid, board, docket_numbers, decision_date, appellant, judge, decision_type, url
  • Detail default: the list fields plus docket_raw, docket_source, decision_date_repaired, decision_type_raw, listing_year, first_listed_at, listed, text_status, text_char_count

Every other field — decision_date_raw, document_id, listing_url and decision_text — is available by naming it in ?shape=. The full list is in the data dictionary.

decision_text — the decision body

GET /api/contract_appeals/{uuid}/?shape=uuid,board,docket_numbers,decision_text

decision_text is the extracted text of the board's own decision document. It requires an Enterprise plan, and three rules govern it:

  • You have to name it. It is in neither default shape, because a single decision runs to roughly 100,000 characters — twenty-five of them is not a list page.
  • Below Enterprise, the request still succeeds. You get 200 with the rest of your shape intact, the field omitted, and an entry in meta.upgrade_hints naming it and the plan that unlocks it. It is not a 403, and the rest of the response is unaffected.
  • The field can be null at any plan. decision_text is null until the decision's document has been through text extraction, and on a decision whose document yielded no text at all. text_status (completed, empty, failed, or null when not yet attempted) and text_char_count tell you which case you are in, on every plan — so you can decide whether a body is worth buying before you buy it.

A below-Enterprise response to a shape naming decision_text:

{
  "count": 1,
  "meta": {
    "upgrade_hints": {
      "message": "Some requested fields were omitted because they require a higher tier subscription.",
      "fields": [
        {"field": "decision_text", "required_tier": "Enterprise A"}
      ],
      "upgrade_url": "https://docs.makegov.com/getting-started/pricing/"
    }
  },
  "results": [
    {
      "uuid": "7a2e4c1b-3d5f-5e8a-9b0c-1d2e3f4a5b6c",
      "board": "asbca",
      "docket_numbers": ["63456", "63457"]
    }
  ]
}

Every other field on the resource, including the board's own link to the decision document in url, is available on every plan.

Alerts

contract_appeal is an alertable query type — alerts.contract_appeal.match, using the same filters documented above. See the webhooks guide.

An alert fires when a matching decision first appears and when a matching decision changes. Two changes are worth calling out, because both are real writes rather than query-time derivations:

  • A delisting fires. When a board's newest listing stops carrying a decision, listed flips to false and subscribers hear about it.
  • Newly extracted text fires. A decision gaining its decision_text is a change to the record, so a subscription can be used to follow extraction — including on plans that cannot read the text itself.

Examples

# One ASBCA docket
GET /api/contract_appeals/?board=asbca&docket=57530

# Differing-site-conditions decisions since 2024
GET /api/contract_appeals/?search=differing+site+conditions&decision_date_after=2024-01-01

# Construction appellants, newest first
GET /api/contract_appeals/?appellant=construction&ordering=-decision_date

# CBCA dismissals in a window, timeline payload
GET /api/contract_appeals/?board=cbca&decision_type=Dismissal&decision_date_after=2025-01-01&shape=uuid,docket_numbers,appellant,decision_date,url

# Everything new since your last poll
GET /api/contract_appeals/?ordering=-first_listed_at&shape=uuid,board,docket_numbers,appellant,decision_date,first_listed_at

# Decisions the boards no longer list
GET /api/contract_appeals/?listed=false&shape=uuid,board,docket_numbers,decision_date

# Detail, with the decision body (Enterprise)
GET /api/contract_appeals/{uuid}/?shape=uuid,board,docket_numbers,appellant,decision_date,decision_text

Notes:

  • * is only valid inside expansions; contract appeals have none, so use explicit field lists.
  • Invalid shape fields return HTTP 400 with structured validation errors.