This document provides the full API reference for the Node.js / TypeScript version of the Tango SDK. It is a translation of the Python SDK documentation, rewritten for JavaScript runtime semantics, async/await, and the TypeScript type system.
import { TangoClient, ShapeConfig } from "@makegov/tango-node";
// Models (optional)
import type { Contract } from "@makegov/tango-node/models";All methods are async and return Promises.
List federal departments and subagencies.
const resp = await client.listAgencies({ page: 1, limit: 25 });| Name | Type | Description |
|---|---|---|
page |
number |
Page number (default 1). |
limit |
number |
Max results per page (default 25, max 100). |
PaginatedResponse<AgencyLike>
Fetch a single agency by its code.
const agency = await client.getAgency("2000");Returns a shaped Agency object. Responses are materialized via the dynamic model pipeline (dates parsed, nested objects built).
Search and list contract records.
const resp = await client.listContracts({
keyword: "cloud",
naics_code: "541511",
shape: ShapeConfig.CONTRACTS_MINIMAL,
flat: true,
});These mirror the Python SDK:
| Filter | Maps to API param |
|---|---|
keyword |
search |
naics_code |
naics |
psc_code |
psc |
recipient_name |
recipient |
recipient_uei |
uei |
set_aside_type |
set_aside |
key is also a typed filter — pass a contract key (or several separated by |) to fetch specific records through the list endpoint.
The same key filter exists on listIdvs, listOtas, and listOtidvs.
recipient_uei (and uei on listIdvs, listOtas and listOtidvs) accepts several UEIs joined by |, matching awards to any of them, up to 500 (Tango API 5.11.0).
The SDK passes the value through unchanged.
commercial_item_acquisition_procedures filters by the FPDS commercial item acquisition procedures code (Tango API 5.10.0): A (commercial products/services), B (products or services pursuant to FAR 12.102(f)), C (services pursuant to FAR 12.102(g)), D (commercial procedures not used) or E (DoD Section 803 Commercial Solutions Opening).
Codes are case-insensitive and pipe-separated for OR, e.g. "A|E"; an unknown or multi-character code matches nothing.
It is also typed on listIdvs, listIdvAwards and listIdvChildIdvs, but the API does not offer it on OTAs or OT IDVs.
Obligations by fiscal year — obligations_by_fiscal_year(fiscal_year, obligated) (Tango API 5.11.0):
import type { FiscalYearObligation } from "@makegov/tango-node/models";
const resp = await client.listContracts({
piid: "SOME_PIID",
shape: "key,piid,obligated,obligations_by_fiscal_year(fiscal_year,obligated)",
});
for (const year of (resp.results[0].obligations_by_fiscal_year ?? []) as FiscalYearObligation[]) {
console.log(year.fiscal_year, year.obligated); // number, decimal string
}- Sums the award's transactions per federal fiscal year (October 1 starts the next fiscal year), oldest year first, each rounded to cents. A de-obligation nets against its own year, so a year can be zero or negative.
- Returned only when named: no default shape includes it.
- The same expand works on
listIdvs(),listOtas(),listOtidvs()and theget*methods. On IDVs and OT IDVs the API always sendsnull, because their transactions report a running lifetime total rather than each action's obligation. - The API also sends
nullfor a contract or OTA with no dated transactions. On the shaped methods (listContracts,getContract,listIdvs,getIdvand the IDV child lists) the client reads anullnested list as[], as it does for every nested list;listOtas()andlistOtidvs()return the API's JSON as-is.
Sorting:
sort: "award_date",
order: "desc" // -> ordering="-award_date"Pagination + shaping options:
shape: string,
flat: boolean,
flatLists: boolean,
page: number,
limit: number,
cursor: string, // mutually exclusive with `page` — if provided, `page` is ignoredContracts support both page-based and cursor-based pagination. Use cursor for deep pagination (faster and more stable on large result sets); use page for small offsets or when you need to jump to a specific page. page and cursor are mutually exclusive — if you pass cursor, the SDK ignores page.
PaginatedResponse<Contract> materialized according to the requested shape. Date/datetime fields are parsed, decimals normalized to strings, nested recipients, agencies, and locations are objects.
Vehicles provide a solicitation-centric grouping of related IDVs.
const resp = await client.listVehicles({
search: "GSA schedule",
shape: ShapeConfig.VEHICLES_MINIMAL,
page: 1,
limit: 25,
});Supported parameters:
search(vehicle-level full-text search)page,limit(max 100)shape,flat,flatLists
Company counts (on both the list and detail defaults; neither is orderable or filterable):
holder_count: distinct companies holding one of the vehicle's IDVs.order_winner_count: distinct companies that have won a task order under the vehicle.awardee_count: deprecated alias oforder_winner_count(same value), removed at the API's next major version.
const vehicle = await client.getVehicle("00000000-0000-0000-0000-000000000001", {
shape: ShapeConfig.VEHICLES_COMPREHENSIVE,
});Notes:
- On vehicle detail,
searchfilters expandedawardees(...)when included in yourshape(it does not filter the vehicle itself). - When using
flat: true, you can override the joiner withjoiner(default".").
const awardees = await client.listVehicleAwardees("00000000-0000-0000-0000-000000000001", {
shape: ShapeConfig.VEHICLE_AWARDEES_MINIMAL,
});IDVs (indefinite delivery vehicles) are the parent “vehicle award” records that can have child awards/orders under them.
const idvs = await client.listIdvs({
limit: 25,
cursor: null,
shape: ShapeConfig.IDVS_MINIMAL,
awarding_agency: "4700",
});Notes:
- This endpoint uses keyset pagination (
cursor+limit) rather thanpage.
const idv = await client.getIdv("SOME_IDV_KEY", {
shape: ShapeConfig.IDVS_COMPREHENSIVE,
});Lists child awards (contracts) under an IDV.
const awards = await client.listIdvAwards("SOME_IDV_KEY", { limit: 25 });const children = await client.listIdvChildIdvs({ key: "SOME_IDV_KEY", limit: 25 });const tx = await client.listIdvTransactions("SOME_IDV_KEY", { limit: 100 });const resp = await client.listEntities({
search: "Acme",
shape: ShapeConfig.ENTITIES_MINIMAL,
});Filters:
searchcage(CAGE code, typed alongside the existingcage_code)- any field names supported by the API
Fetch a single entity by UEI or CAGE.
Returns a shaped entity object with nested addresses/fields based on the shape.
Forecast search, with optional shaping.
id is a typed filter for fetching specific forecast records through the list endpoint.
Search SAM.gov opportunities with shaping.
opportunity_id is a typed filter for fetching specific opportunities through the list endpoint.
awarded (boolean) filters by whether the opportunity has an award, either posted on it or linked to it from a separate award notice, and awardee_uei filters by the awardee's UEI (case-insensitive; OR several with |).
Both search every opportunity rather than only active ones, and both require Tango API 5.5.0.
Attachment counts. meta(attachments_count,files_count,links_count) counts an opportunity's attachments.
An attachment's type is file (a document; only a file has extracted text) or link (a URL the notice lists).
files_count and links_count count each, and attachments_count still counts every attachment, links included.
An attachment of any other type counts only in the total, so files_count + links_count need not equal attachments_count.
Both new counts are null until the opportunity has been counted, so read null as unknown, not zero.
They require Tango API 5.9.0 and are not in the SDK's default shape; name them in shape.
const page = await client.listOpportunities({
shape: "opportunity_id,title,meta(attachments_count,files_count,links_count)",
});Extraction status. attachments(extraction_status) reports where text extraction stands for each attachment: pending, extracting, completed, failed or unsupported, so you can tell why extracted_text is missing (Tango API 5.11.0).
It works on notices too, and like every attachment field it must be named, e.g. shape: "opportunity_id,attachments(name,extracted_text,extraction_status)".
Attachment counts. file_count and link_count sit beside attachment_count and follow the same rules as the opportunity counts above: files are documents, links are URLs the notice lists, attachment_count still counts every attachment, the two need not add up to it, and each is null (unknown, not zero) until the notice has been counted.
They require Tango API 5.9.0; name them in shape, e.g. shape: "notice_id,title,attachment_count,file_count,link_count".
The canonical agency/department/office hierarchy. level filters by hierarchy depth: 1 = department, 2 = agency, 3 = sub-agency, and so on.
const orgs = await client.listOrganizations({
level: 1, // 1 = department, 2 = agency, 3 = sub-agency, …
include_inactive: false,
search: "Defense",
limit: 25,
});const org = await client.getOrganization("ORG_KEY");const offices = await client.listOffices({ search: "acquisitions" });const office = await client.getOffice("4732XX");Deprecated. Use
listOrganizations({ level: 1 })instead. The standalone departments endpoint is retained for backward compatibility and will be removed in a future API version.
const depts = await client.listDepartments({ page: 1, limit: 25 });const dept = await client.getDepartment("097");Other Transaction Agreements — non-FAR-based awards.
Uses keyset pagination (cursor + limit).
const otas = await client.listOtas({ limit: 25, awarding_agency: "4700" });const ota = await client.getOta("OTA_KEY");Other Transaction IDVs — umbrella OT agreements with child awards.
Uses keyset pagination (cursor + limit).
const otidvs = await client.listOtidvs({ limit: 25 });const otidv = await client.getOtidv("OTIDV_KEY");const awards = await client.listOtidvAwards("OTIDV_KEY", { limit: 25 });const subs = await client.listSubawards({ prime_uei: "ABC123DEF456", limit: 25 });const contracts = await client.listGsaElibraryContracts({ schedule: "MAS", limit: 25 });Fetch a single GSA eLibrary contract by UUID, with the standard shape / flat / flatLists / joiner options.
Defaults to ShapeConfig.GSA_ELIBRARY_CONTRACTS_MINIMAL when no shape is passed.
const contract = await client.getGsaElibraryContract("00000000-0000-0000-0000-000000000001", {
shape: ShapeConfig.GSA_ELIBRARY_CONTRACTS_MINIMAL,
});Requests for quotes, proposals and information (RFQs, RFPs, RFIs) posted to GSA eBuy under GSA schedule contracts. Requires the Pro tier or above; below it the request endpoints return 403.
eBuy results are scoped to the GSA schedule contracts linked to your account. A caller with no linked contract gets an empty list, not an error — call getEbuyAccess() to tell "no access" from "no matches".
const requests = await client.listEbuyRequests({
sin: "54151S",
status: "Open",
close_date_after: "2026-10-01",
limit: 25,
});| Name | Type | Description |
|---|---|---|
search |
string |
Full-text search over title, description, reference number, request id and attachment text. Ranks by relevance unless ordering is given. |
rfq_id |
string |
eBuy request id, exact (e.g. RFQ1835158). |
reference_number |
string |
The buyer's own solicitation number; dashed and undashed spellings both match. |
request_type |
string |
RFQ, RFP or RFI. |
status |
string |
Open or Cancelled — frozen at the last-seen state (see below). |
sin |
string |
Special Item Number the request was posted under. |
schedule |
string |
GSA schedule the request was posted under. |
buyer_agency |
string |
Buying department as fed, free text. |
agency |
string |
Agency name, abbreviation or code, including every sub-agency and office beneath it (Tango API 5.3.0+). |
contract_number |
string |
Narrow to one of your own linked contracts. A contract you do not hold returns nothing rather than an error. |
issue_date_after / _before |
string |
YYYY-MM-DD, inclusive. |
close_date_after / _before |
string |
YYYY-MM-DD, inclusive. |
ordering |
string |
issue_date (default -issue_date), close_date, last_seen, or modified; prefix - for descending. |
Every filter except contract_number and the date bounds accepts | for OR. The standard page / limit / shape / flat / flatLists / joiner options apply. The default list shape is rfq_id,request_type,title,schedule,sin,status,buyer_name,buyer_agency,buyer_agency_code,reference_number,issue_date,close_date,attachment_count,link_count,last_seen.
Three properties of the data worth knowing:
statusis frozen at the last-seen state. eBuy only carries currently-active requests, so a request that closes stops appearing rather than getting a final row.Openmeans "open the last time it was seen" — uselast_seenfor staleness.- The contract number a request was posted under is never returned in any payload.
buyer_agency_codeand several buyer and contracting-officer fields are sparse on older requests.
const request = await client.getEbuyRequest("RFQ1835158");
for (const attachment of request.attachments ?? []) {
console.log(attachment.doc_seq_num, attachment.doc_name, attachment.is_link);
}Returns an EbuyRequestRecord. The default shape is every field plus organization(*) (the buying office, in the same shape as other resources' awarding office) and attachments(*). A request outside your contract scope throws TangoNotFoundError, the same as an id that does not exist.
Reading a document's text — attachments(extracted_text) (Tango API 5.9.1+):
const request = await client.getEbuyRequest("RFQ1835158", {
shape: "rfq_id,attachments(doc_seq_num,doc_name,extracted_text)",
});
for (const attachment of request.attachments ?? []) {
if ("extracted_text" in attachment) console.log(attachment.doc_name, attachment.extracted_text);
}extracted_text is the text extracted from an attachment's document. Three behaviors to know:
- It must be named. Ask for it alone or alongside other attachment fields.
- Neither the default detail shape nor
attachments(*)carries it. - The key is absent rather than null for an attachment with no text: a link, an empty scan, or a document not yet extracted. Check with
inrather than for a nullish value.
const url = await client.getEbuyAttachmentUrl("RFQ1835158", 3852759);
const res = await fetch(url);Returns a presigned download URL for one attachment without downloading it. The URL expires after about five minutes, so fetch it promptly rather than storing it.
- An attachment with
is_link: trueis an external link, not a stored document: the call throwsTangoEbuyAttachmentLinkError, whoseurlis the link target. - A document that has not been captured yet throws
TangoNotFoundError.
const access = await client.getEbuyAccess();
// { enabled: false, reason: "no_contract_grant", contracts: [] }Returns { enabled, reason, contracts }. reason is "tier_required" below the Pro tier, "no_contract_grant" when no contract is linked to your account, and null when enabled is true; tier_required wins when both apply. contracts lists your own active grants, sorted.
Bid protests from GAO, the Court of Federal Claims (COFC) and SBA OHA.
const protests = await client.listProtests({ source_system: "gao", limit: 25 });naics_code is a typed filter sent to the API verbatim (it is not remapped to naics, unlike the contracts alias).
const protest = await client.getProtest("CASE_UUID");Takes the case's case_id UUID, not a case number. To look a case up by number, list with case_number and read case_id off the result.
Contract Disputes Act decisions from the two boards of contract appeals — the CBCA (civilian) and the ASBCA (defense). These are disputes under an existing contract; a challenge to an award is a bid protest, which is the separate Protests resource.
const appeals = await client.listContractAppeals({
board: "asbca",
decision_date_after: "2026-01-01",
limit: 25,
});| Name | Type | Description |
|---|---|---|
search |
string |
Ranked full-text search across the decision. |
board |
string |
cbca or asbca. |
docket |
string |
Docket number, as the board publishes it. |
appellant |
string |
Appellant name. |
judge |
string |
Deciding judge. |
decision_type |
string |
Normalized decision type. |
decision_date_after / _before |
string |
ISO date bounds on the decision date. |
listed |
boolean |
Whether the decision is currently on the board's published listing. |
document_id |
string |
The board's own document identifier. |
ordering |
string |
decision_date (default -decision_date), appellant, first_listed_at, or rank. |
rank ordering is only meaningful with a non-empty search. The standard page / limit / shape / flat / flatLists / joiner options apply.
Without a shape the API returns a core subset of the decision, so name the rest explicitly when you need it. decision_text is served on the Enterprise tier only, and below it the key is absent rather than null — test for presence, not for a nullish value.
const appeal = await client.getContractAppeal("00000000-0000-0000-0000-000000000001", {
shape: "uuid,board,docket_numbers,decision_date,appellant,judge,decision_text",
});Returns a ContractAppealRecord. Every property on it is optional, for the same two reasons: an unshaped list carries only the core subset, and decision_text is tier-gated.
Federal Register documents: rules, proposed rules, notices and presidential documents published since 1994.
A document is identified by uuid. document_number is not unique on its own: the Federal Register reused some numbers before 2016, so document_number can return more than one document.
const documents = await client.listFederalRegisterDocuments({
type: "Proposed Rule",
comments_open: true,
agency: "EPA",
limit: 25,
});
for (const doc of documents.results) {
console.log(doc.document_number, doc.publication_date, doc.title, doc.comments_close_on);
}| Name | Type | Description |
|---|---|---|
search |
string |
Ranked full-text search over the title, abstract and action. Wrap in double quotes for a phrase. |
document_number |
string |
Exact FR document number, e.g. 2016-31922. OR several with |. |
type |
string |
Notice, Rule, Proposed Rule, Presidential Document, Correction, Sunshine Act Document or Uncategorized Document (case-insensitive). OR several with |. An unknown type is an error, not an empty page. |
agency |
string |
A Tango agency name, abbreviation, code or organization key, e.g. EPA. Matches a document when any agency it lists falls within that organization, so a department includes its sub-agencies. OR several with |. |
fr_agency |
string |
The Federal Register's own agency slug, e.g. environmental-protection-agency. OR several with |. |
publication_date_after / _before |
string |
Publication date range (YYYY-MM-DD). |
effective_on_after / _before |
string |
Effective date range (YYYY-MM-DD). |
comments_close_on_after / _before |
string |
Comment-deadline range (YYYY-MM-DD). |
comments_open |
boolean |
true for documents whose comment period closes today or later, false for those already closed. Documents with no comment deadline match neither. |
cfr_title |
string | number |
A CFR title number, e.g. 40. |
cfr_part |
string | number |
A CFR part number, e.g. 52. Requires cfr_title, and both must match the same CFR reference, so cfr_title: 40, cfr_part: 52 finds 40 CFR 52. |
significant |
boolean |
Significant under Executive Order 12866. |
rin |
string |
A Regulation Identifier Number, e.g. 2060-AV16. OR several with |. |
executive_order_number |
string | number |
Exact executive order number. |
ordering |
string |
publication_date (default -publication_date), effective_on, comments_close_on, document_number or rank. |
rank ordering requires a non-empty search. The standard page / limit / shape / flat / flatLists / joiner options apply, and the list defaults to ShapeConfig.FEDERAL_REGISTER_MINIMAL.
Documents published before 2008 are mostly Uncategorized Document, so a type filter undercounts that era.
const document = await client.getFederalRegisterDocument("00000000-0000-0000-0000-000000000001", {
shape: "uuid,document_number,title,full_text",
});Takes the document's uuid, not its document_number. To look one up by number, use listFederalRegisterDocuments({ document_number }). Defaults to ShapeConfig.FEDERAL_REGISTER_COMPREHENSIVE.
agencies,cfr_references,docketsandtopicsare the Federal Register's own structures, served as published.agenciescarries Federal Register agency slugs, not Tango organization keys.full_text, the document's plain text, is available on this method only and only when named inshape. It can run to several MB, so neither default shape includes it.publication_date,effective_on,comments_close_onandsigning_dateare parsed toDate. TheFederalRegisterDocumentinterface in@makegov/tango-node/modelslists every field.
const investments = await client.listItDashboard({ search: "cloud", limit: 25 });const investment = await client.getItDashboard("023-000001234");listItDashboard also accepts previous_uii as a typed filter, for tracing an investment across UII renumbering.
agency takes a Tango agency name, abbreviation, code or organization key (e.g. EPA) and matches the whole organization, so a department includes its sub-agencies; OR several with |. It is available at every plan and requires Tango API 5.3.0.
OMB budget appendix accounts with lifecycle amounts (requested → enacted → apportioned → obligated → outlayed), derived ratios, and trends.
const accounts = await client.listBudgetAccounts({
fiscal_year: 2025,
agency_code: "097",
unobligated_balance__gte: 1_000_000_000,
ordering: "-unobligated_balance",
});ListBudgetAccountsOptions types the full filter surface of /api/budget/accounts/ — every numeric lifecycle, ratio, and trend field exposes an exact / __gte / __lte triplet, and categorical filters carry __in / __icontains variants.
The range filters use the API's dunder wire names (double underscore, e.g. fiscal_year__gte) — these are passed through verbatim.
A representative sample:
| Filter family | Example params |
|---|---|
| Identity / categorical | federal_account_symbol, fiscal_year, agency, agency_code__in, bureau_name__icontains, bea_category, account_category__in, subfunction_code, account_title__icontains |
| Lifecycle amounts | requested_ba__gte, enacted_ba__lte, apportioned__gte, obligated_total__gte, outlayed_total__lte, unobligated_balance__gte |
| Contract / assistance breakdowns | contract_obligated__gte, assistance_outlayed__lte, contract_share_of_obligated_capped__gte |
| Ratios | obligated_to_apportioned_pct__gte, apportioned_to_enacted_pct_capped__lte, outlayed_to_obligated_pct__gte, unobligated_pct__gte |
| Trends | enacted_ba_yoy_pct__gte, obligated_yoy_pct__lte, enacted_ba_5yr_cagr__gte, ba_growth_next_year_pct__gte, actual_vs_requested_contract__gte |
See ListBudgetAccountsOptions in src/client.ts for the complete list — every filter is a typed, autocompleted option.
Any of the numeric fields is a valid ordering target (ordering: "-unobligated_balance" ranks by largest headroom first), and search covers account title, agency name, and bureau name.
With no ordering, rows come back newest fiscal year first, then by enacted_ba descending, with accounts that have no enacted_ba last.
Data through period. Each row carries data_through_period, the File A fiscal period (1–12) the account-year's figures run through, so the latest fiscal year is partial until it reaches 12.
It is null when the row has no File A data.
data_through_period, data_through_period__gte, data_through_period__lte and data_through_period__isnull filter on it; for example, data_through_period__isnull: false keeps only rows with File A data.
Account category. Each row carries account_category ("budgetary" or "credit_financing" today; treat it as an open string), and account_category / account_category__in filter on it.
For example, account_category: "budgetary" leaves out credit financing accounts.
Source anomalies. Each row carries source_anomalies, a list typed as BudgetSourceAnomaly[] that is [] when the row is clean.
An entry records a source-data inconsistency the API found and what it did: action: "capped" means a served value was clamped to its bound (reported_value is what the source said, served_value is what the row carries), and action: "flagged" means the value is served as reported and only annotated.
code is an open string (contract_exceeds_obligations, assistance_exceeds_obligations and contract_without_obligations today), affected_fields names the fields the anomaly touches, and source points at the upstream rows behind it.
Every property on an entry is optional.
There is no filter on anomalies, so check the list client-side:
import type { BudgetAccount } from "@makegov/tango-node/models";
const res = await client.listBudgetAccounts({ fiscal_year: 2025, account_category: "budgetary" });
const capped = (res.results as BudgetAccount[]).filter((row) => row.source_anomalies?.some((a) => a.action === "capped"));Legacy aliases. Three pre-1.2 option names are kept and remapped to the params the API actually understands: fiscal_year_gte → fiscal_year__gte, fiscal_year_lte → fiscal_year__lte, and account_title → account_title__icontains.
An explicitly passed dunder param wins over its alias.
const account = await client.getBudgetAccount("ACCOUNT_ID");Quarterly lifecycle history and top recipients for one account.
const quarters = await client.getBudgetAccountQuarters("ACCOUNT_ID");
const recipients = await client.getBudgetAccountRecipients("ACCOUNT_ID");DLA DIBBS solicitations and awards: RFQs, RFPs, and award history.
const rfqs = await client.listDibbsRfqs({
nsn: "5310-01-234-5678",
open: true,
shape: ShapeConfig.DIBBS_RFQS_MINIMAL,
});Typed filters: nsn, part_number, solicitation, purchase_request, organization, status_code, set_aside, open, quantity_min / quantity_max, issue_date_after / issue_date_before, return_by_date_after / return_by_date_before, search, ordering.
const rfps = await client.listDibbsRfps({ open: true, limit: 25 });Typed filters: nsn, part_number, solicitation, organization, buyer_code, open, issued_date_after / issued_date_before, closes_date_after / closes_date_before, search, ordering.
const awards = await client.listDibbsAwards({ awardee_cage: "1ABC2", limit: 25 });Typed filters: award_number, delivery_order_number, solicitation, purchase_request, nsn, part_number, awardee_cage, entity, organization, total_contract_price_min / total_contract_price_max, award_date_after / award_date_before, posted_date_after / posted_date_before, search, ordering.
Two API behaviors worth knowing:
is_openis derived at query time fromreturn_by_date(RFQs) /closes_date(RFPs) — filter with theopenoption rather than shaping onis_open.- DIBBS
total_contract_priceis the order total repeated on every line item — never sum it across rows; deduplicate on award + delivery-order number first.
State, local and education procurement — solicitations that never appear on SAM.gov because they were never federal. Coverage is partial and grows one jurisdiction at a time.
This data does not join to the federal data: no UEI, no PIID, no agency-hierarchy key and no NAICS/PSC crosswalk. organization(*) here is three strings, not the federal 7-key office payload.
const open = await client.listSledOpportunities({
state: "TX",
response_deadline_before: "2026-10-01",
ordering: "response_deadline",
});Typed filters: state, jurisdiction, status, active, agency, solicitation_number, solicitation_type, has_documents, revision_kind, naics, nigp, unspsc, category, category_code, posted_after / posted_before, response_deadline_after / response_deadline_before, first_seen_after / first_seen_before, change_seen_after, modified_after / modified_before, platform, native_id, external_id, search, ordering.
Two defaults to know before your first call:
- Passing neither
statusnoractivereturns open solicitations only. Only about a fifth of the corpus is open, and a portal drops a closed solicitation rather than restating it, so the API defaults the list tostatus=open. Pass an explicitstatusto page the whole corpus;status: "open|unknown"also reaches the standing rosters and dateless RFIs thatunknowncovers.getSledOpportunity()returns a solicitation whatever its status. statusis Tango's answer, not the portal's. It is derived from the portal's word, the deadline and the clock, and refreshed every fifteen minutes. The portal's own word is served assource_status, is frozen at last capture, and is not filterable — most of what it calls open already has a passed deadline.
?search= is ranked over title, agency, identifiers, category labels and description, widened by the solicitations whose attachment text matched. A row that matched on its description carries a snippet with the matching passage; a title-or-agency match honestly carries none. Attachment matching contributes ids only — a caller learns that a document matched, never what it said.
category_codes scheme tagging is mid-migration, so naics matches only the small tagged share. Use category_code to match a code under any scheme, including the untagged pre-migration strings.
const row = await client.getSledOpportunity(id, {
shape: "opportunity_id,title,status,meta(*),attachments(*),revisions(*)",
});meta.attachment_count can be lower than row.attachments.length. Some portals auto-generate a cover sheet alongside the real documents; it is listed and flagged is_generated_summary but excluded from the count and from has_documents. The count answers "does this record hold its solicitation package"; the array answers "what files exist". size_bytes and char_count only mean something as a pair.
Reading a document body — attachments(extracted_text):
const row = await client.getSledOpportunity(id, {
shape: "opportunity_id,attachments(name,size_bytes,extracted_text)",
});Requires a Small plan or above and Tango API 4.25.1+. Three rules:
- You have to name it.
attachments(*)does not carry the body and neitherShapeConfigdefault names it — the API only resolves it for a caller who asked, so a default would make every detail fetch pay for a document nobody wanted to read. - The key is absent, not null, whenever the text is not being served to you: below Small (withheld and named in
meta.upgrade_hints), on a contested document, or where it could not be resolved. - A contested document never returns text, at any plan — its stored bytes disagree with what the record advertised.
Searching document text and reading it are separate. search matches inside attachment text on every plan and returns no fragment of it; the body is a per-record read on Small and above.
raw(*) needs a Small plan or above and is explicitly unstable: its shape varies by portal platform.
const revisions = await client.listSledOpportunityRevisions(id, { kind: "deadline_change" });Typed filters: kind, source_declared, observed_after / observed_before.
observed_at is the scrape that saw the change, not the date the agency made it. No state portal emits amendment notices, so kind is Tango's inference from the diff on about 95% of revisions, resolution is that state's crawl cadence, and history starts when Tango began reading the jurisdiction rather than when the solicitation was posted.
Unlike the revisions(*) expand, this route serves enrichment rows — Tango's own detail fetch filling in coverage rather than an agency amendment. Pass kind: "enrichment" for only those. changes (the per-field before and after) needs a Small plan, which is why it is left out of SLED_REVISIONS_MINIMAL; changed_fields is in the default and available at every plan.
const coverage = await client.getSledCoverage();
for (const row of coverage.states as Array<Record<string, unknown>>) {
console.log(row.state, row.total_count, row.by_status, row.last_change_observed_at);
}Call this before treating a per-state count as market size. A thin result for a state is at least as likely to be a portal Tango does not read as a quiet market, and that is the ambiguity this endpoint exists to resolve. Every state row carries all five status buckets whether or not they have rows, so a total and two buckets never invite subtraction. Takes no parameters and is neither shaped nor paginated.
const forecasts = await client.listSledForecasts({ state: "MD", advertisement_after: "2026-10-01" });Typed filters: state, agency, procurement_category, procurement_method, contract_number, incumbent_name, advertisement_after / advertisement_before, first_seen_after / first_seen_before, modified_after / modified_before, search, ordering.
- Forecasts carry no liveness at all — no deadline to have passed, so no
status, noactive, and no open-only default. Currency is the caller's call fromestimated_advertisement_date. estimated_advertisement_dateis the start of the published quarter, not a posting date.estimated_advertisement_rawkeeps the portal's own words ("Q3 (Jan.-March 2027)"), and a large share of rows publish no quarter at all.estimated_value(min,max,raw)is parsed from a free-text award band at serve time. A band naming one number is a floor, somaxis null — never read a missingmaxas an unbounded ceiling.incumbent_nameis published text, not a resolved Tango entity.
SAM.gov exclusion records (debarments, suspensions, and other ineligibility actions).
const exclusions = await client.listExclusions({
active: true,
classification_type: "Firm",
shape: ShapeConfig.EXCLUSIONS_MINIMAL,
});Typed filters: uei, entity_uei, cage_code, npi, classification_type, exclusion_type, exclusion_program, excluding_agency_code, excluding_agency_name, agency, active, delisted, activate_date_after / activate_date_before, termination_date_after / termination_date_before, update_date_after / update_date_before, search, ordering.
is_currently_excluded is derived at query time — filter with active: true for records currently in effect rather than shaping on it.
agency scopes by the excluding agency: it takes a Tango agency name, abbreviation, code or organization key and matches the whole organization, so a department includes its sub-agencies (Tango API 5.3.0).
The organization(...) expand and organization_id are accepted in shape.
SBIR/STTR topics and DoD DSIP solicitation cycles.
const topics = await client.listSbirTopics({
agency: "DOD",
year: 2026,
shape: ShapeConfig.SBIR_TOPICS_MINIMAL,
});Typed filters: topic_number, solicitation_number, agency, activity, year, doc_source, cycle_name (exact DSIP solicitation cycle, e.g. DOD_SBIR_2026_P1_CBZ; Tango API 5.3.1), open_date_after / open_date_before, close_date_after / close_date_before, release_date_after / release_date_before, search, ordering.
const cycles = await client.listSbirSolicitations({ program: "SBIR", year: 2026 });Typed filters: solicitation_number, solicitation_status, program, activity, cycle_name, out_of_cycle, year, start_date_after / start_date_before, end_date_after / end_date_before, search, ordering.
Requires either { uei } (entity LCATs) or { idvKey } (IDV LCATs) — throws TangoValidationError if neither is provided.
const lcats = await client.listLcats({ uei: "ABCDEF123456" });
// or:
const lcats = await client.listLcats({ idvKey: "GS-00F-XXXX" });Labor Categories (/api/idvs/{key}/lcats/) attached to an IDV.
const lcats = await client.listIdvLcats("GS-00F-XXXX", { limit: 25 });List metrics for a NAICS code, PSC code, or entity. ownerType, ownerId, months, and periodGrouping are all required.
const metrics = await client.listMetrics({
ownerType: "naics",
ownerId: "541511",
months: 12,
periodGrouping: "month",
});const m = await client.getNaicsMetrics("541511", 12, "month");const m = await client.getPscMetrics("D302", 12, "month");const m = await client.getEntityMetrics("ABCDEF123456", 12, "month");const naics = await client.listNaics({ search: "software" });
const code = await client.getNaics("541511");const psc = await client.listPsc({ has_awards: true });
const code = await client.getPsc("D302");has_awards: true restricts the list to PSC codes that actually appear on awards.
const sins = await client.listMasSins();
const sin = await client.getMasSin("54151S");const listings = await client.listAssistanceListings();
const listing = await client.getAssistanceListing("10.310");const types = await client.listBusinessTypes();
const bt = await client.getBusinessType("A6");Resolve a free-text name to ranked entity or organization candidates.
const result = await client.resolve({ name: "Lockheed Martin", target_type: "entity" });
// result.candidates[0].display_name, result.countRequired fields: name, target_type ("entity" | "organization").
Validate the format of a PIID, solicitation number, or UEI.
const result = await client.validate({ type: "uei", value: "ABCDEF123456" });Required fields: type ("piid" | "solicitation" | "uei"), value.
const contracts = await client.listEntityContracts("ABCDEF123456", { limit: 25 });const idvs = await client.listEntityIdvs("ABCDEF123456");const subawards = await client.listEntitySubawards("ABCDEF123456");const contracts = await client.listAgencyAwardingContracts("4700", { limit: 25 });const contracts = await client.listAgencyFundingContracts("4700", { limit: 25 });Semantic search over opportunity attachments. q is required.
const results = await client.searchOpportunityAttachments({
q: "cybersecurity",
topK: 10, // max results (optional)
includeExtractedText: false, // include raw extracted text (optional)
});| Name | Type | Description |
|---|---|---|
q |
string |
Required. Search query. |
topK |
number |
Maximum number of results to return. |
includeExtractedText |
boolean |
Whether to include raw extracted text. |
All list methods can be iterated page-by-page via the generic iterate() helper or the named convenience wrappers.
for await (const contract of client.iterate("listContracts", { awarding_agency: "9700" })) {
console.log(contract.piid);
}Named wrappers: iterateContracts, iterateEntities, iterateOpportunities, iterateNotices, iterateGrants, iterateForecasts, iterateIdvs, iterateVehicles, iterateDibbsRfqs, iterateDibbsRfps, iterateDibbsAwards, iterateExclusions, iterateSbirTopics, iterateSbirSolicitations, iterateSledOpportunities, iterateSledForecasts.
const v = await client.getVersion();const keys = await client.listApiKeys();Webhook APIs let Large / Enterprise users manage subscription filters for outbound Tango webhooks.
Discover supported event_type values.
const info = await client.listWebhookEventTypes();In production, MakeGov provisions the initial endpoint for you. These methods are most useful for dev/self-service.
const endpoints = await client.listWebhookEndpoints({ page: 1, limit: 25 });
const endpoint = await client.getWebhookEndpoint("ENDPOINT_UUID");createWebhookEndpoint accepts the canonical snake_case shape (callback_url, is_active, name) or the legacy camelCase aliases (callbackUrl, isActive). If name is not provided, the SDK falls back to the URL host.
// Create (canonical snake_case)
const created = await client.createWebhookEndpoint({
name: "Prod receiver",
callback_url: "https://example.com/tango/webhooks",
// is_active defaults to true on create
});
// Legacy camelCase still works:
const created2 = await client.createWebhookEndpoint({
callbackUrl: "https://example.com/tango/webhooks",
isActive: true,
});
// Update
await client.updateWebhookEndpoint(created.id, { is_active: false });
// Delete
await client.deleteWebhookEndpoint(created.id);Send an immediate test webhook to a specific endpoint. endpointId is required. The SDK sends { endpoint: <id> } in the request body (canonical post-tango#2252 cleanup; the API also accepts endpoint_id as a deprecated alias).
const result = await client.testWebhookEndpoint("ENDPOINT_UUID");
console.log(result.success, result.status_code);Legacy wrapper around testWebhookEndpoint. endpointId may be omitted, in which case the API auto-resolves the user's only endpoint (404 if 0, 400 if >1). Prefer testWebhookEndpoint for new code.
const result = await client.testWebhookDelivery({ endpointId: "ENDPOINT_UUID" });Fetch Tango-shaped sample deliveries.
const sample = await client.getWebhookSamplePayload({ eventType: "alerts.contract.match" });The Alerts API is a filter-subscription convenience layer on top of subscriptions. The SDK uses cleaner field names than the underlying API: name (vs subscription_name), filters (vs filter_definition), and singular query_type values.
// Create
const alert = await client.createWebhookAlert({
name: "New IT cloud contracts", // vs subscription_name on the wire
query_type: "contract", // SINGULAR — not "contracts"
filters: { naics: "541511" }, // vs filter_definition on the wire
frequency: "realtime", // realtime | daily | weekly | custom
cron_expression: undefined, // required if frequency === "custom"
});
// List
const alerts = await client.listWebhookAlerts({ page: 1, pageSize: 25 });
// Get / Update / Delete
const got = await client.getWebhookAlert("ALERT_UUID");
await client.updateWebhookAlert("ALERT_UUID", { name: "Updated name" });
await client.deleteWebhookAlert("ALERT_UUID");Notes:
nameandquery_typeare required on create.query_typeis singular (e.g."contract", not"contracts").- Only
name,frequency,cronExpression, andisActiveare writable viaupdateWebhookAlert—query_typeandfiltersare read-only after creation.
The API does not currently expose a public /api/webhooks/deliveries/ or redelivery endpoint. Use:
testWebhookEndpoint(endpointId)for connectivity checksgetWebhookSamplePayload()for building handlers + alert payloads
Every delivery includes an HMAC signature header:
X-Tango-Signature: sha256=<hex digest>
Use the SDK's verifySignature helper — do not hand-roll HMAC. Verify against the raw request body bytes (not a re-serialized parsed body). Arg order is (body, header, secret).
import { verifySignature } from "@makegov/tango-node";
// Express — use express.raw() to get the body as a Buffer before JSON parsing
app.post("/tango/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body; // Buffer
const signatureHeader = req.headers["x-tango-signature"];
if (!verifySignature(rawBody, signatureHeader, process.env.TANGO_WEBHOOK_SECRET)) {
return res.status(401).json({ error: "invalid_signature" });
}
const payload = JSON.parse(rawBody.toString("utf8"));
// ... handle payload.events ...
res.json({ ok: true });
});verifySignature signature:
function verifySignature(body: string | Buffer, header: string | null | undefined, secret: string): boolean;Returns false for missing, malformed, or mismatched headers — never throws on mismatch. Uses timingSafeEqual internally. See WEBHOOKS.md § Signature verification for Fastify and framework-agnostic examples.
All thrown by async methods:
TangoAPIErrorTangoAuthErrorTangoNotFoundErrorTangoRateLimitErrorTangoTimeoutErrorTangoValidationErrorTangoEbuyAttachmentLinkError(aTangoValidationErrorwith aurl)ShapeErrorShapeParseErrorShapeValidationErrorTypeGenerationErrorModelInstantiationError
When the API rejects a request with a structured 400 payload (shape errors especially), TangoValidationError exposes it without any hand-parsing of responseData:
err.issues— the API's issue entries, e.g.[{ path: "tradeoff_process", reason: "unknown_field" }]; an empty array when the response carried no structured issues.err.availableFields— the endpoint's valid field set when the API includes one, elsenull.
try {
await client.listContracts({ shape: "key,tradeoff_process" });
} catch (err) {
if (err instanceof TangoValidationError) {
for (const issue of err.issues) console.error(issue.path, issue.reason);
console.error("valid fields:", err.availableFields);
}
}All list endpoints return:
interface PaginatedResponse<T> {
count: number;
countType: "exact" | "approximate" | null;
next: string | null;
previous: string | null;
pageMetadata: Record<string, unknown> | null;
meta: Record<string, unknown> | null;
agencyWarnings: string[];
unresolvedAgencyTokens: Record<string, string[]>;
resolvedAgencies: Record<string, Array<Record<string, unknown>>>;
cursor: string | null;
results: T[];
}You can follow next / previous manually, pass cursor back on keyset-paginated endpoints, or use the iterate* helpers.
countType says whether count is "exact" or "approximate", read from the API's X-Results-CountType header on every list method; it is null when the API does not send it.
Past 1,000 matches the contract, IDV, opportunity and notice lists may return a query-planner estimate as count, which can be well off; this is how to tell the two apart.
It is taken from each response itself, so concurrent calls on one client cannot see each other's value the way client.lastResponseHeaders can.
meta carries any response-level metadata the API attached to the page — currently agency-filter resolution diagnostics.
Three parsed views are always present (empty rather than throwing when meta is absent or malformed):
agencyWarnings— human-readable notes about agency tokens that were dropped or matched loosely; a non-empty list means part of your filter did not apply, so a small or emptyresultsis not evidence that no such records exist.unresolvedAgencyTokens— tokens that matched no organization, keyed by filter name; check this to fail loudly in a pipeline instead of trusting a silently-narrowed result set.resolvedAgencies— the organizations each token actually resolved to, keyed by filter name; agency resolution is fuzzy, so checking the resolvednameis the only way to catch a token matching an agency you did not intend.
const resp = await client.listContracts({ awarding_agency: "Navvy" });
if (resp.agencyWarnings.length > 0) {
console.warn(resp.agencyWarnings);
console.warn("unresolved:", resp.unresolvedAgencyTokens);
console.warn("resolved to:", resp.resolvedAgencies);
}Each entry of meta.resolved_filters[<filter name>] that resolved also says how its token matched, in matched_by (Tango API 5.9.0+):
key— an organization UUID.code— a 3-digit CGAC or 4-digit FPDS code.name— the organization's name, including a department's everyday name, a spelling variant or a rename.alias— an abbreviation or the organization's own alias.fuzzy— a looser text match, worth checking against the resolvedname.
An entry that did not resolve has no matched_by.
meta is typed Record<string, unknown>, so narrow it before reading:
type ResolvedEntry = { token: string; matched_by?: string; resolved: { name: string } | null };
const filters = (resp.meta?.resolved_filters ?? {}) as Record<string, ResolvedEntry[]>;
for (const entry of filters.awarding_agency ?? []) {
if (entry.matched_by === "fuzzy") {
console.warn(`${entry.token} loosely matched ${entry.resolved?.name}`);
}
}