On this page
Welcome to Tango API Documentation¶
The Tango API provides comprehensive access to federal procurement and assistance data, including contracts, grants, entities, and opportunities. This documentation will help you integrate with our API quickly and effectively.
What is Tango?¶
Tango is an integrated platform that consolidates federal procurement and assistance data from multiple sources (FPDS, USAspending, SAM) into a unified system. Our API provides:
- Contract Data: Federal contract awards, modifications, and transactions
- Grant Data: Financial assistance awards and transactions
- Entity Information: Vendor and recipient details with business classifications
- Opportunity Data: Contract opportunities and solicitation notices (SAM.gov)
- Agency Data: Government department, agency, and office information
- Procurement Forecasts: Upcoming procurement opportunities across many agencies
- Webhooks: Near-real-time notifications when new data is available (awards, opportunities, entities, grants, forecasts)
- And much more to come...
Getting Started¶
Quick Start Guide¶
Get up and running in 5 minutes.
Python SDK¶
Install tango-python for a batteries-included Python client with response shaping, type hints, and pagination helpers.
pip install tango-python
Swagger documentation¶
See the full documentation for the endpoints.
OpenAPI spec (JSON)¶
Full endpoint definitions, parameters, and response models in one fetch — for tools, scripts, and AI consumers.
API Reference¶
Filtering, ordering, and pagination by endpoint (curated reference; Swagger remains canonical for full surface area).
Data Dictionary¶
Field definitions for API resources.
Rate limits¶
Learn how to read rate limit headers, handle 429s, and retry responsibly.
Machine-readable: For AI and scripts, use the OpenAPI spec (JSON), the single-page HTML reference, or the combined markdown (all docs in one file).
Key Features¶
🔐 Flexible Authentication¶
- API Keys: Simple server-to-server integration
- OAuth2: For web applications and user tokens
📊 Rich Data Model¶
- Comprehensive Coverage: Contracts, grants, entities, opportunities
- Historical Data: Years of federal spending data
- Real-time Updates: Frequent data refreshes + optional webhooks for near-real-time notifications
⚡ High Performance¶
- Smart Filtering: Efficient query optimization
- Pagination: Handle large result sets
- Caching: Fast response times
- Rate Limiting: Fair usage policies
🔍 Advanced Search¶
- Full-text Search: Search across titles and descriptions
- Complex Filtering: OR/AND patterns for precise queries
- Geographic Search: Location-based filtering
- Date Ranges: Flexible temporal queries
🎯 Response Shaping¶
- Custom Field Selection: Request only the data you need to reduce payload size
- Nested Expansions: Include related objects (recipients, offices, transactions) in a single request
- Flattening Support: Convert nested JSON to flat structures for easier consumption
- Flexible Aliasing: Rename fields to match your application's naming conventions
Learn more in the Response Shaping Guide. For field definitions, see the Data Dictionary.
🔔 Webhooks¶
Subscribe to event notifications when new data is available (awards, opportunities, entities, grants, forecasts).
Learn more in the Webhooks Partner Guide.
MCP (AI agents)¶
Tango is available as a one-click install in both major assistant directories — the Tango connector for Claude, and the Tango app for ChatGPT. Sign in with your Tango account; no API key to configure. Cursor and any other MCP-compatible client can connect to the same hosted server directly.
The Tango MCP server exposes tools to discover, search, and get details on contracts, opportunities, entities, and more.
Learn more in the MCP (AI agents) guide.
Data Sources¶
Tango integrates data from authoritative federal sources:
- FPDS (Federal Procurement Data System) - Contract awards and modifications
- USAspending - Financial assistance and sub-award data
- SAM.gov - Entity registrations and contract opportunities
- Grants.gov - Federal grant opportunities
- Agency Systems - Direct agency data feeds including the Federal Hierarchy
Provenance¶
Learn more about our data sources and how we use them in Provenance.
Changelog¶
See what's new and what's changed in each release in the Changelog.
Guides
Guides¶
Task-oriented walkthroughs for working with the Tango API. If you know what you want to do, start here. If you know what you want to look up, jump to the API reference.
Getting Started¶
New to Tango? These get you to a working request fast.
- Quick start — your first API call in under five minutes
- Authentication — API keys and OAuth2
- Plans & pricing — what each tier unlocks
Patterns¶
Cross-cutting techniques you'll use on every endpoint.
- Response shaping — pick exactly the fields you want
- Pagination & result counts — cursor pagination, totals, large result sets
- Rate limits & retries — what the headers tell you and how to back off
- Vehicles explained — IDVs, BPAs, GWACs, schedule contracts
How-to recipes¶
Specific jobs, end-to-end.
- Search opportunities by NAICS
- Resolve free-text agency names
- Stream contract awards in real time
- Migrate from USAspending bulk download
- Explore federal budget data — Beta
Webhook recipes¶
Filter-based webhook alerts (/api/webhooks/alerts/) — see also the Webhooks user guide.
Looking for SDK-specific examples? See the Python or Node SDK docs.
Getting Started
Quick Start Guide¶
Get up and running with the Tango API in 5 minutes or less.
Prerequisites¶
- Basic knowledge of HTTP and JSON
- A web browser or API client (like Postman, Insomnia, or curl)
- Optional: Programming language of your choice (Python, JavaScript, etc.)
Step 1: Get Your API Key¶
- Visit Tango Web Interface
- Sign up for an account or log in
- Navigate to your API keys section
- Create a new API key
- Copy your API key (you'll need it for all requests)
Note: The API requires authentication for all access. Anonymous access is not supported. You'll need an API key for any usage.
Step 2: Make Your First API Call¶
Pull the five most recently awarded federal contracts:
curl -H "X-API-KEY: your-api-key-here" \
"https://tango.dev/api/contracts/?limit=5&ordering=-award_date"
import requests
headers = {"X-API-KEY": "your-api-key-here"}
response = requests.get(
"https://tango.dev/api/contracts/",
params={"limit": 5, "ordering": "-award_date"},
headers=headers,
)
data = response.json()
for contract in data["results"]:
print(contract["piid"], contract["recipient"]["display_name"], contract["obligated"])
const params = new URLSearchParams({ limit: "5", ordering: "-award_date" });
const response = await fetch(`https://tango.dev/api/contracts/?${params}`, {
headers: { "X-API-KEY": "your-api-key-here" },
});
const data = await response.json();
for (const contract of data.results) {
console.log(contract.piid, contract.recipient.display_name, contract.obligated);
}
Step 3: Explore the Response¶
You'll get a paginated response shaped like this:
{
"count": 9824315,
"next": "https://tango.dev/api/contracts/?limit=5&ordering=-award_date&cursor=cD0yMDI2LTA1LTEx",
"previous": null,
"results": [
{
"key": "CONT_AWD_47QSWA24P0BWF_4732_-NONE-_-NONE-",
"piid": "47QSWA24P0BWF",
"recipient": {
"display_name": "ACME Corporation",
"uei": "ZMXAHH8M8VL8"
},
"award_date": "2026-05-11",
"obligated": 1500000.00,
"description": "IT Services Contract"
}
]
}
Step 4: Try a More Complex Query¶
Now let's find active opportunities in a specific industry and agency:
curl -H "X-API-KEY: your-api-key-here" \
"https://tango.dev/api/opportunities/?naics=541512&active=true&agency=DOD&ordering=response_deadline&limit=5"
import requests
headers = {"X-API-KEY": "your-api-key-here"}
response = requests.get(
"https://tango.dev/api/opportunities/",
params={
"naics": "541512",
"active": "true",
"agency": "DOD",
"ordering": "response_deadline",
"limit": 5,
},
headers=headers,
)
for opp in response.json()["results"]:
print(opp["response_deadline"], opp["opportunity_id"], opp["title"])
const params = new URLSearchParams({
naics: "541512", active: "true", agency: "DOD",
ordering: "response_deadline", limit: "5",
});
const response = await fetch(`https://tango.dev/api/opportunities/?${params}`, {
headers: { "X-API-KEY": "your-api-key-here" },
});
for (const opp of (await response.json()).results) {
console.log(opp.response_deadline, opp.opportunity_id, opp.title);
}
This query:
- Filters by NAICS
541512(Computer Systems Design Services) - Restricts to DOD opportunities
- Returns only currently-biddable opportunities (
active=true) - Orders by response deadline (soonest first)
- Limits to 5 results
For more on multi-value filters, agency resolution, and webhook subscriptions for new opportunities, see the Search opportunities by NAICS guide.
Step 5: Understand the Response Format¶
Most list endpoints return paginated responses. The count may be exact or approximate depending on the endpoint (see Result counts) — check the X-Results-CountType response header to know which:
{
"count": 1250,
"next": "https://tango.dev/api/opportunities/?...&page=2",
"previous": null,
"results": [
{
"opportunity_id": "75D30126R00012",
"title": "Cyber Workforce Development Services",
"naics_code": 541512,
"psc_code": "D316",
"set_aside": "SBA",
"response_deadline": "2026-06-14T17:00:00Z",
"first_notice_date": "2026-05-08T15:32:11Z",
"active": true,
"sam_url": "https://sam.gov/opp/75D30126R00012/view",
"office": {
"office_code": "FA7014",
"office_name": "FA7014 AFDW PK",
"agency_code": "5700",
"agency_name": "Department of the Air Force"
},
"meta": {
"notices_count": 5,
"notice_type": {"code": "o", "type": "Solicitation"}
}
}
]
}
Step 6: Use Response Shaping to Customize Your Data¶
Response shaping lets you request only the specific fields you need, reducing data transfer and speeding up your application.
Basic Shaping Example¶
Instead of receiving all fields, request just what you need using the shape parameter:
curl -H "X-API-KEY: your-api-key-here" \
"https://tango.dev/api/contracts/?shape=key,piid,award_date,recipient(display_name,uei)&limit=5"
This request returns only:
- Contract key
- PIID (contract identifier)
- Award date
- Recipient name and UEI (nested)
Why Use Response Shaping?¶
- Faster responses: Less data to transfer means quicker API calls
- Reduced bandwidth: Only get the fields you actually need
- Cleaner code: Shape the response to match your application's data model
- Flexible expansions: Include related data (offices, transactions, recipients) without separate API calls
Response Example¶
{
"count": 1250,
"results": [
{
"key": "CONT_AWD_47QSWA24P0BWF_4732_-NONE-_-NONE-",
"piid": "47QSWA24P0BWF",
"award_date": "2024-01-15",
"recipient": {
"display_name": "ACME Corporation",
"uei": "ZMXAHH8M8VL8"
}
}
]
}
Learn more about advanced features like flattening, aliasing, and multiple expansions in the Response Shaping Guide.
Common Issues¶
Rate Limit Exceeded¶
If you see a 429 Too Many Requests error, you've hit a rate limit. Check X-RateLimit-Remaining and X-RateLimit-Reset (seconds until reset), and follow the retry guidance in the Rate limits guide.
Authentication Error¶
If you get a 401 Unauthorized error, check that:
- Your API key is correct
- You're including the
X-API-KEYheader - Your API key is active
Invalid Request¶
If you get a 400 Bad Request error, check that:
- Your URL parameters are properly formatted
- Required parameters are included
- Date formats are YYYY-MM-DD
Authentication¶
The Tango API supports multiple authentication methods to suit different use cases and security requirements.
Authentication Methods¶
1. API Keys (Recommended)¶
API keys are the simplest and most secure method for server-to-server integration.
Getting an API Key¶
- Visit Tango Web Interface
- Sign up for an account or log in
- Navigate to your account profile
- Under Create a new API key, give the key a name (e.g., "Laptop") and create it
- Copy the key and store it securely — new accounts start with no keys until you create one
Using API Keys¶
Include your API key in the X-API-KEY header with every request:
curl -H "X-API-KEY: your-api-key-here" \
"https://tango.dev/api/contracts/"
import httpx
headers = {'X-API-KEY': 'your-api-key-here'}
response = httpx.get(
'https://tango.dev/api/contracts/',
headers=headers
)
const response = await fetch('https://tango.dev/api/contracts/', {
headers: {
'X-API-KEY': 'your-api-key-here'
}
});
2. OAuth2¶
OAuth2 is recommended for web applications and user-specific integrations.
OAuth2 Flow¶
- Register your application in the Tango web interface
- Get client credentials (client ID and secret)
- Implement OAuth2 flow in your application
- Use access tokens for API requests
Example OAuth2 Implementation¶
import requests
from requests_oauthlib import OAuth2Session
# OAuth2 configuration
client_id = 'your-client-id'
client_secret = 'your-client-secret'
authorization_base_url = 'https://tango.makegov.com/o/authorize/'
token_url = 'https://tango.makegov.com/o/token/'
# Create OAuth2 session
oauth = OAuth2Session(client_id)
# Get authorization URL
authorization_url, state = oauth.authorization_url(authorization_base_url)
# Redirect user to authorization_url
print(f"Please go to {authorization_url} and authorize access")
# After authorization, get the authorization response URL
authorization_response = input('Enter the full callback URL: ')
# Fetch the access token
token = oauth.fetch_token(
token_url,
authorization_response=authorization_response,
client_secret=client_secret
)
# Use the token for API requests
response = oauth.get('https://tango.dev/api/contracts/')
OAuth2 Endpoints¶
| Endpoint | Path | Purpose |
|---|---|---|
| Authorization | https://tango.makegov.com/o/authorize/ |
Authorization Code flow start |
| Token | https://tango.makegov.com/o/token/ |
Exchange code for access token; client credentials |
| Refresh | https://tango.makegov.com/o/token/ |
Refresh an access token (same path; use grant_type=refresh_token) |
Supported grant types: authorization_code, client_credentials, refresh_token.
OAuth2 Scopes¶
Available scopes for OAuth2 applications:
read- Read access to all data
Monitoring Usage¶
Response Headers¶
Check these headers to monitor your API usage:
curl -I -H "X-API-KEY: your-api-key-here" \
"https://tango.dev/api/contracts/"
Response headers:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 45
X-Execution-Time: 0.045s
Rate Limit Headers¶
X-RateLimit-Limit: Total requests allowed for the most restrictive windowX-RateLimit-Remaining: Requests remaining in the most restrictive windowX-RateLimit-Reset: Seconds until reset for the most restrictive windowX-Execution-Time: Request execution time
Some endpoints carry their own premium query budget in place of these limits, with its own headers (X-RateLimit-Premium-Limit, X-RateLimit-Premium-Remaining, X-RateLimit-Premium-Reset). Premium headers appear only on responses from endpoints with a premium budget.
For the full list of per-window headers (daily/burst), premium headers, and practical retry guidance, see the Rate limits guide.
Error Handling¶
Authentication Errors¶
401 Unauthorized¶
{
"detail": "Authentication credentials were not provided."
}
Causes:
- Missing API key
- Invalid API key
- Expired API key
- Inactive API key
Solutions:
- Check that you're including the
X-API-KEYheader - Verify your API key is correct
- Ensure your API key is active
- Generate a new API key if needed
403 Forbidden¶
Tier-gated endpoints return a structured tier_required error:
{
"error": "This endpoint requires Small tier or above. Your current tier is Free.",
"code": "tier_required",
"required_tier": "Small",
"current_tier": "Free",
"upgrade_url": "https://docs.makegov.com/getting-started/pricing/"
}
Causes:
- The endpoint or filter requires a higher subscription plan than your current one
Solutions:
- Use
required_tier,current_tier, andupgrade_urlto surface an upgrade prompt programmatically - See Plans & pricing for what each plan includes, or upgrade from the pricing page
Rate Limit Errors¶
429 Too Many Requests¶
{
"detail": "Rate limit exceeded for burst. Please try again in 45 seconds.",
"wait_in_seconds": 45
}
Solutions:
- Wait for the rate limit window to reset
- Implement exponential backoff in your application
- Consider upgrading your account for higher limits
- Optimize your requests to reduce frequency
See the Rate limits guide for header semantics, examples (curl/Python/JS), and recommended client behavior.
Plans & Data Access¶
All Tango API plans include full access to raw federal procurement data from FPDS, SAM.gov, USAspending, Grants.gov, and agency feeds. Paid plans unlock Tango-computed enrichments and higher rate limits.
For current pricing, see the pricing page.
Tier shorthand
Plans available to new customers are named Free, Small, Medium, Large, and Enterprise. Where these docs (and API error messages) say Pro+, that means Small and above — and the retired Micro plan, which still counts as Pro for the accounts still on it. Business+ means Medium and above.
What's included in every plan¶
Every plan -- including Free -- provides complete access to:
- Contracts, IDVs, OTAs, OTIDVs -- all FPDS award data with full transaction history
- Vehicles -- solicitation-centric groupings of IDVs, with metrics, awardees, and task orders
- Subawards -- USAspending/FSRS subcontract data
- Entities -- SAM.gov vendor/recipient records (UEI, addresses, business types, NAICS codes, ownership)
- Opportunities & Notices -- SAM.gov solicitations with attachments, contacts, and history
- Grants -- Grants.gov opportunities
- Forecasts -- Agency procurement forecasts
- Protests -- GAO and SBA OHA bid protest cases with dockets and decisions
- Contract appeals -- CBCA and ASBCA Contract Disputes Act decisions, including full-text search over the decision text. Only the
decision_textfield itself is gated. - Federal Register -- Rules, proposed rules, notices and presidential documents since 1994, including each document's full text
- IT Dashboard -- Federal IT investment data from itdashboard.gov, including all expansion data (CIO evaluations, contracts, projects, performance metrics, funding sources). Only the full
business_case_htmlblob is gated. - Agencies, Departments, Offices, Organizations -- Full government organization hierarchy
- Response shaping -- Request only the fields you need via
?shape= - Full-text search -- Rich filtering across most endpoints (some advanced search filters require a paid plan)
What paid plans add¶
Paid plans (Small and above) unlock Tango-computed enrichments -- fields that Tango derives, resolves, or extracts from raw data. These are not available from the underlying public data sources directly.
Enriched data fields (Small and above)¶
| Resource | Gated fields | What they provide |
|---|---|---|
| Entities | past_performance(*) |
Award history aggregated from FPDS -- total contracts, dollar values, agency breakdown |
| Entities | relationships(*) |
Corporate ownership and succession resolved from the SAM.gov entity graph, plus prime/subcontractor ties derived from reported subaward data |
| Opportunities | attachments(extracted_text) |
Full text extracted (OCR) from solicitation attachments |
| Notices | attachments(extracted_text) |
Full text extracted (OCR) from notice attachments |
| Protests | resolved_protester(*) |
Protester name resolved to a SAM.gov entity via Bayesian matching |
| Protests | resolved_agency(*) |
Agency name resolved to a Tango organization record via Bayesian matching |
| IT Dashboard | business_case_html |
Full HTML of the investment's business case page (Business+ plans only — Medium and above) |
Contract appeal decision text (Enterprise)¶
decision_text on /api/contract_appeals/ -- the extracted body of a CBCA or ASBCA decision -- requires an Enterprise plan. Everything else about the resource, including the full-text search that runs over that text, is available on every plan including Free: a search response returns matching decisions and no fragment of the body, so there is nothing to gate. Requesting decision_text on a lower plan returns 200 with the field omitted and a meta.upgrade_hints entry, exactly as described below.
GSA eBuy requests (paid plans, by arrangement)¶
/api/ebuy/requests/ serves the GSA eBuy requests posted under your own Schedule contracts. It is available on paid plans, and needs a contract link that MakeGov sets up for your account — reach out to get started.
CALC labor rates (Small and above)¶
The /api/lcats/ endpoint provides GSA CALC labor category rate data. This entire endpoint requires a paid plan.
Identifier validation (Small and above)¶
The POST /api/validate/ endpoint validates the format of a PIID, solicitation number, or UEI. Available on Pro+ plans (Small and above); Free-tier callers receive HTTP 403.
How field gating works¶
When you request a gated field on a Free plan, the API:
- Returns a
200 OKresponse (not an error) - Omits the gated field from the response
- Includes a
meta.upgrade_hintsobject listing which fields were omitted
{
"results": [
{
"uei": "ABC123",
"display_name": "Example Corp"
}
],
"meta": {
"upgrade_hints": {
"message": "Some requested fields were omitted because they require a higher tier subscription.",
"fields": [
{"field": "past_performance", "required_tier": "Small"}
],
"upgrade_url": "https://docs.makegov.com/getting-started/pricing/"
}
}
}
You can always check which fields are available by inspecting the meta.upgrade_hints in your response.
Rate limits by plan¶
Each plan has both a daily quota (resets at UTC midnight) and a burst rate (per-minute). The most-restrictive window is what limits a given burst of activity. Headers (X-RateLimit-Daily-*, X-RateLimit-Burst-*) and retry guidance are documented in Rate limits.
| Plan | Daily | Burst |
|---|---|---|
| Free | 100 / day | 25 / min |
| Small | 1,500 / day | 100 / min |
| Medium | 7,500 / day | 100 / min |
| Large | 25,000 / day | 100 / min |
| Enterprise | custom | custom |
For Enterprise limits, contact sales.
Premium query budgets¶
Some endpoint families carry their own premium query budget. On those endpoints it replaces your account-wide daily and burst limits rather than adding to them, and your own figures are on your account profile. See the Rate limits guide for details and the CALC labor rates API reference for an example of a premium-limited endpoint family.
Webhook subscriptions by plan¶
| Plan | Webhook subscriptions | Custom cron |
|---|---|---|
| Free | 1 | -- |
| Small | 5 | Yes |
| Medium | 10 | Yes |
| Large | 25 | Yes |
| Enterprise | custom | Yes |
Webhook subscriptions monitor resources by query filters (agency, NAICS, search terms, etc.) and POST alerts.*.match events to your endpoint. See the Webhooks guide for details.
Upgrading your plan¶
You can upgrade or downgrade at any time from the pricing page. Changes take effect immediately with prorated billing. Annual plans save 20%.
For Enterprise plans, contact sales.
Patterns
Agency search¶
The agency filter (and its variants — awarding_agency, funding_agency) on every endpoint that exposes one is a resolver, not a substring search. You hand it any agency identifier — a name, abbreviation, code — and Tango figures out which Organization (and its hierarchy subtree) you meant.
What it accepts¶
A single agency identifier value, in any of these forms:
| You pass | Example | Resolves to |
|---|---|---|
| Abbreviation | HHS, DOD, FDA |
The agency whose canonical short name matches |
| Name (full or partial) | Health and Human Services, Defense |
The closest matching agency by full-text / fuzzy name |
| CGAC code (3-digit) | 075 |
The L1 department |
| FPDS sub-agency code (4-digit) | 7530 |
The L2 agency under the matching department |
| Federal Hierarchy key (fh_key) | 100004222 |
The exact org row in agencies_organization |
| AAC code | 87FCAB |
The org row whose AAC matches |
You don't pick a "type". You just hand over the identifier you have, and Tango ranks candidates across multiple signal types (abbreviation > acronym > alternate name > hierarchy) plus direct field matches (CGAC, FPDS code, fh_key, name trigram).
Multi-value OR (|)¶
Use | to filter by multiple agencies at once. Each token is resolved independently, then results are the union of every token's subtree.
GET /api/contracts/?awarding_agency=HHS|DOD
Returns contracts awarded by anything under HHS or anything under DOD, as a flat list in the endpoint's default order.
What | does not do:
- It does not group results by token.
- It does not preserve token order.
- It is not "search by N inputs and merge with deduplication" — each token is an independent agency selection.
If you need bucketed-by-agency results (e.g. 5 HHS contracts + 5 DOD contracts), make N separate calls. A lopsided union (huge agency + tiny agency) buries the small one in pagination.
Pagination¶
The response shape is identical to a single-token query. Standard pagination fields (count, next, previous, results) apply to the union.
Examples¶
Single agency by abbreviation¶
GET /api/contracts/?awarding_agency=HHS&limit=5
Single agency by FPDS code¶
GET /api/contracts/?awarding_agency=7530
Two agencies (OR)¶
GET /api/contracts/?awarding_agency=HHS|DOD
Forecast filter with fh_key¶
GET /api/forecasts/?agency=100004222
Mixed inputs¶
You can mix forms across |-separated tokens — abbreviation in one, fh_key in the next, name in the third:
GET /api/opportunities/?agency=HHS|100000700|Treasury
Each token is resolved independently.
Endpoints¶
The same resolver is used wherever you see an agency filter:
/api/contracts/—awarding_agency,funding_agency/api/idvs/—awarding_agency,funding_agency/api/otas/—awarding_agency,funding_agency/api/otidvs/—awarding_agency,funding_agency/api/subawards/—awarding_agency,funding_agency/api/vehicles/—agency/api/opportunities/—agency/api/notices/—agency/api/forecasts/—agency/api/grants/—agency/api/protests/—agency
For the per-endpoint filter list, see the relevant API reference page (e.g. Contracts).
See also¶
- Federal agency hierarchy — how Tango models L1 / L2 / L3 / sub-office relationships.
- Organizations API — direct access to the underlying
Organizationrecords.
Response Shaping (Custom Fields)¶
Tango’s API lets you request only the fields you want by passing a shape query parameter. Shaped responses are built directly from your selection for speed and consistency. If you omit shape, the endpoint returns the default serializer output.
Endpoints with shaping¶
- Agencies (legacy lookups):
- Departments:
/api/departments/ - Agencies:
/api/agencies/ - Offices:
/api/offices/
- Departments:
- Reference data:
- Business Types:
/api/business_types/ - NAICS:
/api/naics/ - PSC:
/api/psc/ - Assistance Listings:
/api/assistance_listings/ - MAS SINs:
/api/mas_sins/
- Business Types:
- Awards (canonical routes):
- Contracts:
/api/contracts/ - IDVs:
/api/idvs/ - OTAs:
/api/otas/ - OTIDVs:
/api/otidvs/ - Subawards:
/api/subawards/ - Vehicles:
/api/vehicles/ - GSA eLibrary Contracts:
/api/gsa_elibrary_contracts/ - CALC Rates (nested):
/api/idvs/{key}/lcats/,/api/entities/{uei}/lcats/
- Contracts:
- Organizations:
/api/organizations/ - Entities:
/api/entities/ - Exclusions:
/api/exclusions/ - Forecasts:
/api/forecasts/ - Grants:
/api/grants/ - IT Dashboard:
/api/itdashboard/ - Opportunities (canonical routes):
- Opportunities:
/api/opportunities/ - Opportunity Notices:
/api/notices/
- Opportunities:
- SBIR/STTR:
- Topics:
/api/sbir/topics/ - Solicitations:
/api/sbir/solicitations/
- Topics:
- DIBBS:
- RFQs:
/api/dibbs/rfqs/ - Solicitations (RFP/IFB):
/api/dibbs/rfps/ - Awards:
/api/dibbs/awards/
- RFQs:
- State & Local (Beta):
- Solicitations:
/api/sled/opportunities/ - Forecasts:
/api/sled/forecasts/
- Solicitations:
- Protests:
/api/protests/ - Contract appeals:
/api/contract_appeals/ - Federal Register:
/api/federal_register/ - eBuy:
/api/ebuy/requests/
All standard filters, ordering, pagination, throttling, and rate‑limit headers still apply.
Shape syntax¶
- Root leaves:
key,piid,award_date - Expansions (related objects):
recipient(display_name,uei) - Multiple items:
key,piid,recipient(display_name),awarding_office(office_code,office_name) - Star in child selections:
recipient(*)returns all allowed fields ofrecipient - Aliasing:
field::aliasorchild(field::alias) - Bracket parameters:
expand[key=value](children)— pass configuration to an expansion
Bracket parameters¶
Some expansions accept parameters via bracket syntax. Parameters go between the expansion name and the child selection:
# Top 10 agencies in past_performance
/api/entities/?shape=uei,past_performance[top=10](*)
# Multiple params (comma-separated)
/api/entities/?shape=uei,past_performance[top=3](summary,top_agencies)
Parameters are validated server-side with type checking and range enforcement. Unknown or out-of-range parameters return a 400 error with details.
Leaves vs expansions¶
In the shape DSL there are two different kinds of selections:
- Leaf:
field_name - Expansion:
expand_name(child1,child2,...)
Some endpoints expose the same name as both a leaf and an expansion. In those cases, the API will keep the selection as a leaf unless the shaping runtime determines that treating the token as a leaf would be unsafe (see “Bare expansions” below).
Example (Opportunities set-aside):
# Leaf (code string)
/api/opportunities/?shape=opportunity_id,title,set_aside
# Expansion (structured form)
/api/opportunities/?shape=opportunity_id,title,set_aside(code,description)
Bare expansions (shorthand)¶
If you write an expansion name without parentheses, e.g. office instead of office(*), Tango will sometimes treat it as shorthand for office(*).
This shorthand is applied only when needed for safety:
- when the expansion is a relation fetched via a database join, or
- when the expansion is a “virtual” expansion that reads its values from the parent object, unless the value is field-map-backed.
Examples (Notices):
# Bare expansions: office == office(*), opportunity == opportunity(*)
/api/notices/?shape=notice_id,opportunity_id,office,opportunity&limit=1
Example response (shape excerpt):
[
{
"notice_id": "d2e3f9bf-9c2e-4c58-84f1-2b2b8a1d3b4d",
"opportunity_id": "ff12a2d0-3b8a-4a1d-8d7f-7d8c2b6c0a11",
"office": {
"organization_id": "f3ab8e7c-6c2d-4e8e-9f1a-2b3c4d5e6f70",
"office_code": "W91QF5",
"office_name": "ACC-APG",
"agency_code": "9700",
"agency_name": "DEPT OF THE ARMY",
"department_code": "97",
"department_name": "DEPARTMENT OF DEFENSE"
},
"opportunity": {
"opportunity_id": "ff12a2d0-3b8a-4a1d-8d7f-7d8c2b6c0a11",
"link": "/api/opportunities/ff12a2d0-3b8a-4a1d-8d7f-7d8c2b6c0a11/"
}
}
]
Examples:
# Basic
/api/contracts/?shape=key,piid,recipient(display_name)
# Aliasing
/api/contracts/?shape=key::id,recipient(display_name::name)
# Star in nested expansion
/api/contracts/?shape=key,recipient(*),transactions(*)
Child field aliasing inside an expand:
/api/contracts/?shape=key,awarding_office(agency_name::foo)
Response:
[
{
"key": "CONT_AWD_...",
"awarding_office": { "foo": "General Services Administration" }
}
]
Notes:
*is only valid inside a child selection (e.g.,recipient(*)). Root‑level*is not supported.- Shaping requests that include unknown fields/expands, malformed shapes, or
shapeon non-shaping endpoints return a 400 with a structured error payload.
Flattening¶
Add flat=true to flatten nested objects with a joiner (default .). Works with both paginated and single‑object responses.
curl -H "X-API-KEY: <key>" \
"https://tango.dev/api/contracts/?shape=key,recipient(display_name)&flat=true&joiner=."
Response:
[
{
"key": "CONT_AWD_...",
"recipient.display_name": "ACME CORP"
}
]
Notes:
- Flattening applies to nested objects (dicts). Lists remain arrays for readability and stability by default. Expansions like
relationships(entities) andattachments(opportunities/notices) will remain arrays of objects even whenflat=true. - Optional:
flat_lists=truewill additionally flatten lists of objects into indexed keys (e.g.,relationships.0.uei). This is opt‑in and defaults tofalse.
Examples:
# Flatten dicts only (default list behavior)
/api/entities/?shape=uei,relationships(uei,display_name)&flat=true
# Also flatten list elements into indexed keys
/api/entities/?shape=uei,relationships(uei,display_name)&flat=true&flat_lists=true&joiner=.
Validation & limits¶
- Unknown fields or expansions return a 400 with structured issues.
- Malformed shapes (syntax errors) return a 400 with a
parse_errorissue. - Passing
shapeto an endpoint that does not support shaping returns a 400 (instead of ignoringshape). - Default limits (subject to change):
SHAPE_MAX_DEPTH= 2SHAPE_MAX_FIELDS= 64 (for user-supplied?shape=params; server-controlled default shapes may exceed this)
Example errors:
{
"error": "Invalid shape",
"issues": [{"path": "foo", "reason": "unknown_field"}],
"available_fields": {
"fields": ["award_date", "key", "piid", "recipient"],
"expands": {"recipient": {"fields": ["uei", "display_name"], "expands": {}, "wildcard_fields": false}},
"wildcard_fields": false
}
}
{
"error": "Invalid shape",
"issues": [{"path": "$", "reason": "parse_error (Unclosed '(')"}],
"available_fields": {
"fields": ["award_date", "key", "piid", "recipient"],
"expands": {"recipient": {"fields": ["uei", "display_name"], "expands": {}, "wildcard_fields": false}},
"wildcard_fields": false
}
}
{
"error": "Invalid shape value(s): foo. Shaping is not available for /api/example/.",
"issues": [{"path": "$", "reason": "shape_not_supported"}]
}
Discovering available fields¶
Every shaped endpoint supports ?show_shapes=true for schema discovery. Truthy values: true, 1, yes, on.
It works in two modes:
Schema-only (no ?shape=)¶
Pass ?show_shapes=true without a ?shape= parameter to get just the schema — no data is fetched, no DB query is made:
/api/contracts/?show_shapes=true
{
"available_fields": {
"fields": ["award_date", "awarding_office", "description", "key", "naics_code", "piid", "recipient", "transactions"],
"expands": {
"awarding_office": {"fields": ["organization_id", "agency_code", "agency_name", "office_code", "office_name", "department_code", "department_name"], "expands": {}, "wildcard_fields": false},
"recipient": {"fields": ["cage_code", "display_name", "legal_business_name", "uei"], "expands": {}, "wildcard_fields": false},
"transactions": {"fields": ["action_type", "description", "modification_number", "obligated", "transaction_date"], "expands": {}, "wildcard_fields": false}
},
"wildcard_fields": false
}
}
Schema appended to data (with ?shape=)¶
Pass both ?shape=... and ?show_shapes=true to get the normal shaped response with a _shaping block appended:
/api/contracts/?shape=key,piid&show_shapes=true
{
"count": 1234,
"next": "...",
"previous": null,
"results": [
{"key": "CONT_AWD_...", "piid": "GS00Q14OADS134"}
],
"_shaping": {
"available_fields": {
"fields": ["award_date", "awarding_office", "description", "key", "naics_code", "piid", "recipient", "transactions"],
"expands": {
"awarding_office": {"fields": ["organization_id", "agency_code", "agency_name", "office_code", "office_name", "department_code", "department_name"], "expands": {}, "wildcard_fields": false},
"recipient": {"fields": ["cage_code", "display_name", "legal_business_name", "uei"], "expands": {}, "wildcard_fields": false},
"transactions": {"fields": ["action_type", "description", "modification_number", "obligated", "transaction_date"], "expands": {}, "wildcard_fields": false}
},
"wildcard_fields": false
}
}
}
Schema structure¶
Each level of the schema contains:
| Key | Type | Meaning |
|---|---|---|
fields |
array | All valid shape keys at this level, including both leaf fields and expand names. |
expands |
object | Expandable relations, each containing its own nested {fields, expands, wildcard_fields} schema. |
wildcard_fields |
boolean | When true, the expansion accepts any field name (i.e., expand(*) returns all keys from the underlying data, and you can also request arbitrary keys by name). When false, only the fields listed in fields are valid. Most endpoints return false; wildcard_fields: true appears on expansions backed by freeform JSON (e.g., raw_data(*) on forecasts). |
Notes:
- Schema-only mode (
?show_shapes=truealone) makes no DB queries — safe to call frequently. _shapingis appended afterresultsin paginated responses and after all other keys in detail responses, so existing clients that ignore unknown keys are unaffected.- All 400 error responses for invalid shapes also include
available_fieldsat the top level (no?show_shapesneeded).
Date/time formatting¶
All datetimes in shaped responses are formatted using a consistent ISO‑8601 format.
Identity fallback for missing relations¶
When an expanded relation is missing (e.g., a join yields no row), the API returns a minimal identity object using the relation's key value instead of null:
{
"parent_award": {"key": "PARENT-123"}
}
Field reference by endpoint (excerpt)¶
Below is a concise reference of commonly used fields and expansions per endpoint. Use (*) inside an expansion to request all allowed fields for that relation.
Departments /api/departments/¶
- Leaves:
code,name,abbreviation,description,cgac,website,congressional_justification - No expansions.
Default response (no ?shape=): code,name,abbreviation
# Select specific fields
/api/departments/?shape=code,name
# Extended department info
/api/departments/?shape=code,name,description,cgac,website,congressional_justification
Agencies /api/agencies/¶
- Leaves:
code,name,abbreviation - Expansions:
department(code,name,abbreviation,description,cgac,website,congressional_justification)
Default response (no ?shape=): code,name,abbreviation,department(name,code)
# Just codes and names
/api/agencies/?shape=code,name
# Include department info
/api/agencies/?shape=code,name,department(name,code)
# Department with extended fields
/api/agencies/?shape=code,department(name,description,cgac,website)
# All department fields
/api/agencies/?shape=code,department(*)
Offices /api/offices/¶
- Leaves:
code,name, plus flat aliasesoffice_code,office_name,agency_code,agency_name,department_code,department_name - Expansions:
agency(code,name,abbreviation)department(code,name,abbreviation,description,cgac,website,congressional_justification)
Default response (no ?shape=): office_code,office_name,agency_code,agency_name,department_code,department_name (flat aliases)
# Default flat format
/api/offices/?shape=office_code,office_name,agency_name
# Direct leaves with nested expansions
/api/offices/?shape=code,name,agency(code,name,abbreviation)
# Office with department details
/api/offices/?shape=code,name,department(name,description,website)
# Both expansions
/api/offices/?shape=code,name,agency(code,name),department(code,name)
Business Types /api/business_types/¶
- Leaves:
name,code - No expansions.
Default response (no ?shape=): name,code
# Select a single field
/api/business_types/?shape=name
NAICS /api/naics/¶
- Leaves:
code,description - Expansions:
size_standards(employee_limit,revenue_limit)— SBA size standards for this NAICS code.revenue_limitis in whole dollars (e.g.,2250000).federal_obligations(total,active)— obligation rollups. Each sub-object containsawards_obligated(float) andawards_count(int).
Default response (list, no ?shape=): code,description
Default response (detail or show_limits=1): code,description,size_standards(*),federal_obligations(*)
Note: The legacy ?show_limits=1 parameter is still supported — it triggers the same response as ?shape=code,description,size_standards(*),federal_obligations(*). Prefer using ?shape= directly.
# List with just code and description (default)
/api/naics/?shape=code,description
# Include size standards and obligations (equivalent to ?show_limits=1)
/api/naics/?shape=code,description,size_standards(*),federal_obligations(*)
# Size standards only
/api/naics/541330/?shape=code,description,size_standards(employee_limit,revenue_limit)
# Obligations total only
/api/naics/541330/?shape=code,federal_obligations(total)
PSC /api/psc/¶
- Leaves:
code,parent,category,level_1_category,level_1_category_code,level_2_category,level_2_category_code - Expansions:
current(name,active,start_date,end_date,description,includes,excludes)— active or latest descriptionhistorical(name,active,start_date,end_date,description,includes,excludes)— all descriptions
Default response (no ?shape=): code,current(*),parent,category,level_1_category,level_1_category_code,level_2_category,level_2_category_code
# Just code and current description name
/api/psc/?shape=code,current(name)
# Code with current and historical descriptions
/api/psc/AD12/?shape=code,current(name,active),historical(name,active)
Assistance Listings /api/assistance_listings/¶
- Leaves:
number,title,published_date,archived_date,popular_name,objectives,applicant_eligibility,benefit_eligibility - No expansions.
Default response (list, no ?shape=): number,title
Note: Detail responses (/api/assistance_listings/{number}/) use the full serializer (includes all fields) when no ?shape= param is provided.
# Include extra fields
/api/assistance_listings/?shape=number,title,objectives
# Detail with specific fields
/api/assistance_listings/10.001/?shape=number,title,objectives,popular_name
MAS SINs /api/mas_sins/¶
- Leaves:
sin,large_category_code,large_category_name,sub_category_code,sub_category_name,psc_code,state_local,set_aside_code,service_comm_code,expiration_date,tdr,olm,naics_codes,title,description - No expansions.
Default response (no ?shape=): all 15 fields above.
# Select a subset of fields
/api/mas_sins/?shape=sin,title,description
Organizations /api/organizations/¶
Organizations uses a default shape — all responses go through the shaping pipeline even without ?shape=.
- Default shape:
key,fh_key,name,short_name,type,level,is_active,code,fpds_code,cgac,canonical_code,parent_fh_key,full_parent_path_name - Additional leaves:
fpds_org_id,aac_code,start_date,end_date,logo,summary,l1_fh_keythroughl8_fh_key,total_obligations,tree_obligations,mod_status,description,obligations,obligation_rank - Expansions:
parent(key,fh_key,name,short_name,type,level,is_active,code,cgac)ancestors(fh_key,name,short_name,level)children(key,fh_key,name,short_name,type,level,is_active,code,cgac)department(code,name,abbreviation)agency(code,name,abbreviation)
# Default response (all responses are shaped)
/api/organizations/
# Select specific fields
/api/organizations/?shape=fh_key,name,type,level
# Include department and agency expands
/api/organizations/?shape=fh_key,name,department(code,name),agency(code,name,abbreviation)
Contracts /api/contracts/¶
- Leaves:
key,piid,award_date,naics_code,psc_code,total_contract_value,description,base_and_exercised_options_value,fiscal_year,obligated,set_aside,award_type,transactions,subawards_summary - Expansions:
recipient(uei,display_name,legal_business_name,cage,cage_code,...)awarding_office(organization_id,office_code,office_name,agency_code,agency_name,department_code,department_name)funding_office(...)— same 7-key shape asawarding_officeplace_of_performance(country_code,country_name,state_code,state_name,city_name,zip_code)parent_award(key,piid)period_of_performance(start_date,current_end_date,ultimate_completion_date)transactions(modification_number,transaction_date,obligated,description,action_type)subawards_summary(count,total_amount)competition(contract_type,extent_competed,number_of_offers_received,other_than_full_and_open_competition,solicitation_date,solicitation_identifier,solicitation_procedures)officers(highly_compensated_officer_1_name,highly_compensated_officer_1_amount,highly_compensated_officer_2_name,highly_compensated_officer_2_amount,highly_compensated_officer_3_name,highly_compensated_officer_3_amount,highly_compensated_officer_4_name,highly_compensated_officer_4_amount,highly_compensated_officer_5_name,highly_compensated_officer_5_amount)– returnsnullif no officers are on record. Not included in default serializers; available only via shaping.
Examples:
# Minimal contract fields
/api/contracts/?shape=key,piid,award_date
# Recipient and awarding office
/api/contracts/?shape=key,recipient(display_name),awarding_office(office_code,office_name)
# Period and transactions
/api/contracts/?shape=key,period_of_performance(start_date,current_end_date),transactions(modification_number,obligated)
# Officers (subset)
/api/contracts/?shape=key,officers(highly_compensated_officer_1_name,highly_compensated_officer_1_amount)
# Officers (all allowed fields)
/api/contracts/?shape=key,officers(*)
IDVs /api/idvs/¶
- Leaves:
key,piid,vehicle_uuid,solicitation_identifier,award_date,naics_code,psc_code,total_contract_value,description,fiscal_year,obligated,idv_type,multiple_or_single_award_idv,type_of_idc,set_aside,transactions,subawards_summary - Detail-only leaves:
commercial_item_acquisition_procedures,consolidated_contract,contingency_humanitarian_or_peacekeeping_operation,contract_bundling,contract_financing,cost_accounting_standards_clause,cost_or_pricing_data,dod_acquisition_program,dod_transaction_number,domestic_or_foreign_entity,email_address,epa_designated_product,evaluated_preference,fair_opportunity_limited_sources,fed_biz_opps,fee_range_lower_value,fee_range_upper_value,fixed_fee_value,foreign_funding,government_furnished_property,idv_website,inherently_governmental_functions,local_area_set_aside,major_program,number_of_actions,number_of_offers_source,ordering_procedure,performance_based_service_acquisition,program_acronym,recovered_materials_sustainability,research,sam_exception,simplified_procedures_for_certain_commercial_items,small_business_competitiveness_demonstration_program,subcontracting_plan,total_estimated_order_value,tradeoff_process,type_of_fee_for_use_of_service,undefinitized_action,who_can_use - Expansions:
recipient(uei,display_name,legal_business_name,cage,cage_code,...)awarding_office(organization_id,office_code,office_name,agency_code,agency_name,department_code,department_name)funding_office(...)— same 7-key shape asawarding_officeplace_of_performance(country_code,country_name,state_code,state_name,city_name,zip_code)parent_award(key,piid)period_of_performance(start_date,last_date_to_order)— note: useslast_date_to_orderinstead of contracts'current_end_date/ultimate_completion_datetransactions(modification_number,transaction_date,obligated,description,action_type)subawards_summary(count,total_amount)idv_type(code,description)— IDV type as structured code/descriptionmultiple_or_single_award_idv(code,description)— single/multiple award designationtype_of_idc(code,description)— IDC type as structured code/descriptionset_aside(code,description)— set-aside typenaics(code,description)— resolved NAICS lookuppsc(code,description)— resolved PSC lookupcompetition(contract_type,extent_competed,number_of_offers_received,other_than_full_and_open_competition,solicitation_date,solicitation_identifier,solicitation_procedures)officers(...)— same fields as contracts;nullwhen absent; shaping-onlyawards(key,piid,award_date,naics_code,psc_code,total_contract_value,description,base_and_exercised_options_value,fiscal_year,obligated,transactions)— child awards under this IDVgsa_elibrary(schedule,contract_number,uei,sins,cooperative_purchasing,disaster_recovery_purchasing,file_urls,extracted_text,external_id,source_data)— GSA eLibrary enrichment (nullwhen no match)legislative_mandates(...)— legislative mandate flags
Default response (list, no ?shape=): award_date,awarding_office(*),description,fiscal_year,funding_office(*),gsa_elibrary(*),idv_type(*),key,legislative_mandates(*),multiple_or_single_award_idv(*),naics_code,obligated,parent_award(*),period_of_performance(*),piid,place_of_performance(*),psc_code,recipient(*),set_aside,solicitation_identifier,subawards_summary(*),total_contract_value,type_of_idc(*),vehicle_uuid
Default response (detail): adds all detail-only leaves plus competition(*) and officers(*)
# Minimal IDV fields
/api/idvs/?shape=key,piid,award_date
# Expand idv_type to get code + description
/api/idvs/?shape=key,piid,idv_type(code,description)
# IDV with recipient and awarding office
/api/idvs/?shape=key,piid,recipient(display_name,uei),awarding_office(office_name,agency_name)
# Period of performance with last_date_to_order
/api/idvs/?shape=key,piid,period_of_performance(start_date,last_date_to_order)
# GSA eLibrary enrichment
/api/idvs/?piid=GS-35F-0001X&shape=key,piid,gsa_elibrary(schedule,contract_number,sins,file_urls)
# Child awards under an IDV
/api/idvs/{key}/?shape=key,piid,awards(key,piid,total_contract_value)
Vehicles /api/vehicles/¶
- Leaves:
uuid,solicitation_identifier,agency_id,organization_id,vehicle_type,fiscal_year,agency_details,descriptions,competition_details,type_of_idc,contract_type,awardee_count,order_count,vehicle_obligations,vehicle_contracts_value,solicitation_title,solicitation_description,solicitation_date,naics_code,psc_code,set_aside - Expansions:
awardees(...): expands to the underlying IDVs that make up the vehicleopportunity(...): expands to the linked SAM.gov Opportunity (supports all Opportunity fields and expansions likeoffice,attachments,meta)competition_details(...): expands thecompetition_detailsJSON object (fields:extent_competed,number_of_offers_received,set_aside,solicitation_procedures, etc., or*for all)
Default response (list, no ?shape=): agency_details,award_date,awardee_count,competition_details(*),contract_type,description,fiscal_year,last_date_to_order,latest_award_date,naics_code,order_count,psc_code,set_aside,solicitation_date,solicitation_identifier,solicitation_title,type_of_idc,uuid,vehicle_contracts_value,vehicle_obligations,vehicle_type,who_can_use
Default response (detail): adds agency_id, organization_id, solicitation_description
Notes:
GET /api/vehicles/?search=...performs vehicle-level full-text search.- On
GET /api/vehicles/{uuid}/,?search=is reserved for filtering expandedawardees(...)(it does not filter the vehicle itself). - Opportunity-derived fields (
solicitation_title,solicitation_description,solicitation_date,naics_code,psc_code,set_aside) are populated from the linked SAM.gov Opportunity's latest notice when available.
Examples:
# Find vehicles by term
/api/vehicles/?search=schedule
# Vehicle detail + shaped award expansion
/api/vehicles/<vehicle-uuid>/?shape=uuid,solicitation_identifier,awardees(key,uuid,piid,award_date,recipient(display_name,uei))
# Include opportunity-derived fields
/api/vehicles/<vehicle-uuid>/?shape=uuid,solicitation_identifier,solicitation_title,solicitation_date,naics_code,psc_code
# Expand linked Opportunity with office details
/api/vehicles/<vehicle-uuid>/?shape=uuid,solicitation_identifier,opportunity(opportunity_id,title,first_notice_date,office(office_name,agency_name))
# Expand JSON detail fields to select specific sub-fields
/api/vehicles/<vehicle-uuid>/?shape=uuid,competition_details(extent_competed,number_of_offers_received),type_of_idc,contract_type
# Filter expanded awards within a vehicle
/api/vehicles/<vehicle-uuid>/?shape=uuid,awardees(key,uuid,recipient(display_name))&search=acme
OTAs /api/otas/¶
- Leaves:
key,piid,award_date,psc_code,total_contract_value,description,base_and_exercised_options_value,fiscal_year,obligated,award_type,type_of_ot_agreement,extent_competed,consortia,consortia_uei,dod_acquisition_program,non_governmental_dollars,non_traditional_government_contractor_participation,parent_award_modification_number,transactions - Expansions:
recipient(uei,display_name,legal_business_name,cage,cage_code,...)awarding_office(organization_id,office_code,office_name,agency_code,agency_name,department_code,department_name)funding_office(...)— same 7-key shape asawarding_officeplace_of_performance(country_code,country_name,state_code,state_name,city_name,zip_code)parent_award(key,piid)period_of_performance(start_date,current_end_date,ultimate_completion_date)transactions(modification_number,transaction_date,obligated,description,action_type)psc(code,description)award_type(code,description)— OTA-specific choices ("O" = Other Transaction Non-Research, "R" = Other Transaction for Research)type_of_ot_agreement(code,description)extent_competed(code,description)
Default response (no ?shape=): award_date,award_type,awarding_office(*),base_and_exercised_options_value,consortia,consortia_uei,dod_acquisition_program,extent_competed,fiscal_year,funding_office(*),key,non_governmental_dollars,non_traditional_government_contractor_participation,parent_award(key,piid),parent_award_modification_number,period_of_performance(start_date,current_end_date,ultimate_completion_date),piid,place_of_performance(*),psc_code,recipient(*),type_of_ot_agreement
Note: OTAs have no naics_code field and no naics expand.
# Minimal OTA fields
/api/otas/?shape=key,piid,award_date
# Expand award_type to get OTA-specific code + description
/api/otas/?shape=key,piid,award_type(code,description)
# Expand extent competed and type of OT agreement
/api/otas/?shape=key,piid,extent_competed(code,description),type_of_ot_agreement(code,description)
# OTA with recipient and awarding office
/api/otas/?shape=key,piid,recipient(display_name,uei),awarding_office(office_name,agency_name)
OTIDVs /api/otidvs/¶
- Leaves:
key,piid,award_date,psc_code,total_contract_value,description,base_and_exercised_options_value,fiscal_year,obligated,idv_type,type_of_ot_agreement,extent_competed,consortia,consortia_uei,dod_acquisition_program,non_governmental_dollars,non_traditional_government_contractor_participation,transactions - Expansions:
recipient(uei,display_name,legal_business_name,cage,cage_code,...)awarding_office(organization_id,office_code,office_name,agency_code,agency_name,department_code,department_name)funding_office(...)— same 7-key shape asawarding_officeplace_of_performance(country_code,country_name,state_code,state_name,city_name,zip_code)period_of_performance(start_date,current_end_date,ultimate_completion_date)transactions(modification_number,transaction_date,obligated,description,action_type)psc(code,description)type_of_ot_agreement(code,description)extent_competed(code,description)
Default response (no ?shape=): award_date,awarding_office(*),base_and_exercised_options_value,consortia,consortia_uei,dod_acquisition_program,extent_competed,fiscal_year,funding_office(*),idv_type,key,non_governmental_dollars,non_traditional_government_contractor_participation,period_of_performance(start_date,current_end_date,ultimate_completion_date),piid,place_of_performance(*),psc_code,recipient(*),type_of_ot_agreement
Note: OTIDVs have no naics_code field, no naics expand, and no parent_award expand.
# Minimal OTIDV fields
/api/otidvs/?shape=key,piid,award_date
# Expand extent competed and type of OT agreement
/api/otidvs/?shape=key,piid,extent_competed(code,description),type_of_ot_agreement(code,description)
# OTIDV with recipient
/api/otidvs/?shape=key,piid,recipient(display_name,uei),awarding_office(office_name,agency_name)
Subawards /api/subawards/¶
Subawards use a default shape — all responses go through the shaping pipeline even without ?shape=.
- Default response (no
?shape=):award_key,awarding_office(*),fsrs_details(*),funding_office(*),key,piid,place_of_performance(*),prime_recipient(uei,display_name),subaward_details(*),subaward_recipient(uei,display_name) - Leaves: identity + prime award metadata (
piid,prime_award_total_outlayed_amount, dates/NAICS/description, prime/subawardee identities, business types, andusaspending_permalink) - Expansions:
subaward_details(description,type,number,amount,action_date,fiscal_year)fsrs_details(last_modified_date,id,year,month)place_of_performance(city,state,zip,country_code)highly_compensated_officers(name,amount)prime_recipient(uei,display_name),subaward_recipient(uei,display_name,duns)awarding_office(*),funding_office(*)
# Default response (shaped automatically)
/api/subawards/
# Select specific fields
/api/subawards/?shape=key,piid,subaward_details(*)
# Recipient and office info
/api/subawards/?shape=key,prime_recipient(uei,display_name),awarding_office(*)
GSA eLibrary Contracts /api/gsa_elibrary_contracts/¶
- Leaves:
uuid,schedule,contract_number,sins,cooperative_purchasing,disaster_recovery_purchasing,file_urls - Expansions:
recipient(uei,display_name)idv(key,award_date)
# Basic fields
/api/gsa_elibrary_contracts/?shape=uuid,schedule,contract_number
# With recipient info
/api/gsa_elibrary_contracts/?shape=uuid,contract_number,recipient(uei,display_name)
Opportunities /api/opportunities/¶
- Leaves:
opportunity_id,latest_notice_id,archive_date,title,description,naics_code,psc_code,response_deadline,first_notice_date,last_notice_date,active,set_aside,award_number,awarded,award_date,award_amount,awardee,awardee_uei,award_count,solicitation_opportunity_id,solicitation_number,snippet,sam_url, plus relation id leaves:agency_id,department_id,office_id - Expansions:
attachments(attachment_id,extracted_text,file_size,mime_type,name,posted_date,resource_id,type,url)(shorthand:attachments→attachments(*))awards(opportunity_id,notice_id,award_number,award_date,award_amount,awardee,awardee_uei)— up to ten of the most recent awards linked to a solicitation;award_countis the totaloffice(office_code,office_name,agency_code,agency_name,department_code,department_name)(shorthand:office→office(*))agency(*)(shorthand:agency→agency(*))department(*)(shorthand:department→department(*))latest_notice(notice_id,link)(shorthand:latest_notice→latest_notice(*))place_of_performance(street_address,city,state,zip,country)meta(notices_count,attachments_count,files_count,links_count,notice_type(code,type))—files_countandlinks_countfrom API version 5.9.0set_aside(code,description)notice_history(index,notice_id,latest,title,deleted,posted_date,notice_type_code,solicitation_number,parent_notice_id,related_notice_id)primary_contact(title,full_name,email,phone,fax)secondary_contact(title,full_name,email,phone,fax)
Default response (list, no ?shape=): active,award_number,awarded,first_notice_date,last_notice_date,meta(*),naics_code,office(*),opportunity_id,place_of_performance(*),psc_code,response_deadline,sam_url,set_aside,solicitation_number,title
Default response (detail): active,attachments(attachment_id,file_size,mime_type,name,posted_date,resource_id,type,url),award_amount,award_count,award_date,award_number,awarded,awardee,awardee_uei,awards(*),description,first_notice_date,last_notice_date,meta(*),naics_code,notice_history(*),office(*),opportunity_id,place_of_performance(*),primary_contact(*),psc_code,response_deadline,sam_url,set_aside,solicitation_number,solicitation_opportunity_id,title
Award fields are filled only where SAM has posted an award notice; see Opportunities → Awards.
meta.attachments_count counts links as well as files; see Attachments: files and links.
# Expand set_aside to get code + description
/api/opportunities/?shape=opportunity_id,title,set_aside(code,description)
# Office + attachments using bare expand shorthand
/api/opportunities/?shape=opportunity_id,title,office,attachments&limit=1
# Detail with notice history
/api/opportunities/{id}/?shape=opportunity_id,title,notice_history(*)
# Contacts
/api/opportunities/{id}/?shape=opportunity_id,primary_contact(*),secondary_contact(*)
# A solicitation and the awards linked to it
/api/opportunities/{id}/?shape=opportunity_id,title,awarded,award_count,awards(*)
Notices /api/notices/¶
- Leaves:
notice_id,opportunity_id,title,description,naics_code,psc_code,posted_date,response_deadline,last_updated,active,set_aside,sam_url,attachment_count,file_count,link_count,award_number,award_date,award_amount,awardee,awardee_uei,solicitation_number - Expansions:
attachments(attachment_id,extracted_text,file_size,mime_type,name,posted_date,resource_id,type,url)(shorthand:attachments→attachments(*))address(city,state,zip,country)archive(date,type)place_of_performance(street_address,city,state,zip,country)primary_contact(title,full_name,email,phone,fax)secondary_contact(title,full_name,email,phone,fax)meta(parent_notice_id,related_notice_id,notice_type(code,type),link)office(office_code,office_name,agency_code,agency_name,department_code,department_name)(shorthand:office→office(*))opportunity(opportunity_id,link)(shorthand:opportunity→opportunity(*))set_aside(code,description)
Default response (list, no ?shape=): active,address(*),attachment_count,award_number,description,file_count,last_updated,link_count,meta(*),naics_code,notice_id,office(*),opportunity(*),place_of_performance(*),posted_date,psc_code,response_deadline,sam_url,set_aside,solicitation_number,title
Default response (detail): adds archive(*), primary_contact(*), secondary_contact(*)
file_count and link_count are available from API version 5.9.0.
attachment_count counts links as well as files; see Attachments: files and links.
# Expand set_aside to get code + description
/api/notices/?shape=notice_id,title,set_aside(code,description)
# Include archive and contacts on detail
/api/notices/{id}/?shape=notice_id,title,archive(*),primary_contact(*)
# Get linked opportunity reference
/api/notices/?shape=notice_id,opportunity(opportunity_id,link)
Entities /api/entities/¶
Entities default to the shaping pipeline. Default responses include core identity fields (list) or the full detail set (retrieve) with normalized field formats. Use ?shape= to customize.
- Leaves:
uei,uuid,display_name,legal_business_name,dba_name,cage_code,dodaac,registered,registration_status,primary_naics,psc_codes,email_address,entity_url,description,capabilities,keywords,sam_activation_date,sam_registration_date,sam_expiration_date,last_update_date,congressional_district,evs_source,uei_status,uei_expiration_date,uei_creation_date,public_display_flag,exclusion_status_flag,exclusion_url,entity_division_name,entity_division_number,entity_start_date,fiscal_year_end_close_date,submission_date - Expansions:
physical_address(address_line1,address_line2,city,state_or_province_code,zip_code,zip_code_plus4,country_code,country_name)— normalized snake_case keysmailing_address(...)— same fields asphysical_addressbusiness_types(code,description)— normalized[{code, description}]; auto-normalizes dict-format DSBS datasba_business_types(code,description,entry_date,exit_date)— normalized SBA certificationsnaics_codes(code,sba_small_business)— normalized; converts plain string arrays automaticallyfederal_obligations(total,active)— expand-only;totalandactivesub-objects with obligation metricshighest_owner(cage_code,legal_business_name,uei)— highest corporate ownerimmediate_owner(cage_code,legal_business_name,uei)— immediate parent entityrelationships(relation,type,source,uei,display_name)— corporate ownership, corporate succession, and reported prime/sub ties; match ontype, not therelationdisplay label (see the entities data dictionary)entity_structure(code,description)— falls back to ENTITY_STRUCTURE_MAP for descriptionentity_type(code,description)— falls back to BUSINESS_TYPE_MAP for descriptionprofit_structure(code,description)— falls back to BUSINESS_TYPE_MAP for descriptionorganization_structure(code,description)— falls back to BUSINESS_TYPE_MAP for descriptionstate_of_incorporation(code,description)— uses model descriptioncountry_of_incorporation(code,description)— falls back to the GENC standard for country names when SAM.gov doesn't provide onepurpose_of_registration(code,description)— falls back to PURPOSE_OF_REGISTRATION_MAP for descriptionpast_performance[top=N](summary,top_agencies)— aggregated contract history;summaryhastotal_obligated,total_awards,agency_count,naics_count,psc_count;top_agencieslists top N agencies (default 5, max 100) withagency_code,agency_name,department_code,department_name,obligations,awards_count,top_naics,top_psc. Accepts bracket param[top=N].
# List with default shape (core identity fields)
/api/entities/
# Detail with all fields + normalized expands
/api/entities/ZQGGHJH74DW7/
# Custom shape: just UEI and business types
/api/entities/?shape=uei,business_types(code,description)
# Federal obligations for a specific entity
/api/entities/ZQGGHJH74DW7/?shape=uei,federal_obligations(total)
# Structured code/description fields
/api/entities/ZQGGHJH74DW7/?shape=uei,entity_structure(*),purpose_of_registration(*)
# Owner info
/api/entities/ZQGGHJH74DW7/?shape=uei,highest_owner(*),immediate_owner(*)
# Past performance (default top 5 agencies)
/api/entities/ZQGGHJH74DW7/?shape=uei,past_performance(*)
# Past performance with top 10 agencies
/api/entities/ZQGGHJH74DW7/?shape=uei,past_performance[top=10](summary,top_agencies)
Forecasts /api/forecasts/¶
- Leaves:
id,source_system,external_id,agency,title,description,anticipated_award_date,fiscal_year,naics_code,is_active,status,created,modified,primary_contact,place_of_performance,estimated_period,set_aside,contract_vehicle,raw_data - Expansions:
display(title,description,agency,anticipated_award_date,fiscal_year,naics_code,status,primary_contact,place_of_performance,estimated_period,set_aside,contract_vehicle)— normalized view of the forecast's display dataraw_data(*)— the raw JSON data from the source system; accepts wildcard fields
# Basic forecast fields
/api/forecasts/?shape=id,title,agency,anticipated_award_date,status
# With display expansion for normalized view
/api/forecasts/?shape=id,title,display(agency,anticipated_award_date,primary_contact,place_of_performance)
# Include raw source data
/api/forecasts/?shape=id,title,raw_data(*)
Grants /api/grants/¶
- Leaves:
agency_code,applicant_eligibility_description,description,forecast,funding_activity_category_description,grant_id,grantor_contact,last_updated,opportunity_history,opportunity_number,status,synopsis,title - Expansions:
cfda_numbers(number,title)— associated CFDA/Assistance Listing numbersapplicant_types(code,description)category(code,description)— opportunity categoryfunding_categories(code,description)funding_instruments(code,description)status(code,description)— expanded status with code and label (use this when you want the structured form;statusalone is treated as a leaf unless the runtime must expand it for safety)important_dates(posted_date,response_date,response_date_description,estimated_synopsis_post_date,estimated_application_response_date,estimated_application_response_date_description,estimated_project_start_date)— key dates (fields vary by status: posted opportunities returnposted_date/response_date; forecasted ones returnestimated_*fields)funding_details(award_ceiling,award_floor,estimated_total_funding,expected_number_of_awards)grantor_contact(name,phone,email)additional_info(link,description)attachments(mime_type,name,posted_date,resource_id,type,url)
# Basic grant fields
/api/grants/?shape=grant_id,title,status,agency_code
# With CFDA numbers and applicant types
/api/grants/?shape=grant_id,title,cfda_numbers(number,title),applicant_types(code,description)
# Expanded status and funding instruments
/api/grants/?shape=grant_id,title,status(code,description),funding_instruments(code,description)
IT Dashboard /api/itdashboard/¶
IT Dashboard exposes tiered shaping: Free gets basic investment metadata, Small+ adds the funding and details expansions, and Medium+ adds nested sub-tables (contracts, projects, CIO evaluations, performance metrics, etc.) plus the business_case_html leaf. Fields or expansions above the caller's tier are silently stripped from the response, and removed nodes are listed in meta.upgrade_hints. For the full tier/field matrix, see the IT Dashboard API Reference.
- Leaves (all tiers):
uii,agency_code,agency_name,bureau_code,bureau_name,investment_title,type_of_investment,part_of_it_portfolio,updated_time,url - Medium+ leaf:
business_case_html - Expansions (Small+):
funding(*)— fiscal-year internal funding and contributions (FY2020–FY2025)details(*)— extended metadata: description, previous/current UII, classification, business case URL, public URLs
- Expansions (Medium+):
cio_evaluation(*),contracts(*),projects(*),cost_pools_towers(*),funding_sources(*),performance_metrics(*),performance_actual(*),operational_analysis(*)
Default response (no ?shape=):
- List:
uii,agency_name,bureau_name,investment_title,type_of_investment,part_of_it_portfolio,updated_time,url - Detail: adds
agency_code,bureau_code
# Free tier: basic metadata
/api/itdashboard/?shape=uii,investment_title,agency_name,url
# Small+ tier: funding and details
/api/itdashboard/?shape=uii,investment_title,funding(*),details(*)
# Medium+ tier: nested sub-tables + business case HTML
/api/itdashboard/{uii}/?shape=uii,investment_title,cio_evaluation(*),performance_metrics(*),business_case_html
Protests /api/protests/¶
Both list and detail return case-level objects identified by case_id. Dockets are available via the dockets(...) expansion.
- Leaves:
case_id,source_system,case_number,title,protester,agency,solicitation_number,case_type,outcome,filed_date,posted_date,decision_date,due_date,docket_url,decision_url,digest - Expansions:
dockets(source_system,case_number,docket_number,title,protester,agency,solicitation_number,case_type,outcome,filed_date,posted_date,decision_date,due_date,docket_url,decision_url,digest)
Default response (no ?shape=): case_id,source_system,case_number,title,protester,agency,solicitation_number,case_type,outcome,filed_date,posted_date,decision_date,due_date,docket_url,decision_url (no nested dockets)
Notes:
case_numberis the base B-number (e.g.b-424214) identifying the case.docket_number(e.g.b-424214.1) identifies a specific sub-docket; only available insidedockets(...).digestis opt-in only (decision summary text fromraw_data).
# Case-level fields with nested dockets
/api/protests/?shape=case_id,case_number,title,dockets(docket_number,filed_date,outcome)
# Case-level only
/api/protests/?shape=case_id,title,outcome,decision_date
# Detail with dockets
/api/protests/{case_id}/?shape=case_id,title,dockets(docket_number,filed_date)
Contract appeals /api/contract_appeals/¶
A flat resource: every field is a leaf and there are no expansions, so use explicit field lists.
- Leaves:
uuid,board,docket_numbers,docket_source,docket_raw,decision_date,decision_date_raw,decision_date_repaired,appellant,judge,decision_type,decision_type_raw,url,document_id,listing_url,listing_year,first_listed_at,listed,text_status,text_char_count,decision_text
Default list shape: uuid,board,docket_numbers,decision_date,appellant,judge,decision_type,url
Default detail shape: 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
Notes:
docket_numbersis an array — a consolidated appeal carries several dockets.decision_textrequires an Enterprise plan and is in neither default shape. Naming it on a lower plan returns 200 with the field omitted and ameta.upgrade_hintsentry; every other field is available on every plan.text_statusandtext_char_countreport whether a decision has extracted text behind it, at any plan.
# List payload for a docket lookup
/api/contract_appeals/?board=asbca&docket=57530&shape=uuid,docket_numbers,appellant,decision_date,url
# Polling shape — newest observations first
/api/contract_appeals/?ordering=-first_listed_at&shape=uuid,board,docket_numbers,appellant,first_listed_at
# Detail with the decision body (Enterprise)
/api/contract_appeals/{uuid}/?shape=uuid,board,docket_numbers,appellant,decision_date,decision_text
Federal Register /api/federal_register/¶
A flat resource: every field is a leaf and there are no expansions, so use explicit field lists.
- Leaves:
uuid,document_number,publication_date,citation,title,type,subtype,abstract,action,dates,comments_close_on,effective_on,signing_date,start_page,end_page,page_length,volume,significant,toc_doc,toc_subject,correction_of,corrections,disposition_notes,executive_order_notes,executive_order_number,presidential_document_number,proclamation_number,not_received_for_publication,comment_url,regulations_dot_gov_url,html_url,body_html_url,pdf_url,public_inspection_pdf_url,raw_text_url,agencies,cfr_references,dockets,docket_ids,topics,regulation_id_numbers,regulation_id_number_info,regulations_dot_gov_info, and on the detail endpoint only,full_text
Default list shape: uuid,document_number,publication_date,type,subtype,title,abstract,action,agencies,cfr_references,citation,significant,comments_close_on,effective_on,html_url,pdf_url
Default detail shape: the list fields plus dates, signing_date, start_page, end_page, volume, docket_ids, dockets, regulation_id_numbers, topics, correction_of, corrections, executive_order_number, presidential_document_number, proclamation_number, comment_url, regulations_dot_gov_url, raw_text_url, body_html_url
Notes:
full_textis detail-only and in neither default shape; naming it on the list endpoint returns 400. It is available on every plan.agencies,cfr_references,docketsandtopicsare the Federal Register's own structures, served as published — they are leaves, not expansions, so they cannot be narrowed withfield(subfield)syntax.
# Lean list payload for a CFR part
/api/federal_register/?cfr_title=40&cfr_part=52&shape=uuid,document_number,publication_date,title,citation
# Comment-deadline tracker
/api/federal_register/?comments_open=true&shape=uuid,document_number,title,comments_close_on,comment_url
# Detail with the body
/api/federal_register/{uuid}/?shape=uuid,document_number,title,full_text
eBuy /api/ebuy/requests/¶
- Leaves:
rfq_id,request_type,title,description,schedule,sin,status,buyer_name,buyer_agency,buyer_agency_code,buyer_email,buyer_user_id,reference_number,award_method,contract_type,commercial_type,follow_on,source_sought,issue_date,close_date,cancel_date,last_mod_date,pop_start_date,pop_end_date,oco_name,oco_title,oco_agency,oco_phone,oco_aac,ocs_name,ocs_title,ocs_agency,ocs_phone,ocs_aac,amendment_count,mod_version,qa_document_count,attachment_count,link_count,amendments,line_items,addresses,detail_fetched,first_seen,last_seen - Expansions:
organization(...)— the canonical 7-key office payload;attachments(...)—doc_seq_num,doc_name,doc_type,doc_path,is_link,doc_session_date,extracted_text(only when named;attachments(*)does not include it)
Default list shape: 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
Default detail shape: every leaf, plus organization(*) and attachments(*)
Notes:
- Only requests under your own linked contracts are returned; see the eBuy API reference.
statusis frozen as eBuy supplied it — readlast_seenfor staleness.
# Lean list payload
/api/ebuy/requests/?status=Open&shape=rfq_id,title,sin,close_date,last_seen
# Detail with attachments, for picking a download
/api/ebuy/requests/RFQ1835158/?shape=rfq_id,title,attachments(doc_seq_num,doc_name,is_link)
# Detail with each document's text
/api/ebuy/requests/RFQ1835158/?shape=rfq_id,title,attachments(doc_seq_num,doc_name,extracted_text)
Result counts¶
Every paginated response includes a count field with the total number of matching results. The X-Results-CountType response header indicates whether that count is exact or approximate.
Which endpoints use approximate counts?¶
Most endpoints always return exact counts. These high-volume endpoints use approximate counting by default:
/api/contracts//api/idvs//api/opportunities//api/notices//api/entities/
Other endpoints (grants, subawards, vehicles, protests, etc.) always return exact counts. The ?exact=true parameter has no effect on those — they're already exact. Inspect the X-Results-CountType response header (exact or approximate) on any request to confirm.
How approximate counts work¶
Approximate counts use database query-planner estimates for large result sets and fall back to exact counts for small ones (typically under 1,000 rows). This means even the endpoints listed above often return exact counts when the result set is small enough.
Requesting exact counts¶
For the endpoints that use approximate counting, add ?exact=true to force an exact count:
GET /api/contracts/?exact=true
GET /api/opportunities/?exact=true&search=software
Performance note: Exact counts run a full COUNT(*) query, which can be slow on large, unfiltered result sets (e.g., all contracts). Use this when accuracy matters more than speed.
Knowing whether a count is exact¶
Check the X-Results-CountType response header:
X-Results-CountType: exact
X-Results-CountType: approximate
This header is present on all paginated list responses. It is not included on detail (single-resource) endpoints.
Examples¶
Contracts (approximate by default)¶
curl -sI -H "X-API-KEY: your-key" \
"https://tango.dev/api/contracts/" | grep X-Results
X-Results-CountType: approximate
{
"count": 2541837,
"next": "...",
"results": [...]
}
Contracts with exact count¶
curl -sI -H "X-API-KEY: your-key" \
"https://tango.dev/api/contracts/?exact=true" | grep X-Results
X-Results-CountType: exact
{
"count": 2541293,
"next": "...",
"results": [...]
}
Entities (always exact)¶
curl -sI -H "X-API-KEY: your-key" \
"https://tango.dev/api/entities/" | grep X-Results
X-Results-CountType: exact
{
"count": 487293,
"next": "...",
"results": [...]
}
Rate limits¶
Tango enforces rate limits to keep the API fast and reliable for everyone. Limits apply at the account level: requests made via API keys and OAuth2 tokens for the same account all draw from the same quotas.
Where to see your limits and usage¶
- Your current plan limits + near-real-time usage: Account profile
- Upgrade pricing / higher limits: Pricing
How rate limits work (burst + daily)¶
Most plans have more than one rate limit window:
- Burst: short window (e.g. per minute) that protects the API from sudden spikes
- Daily: fixed window that resets at midnight UTC, caps total volume per day
You may be “fine” on daily usage but still hit burst limits (or vice versa).
Rate limit headers¶
Every /api/* response includes rate limit headers.
Overall headers (most restrictive window)¶
These headers summarize the most restrictive window (the one you’re closest to hitting):
X-RateLimit-Limit: total requests allowed for that windowX-RateLimit-Remaining: requests remaining in that windowX-RateLimit-Reset: seconds until reset for that window
Per-window headers (daily, burst, etc.)¶
For each configured window (commonly Daily and Burst), you’ll also see:
X-RateLimit-Daily-Limit,X-RateLimit-Daily-Remaining,X-RateLimit-Daily-ResetX-RateLimit-Burst-Limit,X-RateLimit-Burst-Remaining,X-RateLimit-Burst-Reset
Each *-Reset value is seconds until that specific window resets.
Quick header check (curl)¶
curl -s -D - -o /dev/null \
-H "X-API-KEY: your-api-key-here" \
"https://tango.dev/api/contracts/?limit=1"
Example response headers:
X-RateLimit-Daily-Limit: 1500
X-RateLimit-Daily-Remaining: 1450
X-RateLimit-Daily-Reset: 18000
X-RateLimit-Burst-Limit: 100
X-RateLimit-Burst-Remaining: 95
X-RateLimit-Burst-Reset: 45
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 45
X-Execution-Time: 0.045s
Here X-RateLimit-Daily-Reset: 18000 means there are 5 hours left in the current UTC day. It does not mean you have been throttled — it is simply how much of today is left.
Understanding the daily reset¶
The daily window is a fixed calendar-day bucket, not a rolling 24 hours. It refills at midnight UTC no matter when you started sending requests.
This has one consequence worth planning around: X-RateLimit-Daily-Reset tells you how much of the current UTC day is left, so the wait you get after exhausting your daily quota depends on when you exhaust it.
- Exhaust your daily quota at 19:00 UTC and you wait ~5 hours for the reset.
- Exhaust the very same quota at 02:00 UTC and you wait ~22 hours.
Midnight UTC is 8:00 PM Eastern (Mar–Nov) or 7:00 PM Eastern (Nov–Mar). If you run in a US timezone, your quota therefore refills in the evening and is spent over the course of the business day — so a heavy morning can leave you throttled for the rest of the afternoon. If you have a large daily batch job, running it just after the midnight-UTC reset gives it the full day's quota to draw from.
Premium query limits (select endpoints)¶
Some endpoint families carry their own premium query budget instead of your account-wide limits. A request to one of those endpoints draws from that budget and does not count against your daily or per-minute allowance.
How premium limits work¶
- Separate budget: The premium budget exists only for designated endpoint families. Standard endpoints use your account-wide quotas.
- Replaces your standard limits: on these endpoints the premium budget is the only limit that applies — your daily and per-minute quotas are neither checked nor spent.
- Shared across endpoint family: Multiple endpoints may share a single premium counter. For example, the CALC labor-rate endpoints (
GET /api/idvs/{key}/lcats/andGET /api/entities/{uei}/lcats/) share one premium budget. - Counts requests, not rows: Each API call costs one request, regardless of pagination or the number of rows returned.
- Fixed daily window: The premium budget resets daily at 00:00 UTC.
Premium headers¶
On responses from endpoints with a premium budget (including 429s), you'll see:
X-RateLimit-Premium-Limit: total requests allowed in the premium budgetX-RateLimit-Premium-Remaining: requests remaining in the premium budgetX-RateLimit-Premium-Reset: seconds until reset for the premium budget
These headers appear only on requests to endpoints with a premium budget — they are absent everywhere else.
Finding your premium limits¶
Check your account profile to see your current premium usage and limits per endpoint family. To request a higher limit, email [email protected].
What happens when you exceed a limit (HTTP 429)¶
When you hit a rate limit, Tango responds with HTTP 429 and a JSON body like:
{
"detail": "Rate limit exceeded for burst. Please try again in 45 seconds.",
"wait_in_seconds": 45
}
Recommended client behavior¶
- Stop retrying immediately after a 429.
- Sleep for at least
wait_in_seconds(preferred) orX-RateLimit-Reset. - But cap how long you are willing to sleep. A burst 429 clears in under a minute; a daily 429 can report a wait of many hours (however much of the UTC day is left). Blindly sleeping on that value will park your process until the small hours. Treat a multi-hour wait as "daily quota exhausted" — log it, alert, and stop, rather than sleeping through it.
- Then retry with exponential backoff + jitter to avoid a thundering herd.
Python example¶
import random
import time
import httpx
url = "https://tango.dev/api/contracts/?limit=1"
headers = {"X-API-KEY": "your-api-key-here"}
# Never block the process for hours. A burst 429 clears in under a minute, but
# a *daily* 429 can report a wait of many hours (until midnight UTC) — that is
# a quota problem to surface, not to sleep through.
MAX_SLEEP_SECONDS = 120
backoff = 1.0
for _ in range(10):
r = httpx.get(url, headers=headers)
if r.status_code != 429:
r.raise_for_status()
break
body = r.json()
wait = body.get("wait_in_seconds")
if wait is None:
wait = r.headers.get("X-RateLimit-Reset")
if wait is not None:
wait = float(wait)
if wait > MAX_SLEEP_SECONDS:
raise RuntimeError(
f"Daily quota exhausted; resets in {wait / 3600:.1f}h "
f"(midnight UTC). Upgrade your plan or retry after the reset."
)
time.sleep(wait)
continue
time.sleep(backoff + random.random())
backoff = min(backoff * 2, 60)
JavaScript example¶
This example assumes fetch is available (modern browsers or Node 18+).
(async () => {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const url = "https://tango.dev/api/contracts/?limit=1";
const headers = { "X-API-KEY": "your-api-key-here" };
// Never block for hours. A burst 429 clears in under a minute, but a *daily*
// 429 can report a wait of many hours (until midnight UTC) — that is a quota
// problem to surface, not to sleep through.
const MAX_SLEEP_SECONDS = 120;
let backoffMs = 1000;
for (let i = 0; i < 10; i++) {
const r = await fetch(url, { headers });
if (r.status !== 429) {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
break;
}
const body = await r.json();
const wait = body.wait_in_seconds ?? r.headers.get("X-RateLimit-Reset");
if (wait != null) {
const waitSeconds = Number(wait);
if (waitSeconds > MAX_SLEEP_SECONDS) {
throw new Error(
`Daily quota exhausted; resets in ${(waitSeconds / 3600).toFixed(1)}h ` +
`(midnight UTC). Upgrade your plan or retry after the reset.`,
);
}
await sleep(waitSeconds * 1000);
continue;
}
await sleep(backoffMs + Math.random() * 250);
backoffMs = Math.min(backoffMs * 2, 60_000);
}
})().catch((err) => {
console.error("Request failed:", err);
});
Reduce calls (and avoid limits) in practice¶
- Use response shaping (
shape=) to avoid extra “follow-up” requests. See the Response Shaping Guide. - Paginate responsibly; avoid re-fetching the same pages repeatedly.
- Cache hot lookups on your side when appropriate (e.g. “entity by UEI”).
- Prefer webhooks for event-driven updates instead of polling where possible.
Vehicles¶
Vehicles provide a solicitation-centric way to discover groups of related IDVs and then expand into the underlying awards.
What is a Vehicle?¶
In Tango, a Vehicle is a grouping of multiple IDVs that share the same solicitation_identifier and the same awarding-agency identifier derived from the IDV award key.
In federal data, each IDV award is a vehicle. In practice, people often think of a “vehicle” as the solicitation that produced many IDV awards (one per awardee). We model that higher-level grouping explicitly.
Vehicles are useful when you want to:
- Find “the contract vehicle” behind a set of IDVs (e.g., the solicitation that produced a schedule/IDIQ vehicle)
- Search across a vehicle’s underlying IDVs
- Pull a shaped list of the IDVs that make up a vehicle
Endpoints¶
- List:
GET /api/vehicles/ - Detail:
GET /api/vehicles/{uuid}/ - Awardees (the underlying IDVs):
GET /api/vehicles/{uuid}/awardees/— supports?search= - Task Orders:
GET /api/vehicles/{uuid}/orders/
Search¶
Vehicle-level search (list)¶
Use ?search= on the list endpoint to search vehicles:
curl -H "X-API-KEY: <key>" \
"https://tango.dev/api/vehicles/?search=GSA%20schedule&page=1"
Award-within-vehicle search (detail, when expanding awards)¶
On the detail endpoint, ?search= is used to filter expanded awardees (IDVs) when your shape includes awardees(...).
Filtering¶
Vehicles support a rich filter set across enums, reference codes, org hierarchy, numeric ranges, and dates. Multi-value enum filters use | for OR semantics. See the full list and details on the Vehicles API Reference.
Quick examples:
# All SEWP-program vehicles awarded in FY 2024 or later
GET /api/vehicles/?program_acronym=SEWP&fiscal_year=2024
# IDIQ vehicles where DOD or DHS is the awarding org
GET /api/vehicles/?type_of_idc=A&agency=DOD|DHS
# Vehicles with at least 100 task orders and $100M+ obligated
GET /api/vehicles/?order_count_min=100&total_obligated_min=100000000
Ordering¶
The vehicles list endpoint supports ordering by activity metrics and statistics using the ?ordering= query parameter.
Available ordering fields (10 total)¶
vehicle_obligations- Total obligations across all IDVs in the vehicle (annotated)latest_award_date- Most recent IDV award date in the vehicle (annotated)awardee_count- Number of IDVs in the vehicle (annotated)vehicle_contracts_value- Sum of total contract value across child orders (annotated)total_obligated- Direct column rollupaward_date- Earliest IDV award datelast_date_to_order- Latest "last date to order" across IDVsfiscal_year- Vehicle fiscal yearidv_count- Denormalized IDV countorder_count- Denormalized task-order count
Ordering syntax¶
- Ascending:
?ordering=vehicle_obligations - Descending:
?ordering=-vehicle_obligations(prefix with-) - Multiple fields:
?ordering=-vehicle_obligations,-latest_award_date
Examples¶
Sort by total obligations (highest first):
curl -H "X-API-KEY: <key>" \
"https://tango.dev/api/vehicles/?ordering=-vehicle_obligations&page=1"
Sort by most recent award activity:
curl -H "X-API-KEY: <key>" \
"https://tango.dev/api/vehicles/?ordering=-latest_award_date&page=1"
Sort by obligations, then by latest award date:
curl -H "X-API-KEY: <key>" \
"https://tango.dev/api/vehicles/?ordering=-vehicle_obligations,-latest_award_date&page=1"
Note: When no ?ordering= parameter is provided, vehicles are ordered by solicitation_identifier, agency_id, and uuid (default ordering).
Task Orders¶
The GET /api/vehicles/{uuid}/orders/ endpoint returns all task orders (contracts) issued against all IDVs within a vehicle. This endpoint supports pagination, response shaping, filtering, search, and transaction expansion, similar to the /api/contracts/ endpoint.
Basic usage¶
curl -H "X-API-KEY: <key>" \
"https://tango.dev/api/vehicles/<vehicle-uuid>/orders/?page=1"
Filtering¶
Filter task orders using the same filters available on /api/contracts/:
# Filter by date range
curl -H "X-API-KEY: <key>" \
"https://tango.dev/api/vehicles/<vehicle-uuid>/orders/?award_date_gte=2024-01-01&award_date_lte=2024-12-31"
# Filter by recipient
curl -H "X-API-KEY: <key>" \
"https://tango.dev/api/vehicles/<vehicle-uuid>/orders/?recipient=ACME"
Response shaping¶
Use the shape parameter to customize fields and expand related objects:
# Include recipient and transaction details
curl -H "X-API-KEY: <key>" \
"https://tango.dev/api/vehicles/<vehicle-uuid>/orders/?shape=key,piid,award_date,recipient(display_name,uei),transactions(description,transaction_date,modification_number)"
Search¶
Search task order descriptions:
curl -H "X-API-KEY: <key>" \
"https://tango.dev/api/vehicles/<vehicle-uuid>/orders/?search=software"
Ordering¶
The orders endpoint supports ordering by contract fields using the ?ordering= parameter (the same allowlist as /api/contracts/):
award_date- Contract award date (default: descending, newest first)obligated- Obligated amounttotal_contract_value- Total contract value (base + all options)
Default ordering: When no ?ordering= parameter is provided, task orders are ordered by -award_date, -uuid (newest first).
Examples:
# Default ordering (newest first)
curl -H "X-API-KEY: <key>" \
"https://tango.dev/api/vehicles/<vehicle-uuid>/orders/?page=1"
# Order by obligated amount (highest first)
curl -H "X-API-KEY: <key>" \
"https://tango.dev/api/vehicles/<vehicle-uuid>/orders/?ordering=-obligated&page=1"
# Order by total contract value
curl -H "X-API-KEY: <key>" \
"https://tango.dev/api/vehicles/<vehicle-uuid>/orders/?ordering=-total_contract_value&page=1"
The response includes vehicle context in the metadata (vehicle UUID and solicitation identifier).
Vehicle metrics and organization¶
Vehicle responses can include two enrichment expansions:
metrics(*)— 12 computed vehicle metrics (HHI, competed-rate, top-recipient share, etc.). Included in the default detail shape; opt-in via?shape=...,metrics(*)on the list endpoint.organization(*)— canonical 7-key office payload (organization_id,office_code,office_name,agency_code,agency_name,department_code,department_name). Both bare?shape=organization(leaf) and?shape=organization(*)(expand) work and return the same payload.
# List vehicles with their metrics bundle and resolved org
curl -H "X-API-KEY: <key>" \
"https://tango.dev/api/vehicles/?shape=uuid,solicitation_identifier,metrics(*),organization(*)&limit=5"
Synthetic GWAC vehicles¶
Some GWAC vehicles lack a real solicitation number; Tango synthesizes a vehicle so the grouping still works. Identify these via:
is_synthetic_solicitation— boolean,truefor synthetic rowsprogram_acronym— the GWAC's identifier (e.g.SEWP)solicitation_identifier— the user-facing value, with the internalACRO:storage prefix stripped automatically
Response shaping¶
Vehicles support the shape parameter, including the awardees(...) expansion.
Example: fetch a vehicle with a shaped list of its IDVs:
curl -H "X-API-KEY: <key>" \
"https://tango.dev/api/vehicles/<vehicle-uuid>/?shape=uuid,solicitation_identifier,organization_id,awardee_count,order_count,vehicle_obligations,vehicle_contracts_value,awardees(key,uuid,piid,award_date,recipient(display_name,uei))"
Example: include opportunity-derived fields and expand the linked Opportunity:
curl -H "X-API-KEY: <key>" \
"https://tango.dev/api/vehicles/<vehicle-uuid>/?shape=uuid,solicitation_identifier,solicitation_title,solicitation_date,naics_code,opportunity(opportunity_id,title,first_notice_date,office(office_name,agency_name))"
Example: expand JSON detail fields to select specific sub-fields:
curl -H "X-API-KEY: <key>" \
"https://tango.dev/api/vehicles/<vehicle-uuid>/?shape=uuid,solicitation_identifier,competition_details(extent_competed,number_of_offers_received),type_of_idc,contract_type"
Example: filter expanded awards within a vehicle:
curl -H "X-API-KEY: <key>" \
"https://tango.dev/api/vehicles/<vehicle-uuid>/?shape=uuid,awardees(key,uuid,recipient(display_name))&search=acme"
See Response Shaping for full syntax and flattening options.
How-tos
Search opportunities by NAICS¶
Find live SAM.gov opportunities matching one or more NAICS codes, sorted by response deadline.
The 1-line answer¶
GET /api/opportunities/ with naics=<code> + active=true + ordering=response_deadline.
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/opportunities/?naics=541512&active=true&ordering=response_deadline"
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
resp = client.list_opportunities(
naics="541512",
active=True,
ordering="response_deadline",
limit=50,
)
for opp in resp.results:
print(opp.response_deadline, opp.opportunity_id, opp.title)
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const resp = await client.listOpportunities({
naics: "541512",
active: true,
ordering: "response_deadline",
limit: 50,
});
for (const opp of resp.results) {
console.log(opp.response_deadline, opp.opportunity_id, opp.title);
}
That's it. The rest of this guide is refinements: looking up codes you don't know, getting naics_code back in the payload, layering narrowing filters, drilling into a single opportunity, paginating, and staying fresh with webhooks.
Filter name gotcha
The filter is naics, not naics_code. And it's active=true, not status=active — there is no status filter on this endpoint.
Step 1 — Find the NAICS codes you care about¶
Most users don't have 541512 memorized. Browse /api/naics/ with search= first. It matches both code prefix and description text:
# All NAICS in the 5415 (Computer Systems Design) family
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/naics/?search=5415&limit=25"
# Free-text on description (returns 9 hits today)
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/naics/?search=computer&limit=25"
# All NAICS in the 5415 family
family = client.list_naics(search="5415", limit=25)
for r in family.results:
# list_naics() returns plain dicts, not shaped objects — use dict access.
print(r["code"], "-", r["description"])
# Free-text on description
computer = client.list_naics(search="computer", limit=25)
const family = await client.listNaics({ search: "5415", limit: 25 });
for (const r of family.results) {
console.log(r.code, "-", r.description);
}
const computer = await client.listNaics({ search: "computer", limit: 25 });
Search terms that miss
The NAICS table uses Census Bureau wording. Industry jargon often misses — cybersecurity, cyber, and cloud all return zero hits because those words don't appear in any NAICS description. Try the underlying activity: computer, security, programming, data processing, software.
Once you've picked your codes, |-OR them together for the opportunities call (see Multi-NAICS search below). Full reference: NAICS API.
Step 2 — Get naics_code back in the response¶
The default opportunity shape for list_opportunities() is minimal (opportunity_id, title, solicitation_number, response_deadline, active) and does not include naics_code. If you're searching by NAICS you almost certainly want to see which code each result matched. Ask for it explicitly via shape=:
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/opportunities/?naics=541511|541512&active=true&ordering=response_deadline&shape=opportunity_id,title,response_deadline,naics_code,office(*),set_aside"
resp = client.list_opportunities(
naics="541511|541512",
active=True,
ordering="response_deadline",
shape="opportunity_id,title,response_deadline,naics_code,office(*),set_aside",
limit=50,
)
for opp in resp.results:
print(opp.naics_code, opp.response_deadline, opp.title)
const resp = await client.listOpportunities({
naics: "541511|541512",
active: true,
ordering: "response_deadline",
shape: "opportunity_id,title,response_deadline,naics_code,office(*),set_aside",
limit: 50,
});
for (const opp of resp.results) {
console.log(opp.naics_code, opp.response_deadline, opp.title);
}
The list-detail default shape is documented in Opportunities reference → Response Shaping. For the syntax (nested expansions, wildcards, flattening), see the Response Shaping pattern guide.
Step 3 — Add the usual narrowing filters¶
In practice you almost never search by NAICS alone. Layer on whatever applies. Keep it to two or three narrowing filters at a time — five at once usually reduces the result set to zero on any single day:
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/opportunities/?\
naics=541512&\
active=true&\
agency=DOD&\
response_deadline_after=2026-05-15&\
ordering=response_deadline"
resp = client.list_opportunities(
naics="541512",
active=True,
agency="DOD",
response_deadline_after="2026-05-15",
ordering="response_deadline",
limit=50,
)
const resp = await client.listOpportunities({
naics: "541512",
active: true,
agency: "DOD",
response_deadline_after: "2026-05-15",
ordering: "response_deadline",
limit: 50,
});
| Filter | What you'd use it for |
|---|---|
set_aside |
Small-business / 8(a) / WOSB / SDVOSB carve-outs. |
agency |
Limit to a department or sub-agency (vector-backed; "DOD", "Department of Energy", "NAVSEA" all work). |
notice_type |
Solicitation, Combined Synopsis/Solicitation, Sources Sought, Presolicitation, etc. Validated server-side. |
response_deadline_after |
Drop anything already closed or closing too soon to bid. |
response_deadline_before |
Cap how far out you look. |
place_of_performance |
Free-text-ish match on PoP. |
search |
Full-text search on the opportunity itself. |
See the full list in the Opportunities API reference.
Multi-NAICS search¶
Most vendors care about a portfolio of NAICS codes, not just one. Use | to pass multiple:
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/opportunities/?naics=541511|541512|541519&active=true&ordering=response_deadline"
resp = client.list_opportunities(
naics="541511|541512|541519",
active=True,
ordering="response_deadline",
limit=50,
)
const resp = await client.listOpportunities({
naics: "541511|541512|541519",
active: true,
ordering: "response_deadline",
limit: 50,
});
The same | syntax works for psc and other filters that accept multiple values.
Paginating through results¶
A single NAICS code can easily return hundreds of opportunities (541512 alone returns ~500). At the default limit=50 that's ten pages. The endpoint is page-based — there is no cursor for opportunities — and the response includes next/previous URLs.
# Page 2, 3, ... until response.next is null
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/opportunities/?naics=541512&active=true&ordering=response_deadline&limit=50&page=2"
# tango-python doesn't ship an iterator — drive it yourself.
page = 1
while True:
resp = client.list_opportunities(
naics="541512",
active=True,
ordering="response_deadline",
limit=50,
page=page,
)
for opp in resp.results:
print(opp.response_deadline, opp.opportunity_id)
if not resp.next:
break
page += 1
// tango-node ships an async iterator that walks pages for you.
for await (const opp of client.iterateOpportunities({
naics: "541512",
active: true,
ordering: "response_deadline",
limit: 50,
})) {
console.log(opp.response_deadline, opp.opportunity_id);
}
Always pass an ordering= when paginating. Without one, page boundaries are undefined and you can see duplicates or skipped rows across pages.
Drilling into a single opportunity¶
The list endpoint gives you summary fields. To pull the full record — description, attachments, notice history — hit the detail endpoint:
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/opportunities/<opportunity_id>/?shape=opportunity_id,title,naics_code,response_deadline,description,attachments(name,mime_type,url)"
# No public get_opportunity() yet — use the lower-level _get(). See note below.
opp = client._get(
f"/api/opportunities/{opp_id}/",
params={"shape": "opportunity_id,title,naics_code,response_deadline,"
"description,attachments(name,mime_type,url)"},
)
print(opp["title"], "-", len(opp.get("attachments") or []), "attachment(s)")
for a in (opp.get("attachments") or []):
print(" -", a["name"], a["mime_type"], a["url"])
// No public getOpportunity() yet — use fetch directly. See note below.
const r = await fetch(
`https://tango.dev/api/opportunities/${oppId}/?shape=opportunity_id,title,naics_code,response_deadline,description,attachments(name,mime_type,url)`,
{ headers: { "X-API-KEY": process.env.TANGO_API_KEY! } },
);
const opp = await r.json();
console.log(opp.title, "-", (opp.attachments ?? []).length, "attachment(s)");
No get_opportunity() convenience method yet
Neither SDK currently ships a typed get_opportunity() / getOpportunity(). The examples above use client._get(...) (Python) and fetch (Node). A typed wrapper is on the roadmap; the URL shape will not change.
attachments(extracted_text) is Pro+ tier
The default attachments(...) expansion gives you attachment_id, file_size, mime_type, name, posted_date, resource_id, type, url. The extracted_text sub-field (full OCR'd text of each attachment) is Pro+ tier only. Free-tier callers who request it get the response without it plus a meta.upgrade_hints entry. See pricing.
Stay fresh with webhooks¶
Polling /api/opportunities/ every few minutes works, but it's wasteful and you'll miss the window on fast-turn solicitations. Use a filter subscription instead. The full webhooks model — endpoint setup, signing, retries — lives in the Webhooks user guide; this is the abbreviated version for "watch a NAICS portfolio."
endpoint is required for multi-endpoint accounts
If your account has more than one webhook endpoint configured, the alert-create call must include the endpoint UUID (the server returns 400 otherwise so deliveries can't go to the wrong receiver). Single-endpoint accounts may omit it. List your endpoints first with GET /api/webhooks/endpoints/ or client.list_webhook_endpoints().
curl -X POST -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/alerts/" \
-d '{
"name": "NAICS 5415xx opportunities (SBA)",
"query_type": "opportunity",
"filters": {
"naics": "541511|541512|541519",
"active": "true",
"set_aside": "SBA"
},
"frequency": "realtime",
"endpoint": "<your-endpoint-id>"
}'
# Single-endpoint account — `endpoint=` may be omitted.
# Multi-endpoint account — pass the UUID explicitly.
alert = client.create_webhook_alert(
name="NAICS 5415xx opportunities (SBA)",
query_type="opportunity",
filters={
"naics": "541511|541512|541519",
"active": "true",
"set_aside": "SBA",
},
frequency="realtime",
endpoint="<your-endpoint-id>", # required if you have >1 endpoint
)
print(alert.alert_id, alert.status)
const alert = await client.createWebhookAlert({
name: "NAICS 5415xx opportunities (SBA)",
query_type: "opportunity",
filters: {
naics: "541511|541512|541519",
active: "true",
set_aside: "SBA",
},
frequency: "realtime",
endpoint: "<your-endpoint-id>", // required if you have >1 endpoint
});
console.log(alert.alert_id, alert.status);
Tango re-evaluates the filter on its schedule and POSTs matching opportunities to your endpoint as they appear. Deliveries carry IDs only — fetch the full record with the drill-in pattern above. Tier limits and cron-vs-realtime semantics in the Webhooks user guide.
Combining with agency search¶
NAICS + agency together is the most common shape, and agency is vector-backed — you don't have to know the exact CGAC/FPDS code. agency=DOD, agency=Navy, agency=NAVSEA all do something sensible.
If you need precise agency-tree semantics (e.g. the whole Department vs. just this sub-agency), see the agency search pattern guide.
Common pitfalls¶
active=truevsnotice_type.active=truefilters by Tango's archive flag — i.e., the opportunity isn't archived/expired. It does not filter by phase. If you want only currently-biddable solicitations, combineactive=truewithnotice_type=Solicitation(orCombined Synopsis/Solicitation) andresponse_deadline_after=<today>.- NAICS code format. Six-digit string, no hyphens, no leading zeros stripped.
"541512"is correct;541512(int) works in JSON but URL-encode it as a string. Five-digit and four-digit prefixes are not matched — use the NAICS browse path to find the exact six-digit codes you want, then|-OR them together. - Default ordering. Without
ordering=, list responses come back in an undefined order. If you care about "soonest deadline first," always passordering=response_deadline. This matters double when paginating. naics_codeisn't in the default shape. Ifopp.naics_coderaisesAttributeError, you forgot theshape=override from Step 2.
Related¶
Resolve free-text agency names¶
You have a column of agency strings — "Department of Energy", "DOE", "Energy", "Lab @ Argonne" — and you need canonical Tango identifiers (a CGAC code, an FPDS code, an Organization UUID) before you can join, filter, or load. This guide shows you the smallest loop that gets you there.
The 1-line answer¶
curl -X POST https://tango.dev/api/resolve/ \
-H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Department of Energy", "target_type": "organization"}'
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
result = client.resolve(name="Department of Energy", target_type="organization")
for c in result.candidates:
print(c.identifier, c.display_name, c.match_tier)
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const result = await client.resolve({
name: "Department of Energy",
target_type: "organization",
});
for (const c of result.candidates) {
console.log(c.identifier, c.display_name, c.match_tier);
}
target_type is "organization" for any government org (department, agency, sub-agency, office). "entity" is for vendors/recipients.
What you get back¶
{
"candidates": [
{
"identifier": "362361bf-4dd3-5254-85a3-4649884108d6",
"display_name": "ENERGY, DEPARTMENT OF",
"match_tier": "high"
}
],
"count": 1
}
identifier— the UUIDkeyof the matchingOrganization. Use it directly against/api/organizations/{identifier}/. To use in an agency filter on list endpoints, prefer thefh_keyor CGAC code (obtainable by hydrating the org record first, or by using the org'sshort_name/abbreviation directly in the filter). See Agency search pattern guide for full filter semantics.display_name— the canonical name on file.match_tier—"high"/"medium"/"low". Free tier callers do not get this field; they see onlyidentifieranddisplay_nameand at most 3 candidates. Pro+ gets up to 5 candidates withmatch_tier. See the resolve reference for the full contract.
The endpoint does not return hierarchy context (parent department, level, etc.) inline. If you need that, hydrate via /api/organizations/{identifier}/ once you have a winner.
Free-text only
/api/resolve/ is for free-text strings. If your input is already a numeric code (3-digit CGAC, 4-digit FPDS), you don't need to resolve — skip to Numeric codes below.
Boost quality with context¶
The resolver weights signals beyond the bare name. If your spreadsheet has city, state, or any descriptive context, pass it:
curl -X POST https://tango.dev/api/resolve/ \
-H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Lab @ Argonne",
"target_type": "organization",
"state": "IL",
"context": "national laboratory, basic science research, DOE"
}'
result = client.resolve(
name="Lab @ Argonne",
target_type="organization",
state="IL",
context="national laboratory, basic science research, DOE",
)
const result = await client.resolve({
name: "Lab @ Argonne",
target_type: "organization",
state: "IL",
context: "national laboratory, basic science research, DOE",
});
Useful context fields: industry references (NAICS/PSC), parent department name, location signals, mission text from the source row. More context generally narrows the candidate set.
Handling low-confidence matches¶
Treat match_tier as a heuristic, not a verdict. Empirically the tiers are noisy in both directions: a high-tier hit on an abbreviation collision can still be the wrong org, and a low-tier hit on a partial string ("Environmental Protection" → EPA at low) can still be the right one.
| Tier | Reasonable default | Caveat |
|---|---|---|
high |
Promising. Safe enough for human-reviewed surfaces (search dropdowns, autocomplete). | A high tier can still be wrong on abbreviation collisions — don't auto-join into a downstream system without spot-checking. |
medium |
Inspect candidates[1:] before accepting. |
Often the second candidate is what you actually wanted. |
low |
Treat as a suggestion. | Sometimes correct (e.g., a clipped partial name) — don't discard before reviewing. |
count: 0 |
Queue for human review. | No match at all — the string didn't hit anything. |
On the free tier you don't get match_tier, so use ordinal position as a weak proxy (first candidate is best) and lean harder on validating downstream — or upgrade if this is core to your workflow.
Don't assume the top hit is right
The resolver returns ranked candidates, not a single answer. For ambiguous strings ("Energy" could be the Department, an EERE office, an EIA program) the second candidate is sometimes what you wanted. If your input might be ambiguous, log all candidates, not just [0].
Anti-pattern: tiered auto-accept into automated joins
Wiring if tier == "high": auto_accept() straight into an ETL pipeline that writes to a downstream system is a great way to ship silently-wrong joins. Strings like "Dept of Energy" (no period, abbreviated "Dept") have been observed to resolve to unrelated logistics orgs at high tier in some snapshots — the resolver is doing its best on noisy input, but it can't tell you when it's wrong. Rule of thumb: if a wrong answer would corrupt a downstream join, send anything below explicit, reviewed confidence through a human-in-the-loop step regardless of tier.
Numeric codes: skip the resolver¶
If your input is already a federal code, the resolve endpoint is the wrong tool — it's tuned for free-text names. Use the Organizations API or any list endpoint's agency filter, which understand numeric codes directly:
| Input shape | Means | Resolves to |
|---|---|---|
3-digit (069, 075, 089) |
CGAC code | L1 department |
4-digit (2100, 7530) |
FPDS sub-tier code | L2 agency under the matching department |
8-9 digit (100011980) |
fh_key |
Exact Organization row |
Letters + digits (15JCRM) |
Office / AAC code | Office-level org |
# CGAC — direct list filter
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/organizations/?cgac=089"
# FPDS code — multi-stage search picks it up via aliases
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/organizations/?search=2100"
# fh_key — direct path lookup (DOE fh_key = 100011980)
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/organizations/100011980/"
# CGAC — direct list filter
resp = client.list_organizations(cgac="089")
# FPDS code — multi-stage search picks it up via aliases
resp = client.list_organizations(search="2100")
# fh_key — direct path lookup
org = client.get_organization("100011980")
// CGAC — direct list filter
let resp = await client.listOrganizations({ cgac: "089" });
// FPDS code — multi-stage search picks it up via aliases
resp = await client.listOrganizations({ search: "2100" });
// fh_key — direct path lookup
const org = await client.getOrganization("100011980");
For filter-time use ("give me contracts under DOE"), the agency filter on every list endpoint accepts any of these forms — see the Agency search pattern guide for full semantics.
Batch loop: clean a CSV¶
The endpoint is per-call, not bulk. To clean a CSV column, loop with light concurrency.
Illustrative, not production-ready
The scripts below are teaching examples — sequential, no de-duplication, no retries, no concurrency. For real 1000-row ETL jobs, dedupe inputs first ("DOE" appears 400 times → resolve once, fan out), wrap calls in retry-with-backoff, and pace yourself off the SDK's exposed rate-limit headers: client.rate_limit_info (Python) / client.rateLimitInfo (Node). Both expose remaining, reset, and retryAfter after each request.
import csv
import os
import time
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
def resolve_agency(name: str, *, state: str | None = None, context: str | None = None) -> dict:
"""Return the top candidate plus the full ranked list for review."""
result = client.resolve(
name=name,
target_type="organization",
state=state,
context=context,
)
candidates = result.candidates
return {
"input": name,
"match_count": result.count,
"top_identifier": candidates[0].identifier if candidates else None,
"top_display_name": candidates[0].display_name if candidates else None,
"top_tier": candidates[0].match_tier if candidates else None, # None on free tier
"all_candidates": candidates,
}
with open("agencies_in.csv") as fin, open("agencies_resolved.csv", "w") as fout:
reader = csv.DictReader(fin)
writer = csv.DictWriter(
fout,
fieldnames=["input", "identifier", "display_name", "tier", "needs_review"],
)
writer.writeheader()
for row in reader:
result = resolve_agency(row["agency"], state=row.get("state"))
tier = result["top_tier"]
writer.writerow(
{
"input": result["input"],
"identifier": result["top_identifier"] or "",
"display_name": result["top_display_name"] or "",
"tier": tier or "",
"needs_review": tier in (None, "low") or result["match_count"] == 0,
}
)
time.sleep(0.05) # be polite; standard rate limits apply
import { TangoClient, type ResolveCandidate } from "@makegov/tango-node";
import { createReadStream, createWriteStream } from "node:fs";
import { setTimeout as sleep } from "node:timers/promises";
import { parse } from "csv-parse";
import { stringify } from "csv-stringify";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
async function resolveAgency(
name: string,
opts: { state?: string; context?: string } = {},
) {
const result = await client.resolve({
name,
target_type: "organization",
state: opts.state,
context: opts.context,
});
const candidates: ResolveCandidate[] = result.candidates ?? [];
const top = candidates[0];
return {
input: name,
matchCount: result.count ?? 0,
topIdentifier: top?.identifier ?? null,
topDisplayName: top?.display_name ?? null,
topTier: top?.match_tier ?? null, // null on free tier
allCandidates: candidates,
};
}
const parser = createReadStream("agencies_in.csv").pipe(
parse({ columns: true }),
);
const writer = stringify({
header: true,
columns: ["input", "identifier", "display_name", "tier", "needs_review"],
});
writer.pipe(createWriteStream("agencies_resolved.csv"));
for await (const row of parser) {
const result = await resolveAgency(row.agency, { state: row.state });
const tier = result.topTier;
writer.write({
input: result.input,
identifier: result.topIdentifier ?? "",
display_name: result.topDisplayName ?? "",
tier: tier ?? "",
needs_review:
tier === null || tier === "low" || result.matchCount === 0,
});
await sleep(50); // be polite; standard rate limits apply
}
writer.end();
Either script writes identifier (the UUID key returned by the resolver) into the output column. That UUID is Organization.key — it's what /api/organizations/{identifier}/ accepts. It is not an fh_key (the 8–9 digit numeric Federal Hierarchy code); if you need the fh_key for downstream filter use, hydrate the org record first.
Pitfalls¶
target_typeis"organization", not"agency". The endpoint has no"agency"choice; all government orgs (department, agency, sub-agency, office) live under"organization".- GET won't work. This endpoint is POST-only with a JSON body. Drop the
?q=pattern you may have seen in older snippets. - "Department of X" vs codes. The resolver handles both well in isolation, but mixed input shapes ("DOE",
089, "Energy") in one column means you should branch: route numeric-looking values through/api/organizations/?search=and free text through/api/resolve/. - Sub-offices bubble up. A string like
"NNSA Office of Defense Programs"may resolve to NNSA (L2) rather than the specific program office (L3). Pass the office name incontextto nudge the resolver, or hydrate the result and walk down via/api/organizations/?parent_fh_key=.... - Deprecated / reorganized agencies. Tango preserves demoted Federal Hierarchy records by
fh_keyso historical IDs still resolve, but thedisplay_nameyou get back may be the current canonical name, not the historical one. If you're matching contracts from 2010, expect the modern descendant org back — that's intentional. See Federal agency hierarchy for the provenance story. - Empty
candidatesarray, HTTP 200. No match is not an error. Always checkcountbefore indexingcandidates[0]. identifieris a UUID, not anfh_key. The resolver returns the org's UUIDkey. If your downstream system expects the 8–9 digitfh_key, hydrate the org record first — don't pipe the UUID into an?fh_key=...filter.
See also¶
- Resolve endpoint reference — full request/response contract, tier limits, error codes.
- Organizations API — direct lookups by
fh_key, CGAC filter, search. - Federal agency hierarchy — what CGAC, FPDS, L1/L2/L3 mean and where the data comes from.
- Agency search pattern — how the agency filter on every list endpoint resolves the same shapes of input at query time.
Opportunity updates in Slack¶
Get a Slack message every time a new SAM.gov opportunity matches a saved filter — e.g., "new HHS solicitations in NAICS 541512." Tango POSTs an alerts.opportunity.match event; your receiver maps it to a Slack Block Kit message and forwards to a Slack Incoming Webhook.
The 1-line answer¶
Stand up a tiny HTTP receiver that:
- verifies the Tango signature on the inbound POST,
- iterates
events[].matches.new[]— each entry is a summary object with the fields you need (opportunity ID, notice title, solicitation number, NAICS, response deadline), - converts each match to Slack Block Kit blocks and POSTs to your Slack Incoming Webhook URL.
Then register your receiver URL as a Tango webhook endpoint and attach an opportunity alert.
The payload already has what you need
Tango's webhook payload includes a matches.new[] array of summary objects — id (the opportunity's UUID), latest_notice_title, solicitation_number, naics_code, response_deadline. No fan-out fetch required for a Slack notification. See the Webhooks payload format for the full shape.
Prerequisites¶
- Tango API key (
TANGO_API_KEY). - A Slack workspace where you can install an app — you need permission to create an Incoming Webhook.
- Somewhere to host the receiver. Anywhere that can serve a public HTTPS endpoint works: a Cloudflare Worker, an AWS Lambda + API Gateway, a Fly.io / Render / Railway service, a Vercel / Netlify Function, or your own server.
Step 1 — Create the Slack Incoming Webhook¶
Follow Slack's setup guide — it's three clicks: create a Slack app, enable "Incoming Webhooks," and "Add New Webhook to Workspace." Pick the channel you want messages to land in.
Slack hands you a URL that looks like:
https://hooks.slack.com/services/T0XXXX/B0XXXX/abcDEFghi123…
Treat this URL as a secret — anyone with it can post to your channel.
Smoke-test it before wiring up Tango:
curl -X POST -H "Content-Type: application/json" \
--data '{"text": "Hello from Tango setup test."}' \
"$SLACK_WEBHOOK_URL"
You should see a plain message land in the channel within a second.
Step 2 — Stand up the receiver¶
The receiver is one HTTP handler. Four things it must do:
- Verify the
X-Tango-Signatureheader. Reject mismatches with 401. - Dedupe on
delivery_idif you might receive the same delivery twice (Tango retries on 5xx). Thedelivery_idis a UUID on the top-level payload, stable across retries. - Iterate
event.matches.new[]. Each entry is a summary object —id(the opportunity's UUID),latest_notice_title,solicitation_number,naics_code,response_deadline. No additional fetch required. - Convert each match to a Slack message and POST it.
Pick whichever runtime fits your stack. Below are minimal examples in Python and Node; both use the helpers shipped in the official SDKs.
# pip install tango-python[webhooks] fastapi uvicorn httpx
import os
import httpx
from fastapi import FastAPI, Request, HTTPException
from tango.webhooks.signing import verify_signature, SIGNATURE_HEADER
SLACK_URL = os.environ["SLACK_WEBHOOK_URL"]
SECRET = os.environ["TANGO_WEBHOOK_SECRET"]
app = FastAPI()
seen_deliveries: set[str] = set() # use Redis/DB in production
@app.post("/tango/webhooks")
async def receive(req: Request):
body = await req.body()
if not verify_signature(body, SECRET, req.headers.get(SIGNATURE_HEADER)):
raise HTTPException(401, "invalid signature")
payload = await req.json()
# Dedupe on the top-level delivery_id (stable across retries).
delivery_id = payload.get("delivery_id")
if delivery_id and delivery_id in seen_deliveries:
return {"ok": True}
if delivery_id:
seen_deliveries.add(delivery_id)
async with httpx.AsyncClient(timeout=5.0) as http:
for event in payload["events"]:
if event["event_type"] != "alerts.opportunity.match":
continue
alert_id = event.get("alert_id", "")
# The payload carries summary objects — no fetch needed.
for match in event.get("matches", {}).get("new", []):
blocks = opportunity_to_blocks(match, alert_id)
await http.post(SLACK_URL, json={"blocks": blocks})
return {"ok": True}
def opportunity_to_blocks(match: dict, alert_id: str) -> list[dict]:
title = match.get("latest_notice_title") or "(untitled opportunity)"
sol = match.get("solicitation_number") or "—"
deadline = match.get("response_deadline") or "—"
naics = match.get("naics_code") or "—"
url = f"https://sam.gov/opp/{match['id']}/view"
return [
{"type": "header", "text": {"type": "plain_text", "text": f"New: {title[:140]}"}},
{"type": "section", "fields": [
{"type": "mrkdwn", "text": f"*Solicitation*\n{sol}"},
{"type": "mrkdwn", "text": f"*Response by*\n{deadline}"},
{"type": "mrkdwn", "text": f"*NAICS*\n{naics}"},
{"type": "mrkdwn", "text": f"*Alert*\n{alert_id[:8]}…"},
]},
{"type": "actions", "elements": [
{"type": "button", "text": {"type": "plain_text", "text": "View on SAM.gov"}, "url": url},
]},
]
// npm i @makegov/tango-node hono
import { Hono } from "hono";
import { verifySignature, SIGNATURE_HEADER } from "@makegov/tango-node";
const SLACK_URL = process.env.SLACK_WEBHOOK_URL!;
const SECRET = process.env.TANGO_WEBHOOK_SECRET!;
const seenDeliveries = new Set<string>(); // use a real store in production
const app = new Hono();
app.post("/tango/webhooks", async (c) => {
const body = await c.req.text();
if (!verifySignature(body, SECRET, c.req.header(SIGNATURE_HEADER))) {
return c.json({ error: "invalid signature" }, 401);
}
const payload = JSON.parse(body);
// Dedupe on the top-level delivery_id (stable across retries).
const deliveryId: string | undefined = payload.delivery_id;
if (deliveryId && seenDeliveries.has(deliveryId)) {
return c.json({ ok: true });
}
if (deliveryId) seenDeliveries.add(deliveryId);
for (const event of payload.events) {
if (event.event_type !== "alerts.opportunity.match") continue;
const alertId: string = event.alert_id ?? "";
// The payload carries summary objects — no fetch needed.
for (const match of (event.matches?.new ?? []) as any[]) {
await fetch(SLACK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ blocks: opportunityToBlocks(match, alertId) }),
});
}
}
return c.json({ ok: true });
});
function opportunityToBlocks(match: any, alertId: string) {
const title = (match.latest_notice_title ?? "(untitled opportunity)").slice(0, 140);
const sol = match.solicitation_number ?? "—";
const deadline = match.response_deadline ?? "—";
const naics = match.naics_code ?? "—";
const url = `https://sam.gov/opp/${match.id}/view`;
return [
{ type: "header", text: { type: "plain_text", text: `New: ${title}` } },
{ type: "section", fields: [
{ type: "mrkdwn", text: `*Solicitation*\n${sol}` },
{ type: "mrkdwn", text: `*Response by*\n${deadline}` },
{ type: "mrkdwn", text: `*NAICS*\n${naics}` },
{ type: "mrkdwn", text: `*Alert*\n${alertId.slice(0, 8)}…` },
]},
{ type: "actions", elements: [
{ type: "button", text: { type: "plain_text", text: "View on SAM.gov" }, url },
]},
];
}
export default app;
Both examples assume TANGO_WEBHOOK_SECRET matches the secret you set when registering the endpoint with Tango (next step). For full receiver patterns — async deduplication, retries, structured logging — see the Webhooks user guide.
Step 3 — Register the receiver with Tango and create the alert¶
Two API calls: one to register the endpoint, one to create the alert.
# 1. Register the receiver URL as a webhook endpoint.
ENDPOINT_ID=$(curl -sS -X POST -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/endpoints/" \
-d '{
"name": "Slack — HHS NAICS 541512",
"callback_url": "https://your-receiver.example.com/tango/webhooks",
"is_active": true
}' | jq -r '.endpoint_id')
# The response also includes a `secret`. Save it as TANGO_WEBHOOK_SECRET
# on the receiver — that's the shared secret Tango signs with.
# 2. Create the opportunity alert targeting this endpoint.
curl -X POST -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/alerts/" \
-d "{
\"name\": \"HHS NAICS 541512 opportunities\",
\"query_type\": \"opportunity\",
\"filters\": {\"agency\": \"7500\", \"naics\": \"541512\", \"active\": true},
\"frequency\": \"realtime\",
\"endpoint\": \"$ENDPOINT_ID\"
}"
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
endpoint = client.create_webhook_endpoint(
name="Slack — HHS NAICS 541512",
callback_url="https://your-receiver.example.com/tango/webhooks",
is_active=True,
)
print("Save this as TANGO_WEBHOOK_SECRET:", endpoint["secret"])
alert = client.create_webhook_alert(
name="HHS NAICS 541512 opportunities",
query_type="opportunity",
filters={"agency": "7500", "naics": "541512", "active": True},
frequency="realtime",
endpoint=endpoint["endpoint_id"],
)
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const endpoint = await client.createWebhookEndpoint({
name: "Slack — HHS NAICS 541512",
callback_url: "https://your-receiver.example.com/tango/webhooks",
is_active: true,
});
console.log("Save this as TANGO_WEBHOOK_SECRET:", (endpoint as any).secret);
const alert = await client.createWebhookAlert({
name: "HHS NAICS 541512 opportunities",
query_type: "opportunity",
filters: { agency: "7500", naics: "541512", active: true },
frequency: "realtime",
endpoint: (endpoint as any).endpoint_id,
});
Save the endpoint secret
The endpoint-create response includes a one-time secret. Tango signs every delivery with this secret; your receiver verifies with the same value. Store it as TANGO_WEBHOOK_SECRET in your receiver's environment. Lose it and you'll have to rotate by recreating the endpoint.
Step 4 — Test before you wait for a real match¶
You don't want to discover the receiver is broken when the first real match shows up. Use client.test_webhook_delivery() (or client.testWebhookEndpoint() in Node) to fire a synthetic delivery at your endpoint immediately:
client.test_webhook_delivery(endpoint_id=endpoint["endpoint_id"])
await client.testWebhookEndpoint(endpoint.endpoint_id);
You should see a Slack message arrive within a few seconds. If you don't:
- Check the endpoint logs — does the request reach the receiver?
- Did
verifySignaturereturnfalse? Double-checkTANGO_WEBHOOK_SECRETmatches the endpoint secret. - Did
payload["events"]exist? The sample-payload shape is documented in the Webhooks user guide §6.
For deeper local debugging, the SDKs ship a WebhookReceiver that records deliveries to an in-process queue and a simulate.deliver() helper that signs a payload without involving the live Tango API at all. See the Python / Node reference.
Tuning the filter¶
Too many alerts is worse than too few — Slack channels die from noise. Two levers:
- Narrow the filter. Add
set_aside,agency, orplace_of_performanceto cut volume. The same filter parameters available onGET /api/opportunities/work in the alertfiltersobject. - Switch frequency.
realtimefires on every ingestion cycle (multiple times per day). For lower-priority feeds,dailyrolls all new matches from the past 24h into a single delivery — fewer Slack messages, batched.
To see how many matches your current filter is producing before going live:
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/opportunities/?agency=7500&naics=541512&active=true&first_notice_date_after=2026-04-01" | jq '.count'
If count is in the thousands, narrow the filter — that's roughly how many alerts your channel will see when matches start landing.
Updating or pausing the alert¶
# Pause without deleting
curl -X PATCH -H "X-API-KEY: $TANGO_API_KEY" -H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/alerts/$ALERT_ID/" \
-d '{"is_active": false}'
# Resume
curl -X PATCH -H "X-API-KEY: $TANGO_API_KEY" -H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/alerts/$ALERT_ID/" \
-d '{"is_active": true}'
A resumed alert matches from the moment you resume it — opportunities posted or changed while it was paused don't reach the channel. filters and query_type are immutable post-create — to change either, delete and recreate.
What about contracts, grants, forecasts?¶
Same pattern. Swap query_type for contract / grant / forecast, point filters at the same fields you'd pass to the equivalent list endpoint, and adapt the Block Kit mapping to the relevant entity (PIID + recipient + obligation for contracts, opportunity number + funding categories for grants, etc.). The signature verification, dedup, and Slack-post pieces are identical.
Get notified when new awards match your filter¶
You want a heads-up when a new contract award matches a saved filter — say, "DOD awards over $1M in NAICS 541512." There are two ways to do it. Polling is the default for most teams. Webhooks are the upgrade if you already run that kind of infrastructure.
The 1-line answer¶
- Polling:
GET /api/contracts/?<your filters>&modified_after=<last_run>on a 15-30 min cron. Dedupe onkey. Done. - Webhooks: create a filter alert (
POST /api/webhooks/alerts/) — and if your account has more than one webhook endpoint, you must passendpoint=<uuid>(see Step 1: Create the filter alert under Option B).
How fast is "new"?¶
Whichever option you pick, latency is bounded by upstream data sources, not by your transport:
| Source | Cadence |
|---|---|
| FPDS publishes new contract rows | Irregular; weekday business hours, large catch-ups overnight |
| Tango ingests from FPDS | Twice daily |
Tango's realtime alert evaluator |
*/5 * * * * (every 5 min) |
So the gap between a contract being signed and you finding out is typically hours, sometimes a day — not seconds. The 5-min webhook cron is downstream of the twice-daily FPDS pull; it does not make the upstream feed any fresher. If you need true second-level streaming, neither option will give it to you, because FPDS itself doesn't.
When to pick which¶
| Polling | Webhooks | |
|---|---|---|
| Latency from FPDS ingest | 0-30 min (your poll interval) | ≤ 5 min |
| Latency from FPDS publish | Hours (FPDS cadence) | Hours (same) |
| Needs public HTTPS endpoint | No | Yes |
| Needs HMAC verifier | No | Yes |
| Needs a job queue | No | Strongly recommended |
| Retry / dedupe logic | Free (idempotent by construction) | Required (at-least-once delivery) |
| Backpressure if your end is slow | None | Tango opens the circuit breaker on you |
Webhook only wins when you're already running a webhook stack (Stripe, GitHub, Slack, etc.). For a single BD / analytics team, a polling job has half the moving parts and the same effective freshness.
Option A — Polling (the default)¶
Run a cron that hits /api/contracts/ with your filters plus modified_after=<last successful run>. Track the last-run timestamp in your DB or a state file; dedupe on each result's key.
Sketch¶
every 15-30 min:
since = read last_successful_run_ts() # ISO 8601
results = GET /api/contracts/?<filters>&modified_after=<since>&ordering=-award_date&limit=100
for row in results.paginate():
if row.key not in already_seen_table:
handle(row)
already_seen_table.insert(row.key)
write last_successful_run_ts(now_before_request)
A few rules to keep this boring:
- Record
now_before_requestbefore you send the call, not after. Otherwise you'll lose records that landed during your fetch window. - Always paginate (
nextcursor). The default page size is small; a busy filter will exceed it. - Dedupe on
key(the contract's stable primary id), not on(piid, agency). PIIDs are not globally unique. modified_aftercovers both new and modified rows — that's normally what you want. If you only want net-new awards, also filter onaward_date_gte=<some recent floor>.- Allowed
orderingvalues areaward_date,obligated,total_contract_value(and the same with a-prefix).keyis not an allowed ordering.
Three-language smoke test¶
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/contracts/?\
funding_agency=DOD&naics=541512&obligated_gte=1000000\
&modified_after=2026-05-12T00:00:00Z&ordering=-award_date&limit=20"
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
# The Python SDK takes `naics_code=` and maps it to the API's `naics=`.
# Same story for `psc_code` → `psc`.
#
# `modified_after` isn't exposed as a top-level kwarg on list_contracts() yet,
# so pass it via `filters={...}` — values there get forwarded verbatim.
contracts = client.list_contracts(
funding_agency="DOD",
naics_code="541512",
obligated_gte="1000000",
ordering="-award_date",
limit=20,
filters={"modified_after": "2026-05-12T00:00:00Z"},
)
for c in contracts.results:
handle(c)
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY! });
// The Node SDK accepts BOTH `naics` (raw API name) and `naics_code` (alias).
const page = await client.listContracts({
funding_agency: "DOD",
naics: "541512",
obligated_gte: "1000000",
modified_after: "2026-05-12T00:00:00Z",
ordering: "-award_date",
limit: 20,
});
for (const c of page.results) {
handle(c);
}
That's the whole pattern. Half of all "stream awards" use cases stop here. If yours is one of them, you're done.
Option B — Webhooks (advanced)¶
Pick webhooks if you already run a public HTTPS endpoint with HMAC verification and a job queue, and want push delivery instead of polling. Otherwise stick with Option A.
Step 1: Create the filter alert¶
POST /api/webhooks/alerts/ is the entrypoint. Returns 201 Created with the new alert, or 200 OK with the existing one if a subscription with the same query_type + filters already exists (dedup by SHA-256 hash of the canonical filter).
Multi-endpoint accounts must specify endpoint
If your account has more than one webhook endpoint, the API rejects POST /api/webhooks/alerts/ without an explicit endpoint=<uuid> field — it will not guess which one to use. Single-endpoint accounts get auto-resolve for free. List your endpoints with GET /api/webhooks/endpoints/ and copy the id of the one you want.
curl -X POST -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/alerts/" \
-d '{
"name": "DOD IT services > $1M",
"query_type": "contract",
"filters": {
"funding_agency": "DOD",
"naics": "541512",
"obligated_gte": "1000000"
},
"frequency": "realtime",
"endpoint": "<endpoint-uuid-from-/api/webhooks/endpoints/>"
}'
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
# Filter keys here match the RAW API param names (`naics`, `psc`), not the
# `list_contracts()` kwarg aliases (`naics_code`, `psc_code`).
alert = client.create_webhook_alert(
name="DOD IT services > $1M",
query_type="contract",
filters={
"funding_agency": "DOD",
"naics": "541512",
"obligated_gte": "1000000",
},
frequency="realtime",
endpoint="<endpoint-uuid>", # required if you have >1 endpoint
)
print(alert.alert_id, alert.status)
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY! });
const alert = await client.createWebhookAlert({
name: "DOD IT services > $1M",
query_type: "contract",
filters: {
funding_agency: "DOD",
naics: "541512",
obligated_gte: "1000000",
},
frequency: "realtime",
endpoint: "<endpoint-uuid>", // required if you have >1 endpoint
});
console.log(alert.alert_id, alert.status);
Frequency choices¶
| Frequency | When it runs | Tier |
|---|---|---|
realtime |
Every 5 minutes (*/5 * * * *); independent of FPDS ingest |
All tiers |
daily |
Once per day | All tiers |
custom |
5-field cron in cron_expression (e.g. 0 8 * * 1 for weekly) |
Small and above |
"Realtime" is the most-frequent option, not a true streaming pipe. The evaluator polls every 5 min regardless of whether anything new landed upstream.
Step 2: Build the receiver¶
Tango POSTs a signed JSON batch to your endpoint. The receiver has three jobs: verify the signature, dedupe on delivery_id, return 2xx fast.
The payload shape (real production deliveries)¶
{
"timestamp": "2026-05-12T18:20:14Z",
"delivery_id": "8c5e3f6a-1234-4abc-9def-9b21abcde000",
"events": [
{
"event_type": "alerts.contract.match",
"alert_id": "e4c4aaaa-bbbb-cccc-dddd-eeeeffff0000",
"query_type": "contract",
"filters": {"funding_agency": "DOD", "naics": "541512", "obligated_gte": "1000000"},
"matches": {
"new_count": 2,
"modified_count": 0,
"new": [
{"id": "CONT_AWD_47QFWA26F0009_4732_47QTCK18D0004_4732", "piid": "47QFWA26F0009", "total_contract_value": 5364614.65, "recipient_legal_business_name": "ACME CORP"},
{"id": "CONT_AWD_FA8773_24_C_0099_5700_GS35F0119Y_4732", "piid": "FA877324C0099", "total_contract_value": 1450000.00, "recipient_legal_business_name": "BETA INDUSTRIES LLC"}
],
"modified": []
},
"checked_at": "2026-05-12T18:20:12Z"
}
]
}
See Webhooks user guide §6 — Payload format for the field-by-field reference. The summary objects in matches.new[] / matches.modified[] carry just enough to route on (id, piid, total_contract_value, recipient_legal_business_name); pull the full record from GET /api/contracts/{id}/ if you need more.
Sample-payload preview vs production
GET /api/webhooks/endpoints/sample-payload/?event_type=alerts.contract.match returns a simplified preview (just new_ids / modified_ids, no delivery_id, no matches.new[]). Real deliveries from the dispatcher carry the richer shape above. Build against this guide and the webhooks user guide §6 — not against the sample-payload response.
Python receiver (Flask)¶
import hashlib
import hmac
import json
import os
import flask
SECRET = os.environ["TANGO_WEBHOOK_SECRET"].encode()
app = flask.Flask(__name__)
# Replace with Redis / your DB. Must persist across restarts and be fast.
_seen_deliveries: set[str] = set()
@app.post("/tango/webhooks")
def recv():
body = flask.request.get_data() # raw bytes, NOT request.json
sig_header = flask.request.headers.get("X-Tango-Signature", "")
expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
received = sig_header.removeprefix("sha256=")
if not hmac.compare_digest(expected, received):
return "Invalid signature", 401
payload = json.loads(body)
# 1. Idempotency: Tango guarantees at-least-once delivery.
delivery_id = payload.get("delivery_id")
if delivery_id and delivery_id in _seen_deliveries:
return "ok", 200
if delivery_id:
_seen_deliveries.add(delivery_id)
# 2. Enqueue and return 2xx fast. Don't process inline — slow handlers
# trigger retries and eventually trip the circuit breaker.
for event in payload.get("events", []):
if event.get("event_type") != "alerts.contract.match":
continue # ignore other event types in the same batch
matches = event.get("matches", {})
for match in matches.get("new", []) + matches.get("modified", []):
enqueue_match(event["alert_id"], match) # match has id, piid, total_contract_value, recipient_legal_business_name
return "ok", 200
def enqueue_match(alert_id: str, match: dict):
# Push to Celery / RQ / SQS / whatever. Your worker fetches the full
# award details from /api/contracts/{id}/ as needed.
...
Node receiver (Express)¶
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.TANGO_WEBHOOK_SECRET!;
const app = express();
// Replace with Redis / your DB. Must persist across restarts.
const seenDeliveries = new Set<string>();
// IMPORTANT: capture the raw body for HMAC. JSON-parse from the buffer, NOT via express.json().
app.post(
"/tango/webhooks",
express.raw({ type: "*/*" }),
(req, res) => {
const body = req.body as Buffer; // raw bytes
const sigHeader = (req.header("x-tango-signature") || "").replace(/^sha256=/, "");
const expected = crypto.createHmac("sha256", SECRET).update(body).digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(sigHeader, "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("Invalid signature");
}
const payload = JSON.parse(body.toString("utf8"));
// 1. Idempotency
const deliveryId: string | undefined = payload.delivery_id;
if (deliveryId && seenDeliveries.has(deliveryId)) {
return res.status(200).send("ok");
}
if (deliveryId) seenDeliveries.add(deliveryId);
// 2. Enqueue + ack
for (const event of payload.events ?? []) {
if (event.event_type !== "alerts.contract.match") continue;
const matches = event.matches ?? {};
for (const m of [...(matches.new ?? []), ...(matches.modified ?? [])]) {
enqueueMatch(event.alert_id, m);
}
}
res.status(200).send("ok");
},
);
function enqueueMatch(alertId: string, match: unknown) {
// Push to BullMQ / SQS / whatever. Pull full record from
// GET /api/contracts/{id}/ in the worker.
}
Key rules:
- Sign the raw body bytes, not a re-serialized JSON. Any whitespace change breaks the HMAC.
- Constant-time compare (
hmac.compare_digest/crypto.timingSafeEqual) — never==. - Return 2xx within a few seconds. Synchronous work trips retries and eventually opens the circuit breaker.
- Dedupe on
delivery_id— samedelivery_idmay arrive more than once on retry. It is the canonical idempotency key. - Ignore unknown event types in the batch (don't 4xx) — a single delivery may contain multiple event types for the same endpoint.
Step 3: Test end-to-end with the cookbook receiver¶
The public tango-cookbook ships a webhook receiver example — a small FastAPI app that verifies signatures, dedupes redelivered events, and routes each one to stdout or Slack. Use it during integration before you have a production endpoint.
# In your tango-cookbook checkout:
just webhook-serve # starts on localhost:8000
cloudflared tunnel --url http://localhost:8000 # public URL
just webhook-register https://<tunnel>/webhooks/tango
Then trigger a test delivery:
curl -X POST -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/endpoints/test-delivery/" \
-d '{"endpoint": "<endpoint-uuid>"}' # required for multi-endpoint accounts
Managing the alert¶
# List your filter alerts
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/webhooks/alerts/"
# Pause without deleting
curl -X PATCH -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/alerts/<alert_id>/" \
-d '{"is_active": false}'
# Delete
curl -X DELETE -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/webhooks/alerts/<alert_id>/"
query_type and filters are immutable after creation — delete and recreate to change them. You can update name, frequency, cron_expression, and is_active.
Common pitfalls¶
- Expecting sub-minute latency. Don't. FPDS ingests twice daily; effective end-to-end latency from "vendor signs contract" to "your code sees it" is measured in hours, not seconds — regardless of whether you poll or use webhooks.
- Forgetting
endpoint=<uuid>on multi-endpoint accounts. AnyPOST /api/webhooks/alerts/(and anytest-delivery) returns 400 with"You have multiple webhook endpoints. Please specify which endpoint to use via the 'endpoint' field."Pass the UUID explicitly. - Python SDK kwarg surprise.
client.list_contracts(naics=...)raises — the kwarg isnaics_code(the SDK maps it to the API'snaics). Same forpsc_code→psc. The raw HTTP filter, and thefilters={}dict you pass tocreate_webhook_alert(), both use the API name (naics,psc). - Filter doesn't match
/api/contracts/. Test the filter against the API first. If/api/contracts/?<filters>returns nothing, the alert will never fire and your poll loop will never see anything. - HMAC mismatch. You're hashing a re-serialized body, or you stripped a trailing newline. Hash the raw request bytes as received. Signature is
sha256=<64 hex chars>overbodyusing your endpoint's secret. - Slow receiver. If your handler takes more than a few seconds, Tango retries with exponential backoff (up to 10 min between attempts) and eventually opens the circuit breaker. Always enqueue + return 2xx.
- At-least-once duplicates. The same
delivery_idcan land more than once on retry. If your downstream isn't idempotent, you'll double-write. Dedupe ondelivery_id. - Polling without
modified_after. A rawaward_date_gtepoll will miss modifications to existing records.modified_aftercovers both new and updated rows. Use both if you want strict "new awards only". - Tier caps. Free tier gets 1 filter alert; Small 5; Medium 10; Large 25. Hitting the cap returns 400.
Related¶
- Contracts API reference
- Webhooks user guide — full subscription / payload / retry / circuit-breaker reference (see §6 Payload format)
- Vendor watchlist recipe — N-alerts pattern for tracking specific UEIs
- Awards by NAICS recipe — single alert across multiple NAICS codes
Migrate from USAspending bulk download¶
You've been pulling USAspending's nightly bulk CSVs, unpacking them, and bundling transactions into your own award records. This guide moves the FPDS contracts side of that pipeline to the Tango API.
Scope¶
In scope. Prime contract awards (FPDS) and their subawards. The FPDS data Tango ingests is the same FPDS feed USAspending republishes — Tango pulls it directly twice daily and bundles transactions into one row per award.
Out of scope (today). Financial assistance (FABS) — grants, loans, direct payments, cooperative agreements, insurance. If your pipeline consumes USAspending's assistance_transactions.csv, subaward_* assistance rows, or any award_type in the assistance set (02, 03, 04, 05, 06, 07, 08, 09, 10, 11), keep that on USAspending bulk for now. Tango has no first-class FABS endpoint yet — /api/contracts/ is contract-only.
Also out of scope. Outlays on prime contracts (USAspending's prime_award_total_outlayed_amount on the prime side). Obligations are the canonical money metric on /api/contracts/. Outlays are exposed on subawards only today.
What you actually gain¶
- Twice-daily FPDS ingest vs USAspending's nightly FPDS pull. Tango is ~12 hours fresher on contract data specifically. For FABS, USAspending is your only option and they're equivalent (both refresh daily).
- One row per award, not one row per modification. USAspending ships
contract_award_transactions.csvwith a row per FPDS mod; you have to group by award and reconcile dollar fields yourself. Tango's/api/contracts/returns one row per award with all mods rolled up; if you need the per-mod detail, request?shape=...,transactions(*)or hitGET /api/contracts/{key}/transactions/. - Stable canonical
keyper award. Tango exposes a canonicalkeyderived from the FPDS award components (CONT_AWD_{piid}_{agency}_{parent_piid}_{parent_agency}). It's stable across re-pulls and is the URL identifier (/api/contracts/{key}/). PIID is still available for human display and cross-checks. - Filter + shape at request time. Replace the ZIP-and-grep step with query params (
?awarding_agency=GSA&obligated_gte=1000000) and a response-shape selector (?shape=key,piid,obligated). Cuts payload size 80–95% vs the full bulk schema. - Push delivery (optional). Subscribe to filter alerts via webhooks instead of diffing CSVs nightly. See Polling vs webhooks below.
Step-by-step¶
1. Inventory the USAspending columns you actually consume¶
Most pipelines touch 20–40 columns out of USAspending's ~280-column contract schema. List the columns your downstream code reads (parquet writer, BI tool, vendor enrichment, etc.) — that's the only set you need to map.
2. Map each column to a Tango field¶
Use the Field mapping table below. Two things to know up front:
- Agency codes. USAspending uses two codes per office: a 3-digit CGAC for the top-tier (e.g.
047= GSA) and a 4-digit FPDS code for the sub-tier (e.g.4740= GSA Public Buildings Service). Tango exposes both on theawarding_office/funding_officeexpansions asdepartment_code(CGAC) andagency_code(FPDS sub-tier). Don't swap them — the names look transposable but the codes won't match. - Code/description objects. Several USAspending columns are split across
<thing>_codeand<thing>_description. Tango returns them as{code, description}objects via expansion. To get the bare scalar, request?shape=naics_code(scalar) instead of?shape=naics(code,description)(object).
3. Replace the CSV download with a paginated GET¶
/api/contracts/ uses cursor pagination — there is no page= parameter. Each response is {count, next, previous, cursor, results}. Follow next until it's null, or pass the value from the cursor field on the response into the next request.
/api/subawards/ uses page-number pagination (page=N&limit=M). Different surface, same idea.
Both endpoints accept the same filter syntax as the API reference: range filters like obligated_gte, multi-value filters using | (e.g. naics=541511|541512), and full-text via search=.
4. Shape the response¶
Pass ?shape= listing only the fields you actually use. Without shape, the API returns a sensible default but it's not the full FPDS record — and it's still much bigger than what most ETLs need.
5. Decide your dedup key¶
Use key for upserts (canonical, stable, URL-safe). PIID alone is not unique — same PIID can recur across agencies and across fiscal years. The key field encodes the full FPDS award tuple.
Field mapping¶
This covers the most-used contract and subaward columns. For everything else, request ?show_shapes=true on /api/contracts/ to see the full available field set, or check the Contracts data dictionary.
Contracts (/api/contracts/)¶
Identifiers¶
| USAspending column | Tango field | Notes |
|---|---|---|
contract_award_unique_key / award_id |
key |
Use key for all API operations. Format: CONT_AWD_{piid}_{agency}_{parent_piid}_{parent_agency}. |
award_id_piid |
piid |
Same string. PIID alone is not unique across agencies — prefer key for joins. |
parent_award_id_piid |
parent_award.piid |
parent_award is a {key, piid} reference. |
parent_award_id |
parent_award.key |
Dollar amounts¶
| USAspending column | Tango field | Notes |
|---|---|---|
total_obligated_amount |
obligated |
Total dollars obligated across all mods. |
current_total_value_of_award |
base_and_exercised_options_value |
|
potential_total_value_of_award |
total_contract_value |
Includes unexercised options. |
federal_action_obligation (transaction-level) |
transactions[].obligated |
One per FPDS mod. Request via ?shape=transactions(*). |
Dates¶
| USAspending column | Tango field | Notes |
|---|---|---|
action_date (initial transaction) |
award_date |
Date the award was first signed. |
action_date (per modification) |
transactions[].transaction_date |
One per mod, when expanded. |
period_of_performance_start_date |
period_of_performance.start_date |
|
period_of_performance_current_end_date |
period_of_performance.current_end_date |
|
period_of_performance_potential_end_date |
period_of_performance.ultimate_completion_date |
|
fiscal_year |
fiscal_year |
Federal FY (Oct–Sep). Same semantics as USAspending. |
Awarding / funding agency¶
USAspending splits agency identity across four code/name pairs per side (top-tier + sub-tier + office). Tango folds them into a single awarding_office / funding_office object with three levels. Watch the code semantics — department_code is CGAC, agency_code is FPDS sub-tier.
| USAspending column | Tango field | Notes |
|---|---|---|
awarding_agency_code |
awarding_office.department_code |
CGAC top-tier code (3-digit, e.g. 047 for GSA). |
awarding_agency_name |
awarding_office.department_name |
|
awarding_sub_agency_code |
awarding_office.agency_code |
FPDS sub-tier code (4-digit, e.g. 4740 for GSA PBS). |
awarding_sub_agency_name |
awarding_office.agency_name |
|
awarding_office_code |
awarding_office.office_code |
AAC (Activity Address Code), e.g. 47PF52. |
awarding_office_name |
awarding_office.office_name |
|
funding_agency_code |
funding_office.department_code |
Same CGAC/FPDS distinction as awarding. |
funding_agency_name |
funding_office.department_name |
|
funding_sub_agency_code |
funding_office.agency_code |
|
funding_sub_agency_name |
funding_office.agency_name |
|
funding_office_code |
funding_office.office_code |
|
funding_office_name |
funding_office.office_name |
To filter by agency, use ?awarding_agency= or ?funding_agency= — these accept the CGAC code, the FPDS code, the name, or the abbreviation (best-effort matching). For reproducible jobs, use the CGAC code.
Recipient¶
| USAspending column | Tango field | Notes |
|---|---|---|
recipient_uei |
recipient.uei |
UEI is canonical post-SAM transition. |
recipient_name |
recipient.display_name |
DBA / common name. recipient.legal_business_name is the SAM legal name. |
recipient_duns |
(not exposed on /api/contracts/) |
DUNS is no longer the primary entity key. Use UEI. The legacy DUNS is still on subawards as recipient_duns. |
cage_code |
recipient.cage_code |
|
recipient_parent_uei |
recipient.parent_uei (via ?shape=recipient(parent_uei)) |
Place of performance¶
USAspending's primary_place_of_performance_* columns map to the place_of_performance expansion:
| USAspending column | Tango field |
|---|---|
primary_place_of_performance_country_code |
place_of_performance.country_code |
primary_place_of_performance_country_name |
place_of_performance.country_name |
primary_place_of_performance_state_code |
place_of_performance.state_code |
primary_place_of_performance_state_name |
place_of_performance.state_name |
primary_place_of_performance_city_name |
place_of_performance.city_name |
primary_place_of_performance_zip_4 |
place_of_performance.zip_code |
NAICS / PSC / set-aside¶
| USAspending column | Tango field | Notes |
|---|---|---|
naics_code |
naics_code (scalar) or naics.code (via ?shape=naics(code,description)) |
|
naics_description |
naics.description |
|
product_or_service_code |
psc_code (scalar) or psc.code |
|
product_or_service_code_description |
psc.description |
|
type_of_set_aside |
set_aside.code (via ?shape=set_aside(code,description)) |
|
type_of_set_aside_description |
set_aside.description |
Competition¶
USAspending flattens competition fields onto the row; Tango groups them under competition. Request via ?shape=competition(*).
| USAspending column | Tango field | Notes |
|---|---|---|
type_of_contract_pricing |
competition.contract_type.code |
Returned as {code, description} — use .code for direct equivalence. |
type_of_contract_pricing_description |
competition.contract_type.description |
|
extent_competed |
competition.extent_competed.code |
|
extent_competed_description |
competition.extent_competed.description |
|
solicitation_identifier |
competition.solicitation_identifier (also exposed as top-level solicitation_identifier) |
|
solicitation_procedures |
competition.solicitation_procedures.code |
|
number_of_offers_received |
competition.number_of_offers_received |
|
other_than_full_and_open_competition |
competition.other_than_full_and_open_competition.code |
Description and award type¶
| USAspending column | Tango field | Notes |
|---|---|---|
award_description / prime_award_base_transaction_description |
description |
|
award_type_code |
award_type.code (via ?shape=award_type(code,description)) |
|
award_type |
award_type.description |
Subawards (/api/subawards/)¶
Subawards are looser — most columns map 1:1 by name. The default shape already returns most of what FSRS-based pipelines need.
| USAspending column | Tango field | Notes |
|---|---|---|
prime_award_unique_key |
award_key |
Joins to /api/contracts/{award_key}/. |
prime_award_piid |
piid |
|
prime_awardee_uei |
prime_awardee_uei |
Also prime_recipient.uei if you request prime_recipient(*). |
prime_awardee_name |
prime_awardee_name |
|
subawardee_uei / recipient_uei |
recipient_uei |
Also subaward_recipient.uei. |
subawardee_name / recipient_name |
recipient_name |
|
subawardee_duns / recipient_duns |
recipient_duns |
DUNS is retained on subawards as a legacy column. |
subawardee_parent_uei |
recipient_parent_uei |
|
subawardee_parent_duns |
recipient_parent_duns |
|
subaward_number |
subaward_details.number |
|
subaward_action_date |
subaward_details.action_date |
|
subaward_amount |
subaward_details.amount |
|
subaward_description |
subaward_details.description |
|
usaspending_permalink |
usaspending_permalink |
Passed through verbatim. |
highly_compensated_officer_*_name / _amount |
highly_compensated_officers[].name / .amount |
List of {name, amount} objects. |
subaward_fsrs_report_last_modified_date |
fsrs_details.last_modified_date |
Closest thing to a change-detection timestamp on subawards. |
Reference data¶
| USAspending file | Tango endpoint | Notes |
|---|---|---|
| Recipient profiles ZIP | GET /api/entities/, GET /api/entities/{uei}/ |
UEI-keyed. |
| Agency reference CSV | GET /api/organizations/ |
Unified Federal Hierarchy with CGAC, FPDS, and FH keys. See Federal Agency Hierarchy. |
| NAICS reference CSV | GET /api/naics/ |
code + description, full-text search=. |
| PSC reference CSV | GET /api/psc/ |
Same shape as NAICS. |
Worked example: replace one CSV job¶
The classic: "GSA contracts awarded in FY2026 over $1M, with recipient and place of performance." On USAspending this is a bulk-download request + ZIP unpack + filter pass. On Tango it's a paginated loop with a shape selector.
# GSA: pass the name, "047" (CGAC), or the abbreviation — best-effort matching.
# Use CGAC for reproducible jobs.
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/contracts/?\
awarding_agency=047&\
fiscal_year=2026&\
obligated_gte=1000000&\
ordering=-award_date&\
limit=100&\
shape=key,piid,award_date,obligated,total_contract_value,\
recipient(uei,display_name),naics(code,description),\
place_of_performance(state_code,city_name)"
# Then follow the `next` URL on each response until it's null.
import os
from urllib.parse import parse_qs, urlparse
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
SHAPE = (
"key,piid,award_date,obligated,total_contract_value,"
"recipient(uei,display_name),naics(code,description),"
"place_of_performance(state_code,city_name)"
)
cursor: str | None = None
rows: list = []
while True:
resp = client.list_contracts(
awarding_agency="047", # GSA CGAC
fiscal_year=2026,
obligated_gte="1000000",
sort="award_date",
order="desc",
limit=100,
cursor=cursor,
shape=SHAPE,
)
rows.extend(resp.results)
if not resp.next:
break
cursor = parse_qs(urlparse(resp.next).query).get("cursor", [None])[0]
print(f"Pulled {len(rows)} contracts")
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY! });
const SHAPE = [
"key",
"piid",
"award_date",
"obligated",
"total_contract_value",
"recipient(uei,display_name)",
"naics(code,description)",
"place_of_performance(state_code,city_name)",
].join(",");
let cursor: string | null | undefined = undefined;
const rows: unknown[] = [];
while (true) {
const resp = await client.listContracts({
awarding_agency: "047", // GSA CGAC
fiscal_year: 2026,
obligated_gte: "1000000",
sort: "award_date",
order: "desc",
limit: 100,
cursor,
shape: SHAPE,
});
rows.push(...resp.results);
if (!resp.next) break;
cursor = new URL(resp.next).searchParams.get("cursor");
}
console.log(`Pulled ${rows.length} contracts`);
The subaward equivalent uses client.list_subawards(...) / client.listSubawards(...) with page=N instead of cursor.
Change capture: polling vs webhooks¶
Two patterns. Pick the one that matches your tolerance for latency and ops complexity.
Polling (simpler default)¶
Run a scheduled job that pulls awards newer than your last cursor. The change-capture filter is award_date_gte:
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/contracts/?award_date_gte=2026-05-01&ordering=-award_date&limit=100&shape=..."
What this catches:
- New awards since the last poll, ordered by
award_date. ✅ - Mods to existing awards — only if the mod is significant enough that Tango re-materialized the award. The
/api/contracts/API does not expose aupdated_at/modified_afterfilter today;award_dateis the only date-window filter. If you need per-mod change detection, pull the transactions endpoint for the awards you care about (next section) or use webhooks.
The simple pattern most teams need:
- Persist the latest
award_dateyou've seen. - On each run, request
award_date_gte=<that date>and follownextuntil exhausted. - Upsert by
key.
This is enough for most BI / dashboards / vendor-watch use cases.
Webhooks (push, lower latency)¶
For "tell me when a new contract matches this filter, within minutes of FPDS landing," subscribe to a filter alert and consume alerts.contract.match events. The webhook payload ships matched IDs only — your receiver pulls the full record from /api/contracts/{key}/.
Full walkthrough including signature verification, deduping, and the receiver loop: Stream contract awards. The full subscription / payload / retry / circuit-breaker reference: Webhooks user guide.
One thing to know before you POST /api/webhooks/alerts/: if your account has more than one webhook endpoint, you must include "endpoint": "<endpoint_uuid>" in the request body. Single-endpoint accounts auto-resolve; multi-endpoint accounts get a 400 without it. The Python SDK's client.create_webhook_alert(..., endpoint="...") exposes the same kwarg.
Historical backfill¶
For seeding history (years of contracts at cutover), use a date-windowed cursor crawl. The pattern:
- Pick a window — fiscal-year-sized windows (
fiscal_year=2018, then2019, ...) parallelize well because they're independent and roughly balanced (FPDS volumes per FY are within an order of magnitude). - For each window, cursor-paginate
/api/contracts/?fiscal_year=<YYYY>&ordering=-award_date&limit=100and write batches as you go. - Checkpoint the
cursorvalue after each successful batch so you can resume without re-pulling. - Subawards:
?fiscal_year_gte=<YYYY>&fiscal_year_lte=<YYYY>with page-number pagination.
For reference, FY2026 contracts on production are ~1.8M rows; a window-per-FY crawl at limit=100 is on the order of ~20K requests per fiscal year. Use shape= aggressively to keep payloads small.
If you're cutting over from USAspending, validate by spot-checking 50–100 random key lookups against your last USAspending CSV pull for the same agency/FY and confirm obligated, recipient.uei, and awarding_office.agency_code agree.
Transactions completeness¶
/api/contracts/{key}/transactions/ returns the FPDS modifications Tango has for that award. Each item: modification_number, transaction_date, obligated, description, action_type. Transactions are loaded from FPDS at award materialization — for an award that exists in /api/contracts/, the transaction list reflects what FPDS has published for that award up to the most recent ingest cycle.
If your downstream wants USAspending's per-transaction row shape (one row per mod, denormalized), don't try to recreate it from the bundled award. Hit the transactions detail endpoint per key, or request ?shape=...,transactions(*) on the list call to get the mods inline.
NULL semantics¶
USAspending bulk CSVs and Tango JSON disagree on how absent values look. Plan for both:
- USAspending CSVs emit empty strings (
""), and in some columns sentinel strings like"None"or"NULL". Your ingest probably already has cleanup for this. - Tango JSON uses JSON
nullfor absent values and omits keys from shaped responses when the field isn't requested or isn't present. A shaped response with?shape=key,piid,recipient(uei)will not includerecipientat all if the recipient is unresolved — vs an empty object — so check key presence, not just truthiness, when transforming. - Numeric fields (
obligated,total_contract_value) come back as JSON numbers, not strings. USAspending's CSVs are strings — your existing parser needs to handle both or you'll write strings to a numeric column. - DUNS on contracts is not exposed — see the field-mapping note above. Don't try to map
recipient_dunsthrough/api/contracts/; it'll be missing every time. DUNS lives on subawards (recipient_duns) for legacy joins. awarding_officecodes can benullindependently. An award can have a known department (department_code = "047") but a missing sub-tier or office. Don't assume the three levels are co-populated.
Pitfalls¶
- Don't filter agency by name unless you have to.
awarding_agency=047(CGAC code) is exact for GSA.awarding_agency=GSAdoes best-effort name resolution — great for ad-hoc, can match more or fewer orgs than you expect on a production job. Use CGAC for reproducibility. The FPDS sub-tier code (e.g.4700) is stored on org records but isn't a reliable filter on/api/contracts/— use CGAC. fiscal_yearis federal FY (Oct–Sep). Same as USAspending; just don't accidentally pass a calendar year.obligated_gte/obligated_lteare exact USD. Pass1000000, not"1M".- Multi-value filters use
|.naics=541511|541512matches either. Same forpscand other scalar filters. - Cursor pagination doesn't accept
page=./api/contracts/uses keyset cursors — follownextor passcursor=<token>./api/subawards/is the opposite — page-number pagination withpage=N. They are not interchangeable. - Default response shape is opinionated. It's a sensible middle ground, not the full FPDS record. Pass
?shape=explicitly for production jobs;?show_shapes=truelists the available fields. - PIID is not unique. Use
keyfor upserts and joins. PIID alone repeats across agencies.
What's out of scope today¶
- FABS / assistance data. No grants, loans, direct payments, cooperative agreements. Keep that on USAspending bulk. See the Grants API reference for what Tango does ship on the grants side — it's Grants.gov data, not FABS-derived.
- Prime-contract outlays. Obligations only on
/api/contracts/.prime_award_total_outlayed_amountis exposed on subawards. - Per-mod change feed across all contracts. No global "give me every FPDS mod since timestamp X" endpoint. Polling on
award_date_gtecatches new awards; webhook filter alerts cover filtered slices in near-real-time; per-award transaction history is on/api/contracts/{key}/transactions/.
Related¶
- Contracts API reference
- Contracts data dictionary
- Subawards API reference
- Subawards data dictionary
- Federal Agency Hierarchy
- Stream contract awards — webhook receiver pattern for
alerts.contract.match - Webhooks user guide
Explore federal budget data¶
Beta — surface may change
The /api/budget/... endpoints and the budget-related shape leaves on /api/organizations/ and /api/entities/ are beta. The data is real and refreshed on the normal cadence, but field names, shape leaves, default fields, filter names, and URL paths may still change based on early-consumer feedback. Pin a specific request shape (?shape=...) in production code so a default-shape change can't move under you, and watch the changelog for the move-to-stable announcement. If you build something on this and a change would hurt, open an issue — we'd rather learn about it before locking the shape down.
The /api/budget/... surface exposes federal-account budget data — what Congress requested, what it enacted, what got apportioned to spend, what's been obligated, and what's actually been paid out. This guide walks you through the questions consumers actually ask of this data, with copy-paste requests for each.
If you've worked with USAspending's File A (account-level) or OMB's MAX feeds before, this is the same kind of data — pre-joined to FPDS contract activity, with pre-computed ratios and trends so you don't have to derive them yourself.
What's in here¶
| Resource | What it answers |
|---|---|
GET /api/budget/accounts/ |
One row per (federal account × fiscal year). The full lifecycle (requested → enacted → apportioned → obligated → outlayed), contract vs assistance split, year-over-year + 5-year trends, next-year request signal. |
GET /api/budget/accounts/{pk}/recipients/ |
Who actually got paid out of a given account — funding office × recipient × PIIDs, hydrated with the matching contract records. |
GET /api/budget/accounts/{pk}/quarters/ |
Treasury Account Symbol (TAS)-grain quarterly flow — SF-133 obligations and outlays per quarter. FY21+. |
?shape=*,appendix(*) on account detail |
President's Budget Appendix object-class breakdown — what kinds of spending Congress was asked to authorize (personnel, contractual services, grants, etc.). |
?shape=*,narratives(*) on account detail |
Appendix narrative + appropriations text for the account. |
?shape=budget_appropriation(*),budget_spending(*) on /api/organizations/{key}/ |
Rolls up budget data to an org. Departments get their appropriation; offices get their funding-office spending across the org's subtree. |
GET /api/entities/{uei}/budget-flows/ |
Which federal accounts paid a given vendor — the inverse of the recipients drill-down. |
Coverage windows differ across these surfaces (and across years for individual fields) because the underlying source documents do — Appendix runs FY08–FY27, the lifecycle rollup runs FY17–FY26, and SF-133 quarters begin in FY21. Each surface returns an empty list or null for FYs it can't cover.
Quick start¶
All requests need an X-API-KEY header. Set it once:
export TANGO_API_KEY="your-api-key-here"
import os
import requests
HEADERS = {"X-API-KEY": os.environ["TANGO_API_KEY"]}
BASE = "https://tango.dev/api"
The 20 biggest accounts of the most recent fiscal year, by enacted budget authority:
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/budget/accounts/?limit=20"
resp = requests.get(f"{BASE}/budget/accounts/", params={"limit": 20}, headers=HEADERS)
for a in resp.json()["results"]:
print(a["federal_account_symbol"], a["account_title"], a["enacted_ba"])
The default sort is -fiscal_year, -enacted_ba, so without filters or ordering you land on the largest accounts of the current year. Pass any of the filters in the next section to narrow.
Find accounts¶
By identity¶
federal_account_symbol is the canonical key ({agency_cgac}-{account_number} — e.g. 075-0512 for HHS's NIH research account). It's exact-match by default; __in accepts a comma-separated list.
?federal_account_symbol=075-0512&fiscal_year=2024
?federal_account_symbol__in=075-0512,075-0540&fiscal_year=2024
By agency¶
?agency_code=075 # HHS, all years
?agency_code=075&fiscal_year__gte=2022 # HHS, recent years
?bureau_name__icontains=indian # any bureau with "indian" in the name
agency_code is the 3-character CGAC. bureau_name is free-text from the source — use icontains for fuzzy matches.
By taxonomy¶
?bea_category=Defense
?on_off_budget=ON
?subfunction_code=051
?account_category=budgetary
bea_category is the Bureau of Economic Analysis category (Defense, Nondefense, Mandatory programs, Net interest, ...). subfunction_code is the OMB budget subfunction (e.g. 051 = national defense, 551 = health care services). account_category is budgetary, credit_financing, request_only or proposed; see Summing accounts for why it matters.
Full-text search¶
?search=acquisition+services+fund
?search=cybersecurity
Searches across account_title, agency_name, and bureau_name.
By dollar threshold¶
Every dollar field supports __gte / __lte:
?enacted_ba__gte=1000000000&fiscal_year=2024 # accounts ≥ $1B enacted in FY24
?contract_obligated__gte=500000000 # ≥ $500M in contract obligations
Spot pipeline anomalies¶
Three ratio-based questions surface anomalies in the appropriation pipeline.
Where did Congress give more than the President requested?¶
?enacted_to_requested_pct_capped__gte=1.1
enacted_to_requested_pct is the ratio of enacted BA to the President's request. Values above 1.0 mean Congress added money beyond the ask; values below mean it cut.
The _capped variants cap extreme values (typically at 5.0) to keep them sortable and chart-friendly. There's a corresponding _flag boolean if you want to filter to only the rows where the cap was applied.
Where's apportionment lagging vs enacted?¶
?apportioned_to_enacted_pct_capped__lte=0.5&fiscal_year=2024
A low apportioned-to-enacted ratio mid-year usually means OMB has been slow to release funds — a strong leading indicator for spending pushes later in the year.
Where's the obligation pipeline backed up?¶
?obligated_to_apportioned_pct_capped__lte=0.4&fiscal_year=2024&ba_growth_next_year_pct__gte=0.0
Apportioned money that hasn't been obligated mid-year is contract-pipeline pressure. Combine with ba_growth_next_year_pct__gte=0.0 to find accounts whose next-year request is also trending up — those are the ones the agency is likely re-justifying.
Forward-look: next year's request¶
Every account row carries next_year_requested_ba, ba_growth_next_year, and ba_growth_next_year_pct — derived from the President's most recent Budget submission against the current enacted level.
?ba_growth_next_year_pct__gte=0.5&contract_share_of_obligated_capped__gte=0.5
Accounts whose next-year request is ≥ 50% higher than current enacted, and whose obligations historically run ≥ 50% through contracts. Useful for finding "contract-heavy growth accounts" — the accounts most likely to drive new contract opportunities in the coming year.
Look up trends¶
The BUDGET_ACCOUNT_TRENDS_FIELDS cluster is opt-in — pass it explicitly in ?shape= to get year-over-year and 5-year trend signal:
?shape=federal_account_symbol,account_title,enacted_ba,enacted_ba_yoy_pct,enacted_ba_5yr_cagr,contract_obligated_5yr_cagr&agency_code=097&fiscal_year=2024
| Field | What it means |
|---|---|
enacted_ba_yoy_pct |
Year-over-year change in enacted BA, vs the prior FY. |
obligated_yoy_pct |
Year-over-year change in total obligations. |
contract_obligated_yoy_pct |
Year-over-year change in contract-side obligations. |
enacted_ba_5yr_cagr |
5-year compound annual growth rate of enacted BA. |
contract_obligated_5yr_cagr |
5-year CAGR of contract obligations. |
Pull the account detail¶
GET /api/budget/accounts/{pk}/
Returns a single account row with the default shape. To pull everything, pass ?shape=*:
GET /api/budget/accounts/{pk}/?shape=*
Add the Appendix breakdown¶
GET /api/budget/accounts/{pk}/?shape=*,appendix(*)
The appendix leaf returns the President's Budget Appendix object-class breakdown — the 16 OMB request buckets (personnel comp, travel, contractual services, grants, etc.) plus the appendix granule ID and PDF URL.
appendix is null for accounts with no object-class detail in the Appendix — typically large mandatory programs (e.g. Medicaid) whose breakdown isn't published.
Add the narrative text¶
GET /api/budget/accounts/{pk}/?shape=*,narratives(*)
narratives is a list (some accounts have multiple narrative entries per year, split by subaccount) containing the Appendix narrative and appropriations language. Text fields can be a few KB each — they're a separate leaf from appendix(*) so they only load when asked for.
Drill down to recipients¶
GET /api/budget/accounts/{pk}/recipients/
GET /api/budget/accounts/{pk}/recipients/?funding_organization_id={org_uuid}
One row per (funding office × recipient × account-year) on the contract side. Each row is hydrated with:
funding_office— full Department / Agency / Office hierarchy for the funding org.recipient— UEI, legal name, DBA name. Resolvable via/api/entities/{uei}/.contracts— minimal Contract shape (key,piid,naics,psc,obligated,award_date) for every contract under this (account, funding office, recipient) tuple. Use thekeyto follow back to the full Contract record.
Sorted by contract_obligated desc — the largest recipients first. Standard count / next / previous / results pagination.
Pull quarterly cash flow¶
GET /api/budget/accounts/{pk}/quarters/
GET /api/budget/accounts/{pk}/quarters/?tas=075-2024-2024-0512
One row per (Treasury Account Symbol × quarter × period type) for this account-year. A federal account typically rolls up several TAS (different appropriation-year vintages), so the response is a list.
| Field | Meaning |
|---|---|
tas |
Treasury Account Symbol — full TAS code including appropriation year. |
quarter |
1 through 4. |
period |
quarterly or cumulative — both are returned for each quarter. |
obligated_quarterly, outlayed_quarterly |
Flow during the quarter. |
obligated_cumulative, outlayed_cumulative |
Running total through end of the quarter. |
quarterly_source |
Provenance flag — which SF-133 snapshot the row came from. |
Coverage is FY21+. Earlier years return an empty page; use the account detail's lifecycle fields for FY17–FY20.
Roll budget data up to an organization¶
The Organization detail endpoint exposes budget rollups as two shape leaves. Use them when you want a budget view keyed by agency / office rather than by federal account.
GET /api/organizations/{key}/?shape=name,budget_appropriation(*),budget_spending(*)
budget_appropriation(*)¶
The dept-level appropriation rollup — sums every federal account whose agency_code matches the org's CGAC.
The payload carries a scope field so consumers can tell whose number they're looking at:
scope |
When | What you're seeing |
|---|---|---|
"self" |
Org is L1 (a department) | The dept's own appropriation totals. |
"department" |
Org is L2+ (an agency or office inside a department) | The parent dept's totals (sub-orgs inherit the dept's CGAC). Useful as context on a sub-org page. |
When summing across siblings, filter on scope == "self" to avoid double-counting — the same dept payload appears on every sub-org under it.
The rollup leaves out credit financing (account_category=credit_financing) and proposed (account_category=proposed) accounts entirely. Request-only accounts (account_category=request_only) add their requested_ba to the department's request and are left out of the account counts, top_accounts and every other figure.
Returns null only for orgs with no CGAC at all (rare; the org has no usable budget signal).
?shape=name,budget_appropriation(*) # default FY = latest
?shape=name,budget_appropriation[fiscal_year=2024](*) # specific FY
?shape=name,budget_appropriation(cgac,fiscal_year,summary) # narrow projection
The payload includes a top_accounts array (top 10 by enacted BA) for quick orientation.
budget_spending(*)¶
The funding-office-side rollup — aggregates contract spending where the org (or any descendant in the FH subtree) was the funding office. Walks the hierarchy via the denormalized l{level}_fh_key fields, so an L2 like "Technology Transformation Services" can roll up its L3/L4 grandchildren without you having to chase them yourself.
Returns null when nothing in the subtree has funding-office activity in the budget data.
?shape=name,budget_spending(*)
?shape=name,budget_spending[fiscal_year=2024](*)
summary.contract_obligated is the headline number; n_orgs_in_rollup tells you how many orgs participated (1 = the org itself; >1 = the org plus descendants).
Asking for both¶
Departments can request both leaves in one shot — budget_appropriation returns the dept totals (scope: "self"), budget_spending returns the dept's direct funding-office activity (most dept rollups are nominal; the real spending sits in sub-offices).
?shape=name,budget_appropriation(*),budget_spending(*)
Look up budget flows for a vendor¶
The inverse of the recipients drill-down — given a UEI, list the federal accounts that paid this vendor:
GET /api/entities/{uei}/budget-flows/
GET /api/entities/{uei}/budget-flows/?fiscal_year=2024
Each row is one (account × funding office × this UEI) flow, with surrounding account context (account title, agency, bureau, BEA category, total enacted BA, contract share of obligated) and a hydrated contracts list for the PIIDs in this flow. Sorted by contract_obligated desc — the largest accounts funding this vendor first.
This is the right surface for "show me everywhere money flowed to this vendor, with budget context." Note it's contract-side only — grants and other assistance aren't in this index.
A few things worth knowing¶
Capped vs raw ratios¶
Every ratio field comes in three forms — *_pct, *_pct_capped, and *_pct_capped_flag. The capped variant is bounded at a sensible upper limit (typically 5.0) so it sorts and charts cleanly without one extreme row blowing out an axis. The flag tells you whether the cap was actually applied for that row.
Use raw (obligated_to_apportioned_pct) when you need exact math; use capped (obligated_to_apportioned_pct_capped) when filtering, sorting, or visualizing.
Partial years¶
The latest fiscal year is year-to-date. data_through_period (in the default shape, next to fiscal_year) is the fiscal period an account-year's figures run through: 12 for a closed year, lower for the year in progress, null when the row has no File A data. Check it before comparing the latest year with closed years — besides the missing months, mid-year figures for indefinite and trust-fund appropriations can run high until year-end, so a partial year can look larger than the prior full year when it isn't. To compare only complete years:
?agency_code=075&fiscal_year__gte=2022&data_through_period=12&shape=fiscal_year,data_through_period,federal_account_symbol,enacted_ba
The X-Tango-Data-As-Of response header (e.g. 2026-P10) gives the latest period across the whole publication. See Partial fiscal years.
Summing accounts¶
Which account_category values to include depends on what you are adding up.
- Apportionment, obligation and enacted sums: filter
account_category=budgetary. Credit financing accounts (credit_financing) are non-budgetary: they carry apportionments but no obligations and noenacted_ba, and their apportionments are not budget authority, so leaving them in inflates an apportionment total.request_onlyandproposedrows have null execution figures and no obligations, so an unfiltered sum picks them up only as empty rows. - A current-law
requested_batotal: filteraccount_category__in=budgetary,request_only. Request-only accounts (such as NIH075-9915) are current law and carry the request, so=budgetaryalone drops them. Leave outcredit_financingandproposed(requests for programs Congress never created).
The organization budget_appropriation(*) rollup applies these rules for you.
?fiscal_year=2025&agency_code=075&account_category=budgetary&shape=federal_account_symbol,enacted_ba,apportioned,obligated_total
?fiscal_year=2027&agency_code=075&account_category__in=budgetary,request_only&shape=federal_account_symbol,requested_ba
Within a fiscal year, the default ordering puts accounts with no enacted_ba (credit financing, request-only and proposed) last, so they land at the end of an unfiltered list. An explicit ?ordering= also sorts nulls last in both directions.
Source-data anomalies¶
Each account carries source_anomalies, [] when the row is clean. When the USAspending File C contract total for an account exceeds its positive File A total obligations, contract_obligated is capped at obligated_total and the element records the reported value and the File C rows behind the excess. Two other codes are flagged without changing any value. Check this field before treating an extreme contract_share_of_obligated as real. Element-by-element definitions are in the data dictionary. These fields may be empty until the next data refresh.
Top-recipient arrays vs the drill-down¶
top_contract_recipients and top_grant_recipients on the account detail are lightweight name-only arrays — handy for a quick label on a card. For real recipient analysis use the /recipients/ drill-down (or /budget-flows/ from the vendor side) — it carries UEI, hydrated entity payload, PIIDs, and the full matching Contract records.
Coverage windows¶
| Surface | Years covered |
|---|---|
/api/budget/accounts/ lifecycle |
FY17–FY26 |
appendix(*) |
FY08–FY27 |
narratives(*) |
FY08–FY27 |
/quarters/ |
FY21+ |
/recipients/, /budget-flows/ |
FY17–FY26 (matches lifecycle) |
Org rollups (budget_appropriation, budget_spending) |
Whatever coverage the underlying lifecycle rollup has for the requested FY |
When you ask for a year outside the covered window, lists return empty and shape leaves return null — no error, just absence.
Cache TTL on Organization rollups¶
/api/organizations/{key}/ is cached at the response level (1-week TTL by default). If you've just been added to a hierarchy and a budget rollup looks stale, that's expected — the cached version will turn over within the TTL window, or you can bust it by varying any query param. The budget data itself updates whenever the upstream SF-133 / Appendix / contract-share signals refresh.
What's intentionally not in this guide¶
- Account-level forecasts / projections. Tango ships the President's next-year request as a single field (
next_year_requested_ba) plus growth math, but doesn't model multi-year scenarios. Build those on top of the historic lifecycle + 5yr CAGR. - Apportionment line-item detail. OMB publishes line-item apportionments separately; Tango currently exposes only the rollup-level apportioned dollar amount per account, not the per-line restrictions.
- Treasury outlay reconciliation. The outlay numbers are the SF-133 outlay totals as published. Reconciling against Treasury's daily statement is out of scope.
See also¶
- Organizations API reference — full field list and filters for
/api/organizations/. - Entities API reference — the UEI lookup surface.
- Contracts data dictionary — for the
contracts[]rows returned by the recipients and budget-flows drill-downs. - Response shaping — the
?shape=syntax used throughout this guide.
API
API Reference¶
These pages are a curated reference for filtering, ordering, and pagination on Tango's highest-traffic endpoints, with copy/pasteable examples (including SDK usage).
Swagger is still the canonical surface-area reference, but these pages aim to be faster to use day-to-day.
- Swagger (OpenAPI): https://tango.dev/api/
- For field definitions, see the Data Dictionary.
Awards¶
Definitive contracts, indefinite-delivery vehicles, other transactions, subawards, and solicitation-level groupings.
- Contracts — Definitive contract awards. Response shaping →
- IDVs — Indefinite delivery vehicles (GWACs, IDIQs, etc.). Response shaping →
- OTAs — Other Transaction Agreements from FPDS. Response shaping →
- OTIDVs — Other Transaction IDVs from FPDS. Response shaping →
- Subawards — Sub-contracts and sub-grants under prime awards. Response shaping →
- Vehicles — Solicitation-centric grouping of IDVs. Response shaping →
- GSA eLibrary contracts — Persisted GSA eLibrary contract metadata (direct endpoint).
Entities¶
- Entities — Vendors and recipients in federal contracting and assistance; UEI is the canonical identifier (from SAM.gov). Response shaping →
- Exclusions — SAM.gov exclusions (debarments, suspensions);
?active=truefor records in force today. Data dictionary →
DIBBS¶
- DIBBS — DLA Internet Bid Board System: requests for quote, solicitations (RFP/IFB), and awards from the Defense Logistics Agency. Data dictionary →
SBIR/STTR¶
- SBIR/STTR — SBIR/STTR topics across every participating agency plus the DoD DSIP solicitation cycles behind them. Data dictionary →
State & Local — Beta¶
- State & Local — State, local and education (SLED) solicitations and planned procurements from individual government portals, with a per-state coverage endpoint. Does not join to the federal data: no UEI, no PIID, no NAICS/PSC crosswalk. Data dictionary →
eBuy¶
- eBuy — GSA eBuy requests (RFQs, RFPs, RFIs), scoped to the requests posted under your own Schedule contracts, with attachment text search and downloads. Available on paid plans by arrangement: requires a contract link from MakeGov. Response shaping → · Data dictionary →
Forecasts¶
- Forecasts — Upcoming procurement opportunities from agency feeds (e.g., HHS, DHS) before they appear as SAM.gov solicitations. Response shaping →
Grants¶
- Grants — Grant opportunities from Grants.gov (funding opportunities, not assistance transactions). Response shaping →
Budget — Beta¶
- Budget — Federal-account budget data (requested → enacted → apportioned → obligated → outlayed) joined to FPDS contract activity. Includes
/recipients/and/quarters/drill-downs and the entity/budget-flows/action. Data dictionary → · Exploration guide →
IT Dashboard¶
- IT Dashboard — Federal IT investment data from itdashboard.gov, keyed by Unique Investment Identifier (UII). Some filters are tier-gated; all shape expansions are available on every plan (only the
business_case_htmlleaf is gated). Response shaping →
Protests¶
- Protests — Bid protest decisions (GAO and SBA OHA) from 2015 onward, with subordinate dockets available via
?shape=...,dockets(...). Requires authentication. Response shaping → · Data dictionary →
Contract appeals¶
- Contract appeals — Contract Disputes Act decisions of the Civilian Board of Contract Appeals (CBCA, 2007 onward) and the Armed Services Board of Contract Appeals (ASBCA, 2010 onward). Disputes under a contract already awarded, not bid protests. Free on every plan; only the
decision_textfield is gated. Response shaping → · Data dictionary →
Federal Register¶
- Federal Register — Every rule, proposed rule, notice and presidential document published in the Federal Register since 1994, filterable by CFR citation, agency, RIN, type, dates and open comment period. Free on every plan, including the full text. Response shaping → · Data dictionary →
Opportunities¶
SAM.gov contract opportunity and notice data.
- Opportunities — Contract opportunities aggregated by parent. Response shaping →
- Notices — Individual SAM.gov notice records (amendments, updates) under an opportunity. Response shaping →
Metrics¶
- Metrics — Time-series obligation and award-count metrics for entities, NAICS codes, and PSC codes (same URL pattern and query params for all).
Reference data¶
Lookup tables and codes used across awards and entities.
- Assistance listings (CFDA) — CFDA program numbers and titles for federal grants/assistance. Response shaping →
- Business types — SAM.gov business type codes (e.g., small business, 8(a), HUBZone). Response shaping →
- MAS SINs — MAS Special Item Numbers (SINs) reference data. Response shaping →
- NAICS — North American Industry Classification System codes. Response shaping →
- Organizations — Unified federal hierarchy (departments, agencies, offices in one tree with search and obligations). Response shaping →
- PSC — Product/Service Codes for federal contracting. Response shaping →
Shared¶
Objects and legacy endpoints shared across multiple resources. These data structures appear in response shaping expands on many endpoints (e.g., awarding_office(...), department(...), set_aside(...)).
- Shared response objects — Common expanded objects (set-aside, office) returned across endpoints.
- Agencies (deprecated) — Federal agencies (legacy endpoint; use Organizations instead). Response shaping →
- Departments (deprecated) — Top-level federal departments (legacy endpoint; use Organizations instead). Response shaping →
- Offices (deprecated) — Sub-agency offices (legacy endpoint; use Organizations instead). Response shaping →
- Set-aside codes — Set-aside type codes and descriptions.
Awards
Contracts¶
Contracts are awards (FPDS “definitive contracts”) exposed at /api/contracts/. For field definitions, see the Contracts Data Dictionary.
Endpoints¶
GET /api/contracts/(list + filtering + search + ordering)GET /api/contracts/{key}/(detail)GET /api/contracts/{key}/transactions/(detail transactions)GET /api/contracts/{key}/subawards/(subawards scoped to a contract)
Related “scoped” list endpoints that behave like /api/contracts/:
GET /api/entities/{uei}/contracts/GET /api/idvs/{key}/awards/(child awards under an IDV)GET /api/vehicles/{uuid}/orders/(task orders under a vehicle)
Filtering¶
Swagger is the canonical list of query parameters. These are the core filters most people use day-to-day.
Text / parties¶
| Param | What it does |
|---|---|
search |
Full-text-ish search across contract search vectors (good for keywords). |
recipient |
Search by recipient/vendor name. |
uei |
Filter by recipient UEI (exact). |
piid |
Filter by PIID (case-insensitive). |
solicitation_identifier |
Filter by solicitation identifier. Matches every spelling of the number (N6600120R0002 or N66001-20-R-0002, any case). Multi-value: use | for OR. |
Agencies¶
| Param | What it does |
|---|---|
awarding_agency |
Filter by awarding agency (code/name/abbrev; best-effort matching). Implemented via organization UUID for performance; response awarding_office shape unchanged. |
funding_agency |
Filter by funding agency (code/name/abbrev; best-effort matching). Implemented via organization UUID for performance; response funding_office shape unchanged. |
Codes / set-asides¶
| Param | What it does |
|---|---|
naics |
Filter by NAICS. |
psc |
Filter by PSC. |
set_aside |
Filter by set-aside. |
Dates / fiscal years¶
All date filters require YYYY-MM-DD format. Invalid dates or inverted ranges return 400. See Date filters for details.
| Param | What it does |
|---|---|
award_date |
Award date (exact). |
award_date_gte, award_date_lte |
Award date range. |
fiscal_year |
Fiscal year (exact, YYYY). |
fiscal_year_gte, fiscal_year_lte |
Fiscal year range (YYYY). |
Period of performance¶
| Param | What it does |
|---|---|
pop_start_date_gte, pop_start_date_lte |
Period of performance start date range. |
pop_end_date_gte, pop_end_date_lte |
Period of performance current end date range. |
expiring_gte, expiring_lte |
Ultimate completion date range (useful for “expiring soon”). |
Dollars / types¶
| Param | What it does |
|---|---|
obligated_gte, obligated_lte |
Obligated amount range (USD). |
award_type |
Award type code. |
ordering |
Sort results (allowlist: award_date, obligated, total_contract_value; prefix with - for descending). |
Multi-value filter syntax¶
Use | (or the literal word OR) to match any of several values:
naics=541511|541512
The same syntax works on every filter that accepts multiple values.
Ordering¶
Contracts support ordering= with a strict allowlist:
award_dateobligatedtotal_contract_value
Examples:
- Newest first:
GET /api/contracts/?ordering=-award_date - Largest obligations first:
GET /api/contracts/?ordering=-obligated
Vehicle linkage¶
Contracts that are part of an IDIQ / GWAC vehicle can opt-in to a curated subset of vehicle fields via ?shape=...,vehicle(*) (allowed leaves include vehicle_type, plus a curated subset of solicitation / vehicle metadata). The full vehicle detail (awardees, opportunity, totals) stays on /api/vehicles/{uuid}/. The expansion is not in the default response shape; opt in when needed.
Pagination¶
High-volume award endpoints use cursor-based (keyset) pagination.
- Use
limitto control page size. - Follow the
next/previousURLs in responses. - If you’re constructing requests manually, you’ll typically pass a
cursorparameter from thenextURL.
SDK examples¶
See also: Full SDK method reference — tango-python methods · tango-node methods.
Search + ordering + shaping¶
import os
from tango import ShapeConfig, TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
resp = client.list_contracts(
keyword="cloud services", # SDK maps to API param: search
awarding_agency="4700", # GSA
fiscal_year=2024,
sort="award_date", # SDK maps (sort+order) -> API ordering
order="desc",
limit=10,
shape=ShapeConfig.CONTRACTS_MINIMAL,
)
for c in resp.results:
print(c.piid, c.award_date, c.recipient.display_name)
import { ShapeConfig, TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const resp = await client.listContracts({
keyword: "cloud services", // SDK maps to API param: search
awarding_agency: "4700", // GSA
fiscal_year: 2024,
sort: "award_date", // SDK maps (sort+order) -> API ordering
order: "desc",
limit: 10,
shape: ShapeConfig.CONTRACTS_MINIMAL,
});
for (const c of resp.results) {
console.log(c.piid, c.award_date, c.recipient.display_name);
}
Cursor pagination (using the next URL)¶
import os
from urllib.parse import parse_qs, urlparse
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
resp = client.list_contracts(limit=25, sort="award_date", order="desc")
print("next:", resp.next)
if resp.next:
qs = parse_qs(urlparse(resp.next).query)
cursor = (qs.get("cursor") or [None])[0]
if cursor:
resp2 = client.list_contracts(limit=25, cursor=cursor, sort="award_date", order="desc")
print("page 2 results:", len(resp2.results))
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const resp = await client.listContracts({ limit: 25, sort: "award_date", order: "desc" });
console.log("next:", resp.next);
if (resp.next) {
const url = new URL(resp.next);
const cursor = url.searchParams.get("cursor");
if (cursor) {
const resp2 = await client.listContracts({ limit: 25, cursor, sort: "award_date", order: "desc" });
console.log("page 2 results:", resp2.results.length);
}
}
IDVs¶
IDVs (Indefinite Delivery Vehicles) are awards exposed at /api/idvs/.
If you’re trying to work at the “solicitation that produced many IDV awards” level, also see Vehicles. For field definitions, see the IDVs Data Dictionary.
IDV responses include a vehicle UUID when the IDV is grouped under a Tango Vehicle, which you can use to fetch vehicle details at GET /api/vehicles/{uuid}/.
Endpoints¶
GET /api/idvs/(list + filtering + search + ordering)GET /api/idvs/{key}/(detail)GET /api/idvs/{key}/transactions/(detail transactions)GET /api/idvs/{key}/awards/(child awards / task orders under an IDV; behaves like/api/contracts/)GET /api/idvs/{key}/idvs/(child IDVs under an IDV; behaves like/api/idvs/)
Removed (legacy solicitation grouping — these routes no longer exist):
GET /api/idvs/{identifier}/summary/— returns 404GET /api/idvs/{identifier}/summary/awards/— returns 404
Filtering¶
Swagger is the canonical list of query parameters. These are the core filters most people use day-to-day.
Text / parties¶
| Param | What it does |
|---|---|
search |
Full-text-ish search across IDV search vectors (good for keywords). |
recipient |
Search by recipient/vendor name. |
uei |
Filter by recipient UEI (exact). |
piid |
Filter by PIID (case-insensitive). |
solicitation_identifier |
Filter by solicitation identifier. Matches every spelling of the number (N6600120R0002 or N66001-20-R-0002, any case). Multi-value: use | for OR. |
obligated_gte, obligated_lte |
Obligated amount range (USD). |
ordering |
Sort results (allowlist: award_date, obligated, total_contract_value; prefix with - for descending). |
Agencies¶
| Param | What it does |
|---|---|
awarding_agency |
Filter by awarding agency (code/name/abbrev; best-effort matching). |
funding_agency |
Filter by funding agency (code/name/abbrev; best-effort matching). |
Codes / set-asides / types¶
| Param | What it does |
|---|---|
naics |
Filter by NAICS. |
psc |
Filter by PSC. |
set_aside |
Filter by set-aside. |
idv_type |
Filter by IDV type code. |
Dates / fiscal years¶
All date filters require YYYY-MM-DD format. Invalid dates or inverted ranges return 400. See Date filters for details.
| Param | What it does |
|---|---|
award_date |
Award date (exact). |
award_date_gte, award_date_lte |
Award date range. |
fiscal_year |
Fiscal year (exact, YYYY). |
fiscal_year_gte, fiscal_year_lte |
Fiscal year range (YYYY). |
Period of performance / ordering window¶
IDVs differ from contracts:
- They do not have contract-style period-of-performance end dates.
- The ordering window is typically expressed via last-date-to-order fields.
| Param | What it does |
|---|---|
pop_start_date_gte, pop_start_date_lte |
Period of performance start date range. |
last_date_to_order_gte, last_date_to_order_lte |
Last date to order range (primary IDV “expiring” concept). |
expiring_gte, expiring_lte |
Alias for last-date-to-order range filters. |
Multi-value filter syntax¶
Use | (or the literal word OR) to match any of several values:
naics=541511|541512
The same syntax works on every filter that accepts multiple values.
Ordering¶
IDVs support ordering= with a strict allowlist:
award_dateobligatedtotal_contract_value
Examples:
- Newest first:
GET /api/idvs/?ordering=-award_date - Largest obligations first:
GET /api/idvs/?ordering=-obligated
Pagination¶
High-volume award endpoints use cursor-based (keyset) pagination.
- Use
limitto control page size. - Follow the
next/previousURLs in responses. - If you’re constructing requests manually, you’ll typically pass a
cursorparameter from thenextURL.
Response shaping¶
IDV responses use the shaping pipeline by default — even without an explicit ?shape= parameter, responses go through the shaping system with a default shape that includes all standard fields plus expanded choice fields (idv_type(*), type_of_idc(*), multiple_or_single_award_idv(*)), relationships, and period of performance.
Use ?shape= to customize the response. See Response Shaping for the full field reference.
# Expand idv_type to structured code/description
/api/idvs/?shape=key,piid,idv_type(code,description)
# Period of performance (uses last_date_to_order, not end_date)
/api/idvs/?shape=key,piid,period_of_performance(start_date,last_date_to_order)
# Resolved NAICS/PSC lookups
/api/idvs/?shape=key,piid,naics(code,description),psc(code,description)
GSA eLibrary enrichment¶
IDV list and detail responses include best-effort enrichment from GSA eLibrary under gsa_elibrary (or null when no matching eLibrary row exists).
You can use shaping to request only the subfields you care about:
/api/idvs/?piid=<PIID>&shape=key,piid,gsa_elibrary(schedule,contract_number,sins,file_urls)
You can also query persisted eLibrary rows directly via GET /api/gsa_elibrary_contracts/.
SDK examples¶
See also: Full SDK method reference — tango-python methods · tango-node methods.
The official SDKs don’t yet expose a first-class list_idvs() / listIdvs() method.
Until they do, you can still call the endpoint via the SDK’s internal HTTP helper.
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
# Workaround until the SDK ships a first-class list_idvs()
data = client._get(
"/api/idvs/",
params={
"search": "SEWP",
"ordering": "-award_date",
"limit": 10,
},
)
print("count:", data.get("count"))
print("first key:", (data.get("results") or [{}])[0].get("key"))
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
// Workaround until the SDK ships a first-class listIdvs()
const http = (client as any).http;
const data = await http.get("/api/idvs/", {
search: "SEWP",
ordering: "-award_date",
limit: 10,
});
console.log("count:", data.count);
console.log("first key:", data?.results?.[0]?.key);
OTAs¶
OTAs (Other Transaction Awards) are awards exposed at /api/otas/. For field definitions, see the OTAs Data Dictionary.
Endpoints¶
GET /api/otas/(list + filtering + search + ordering)GET /api/otas/{key}/(detail)
Entity-scoped list:
GET /api/entities/{uei}/otas/(behaves like/api/otas/scoped to an entity)
Filtering¶
OTAs share most of the "award" filtering surface with contracts/IDVs, except they do not support some contracts-specific filters (like NAICS).
Common filters you can use:
| Param | What it does |
|---|---|
search |
Full-text-ish search over award search vectors. |
recipient |
Search by recipient/vendor name. |
uei |
Filter by recipient UEI (exact). |
piid |
Filter by PIID (case-insensitive). |
awarding_agency, funding_agency |
Filter by awarding/funding agency (best-effort matching). |
psc |
Filter by PSC. |
award_date, award_date_gte, award_date_lte |
Award date filters. |
fiscal_year, fiscal_year_gte, fiscal_year_lte |
Fiscal year filters. |
pop_start_date_gte, pop_start_date_lte |
Period of performance start date range (when present). |
pop_end_date_gte, pop_end_date_lte |
Period of performance end date range (when present). |
expiring_gte, expiring_lte |
Expiration window range filters. |
obligated_gte, obligated_lte |
Obligated amount range (USD). |
ordering |
Sort results (allowlist: award_date, obligated, total_contract_value; prefix with - for descending). |
Ordering¶
OTAs support ordering= with a strict allowlist:
award_dateobligatedtotal_contract_value
Examples:
- Newest first:
GET /api/otas/?ordering=-award_date - Largest obligations first:
GET /api/otas/?ordering=-obligated
Pagination¶
High-volume award endpoints use cursor-based (keyset) pagination:
limitcursor(follownext/previousURLs)
Response Shaping¶
OTA endpoints support the ?shape= parameter for customizing response fields. See Response Shaping for full syntax.
Shapeable fields¶
| Field | Type | Notes |
|---|---|---|
key |
scalar | Award key |
piid |
scalar | |
award_date |
scalar | |
award_type |
scalar or expand | Expandable to {code, description} (OTA-specific: "O" = Other Transaction Non-Research, "R" = Other Transaction for Research) |
fiscal_year |
scalar | |
obligated |
scalar | |
total_contract_value |
scalar | |
description |
scalar | |
base_and_exercised_options_value |
scalar | |
psc_code |
scalar | |
consortia |
scalar | |
consortia_uei |
scalar | |
dod_acquisition_program |
scalar | |
non_governmental_dollars |
scalar | |
non_traditional_government_contractor_participation |
scalar | |
parent_award_modification_number |
scalar | |
type_of_ot_agreement |
scalar or expand | Expandable to {code, description} |
extent_competed |
scalar or expand | Expandable to {code, description} |
transactions |
scalar | Raw snapshot list |
Expandable fields¶
| Expand | Fields |
|---|---|
recipient(*) |
uei, display_name, legal_business_name, cage, duns |
place_of_performance(*) |
country_code, country_name, state_code, state_name, city_name, zip_code |
awarding_office(*) |
office_code, office_name, agency_code, agency_name, department_code, department_name |
funding_office(*) |
same as awarding_office |
parent_award(key,piid) |
key, piid |
period_of_performance(*) |
start_date, current_end_date, ultimate_completion_date |
transactions(*) |
modification_number, transaction_date, obligated, description, action_type |
psc(*) |
code, description |
award_type(*) |
code, description (OTA-specific choices) |
type_of_ot_agreement(*) |
code, description |
extent_competed(*) |
code, description |
Examples¶
Request only key fields:
GET /api/otas/?shape=key,piid,award_date,obligated
Expand recipient and extent competed with description:
GET /api/otas/?shape=key,piid,recipient(uei,display_name),extent_competed(code,description)
Expand type of OT agreement:
GET /api/otas/?shape=key,type_of_ot_agreement(code,description)
SDK examples¶
See also: Full SDK method reference — tango-python methods · tango-node methods.
The official SDKs don't yet expose a first-class list_otas() / listOTAs() method.
You can still call the endpoint via the SDK's internal HTTP helper.
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
data = client._get(
"/api/otas/",
params={"search": "DARPA", "ordering": "-award_date", "limit": 10},
)
print("count:", data.get("count"))
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const http = (client as any).http;
const data = await http.get("/api/otas/", {
search: "DARPA",
ordering: "-award_date",
limit: 10,
});
console.log("count:", data.count);
OTIDVs¶
OTIDVs (Other Transaction IDVs) are awards exposed at /api/otidvs/. For field definitions, see the OTIDVs Data Dictionary.
Endpoints¶
GET /api/otidvs/(list + filtering + search + ordering)GET /api/otidvs/{key}/(detail)GET /api/otidvs/{key}/awards/(child OTAs under an OTIDV; behaves like/api/otas/scoped to the OTIDV)
Entity-scoped list:
GET /api/entities/{uei}/otidvs/(behaves like/api/otidvs/scoped to an entity)
Filtering¶
OTIDVs share most of the "award" filtering surface with contracts/IDVs, except they do not support some contracts-specific filters (like NAICS).
Common filters you can use:
| Param | What it does |
|---|---|
search |
Full-text-ish search over award search vectors. |
recipient |
Search by recipient/vendor name. |
uei |
Filter by recipient UEI (exact). |
piid |
Filter by PIID (case-insensitive). |
awarding_agency, funding_agency |
Filter by awarding/funding agency (best-effort matching). |
psc |
Filter by PSC. |
award_date, award_date_gte, award_date_lte |
Award date filters. |
fiscal_year, fiscal_year_gte, fiscal_year_lte |
Fiscal year filters. |
pop_start_date_gte, pop_start_date_lte |
Period of performance start date range (when present). |
pop_end_date_gte, pop_end_date_lte |
Period of performance end date range (when present). |
expiring_gte, expiring_lte |
Expiration window range filters. |
obligated_gte, obligated_lte |
Obligated amount range (USD). |
ordering |
Sort results (allowlist: award_date, obligated, total_contract_value; prefix with - for descending). |
Ordering¶
OTIDVs support ordering= with a strict allowlist:
award_dateobligatedtotal_contract_value
Examples:
- Newest first:
GET /api/otidvs/?ordering=-award_date - Largest obligations first:
GET /api/otidvs/?ordering=-obligated
Pagination¶
High-volume award endpoints use cursor-based (keyset) pagination:
limitcursor(follownext/previousURLs)
Response Shaping¶
OTIDV endpoints support the ?shape= parameter for customizing response fields. See Response Shaping for full syntax.
Shapeable fields¶
| Field | Type | Notes |
|---|---|---|
key |
scalar | Award key |
piid |
scalar | |
award_date |
scalar | |
idv_type |
scalar | Raw code |
fiscal_year |
scalar | |
obligated |
scalar | |
total_contract_value |
scalar | |
description |
scalar | |
base_and_exercised_options_value |
scalar | |
psc_code |
scalar | |
consortia |
scalar | |
consortia_uei |
scalar | |
dod_acquisition_program |
scalar | |
non_governmental_dollars |
scalar | |
non_traditional_government_contractor_participation |
scalar | |
type_of_ot_agreement |
scalar or expand | Expandable to {code, description} |
extent_competed |
scalar or expand | Expandable to {code, description} |
transactions |
scalar | Raw snapshot list |
Expandable fields¶
| Expand | Fields |
|---|---|
recipient(*) |
uei, display_name, legal_business_name, cage, duns |
place_of_performance(*) |
country_code, country_name, state_code, state_name, city_name, zip_code |
awarding_office(*) |
office_code, office_name, agency_code, agency_name, department_code, department_name |
funding_office(*) |
same as awarding_office |
period_of_performance(*) |
start_date, current_end_date, ultimate_completion_date |
transactions(*) |
modification_number, transaction_date, obligated, description, action_type |
psc(*) |
code, description |
type_of_ot_agreement(*) |
code, description |
extent_competed(*) |
code, description |
Examples¶
Request only key fields:
GET /api/otidvs/?shape=key,piid,award_date,obligated
Expand recipient and extent competed with description:
GET /api/otidvs/?shape=key,piid,recipient(uei,display_name),extent_competed(code,description)
Expand type of OT agreement:
GET /api/otidvs/?shape=key,type_of_ot_agreement(code,description)
SDK examples¶
See also: Full SDK method reference — tango-python methods · tango-node methods.
The official SDKs don't yet expose a first-class list_otidvs() / listOTIDVs() method.
You can still call the endpoint via the SDK's internal HTTP helper.
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
data = client._get(
"/api/otidvs/",
params={"search": "DARPA", "ordering": "-award_date", "limit": 10},
)
print("count:", data.get("count"))
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const http = (client as any).http;
const data = await http.get("/api/otidvs/", {
search: "DARPA",
ordering: "-award_date",
limit: 10,
});
console.log("count:", data.count);
Subawards¶
Subawards (FSRS/USAspending subawards) are exposed at /api/subawards/. For field definitions, see the Subawards Data Dictionary.
Endpoints¶
GET /api/subawards/(list + filtering + ordering)GET /api/subawards/{key}/(detail)
Scoped lists:
GET /api/contracts/{key}/subawards/(subawards under a contract)GET /api/entities/{uei}/subawards/(subawards for an entity)
Default response¶
All subaward responses use the shaping pipeline by default. No ?shape= parameter is required to get a shaped response.
Default fields (equivalent to requesting):
award_key,awarding_office(*),fsrs_details(*),funding_office(*),key,piid,
place_of_performance(*),prime_recipient(uei,display_name),
subaward_details(*),subaward_recipient(uei,display_name)
Shaping¶
Use ?shape= to request a custom field set. Subawards support the following leaves and expansions.
Leaves¶
Identity and prime award scalar fields:
| Field | Description |
|---|---|
key |
Subaward primary key |
award_key |
Prime award identifier |
piid |
Prime award PIID (mapped from prime_award_piid) |
prime_award_amount |
Total prime award amount |
prime_award_total_outlayed_amount |
Prime award total outlayed amount |
prime_award_base_action_date |
Prime award base action date |
prime_award_base_action_date_fiscal_year |
Fiscal year of base action date |
prime_award_latest_action_date |
Prime award latest action date |
prime_award_latest_action_date_fiscal_year |
Fiscal year of latest action date |
prime_award_base_transaction_description |
Prime award description |
prime_award_project_title |
Prime award project title |
prime_award_naics_code |
NAICS code |
prime_award_naics_description |
NAICS description |
prime_awardee_uei |
Prime awardee UEI |
prime_awardee_name |
Prime awardee name |
prime_awardee_parent_uei |
Prime awardee parent UEI |
prime_awardee_parent_name |
Prime awardee parent name |
subawardee_uei |
Subawardee UEI |
subawardee_duns |
Subawardee DUNS |
subawardee_name |
Subawardee name |
subawardee_dba_name |
Subawardee DBA name |
subawardee_parent_uei |
Subawardee parent UEI |
subawardee_parent_duns |
Subawardee parent DUNS |
subawardee_parent_name |
Subawardee parent name |
subawardee_business_types |
Subawardee business types |
usaspending_permalink |
USASpending.gov link |
Expansions¶
| Expansion | Fields |
|---|---|
subaward_details(*) |
description, type, number, amount, action_date, fiscal_year |
fsrs_details(*) |
last_modified_date, id, year, month |
place_of_performance(*) |
city, state, zip, country_code |
highly_compensated_officers(*) |
name, amount — list of up to 5 officers |
prime_recipient(uei,display_name) |
Prime awardee identity, resolved against the Entities resource when available (falling back to the subaward's own recipient fields when no match exists) |
subaward_recipient(uei,display_name,duns) |
Subawardee identity, resolved against the Entities resource when available (falling back to the subaward's own recipient fields when no match exists) |
awarding_office(*) |
organization_id, office_code, office_name, agency_code, agency_name, department_code, department_name |
funding_office(*) |
Same fields as awarding_office |
Examples¶
# Default response (no shape needed)
/api/subawards/
# Minimal fields
/api/subawards/?shape=key,piid,award_key
# Subaward details with recipient info
/api/subawards/?shape=key,piid,subaward_details(*),subaward_recipient(uei,display_name)
# Office hierarchy for awarding office
/api/subawards/?shape=key,awarding_office(office_code,office_name,agency_name)
# All offices
/api/subawards/?shape=key,awarding_office(*),funding_office(*)
# Place of performance
/api/subawards/?shape=key,place_of_performance(city,state,country_code)
# FSRS report details
/api/subawards/?shape=key,fsrs_details(id,year,month,last_modified_date)
# Highly compensated officers
/api/subawards/?shape=key,highly_compensated_officers(name,amount)
# Discover available fields
/api/subawards/?show_shapes=true
Filtering¶
Core filters:
| Param | What it does |
|---|---|
award_key |
Filter by prime award key (exact). |
prime_uei |
Filter by prime awardee UEI (exact). |
sub_uei |
Filter by subawardee UEI (exact). |
recipient |
Search by recipient name (best-effort mapping to UEIs). |
awarding_agency |
Filter by prime awarding agency code (exact). |
funding_agency |
Filter by prime funding agency code (exact). |
fiscal_year |
Filter by subaward action-date fiscal year (exact). |
fiscal_year_gte, fiscal_year_lte |
Fiscal year range. |
Ordering¶
Subawards support ordering= with a strict allowlist:
last_modified_date
Examples:
- Most recently modified first:
GET /api/subawards/?ordering=-last_modified_date
Pagination¶
Subawards use standard page-number pagination:
page(default 1)limit(max 100)
SDK examples¶
See also: Full SDK method reference — tango-python methods · tango-node methods.
The official SDKs don't yet expose a first-class list_subawards() / listSubawards() method.
You can still call the endpoint via the SDK's internal HTTP helper.
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
data = client._get(
"/api/subawards/",
params={"recipient": "deloitte", "ordering": "-last_modified_date", "limit": 10},
)
print("count:", data.get("count"))
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const http = (client as any).http;
const data = await http.get("/api/subawards/", {
recipient: "deloitte",
ordering: "-last_modified_date",
limit: 10,
});
console.log("count:", data.count);
Vehicles¶
Vehicles are a solicitation-centric grouping of multiple IDVs (the "thing people usually mean" when they say "this vehicle"), exposed at /api/vehicles/.
If you want the conceptual model first, start with Getting Started – Vehicles. For field definitions, see the Vehicles Data Dictionary.
Endpoints¶
GET /api/vehicles/(list + filtering + search + ordering)GET /api/vehicles/{uuid}/(detail; supports shaping)GET /api/vehicles/{uuid}/awardees/(the underlying IDVs; supports?search=and shaping)GET /api/vehicles/{uuid}/orders/(task orders / contracts under all IDVs in the vehicle; behaves like/api/contracts/)
Filtering¶
The list endpoint supports a rich filter set across enums, reference codes, org hierarchy, numeric ranges, and dates. Multi-value enum filters use pipe (|) for OR semantics, e.g. ?vehicle_type=A|B|C.
| Param | What it does |
|---|---|
search |
Full-text vehicle search (solicitation identifier + aggregated award terms). |
vehicle_type |
Vehicle (IDV) type code (e.g. A, B). Multi-value via \|. Case-insensitive. |
type_of_idc |
Type of IDC code. Multi-value via \|. |
contract_type |
Contract type code (J, M, etc.). Multi-value via \|. |
set_aside |
Set-aside type (8A, 8AN, BICiv, etc.). Multi-value via \|. |
who_can_use |
Who-can-use code. |
naics_code |
NAICS code (exact integer). |
psc_code |
Product / Service Code. |
program_acronym |
Program acronym (SEWP, OASIS, GSA Schedule, etc.). |
agency |
Awarding agency or department. Examples: GSA, DOD, DHS. Multi-value via \|. |
organization_id |
Awarding organization UUID (exact match). |
total_obligated_min / total_obligated_max |
Total obligated USD lower / upper bound. |
idv_count_min / idv_count_max |
Number of child IDVs lower / upper bound. |
order_count_min / order_count_max |
Number of task orders lower / upper bound. |
fiscal_year |
Fiscal year (YYYY). |
award_date_after / award_date_before |
Award-date range (YYYY-MM-DD). Invalid dates or inverted ranges return 400. |
last_date_to_order_after / last_date_to_order_before |
Last-date-to-order range (YYYY-MM-DD). |
Range filters are not exposed for
vehicle_obligationsorawardee_count. Use the correspondingtotal_obligated_min/maxandorder_count_min/maxparameters instead — they cover the same use cases against the precomputed rollup fields.
Vehicle awardee filtering and search¶
GET /api/vehicles/{uuid}/awardees/ supports ?search= for entity-aware full-text search across the underlying IDVs (PIID, key, solicitation_identifier, NAICS, PSC, recipient name / address). Pagination is page-based.
On the detail endpoint (GET /api/vehicles/{uuid}/), ?search= does not search vehicles — it filters the expanded awardees when your ?shape= includes awardees(...).
Example:
GET /api/vehicles/{uuid}/?shape=uuid,solicitation_identifier,awardees(key,piid,recipient(display_name,uei))&search=deloitte
Ordering¶
Vehicles support ?ordering= with a strict allowlist (8 fields). Prefix with - for descending; combine with commas for multi-field sort.
vehicle_obligations— sum of obligations across child orders (computed at query time)latest_award_date— most recent IDV award date in the vehicle (computed at query time)total_obligated— direct column rollupaward_date— earliest IDV award datelast_date_to_order— latest "last date to order" across IDVsfiscal_year— vehicle fiscal yearidv_count— precomputed count of IDVs in the vehicleorder_count— precomputed count of orders against the vehicle's IDVs
Examples:
- Most obligations first:
?ordering=-vehicle_obligations - Soonest stop-orders first:
?ordering=last_date_to_order - Combined:
?ordering=-total_obligated,-award_date
When no ?ordering= is provided, results are returned in solicitation_identifier, agency_id, uuid order.
Pagination¶
Vehicle lists use page-number pagination:
page(default 1)limit(max 100)
Vehicle awardees and vehicle orders also use page-number pagination (page, limit). Vehicle orders is optimized internally for date-ordered traversal of very large vehicles, but the response contract is the same as every other paginated endpoint: page-based with next / previous URLs and a count.
Response shaping¶
Vehicles use the shaping pipeline by default. Even without ?shape= you'll get a curated default response.
Default LIST shape: agency_details, award_date, awardee_count, contract_type, description, fiscal_year, idv_count, last_date_to_order, latest_award_date, naics_code, order_count, organization, program_acronym, psc_code, set_aside, solicitation_date, solicitation_identifier, solicitation_title, total_obligated, type_of_idc, uuid, vehicle_contracts_value, vehicle_obligations, vehicle_type, who_can_use
Default DETAIL shape is a superset: adds agency_details(*) (expanded), agency_id, competition_details(*), metrics(*), opportunity_id, solicitation_description.
Available expansions¶
| Expansion | Description |
|---|---|
awardees(...) |
The underlying IDVs that make up the vehicle. Supports ?search= filtering on detail. |
opportunity(...) |
Linked SAM.gov Opportunity (full Opportunity shape — office, attachments, meta, etc.). |
competition_details(*) |
Aggregated competition information (12 sub-fields including extent_competed, set_aside, solicitation_procedures, number_of_offers_received). |
agency_details(*) |
Aggregated awarding/funding office. Returned on detail (GET /api/vehicles/{uuid}/); list responses return a null stub for this field. |
metrics(*) |
12 computed vehicle metrics (HHI, competed-rate, top-recipient share, etc.). |
organization(*) |
Canonical 7-key office payload (organization_id, office_code, office_name, agency_code, agency_name, department_code, department_name). Both bare ?shape=organization (leaf) and ?shape=organization(*) (expand) work — both converge on the same cached lookup. |
Vehicles also support flat=true|false, flat_lists=true|false, and joiner=. (relevant only when flat=true). See Response Shaping.
Synthetic GWAC vehicles¶
Some GWAC vehicles lack a real solicitation number. Tango synthesizes a vehicle for them so the grouping still works; consumers can identify them via:
is_synthetic_solicitation(boolean) — true on synthetic rowsprogram_acronym— the GWAC's identifier (e.g.SEWP)solicitation_identifier— the user-facing value, with the internalACRO:storage prefix stripped
SDK examples¶
See also: Full SDK method reference — tango-python methods · tango-node methods.
The Python SDK exposes first-class methods for all three vehicle endpoints.
List vehicles (search + ordering + filters)¶
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
resp = client.list_vehicles(
search="SEWP",
program_acronym="SEWP",
ordering="-vehicle_obligations",
page=1,
limit=10,
)
print("count:", resp.count)
for v in resp.results:
print(v.uuid, v.solicitation_identifier, v.vehicle_obligations)
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
// Workaround until the Node SDK ships a first-class listVehicles()
const http = (client as any).http;
const data = await http.get("/api/vehicles/", {
search: "SEWP",
program_acronym: "SEWP",
ordering: "-vehicle_obligations",
page: 1,
limit: 10,
});
console.log("count:", data.count);
Vehicle detail with metrics and organization¶
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
vehicle_uuid = "00000000-0000-0000-0000-000000000000" # replace
v = client.get_vehicle(
vehicle_uuid,
shape="uuid,solicitation_identifier,vehicle_obligations,metrics(*),organization(*)",
)
print(v["uuid"], v["organization"]["agency_name"])
Vehicle awardees with search¶
resp = client.list_vehicle_awardees(
"00000000-0000-0000-0000-000000000000", # vehicle UUID is positional (kwarg name: uuid)
search="deloitte",
limit=25,
)
for awd in resp.results:
print(awd.piid, awd.recipient.display_name)
Vehicle orders (task orders)¶
resp = client.list_vehicle_orders(
"00000000-0000-0000-0000-000000000000", # vehicle UUID is positional (kwarg name: uuid)
ordering="-award_date",
limit=50,
)
print("orders:", resp.count)
GSA eLibrary contracts¶
Persisted GSA eLibrary contract metadata is exposed directly at /api/gsa_elibrary_contracts/.
For field definitions, see the GSA eLibrary Contracts Data Dictionary.
These endpoints exist in addition to the gsa_elibrary enrichment on /api/idvs/.
Endpoints¶
GET /api/gsa_elibrary_contracts/(list + filtering + ordering)GET /api/gsa_elibrary_contracts/{uuid}/(detail)
Response shape (non-shaped)¶
Each result includes:
uuid,schedule,contract_numbersins,cooperative_purchasing,disaster_recovery_purchasing,file_urlsrecipient(cached lookup by UEI; fields:uei,display_name)idv(best-effort; fields:key,award_date)
Filtering¶
Core filters:
| Param | What it does |
|---|---|
schedule |
Filter by schedule (exact). Example: MAS |
contract_number |
Filter by contract number (exact, case-insensitive). Example: GS-07F-0251W |
uei |
Filter by recipient UEI (from eLibrary provenance; exact). Multi-value: use \| for OR. |
sin |
Filter by SIN code (exact). Multi-value: use \| for OR. |
search |
Search across schedule, contract_number, UEI, SIN, and linked IDV fields. Multi-value: use \| for OR. |
key |
Filter by linked IDV key (exact, case-insensitive). |
piid |
Filter by linked IDV PIID (exact, case-insensitive). |
Ordering¶
Supports ordering= with:
last_updatedschedulecontract_number
Example:
/api/gsa_elibrary_contracts/?schedule=MAS&ordering=-last_updated
Pagination¶
Uses page-number pagination.
Response shaping¶
Like other endpoints, shaping is available via shape=:
/api/gsa_elibrary_contracts/?contract_number=47QRAA26D003K&shape=uuid,schedule,contract_number,sins,file_urls,recipient(uei,display_name),idv(key,award_date)
CALC Labor Category Rates¶
CALC (Contract Awarded Labor Category) labor rates are exposed as nested endpoints under IDVs and entities. For field definitions, see the CALC Labor Rates Data Dictionary.
Access: Requires Pro tier or above.
Endpoints¶
GET /api/idvs/{key}/lcats/(rates for a specific IDV)GET /api/entities/{uei}/lcats/(rates for a specific entity, via IDV recipient linkage)
These are nested endpoints -- there is no top-level /api/lcats/ route. Use the IDV key or entity UEI to scope the results.
Filtering¶
Core filters:
| Param | What it does |
|---|---|
search |
Full-text search across vendor name, labor category, category, and subcategory. |
vendor_name |
Case-insensitive partial match on vendor name. |
labor_category |
Case-insensitive partial match on labor category title. |
price_gte, price_lte |
Filter by current price range (decimal). |
education_level |
Exact match on education level (case-insensitive). |
min_years_experience_gte, min_years_experience_lte |
Filter by minimum years of experience range. |
security_clearance |
Exact match on security clearance requirement (case-insensitive). |
worksite |
Exact match on work location type (case-insensitive). |
business_size |
Exact match on business size code (case-insensitive). |
sin |
Exact match on Special Item Number (case-insensitive). |
category |
Case-insensitive partial match on service category. |
idv_piid |
Exact match on the stored PIID from CALC. |
key |
IDV key (case-insensitive). |
piid |
IDV PIID (case-insensitive). |
Ordering¶
CALC labor rates support ordering= with a strict allowlist:
current_pricevendor_namelabor_categoryeducation_levelmin_years_experiencemodified
Prefix with - for descending order.
Examples:
- Cheapest first:
GET /api/idvs/{key}/lcats/?ordering=current_price - Most experienced first:
GET /api/idvs/{key}/lcats/?ordering=-min_years_experience
Pagination¶
Uses page-number pagination:
page(default 1)limit(default 10, max 100)
Response shaping¶
Use ?shape= to customize the response. Root fields:
uuid,labor_category,current_priceeducation_level,min_years_experience,experience_rangesecurity_clearance,worksitesin,category,subcategory,schedulecontract_start,contract_end
Expansions:
idv(key, piid, award_date)-- linked IDV details (Pro tier required)recipient(uei, display_name)-- entity details via IDV recipient (Pro tier required)business_size(code, description)-- business size with human-readable description
A default shape is applied when no ?shape= parameter is provided, so ?flat=true works without needing an explicit shape.
Example:
/api/idvs/{key}/lcats/?shape=uuid,labor_category,current_price,idv(key,piid)
Flat example:
/api/idvs/{key}/lcats/?flat=true
Rate limiting¶
These endpoints (GET /api/idvs/{key}/lcats/ and GET /api/entities/{uei}/lcats/) carry a separate premium query budget that replaces your account's standard daily and per-minute rate limits — it is not additive.
A premium budget is tied to an endpoint family (these LCATS endpoints share one counter). Requests count as one request per API call, regardless of how many rows are returned. The budget resets daily at 00:00 UTC.
Check your current premium usage and limits on your account profile. To request a higher limit, email [email protected].
For header details and how 429 responses work, see the Rate limits guide.
Entities¶
Entities are vendors/recipients exposed at /api/entities/ (UEI is canonical). For field definitions, see the Entities Data Dictionary.
Endpoints¶
GET /api/entities/(list + filtering + search)GET /api/entities/{uei}/(detail; UEI lookup is case-insensitive)
Related “scoped” list endpoints (awards for an entity):
GET /api/entities/{uei}/contracts/GET /api/entities/{uei}/idvs/GET /api/entities/{uei}/otas/GET /api/entities/{uei}/otidvs/GET /api/entities/{uei}/subawards/
Metrics: Time-series obligation/award metrics for an entity: GET /api/entities/{uei}/metrics/<months>/<period_grouping>/. See Metrics for parameters and behavior.
Budget flows (Beta): Federal accounts that paid this vendor, with surrounding account context and hydrated contracts: GET /api/entities/{uei}/budget-flows/. See Budget.
Response Shaping¶
Entities default to the shaping pipeline — all responses go through shaping even without ?shape=. Use ?shape= to customize which fields are returned.
- List default:
uei,legal_business_name,dba_name,entity_url,cage_code,business_types(*),sba_business_types(*),primary_naics,physical_address(*),purpose_of_registration(*),relationships(*) - Detail default: All list fields plus
display_name,uuid,dodaac,description,email_address,capabilities,congressional_district,keywords,registered,registration_status,federal_obligations(*),naics_codes(*),psc_codes,highest_owner(*),immediate_owner(*),mailing_address(*), all date fields, and structured code/description expands:entity_structure(*),entity_type(*),profit_structure(*),organization_structure(*),state_of_incorporation(*),country_of_incorporation(*). - On-demand expand:
past_performance(*)— aggregated contract history withsummaryandtop_agencies. Accepts[top=N]bracket param (default 5, max 100). Example:?shape=uei,past_performance[top=10](*).
Complex fields are normalized by the shaping pipeline: address keys are always snake_case (address normalization now includes county, county_code, fips_code when available from DSBS), business_types and sba_business_types are always [{code, description, ...}] arrays, naics_codes are always [{code, sba_small_business}] objects, and code/description pairs (entity_structure, purpose_of_registration, etc.) are always {code, description} objects with map-based description fallback.
See Response Shaping for full syntax and the Entities Data Dictionary for field definitions.
Filtering¶
Swagger is the canonical list of query parameters. These are the core filters most people use day-to-day.
Identity / text search¶
| Param | What it does |
|---|---|
search |
Search entities (name + aliases + other indexed fields). |
uei |
Filter by UEI (exact, case-insensitive). |
cage_code |
Filter by CAGE code (exact, case-insensitive). |
name |
Filter by legal business name (substring match). |
Location¶
| Param | What it does |
|---|---|
state |
Filter by entity physical address state/province code. |
zip_code |
Filter by ZIP code (exact). |
Classification¶
| Param | What it does |
|---|---|
naics |
Filter by NAICS. |
psc |
Filter by PSC. |
socioeconomic |
Filter by business type / socioeconomic code. |
Other¶
| Param | What it does |
|---|---|
purpose_of_registration_code |
Filter by purpose-of-registration code (when present). |
total_awards_obligated_gte |
Filter entities with total obligated amount greater than or equal to a USD value. |
total_awards_obligated_lte |
Filter entities with total obligated amount less than or equal to a USD value. |
Multi-value filter syntax¶
Use | (or the literal word OR) to match any of several values:
socioeconomic=8A|WOSB
The same syntax works on every filter that accepts multiple values.
Ordering¶
/api/entities/ does not currently support ordering=....
If you send ?ordering=... anyway, Tango returns HTTP 400 (ordering is opt-in per endpoint).
Pagination¶
Entities use standard page-number pagination:
page(default 1)limit(max 100)
SDK examples¶
See also: Full SDK method reference — tango-python methods · tango-node methods.
List entities (search + filters + shaping)¶
import os
from tango import ShapeConfig, TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
resp = client.list_entities(
search="acme",
state="VA",
naics="541511",
limit=10,
shape=ShapeConfig.ENTITIES_MINIMAL,
)
for e in resp.results:
print(e.uei, e.legal_business_name)
import { ShapeConfig, TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const resp = await client.listEntities({
search: "acme",
state: "VA",
naics: "541511",
limit: 10,
shape: ShapeConfig.ENTITIES_MINIMAL,
});
for (const e of resp.results) {
console.log(e.uei, e.legal_business_name);
}
Get an entity (detail)¶
import os
from tango import ShapeConfig, TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
entity = client.get_entity(
"ZQGGHJH74DW7",
shape=ShapeConfig.ENTITIES_COMPREHENSIVE,
)
print(entity.uei, entity.legal_business_name)
import { ShapeConfig, TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const entity = await client.getEntity("ZQGGHJH74DW7", {
shape: ShapeConfig.ENTITIES_COMPREHENSIVE,
});
console.log(entity.uei, entity.legal_business_name);
Exclusions¶
Exclusions are SAM.gov exclusion records — debarments, suspensions, and other exclusions from federal awards — exposed at /api/exclusions/. For field definitions, see the Exclusions Data Dictionary.
Authentication: Exclusions endpoints require authentication (API key or OAuth2). Unauthenticated requests receive HTTP 401. Available on every plan, including Free — no tier gating on any field.
Endpoints¶
GET /api/exclusions/(list + filtering + search + ordering)GET /api/exclusions/{exclusion_key}/(detail)
The detail path segment is the exclusion_key — a 64-character SHA-256 hex digest that is the record's primary key. Any other shape returns HTTP 404.
Currency: who is excluded today¶
Whether an exclusion is in force is derived at query time from its dates, not stored as a flag:
?active=truereturns the exclusions in force today.?active=falsereturns the complement (expired, not-yet-active, or lifted). Omittingactivereturns everything.- Every result carries a computed
is_currently_excludedboolean regardless of the filter. - An exclusion is current when it has not been lifted (
delisted_atis null), itsactivate_dateis null or on/before today, and itstermination_dateis null or after today. A nullactivate_datecounts as current (fails closed for OFAC/SDN-style records). termination_dateof year 2227 is SAM.gov's INDEFINITE sentinel, stored as a real date — such records read as current until explicitly lifted.
Because currency is derived, an exclusion merely reaching its termination_date is not a change to the record — see Webhooks for what does and does not fire an alert.
Filtering¶
Core filters (identifier and classification filters accept | for OR; date filters require YYYY-MM-DD format — invalid dates or inverted ranges return 400, see Date filters):
| Param | What it does |
|---|---|
active |
Boolean. true returns exclusions in force today; false returns the complement. |
classification_type |
Classification (e.g. Firm, Individual, Special Entity Designation). Multi-value: use | for OR. |
exclusion_type |
Exclusion type (e.g. Ineligible (Proceedings Pending), Prohibition/Restriction). Multi-value: use | for OR. |
exclusion_program |
Exclusion program (e.g. Reciprocal, Non-Procurement, Procurement). Multi-value: use | for OR. |
agency |
Agency, resolved to an organization and rolled up its hierarchy, so a department includes exclusions its components issued (?agency=TREAS includes the Treasury offices'). Multi-value: separate values with a pipe for OR. A value that resolves to no organization falls back to a case-insensitive exact match on excluding_agency_code, with a warning in meta. |
excluding_agency_code |
Code of the agency that created the exclusion, matched exactly with no hierarchy rollup. Multi-value: separate values with a pipe for OR. |
excluding_agency_name |
Name of the excluding agency. Multi-value: use | for OR. |
uei |
SAM.gov Unique Entity Identifier on the exclusion record. Multi-value: use | for OR. |
cage_code |
CAGE code on the exclusion record. Multi-value: use | for OR. |
npi |
National Provider Identifier. Multi-value: use | for OR. |
entity_uei |
UEI of the resolved Tango entity (set when the exclusion links to a registered entity). Multi-value: use | for OR. |
delisted |
Boolean. true returns only lifted exclusions (delisted_at is set); false returns those still in the catalog. |
activate_date_after, activate_date_before |
Activation date range (YYYY-MM-DD). |
termination_date_after, termination_date_before |
Termination date range (YYYY-MM-DD). |
update_date_after, update_date_before |
SAM update-date range (YYYY-MM-DD). |
search |
Full-text search over names, excluding agency, and comments. |
Ordering¶
Exclusions support ordering= with a strict allowlist:
activate_datetermination_datecreate_dateupdate_datemodifiedrank— relevance; meaningful only when?search=is present (results default to relevance order under search)
Examples:
- Most recently updated first:
GET /api/exclusions/?ordering=-update_date - Soonest to terminate:
GET /api/exclusions/?ordering=termination_date
Pagination¶
Exclusions use standard page-number pagination:
page(default 1)limit(default 25, max 100)
Shaping¶
Exclusions default to the shaping pipeline — all responses render through shaping even without ?shape=. There is one expandable relation, organization(*), and neither default includes it: ask for it by name.
- List default:
exclusion_key,classification_type,exclusion_type,excluding_agency_name,entity_name,display_name,activate_date,termination_date,termination_type,is_currently_excluded - Detail default: every allowed field, including identifiers, name parts, addresses, free text, the vessel block, provenance dates, and
organization_id.
Expandable relation:
organization(*)→ the canonical 7-key office payload (organization_id,office_code,office_name,agency_code,agency_name,department_code,department_name) for the excluding agency, resolved fromexcluding_agency_code(orexcluding_agency_namewhen there is no code) at the most specific level the code names — an exclusion SAM files under a Treasury office links to that office, not to the department.nullwhen it doesn't resolve.
The API field references maps to the underlying exclusion_references payload. See the Exclusions Data Dictionary for the full field list and Response Shaping for syntax.
Examples:
# Firms currently excluded, timeline fields
GET /api/exclusions/?active=true&classification_type=Firm&shape=exclusion_key,entity_name,exclusion_type,activate_date,termination_date
# By UEI, minimal payload
GET /api/exclusions/?uei=ABC123DEF456&shape=exclusion_key,entity_name,is_currently_excluded
# Recently lifted exclusions
GET /api/exclusions/?delisted=true&ordering=-update_date&shape=exclusion_key,entity_name,delisted_at
# Detail by exclusion_key
GET /api/exclusions/{exclusion_key}/?shape=exclusion_key,entity_name,exclusion_type,references
Notes:
*is only valid inside expansions; exclusions have none, so use explicit field lists.- Invalid
shapefields return HTTP 400 with structured validation errors.
Opportunities
Opportunities¶
Opportunities are SAM.gov opportunities/solicitations exposed at /api/opportunities/ (with a latest-notice pointer and history behind the scenes). For field definitions, see the Opportunities Data Dictionary.
Endpoints¶
GET /api/opportunities/(list + filtering + search + ordering)GET /api/opportunities/{opportunity_id}/(detail)
Notice UUID Resolution¶
If you pass a notice UUID (instead of an opportunity UUID) to the detail endpoint, the API automatically redirects (HTTP 302) to the parent opportunity. This is useful when you have a subsequent notice UUID (e.g., from an amendment) and want to reach the parent opportunity directly.
# Using a notice UUID redirects to the parent opportunity
GET /api/opportunities/{notice_id}/ → 302 /api/opportunities/{opportunity_id}/
If the notice UUID has no parent opportunity, the endpoint returns 404.
Filtering¶
Swagger is the canonical list of query parameters. These are the core filters most people use day-to-day. All date filters require YYYY-MM-DD format; invalid dates or inverted ranges return 400. See Date filters.
| Param | What it does |
|---|---|
search |
Full-text search over opportunities (vector-backed). |
agency |
Filter by agency or department (vector-backed). |
naics |
Filter by NAICS. |
psc |
Filter by PSC. |
set_aside |
Filter by set-aside. |
notice_type |
Filter by notice type (valid values are validated). |
solicitation_number |
Filter by solicitation number. Matches every spelling of the number (N6600120R0002 or N66001-20-R-0002, any case). Multi-value: use | for OR. |
first_notice_date_after, first_notice_date_before |
First notice date range (inclusive, YYYY-MM-DD). |
last_notice_date_after, last_notice_date_before |
Last notice date range (inclusive, YYYY-MM-DD). |
response_deadline_after, response_deadline_before |
Response deadline range (inclusive, YYYY-MM-DD). |
place_of_performance |
Full-text-ish filter over place-of-performance text. |
active |
Filter active/inactive (default true). |
awarded |
true for opportunities that carry an award or have award notices linked back to them; false for the rest. See Awards. |
awardee_uei |
Filter by the awardee UEI on the opportunity's own award notice. Case-insensitive. Multi-value: use | for OR. |
Active-only by default¶
A list request returns active opportunities only unless it sends active, solicitation_number, notice_type, award_number, awarded or awardee_uei — any of which widens the request (including search) to the full dataset. Send active=true alongside them to keep the active-only scope.
Multi-value filter syntax¶
Use | (or the literal word OR) to match any of several values:
naics=541511|541512
agency=DOD|GSA
The same syntax works on every filter that accepts multiple values.
Ordering¶
Opportunities support ordering= with a strict allowlist:
first_notice_date(alias:posted_date)last_notice_dateresponse_deadline
Examples:
- Newest posted first:
GET /api/opportunities/?ordering=-posted_date - Soonest deadline first:
GET /api/opportunities/?ordering=response_deadline
Note: if you use search without an explicit ordering=..., results default to relevance ordering (rank).
Pagination¶
Opportunities use page-number pagination (page, limit) and return next/previous URLs.
Response Shaping¶
Opportunities support the ?shape= query parameter. When no ?shape= is provided, the endpoint returns a default shape.
Default shape (list): active,award_number,awarded,first_notice_date,last_notice_date,meta(*),naics_code,office(*),opportunity_id,place_of_performance(*),psc_code,response_deadline,sam_url,set_aside,solicitation_number,title
Default shape (detail): active,attachments(attachment_id,file_size,mime_type,name,posted_date,resource_id,type,url),award_amount,award_count,award_date,award_number,awarded,awardee,awardee_uei,awards(*),description,first_notice_date,last_notice_date,meta(*),naics_code,notice_history(*),office(*),opportunity_id,place_of_performance(*),primary_contact(*),psc_code,response_deadline,sam_url,set_aside,solicitation_number,solicitation_opportunity_id,title
Compared with the list default, the detail default adds attachments(...), the award fields (see Awards), description, notice_history(*), primary_contact(*) and solicitation_opportunity_id. The default attachments(...) expansion lists attachment_id, file_size, mime_type, name, posted_date, resource_id, type, url — note that the doc_role, doc_role_alt and extracted_text sub-fields are Pro+ tier and are not in the default. Free-tier callers who request one receive the response without it plus a meta.upgrade_hints entry. None of the three is returned by attachments(*) — name them explicitly.
Not every attachment is a document.
Each row's type is file (a document, the only kind with extracted text and a document role) or link (a URL pointing somewhere else), and meta.attachments_count counts both.
From API version 5.9.0, meta(*) also carries files_count and links_count.
meta.files_count greater than 0 means the opportunity has documents, 0 means it has none, and null means not yet counted.
See Attachments: files and links for the definitions, the null case and a worked example.
Document roles¶
doc_role says what job a document does inside its solicitation package, so you can decide which attachments are worth opening (or worth sending to a model) without downloading them:
| Value | What it means |
|---|---|
requirement |
What the government wants — statements of work, specifications, drawings of the work to be performed |
instructions |
How to bid — submission instructions, forms to fill in, evaluation criteria |
pricing |
Where the money goes — schedules, line-item worksheets, wage determinations |
terms |
The deal's conditions — clauses, representations and certifications, contract terms |
reference |
Context rather than obligation — existing-conditions drawings, amendments' cover pages, background material |
unknown |
The classifier could not read enough of the document to decide |
doc_role_alt carries a runner-up label when the classifier saw a close second, and is absent otherwise. An attachment the classifier has not reached yet omits both keys rather than returning null.
GET /api/opportunities/{id}/?shape=title,attachments(name,doc_role)
Awards¶
SAM.gov posts a contract award as a separate award notice, which appears in Tango as its own opportunity. The award details (award_date, award_amount, awardee, awardee_uei) are filled only where SAM has posted an award notice and are null otherwise.
| Field | What it holds |
|---|---|
awarded |
Boolean. true when the opportunity carries an award number or has award notices linked back to it; never null. |
award_number, award_date, award_amount, awardee, awardee_uei |
The award as posted on this opportunity's own award notice. award_date, award_amount, awardee and awardee_uei always come from the same notice. award_amount is a string, as SAM publishes it. |
solicitation_opportunity_id |
On an award, the opportunity_id of the solicitation it was made under; null when the award is unlinked. |
award_count |
On a solicitation, the total number of award opportunities linked back to it (not capped); null when none are linked. |
awards(...) |
On a solicitation, up to ten of the most recent linked awards, each {opportunity_id, notice_id, award_number, award_date, award_amount, awardee, awardee_uei}. Use award_count for the total. |
An award is linked to its solicitation only when SAM's award notice references that solicitation. An award notice posted without that reference stays unlinked: it still carries its own award fields, but has no solicitation_opportunity_id and does not count toward any solicitation's award_count.
awarded does not change what active means. An award can be posted while its solicitation is still open — multiple-award vehicles are the common case — so an opportunity can be both active and awarded.
# Awards made to one vendor, across the full dataset
/api/opportunities/?awardee_uei=ABC123XYZ456&shape=opportunity_id,title,award_date,award_amount,solicitation_opportunity_id
# A solicitation and the awards linked to it
/api/opportunities/{opportunity_id}/?shape=opportunity_id,title,awarded,award_count,awards(*)
The office(*) expansion is the canonical 7-key office payload — organization_id, office_code, office_name, agency_code, agency_name, department_code, department_name — same shape used on awards, vehicles, forecasts, grants, IT Dashboard, and protests.
set_aside is available as a leaf (returns the code string) or as an expansion set_aside(code,description) for the full label.
See Response Shaping for syntax and examples.
Opportunities shaping notes¶
- New leaves:
latest_notice_id,archive_date, plus relation id leavesagency_id,department_id,office_id. - New expansions:
agency(*),department(*),latest_notice(notice_id,link). - File and link counts:
meta(files_count,links_count)alongsidemeta(attachments_count), from API version 5.9.0. See Attachments: files and links. - Award leaves and expansion:
awarded,award_date,award_amount,awardee,awardee_uei,award_count,solicitation_opportunity_id, andawards(opportunity_id,notice_id,award_number,award_date,award_amount,awardee,awardee_uei). See Awards. - Bare expand shorthand: common expansions can be requested without parentheses when the runtime must expand them for safety (e.g.,
officebehaves likeoffice(*), andattachmentsbehaves likeattachments(*)).
Examples:
# Include latest notice pointer + org expansions
/api/opportunities/?shape=opportunity_id,title,latest_notice_id,latest_notice(*),agency(*),department(*),office&limit=1
# Attachments without specifying subfields (shorthand)
/api/opportunities/{opportunity_id}/?shape=opportunity_id,title,attachments
SDK examples¶
See also: Full SDK method reference — tango-python methods · tango-node methods.
Search opportunities (with shaping)¶
import os
from tango import ShapeConfig, TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
resp = client.list_opportunities(
search="cybersecurity",
agency="DOD",
limit=10,
shape=ShapeConfig.OPPORTUNITIES_MINIMAL,
)
for o in resp.results:
print(o.opportunity_id, o.title)
import { ShapeConfig, TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const resp = await client.listOpportunities({
search: "cybersecurity",
agency: "DOD",
limit: 10,
shape: ShapeConfig.OPPORTUNITIES_MINIMAL,
});
for (const o of resp.results) {
console.log(o.opportunity_id, o.title);
}
Notices¶
Notices are individual SAM.gov notice records exposed at /api/notices/.
If you want “one row per solicitation/opportunity”, use Opportunities. For field definitions, see the Notices Data Dictionary.
Endpoints¶
GET /api/notices/(list + filtering)GET /api/notices/{notice_id}/(detail)
Filtering¶
Swagger is the canonical list of query parameters. These are the core filters most people use day-to-day. All date filters require YYYY-MM-DD format; invalid dates or inverted ranges return 400. See Date filters.
| Param | What it does |
|---|---|
search |
Full-text search over title, description, and solicitation number, ranked by relevance. Requires Small tier or above. Matches active notices only. |
agency |
Filter by agency or department (vector-backed). |
naics |
Filter by NAICS. |
psc |
Filter by PSC. |
set_aside |
Filter by set-aside. |
notice_type |
Filter by notice type (valid values are validated). |
solicitation_number |
Filter by solicitation number. Matches every spelling of the number (N6600120R0002 or N66001-20-R-0002, any case). Multi-value: use | for OR. |
posted_date_after, posted_date_before |
Posted date range (YYYY-MM-DD). |
response_deadline_after, response_deadline_before |
Response deadline range (YYYY-MM-DD). |
active |
Filter active/inactive. |
Ordering¶
/api/notices/ does not support ordering=....
Pagination¶
Notices use standard page-number pagination:
page(default 1)limit(max 100)
Response Shaping¶
Notices support the ?shape= query parameter. When no ?shape= is provided, the endpoint returns a default shape.
Default shape (list): active,address(*),attachment_count,award_number,description,file_count,last_updated,link_count,meta(*),naics_code,notice_id,office(*),opportunity(*),place_of_performance(*),posted_date,psc_code,response_deadline,sam_url,set_aside,solicitation_number,title
Default shape (detail): adds archive(*), primary_contact(*), secondary_contact(*)
file_count and link_count are in both default shapes from API version 5.9.0; GET /api/version reports what you are calling.
attachment_count counts every attachment, including links that point somewhere else, so use file_count greater than 0 to tell whether a notice has documents.
See Attachments: files and links.
set_aside is available as a leaf (returns the description text) or as an expansion set_aside(code,description) for the structured form.
See Response Shaping for syntax and examples.
Notices shaping notes¶
- New leaf:
opportunity_idis available directly as a leaf (UUID). - Award leaves:
award_date,award_amount,awardeeandawardee_ueiare available alongsideaward_number. They are filled only on award notices and arenullotherwise;award_amountis a string, as SAM publishes it. None of them is in a default shape, so name them inshape. - Bare expand shorthand: common expansions can be requested without parentheses when the runtime must expand them for safety (e.g.,
officebehaves likeoffice(*), andopportunitybehaves likeopportunity(*)).
Examples:
# Leaf opportunity_id + bare office/opportunity expansions
/api/notices/?shape=notice_id,opportunity_id,office,opportunity&limit=1
# Award details on award notices
/api/notices/?notice_type=a&shape=notice_id,title,award_number,award_date,award_amount,awardee,awardee_uei&limit=5
SDK examples¶
See also: Full SDK method reference — tango-python methods · tango-node methods.
import os
from tango import ShapeConfig, TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
resp = client.list_notices(
agency="DOD",
notice_type="Presolicitation",
limit=10,
shape=ShapeConfig.NOTICES_MINIMAL,
)
for n in resp.results:
print(n.notice_id, n.title)
import { ShapeConfig, TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const resp = await client.listNotices({
agency: "DOD",
notice_type: "Presolicitation",
limit: 10,
shape: ShapeConfig.NOTICES_MINIMAL,
});
for (const n of resp.results) {
console.log(n.notice_id, n.title);
}
State & Local — Beta¶
State, local and education (SLED) procurement: solicitations that never appear on SAM.gov because they were never federal. For field definitions, see the State & Local Data Dictionary.
Beta — coverage is partial and grows by jurisdiction
There is no national SLED feed. Every jurisdiction publishes on its own portal, in its own vocabulary, on its own schedule, and Tango reads them one at a time. A thin result for a state is at least as likely to be a portal we do not read yet as a quiet market, so call /api/sled/opportunities/coverage/ before treating a per-state count as market size. Field availability varies by state in ways the federal endpoints never taught you to expect — description is absent on every row in several states, and solicitation_number is null wherever the portal publishes none.
Fields and filters may still be added; nothing documented here will be removed without a version bump. The raw(*) expansion is explicitly unstable at any tier (see Shaping).
Authentication: SLED endpoints require authentication (API key or OAuth2). Unauthenticated requests receive HTTP 401. Every filter, every endpoint and the full-text search are available on every plan including Free; three shape leaves — attachments(extracted_text), revisions(changes) and raw(*) — require Small or above.
attachments(extracted_text) needs API version 4.25.1 or later; GET /api/version reports what you are calling.
Endpoints¶
GET /api/sled/opportunities/— state, local and education solicitationsGET /api/sled/opportunities/{opportunity_id}/— one solicitationGET /api/sled/opportunities/{opportunity_id}/revisions/— that solicitation's observed amendment historyGET /api/sled/opportunities/coverage/— what this dataset actually covers, per stateGET /api/sled/forecasts/— planned state procurementsGET /api/sled/forecasts/{forecast_id}/— one forecast
Detail routes are keyed by a Tango-minted UUID (opportunity_id / forecast_id) and the path segment must be a valid RFC 4122 UUID — anything else is a 404, not a 500.
SLED is its own namespace rather than a domain on /api/opportunities/, and deliberately so: the state schema shares almost no columns with a SAM notice. There is no NAICS or PSC on most rows, no set-aside, no office, no notice type, and no entry in the federal agency hierarchy. Folding these rows into the federal endpoint would degrade the federal shape and drag state solicitations into every existing federal filter, search and alert. /api/opportunities/ returns no SLED rows, and /api/sled/opportunities/ returns no federal ones.
Two things to read before your first query¶
A list request with no liveness filter returns open solicitations only¶
Only about a fifth of the corpus is open at any time, and a state portal drops a closed solicitation rather than restating it as closed. So the list endpoint defaults to status=open.
- To page the whole corpus, pass an explicit
status=(oractive=) filter. status=unknown— standing vendor rosters and dateless RFIs, which have no deadline to have passed — is hidden by that default. Reach it withstatus=open|unknown.- A detail request returns the solicitation whatever its status. The default lives on the list action only, so a closed solicitation still resolves on its own URL.
status is Tango's answer, not the portal's¶
status is one of open, closed, awarded, cancelled or unknown, derived from the portal's own word, the response deadline and the clock — and refreshed every fifteen minutes whether or not the feed moved. status_reason says which input decided it (deadline_future, deadline_past, no_deadline, source_terminal, delisted), and status_computed_at says when.
A solicitation the portal stops listing before its deadline reads closed with status_reason=delisted. delisted_at is when a complete crawl of that portal first found it gone; it is null on anything never delisted. The portal's own terminal word (awarded, cancelled) still outranks a delisting, and a delisting outranks the deadline. If the portal lists the solicitation again, delisted_at clears and status is derived from the deadline as before. delisted_at needs API version 4.25.3 or later.
The portal's own status word is a different and much weaker fact: it is frozen at last capture, and most of what the feeds call open already has a deadline in the past. That word is served as source_status so you can see the disagreement, but do not filter liveness on it. The portal's full raw status vocabulary lives behind the Small-and-above raw(*) expansion.
Forecasts carry no status at all — see Forecasts.
Coverage¶
GET /api/sled/opportunities/coverage/ takes no parameters and returns one cached rollup (counts below are illustrative — call the endpoint for live ones):
{
"generated_at": "2026-09-10T14:00:00Z",
"totals": {
"opportunities": 46479,
"forecasts": 1452,
"jurisdictions_covered": 51,
"by_status": {"open": 9207, "closed": 35102, "awarded": 812, "cancelled": 604, "unknown": 754}
},
"states": [
{
"state": "AL",
"total_count": 812,
"by_status": {"open": 131, "closed": 651, "awarded": 12, "cancelled": 4, "unknown": 14},
"jurisdictions": ["local", "state"],
"last_change_observed_at": "2026-09-10T06:12:44Z"
}
]
}
Every state row carries all five status buckets whether or not they have rows, so a total and two buckets never invite you to subtract. last_change_observed_at is the most recent scrape that saw any solicitation in that state — a stale value there means we have not read the portal lately, not that the market is quiet.
The payload is counts and nothing else, by design. The caveats that make a particular state's number readable belong here in the documentation, where they can be corrected; prose shipped in a data payload cannot be kept true.
Read it before concluding that a scheme-specific category filter is broken (see Category codes) or that a state is empty.
Solicitations¶
GET /api/sled/opportunities/
One row is one advertised solicitation, arbitrated across every scrape that observed it, keyed by a stable opportunity_id.
Filtering¶
Identifier 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 agency must be at least 2 characters.
| Param | What it does |
|---|---|
state |
Two-letter state or territory code. The primary partition. Multi-value: use \| for OR. |
jurisdiction |
Level of government: state, local or education. unknown reaches the aggregator rows that cannot tell state from local. Multi-value. |
status |
open, closed, awarded, cancelled or unknown. Absent on a list request, this defaults to open. Multi-value. |
active |
Boolean sugar for federal-shaped callers: active=true is status=open, active=false is its complement (so it includes unknown). |
agency |
Substring match on the buyer's raw text. There is no code resolution behind this — state agencies have no entry in the federal organization tree. Min 2 characters. |
solicitation_number |
The number a human would quote (exact, case-insensitive). Null on roughly a third of the corpus, where the portal publishes none. Multi-value. |
solicitation_type |
rfp, ifb, rfq, rfi, itb, sole_source, grant or other. null (the portal states no type) is a distinct answer from other (a type the vocabulary does not recognize). Multi-value. |
has_documents |
Boolean. Whether the solicitation advertises at least one document. |
revision_kind |
The kind of the most recent substantive revision: deadline_change, status_change, documents_added, documents_removed, documents_replaced, title_change or content_change. Multi-value. |
naics |
Exact match within the naics category scheme. Thin on purpose — see Category codes. Multi-value. |
nigp |
Exact match within the nigp scheme (the most widely tagged of the four). Multi-value. |
unspsc |
Exact match within the unspsc scheme. Multi-value. |
category |
Exact match within the text scheme, where the code is the portal's own human label. Multi-value. |
category_code |
Matches a code under any scheme, including the untagged strings a pre-migration row still carries. The escape hatch when a scheme-specific filter returns less than you expected. Multi-value. |
posted_after, posted_before |
Posted-date range (YYYY-MM-DD). posted_date is null on a sixth of the corpus. |
response_deadline_after, response_deadline_before |
Response-deadline range. |
first_seen_after, first_seen_before |
When Tango first observed the solicitation. This is the polling primitive: everything new since your last call. |
change_seen_after |
When Tango observed the last substantive change. A scrape date, not an amendment date — see Revisions. |
modified_after, modified_before |
When the Tango row last changed. |
platform, native_id, external_id |
Support filters for reproducing a specific record with us. They appear in no response shape and their values are not a contract — quote solicitation_number to a human. external_id accepts at most 500 values. |
search |
Ranked full-text search — see Search. Min 2 characters. |
verbose |
Boolean. verbose=true adds description and contact(*) to the default list shape without costing a join. |
No enum filter is a strict choice list: an unrecognized value returns an empty page rather than a 400, because the live vocabularies are wider than any list transcribed into a doc.
Category codes¶
category_codes is an array of {scheme, code} objects over nigp, unspsc, naics, text and unknown, and the scheme tagging is mid-migration. Roughly half of all entries corpus-wide are still bare pre-migration strings carrying no scheme, which read as unknown.
The practical consequence: ?naics= matches only the entries that are scheme-tagged NAICS, which is a very small share of the corpus. That is a fact about the tagging, not about coverage. ?category_code=541620 matches that code under any scheme and the untagged strings, and is what you want unless you specifically need scheme precision.
Nothing here resolves against the federal NAICS or PSC tables. A state's NIGP commodity code is not a PSC and there is no crosswalk in this dataset.
Ordering¶
ordering= allowlist: rank, response_deadline, posted_date, first_seen_at, last_seen_at, last_change_seen_at, modified.
rank requires a non-empty search — passing ordering=rank without one returns 400. Under search= with no explicit ordering, results sort by relevance. response_deadline, posted_date and last_change_seen_at sort nulls last in both directions, so a page of results is never headed by rows with no date.
Search¶
?search= is a ranked full-text query over title, agency, identifiers, category labels and description, weighted in that order, widened by the solicitations whose attachment text matched.
Three properties worth knowing:
- A title match outranks a body match, and a document-only match ranks below both.
descriptionis null on about a quarter of open rows and absent entirely in several states, so ranking body matches first would bury the states that publish well. snippetis the passage that matched. Undersearch=, a row that matched on its description carries asnippetfield with the matching passage rather than the whole body — which is the answer to "why is this row here" on a resource whose descriptions run to six figures of characters. A row that matched on title or agency alone carries nosnippet; that is the honest answer rather than the opening sentence of its description.- Document matching returns ids, never text — and that is not a tier gate. When a solicitation's attached PDF matches, the solicitation appears in your results. You get no snippet, no highlight and no fragment of the document: you learn that a document matched, never what it said. Because the response carries none of the text, searching it is ungated and available on every plan including Free — there is no tier check anywhere on this path.
Reading a document's body is a separate, paid thing, and it is not part of search.
attachments(extracted_text)serves the extracted text on Small and above — see Attachments. Searching that text stays ungated because a search response gives none of it away; buying the body buys the body, not better search.So there are two independent questions. Did a document match? — every plan, through
search=, as ids. What does the document say? — Small and above, throughattachments(extracted_text), one record at a time. Every plan also gets enough metadata to judge a document without reading it:size_bytes,char_count,pagesandextraction_status.Document matching fails open: when it is unavailable, the metadata search answers on its own — you get fewer rows, never an error. Treat a document match as a bonus signal rather than something to depend on for completeness.
Shaping¶
See Response shaping for the syntax. Pass show_shapes=true on any request to have the API report the shape tree your plan can reach.
- List default:
opportunity_id,solicitation_number,solicitation_type,title,organization(*),status,status_reason,delisted_at,posted_date,response_deadline,bid_opening_date,source_url,has_documents,category_codes,first_seen_at,last_seen_at,last_change_seen_at,meta(*) ?verbose=truelist default: the list fields plusdescriptionandcontact(*)- Detail default: the list fields plus
description,solicitation_type_source,status_computed_at,response_deadline_original,bid_opening_raw,contact(*),attachments(*),revisions(*)
description is detail-only on purpose: its median is around 550 characters and its tail runs past 120,000, so twenty-five of them is not a list page. Reach it on a list by naming it, by ?verbose=true, or by snippet under search=.
Expansions:
| Expand | Leaves | Notes |
|---|---|---|
organization(*) |
state, level, agency |
The buyer. Not the federal 7-key office payload — there is no code resolution, and no organization_id to join on. |
contact(*) |
name, email, phone |
The buyer contact as the portal publishes it. |
meta(*) |
attachment_count, revision_count, last_revision_kind, last_change_source_declared |
Denormalized counts; costs no join. |
attachments(*) |
13 leaves — see Attachments | Document metadata and extraction stats. The wildcard never carries extracted_text — name it explicitly. |
revisions(*) |
observed_at, sequence, kind, changed_fields, changes, source_declared |
The observed amendment history, minus enrichment rows. changes requires Small or above. |
attachments(extracted_text) |
the document's extracted body | Small or above, and opt-in only — see Attachments. |
raw(*) |
wildcard over the platform's own keys | The platform's unparsed payload. Small or above, and explicitly unstable — its shape varies by platform and is not a contract at any tier. |
Below Small, naming raw(*), revisions(changes) or attachments(extracted_text) gets the node withheld and listed in meta.upgrade_hints; under a bare revisions(*) the changes key is simply absent, with no hint.
meta.attachment_count can be lower than the length of attachments(*), and that is not a bug. Some portals auto-generate a cover sheet alongside the real documents; it is a file, not a document the agency wrote, so it is excluded from the count and from has_documents but still listed — flagged is_generated_summary: true. attachment_count answers "does this record hold its solicitation package"; the collection answers "what files exist". A record whose only file is a generated sheet holds no package.
Attachments¶
attachments(*) leaves — available on every plan: name, extension, mime_type, checksum, size_bytes, char_count, word_count, pages, extraction_status, download_status, is_contested, is_generated_summary, first_seen_at.
One further leaf, extracted_text, requires Small or above and is not included by attachments(*) — see below.
There is no download URL on this endpoint. The free metadata is enough to decide whether a document is worth fetching:
size_bytesandchar_countonly mean something as a pair. Three megabytes that yielded no characters is a scan awaiting OCR; three megabytes that yielded 40,000 characters is a specification. Neither number says that alone.extraction_statusiscompleted,emptyorfailed.emptyis extraction succeeding with zero characters — almost always a scanned image, which is a different problem from one that threw.is_contested: truemeans the stored object's checksum disagrees with what this record advertised — another solicitation's bytes under the same key. A contested attachment is served as a name and an existence, never as content.is_generated_summary: trueis the auto-generated cover sheet described above.
extracted_text — the document body¶
GET /api/sled/opportunities/{opportunity_id}/?shape=opportunity_id,attachments(name,size_bytes,extracted_text)
Requires a Small plan or above, and API version 4.25.1 or later. Three rules govern it:
- You have to name it.
attachments(*)does not carry the body, and neither default shape includes it. Addextracted_textto the expand when you want it, and leave it off when you are just listing what documents exist — a record's documents can run to megabytes of text. - The key is absent, not null, whenever the text is not being served to you: below Small (where it is withheld and listed in
meta.upgrade_hints), on a contested document, or where we hold no text for that document. Read it with a default rather than assuming the key is present. - A contested document never returns text, at any plan. The file we retrieved for it does not match what the solicitation listed, so we cannot vouch that its text belongs to this record.
is_contestedis why the body is missing.
Two entries with the same checksum are the same document under two filenames, and return the same body.
This is a per-record read, not a search feature: a ?search= response never carries text at any plan (see Search). char_count reports whether there was text to get, whether or not this response carries it — which is what lets you decide on a Free plan whether the body is worth buying.
Revisions¶
GET /api/sled/opportunities/{opportunity_id}/revisions/
A revision is a diff between two consecutive arbitrated states of one solicitation, stamped with the scrape that saw it. No state portal emits amendment notices, so this is observation rather than publication, and three limits follow from that:
kindis Tango's inference from the diff —deadline_change,status_change,documents_added,documents_removed,documents_replaced,title_change,content_change— on about 95% of revisions.source_declaredistrueonly on the minority where a portal's own amendment marker moved in the same emission.- Resolution is the crawl cadence, not the amendment. A portal restates one page in place and publishes no amendment date, so
observed_atis when we saw the change, not when the agency made it.change_seen_afteron the parent endpoint filters on the same timestamp and inherits the same caveat. - History starts when we started reading that jurisdiction, not when the solicitation was posted. For the most recently added sources that is days, not months.
This route serves enrichment rows, which the revisions(*) expansion does not: an enrichment is Tango filling in coverage it did not have — a description it had not yet read, say — rather than the agency amending anything. It is excluded from revision_count and from the expansion for that reason, but it is real provenance, and ?kind=enrichment shows only those.
Filtering¶
| Param | What it does |
|---|---|
kind |
Revision kind, plus enrichment. Multi-value: use \| for OR. |
source_declared |
Boolean. Whether the portal's own amendment marker moved at this emission. |
observed_after, observed_before |
Observation-date range (YYYY-MM-DD). |
Results are ordered by sequence (1-based within the record) and paginated like every other list.
changes — the per-field before and after — requires Small or above. changed_fields names what moved at every plan, so the Free answer is still a usable amendment log.
Forecasts¶
GET /api/sled/forecasts/
A forecast is a state's own advance notice of something it intends to buy. Maryland is the only state publishing one today; /api/sled/opportunities/coverage/ reports the forecast total alongside the solicitation coverage.
Forecasts carry no liveness at all. There is no deadline to have passed, so a status column would stamp unknown on every row forever. Currency is your call from estimated_advertisement_date — which is the start of the published quarter, not a posting date. estimated_advertisement_raw keeps the portal's own words ("Q3 (Jan.-March 2027)"), and a large share of rows publish no quarter at all.
Filtering¶
search and agency must be at least 2 characters; date filters require YYYY-MM-DD.
| Param | What it does |
|---|---|
state |
Two-letter state code. Multi-value: use \| for OR. |
agency |
Substring match on the buyer's raw text. Min 2 characters. |
procurement_category |
The portal's own category word, passed through verbatim. Multi-value. |
procurement_method |
The portal's own method word, passed through verbatim. Multi-value. |
contract_number |
The number the state expects to award under, where it publishes one in advance. Multi-value. |
incumbent_name |
Substring match on the incumbent vendor's name as published. Not resolved to a Tango entity. Multi-value. |
advertisement_after, advertisement_before |
Estimated-advertisement-date range. Remember the date is a quarter start. |
first_seen_after, first_seen_before |
When Tango first observed the forecast. |
modified_after, modified_before |
When the Tango row last changed. |
search |
Ranked full-text search over title, agency and description. Min 2 characters. |
Ordering¶
ordering= allowlist: rank, estimated_advertisement_date, first_seen_at, last_seen_at, modified. rank requires a non-empty search. estimated_advertisement_date sorts nulls last.
Shaping¶
- List default:
forecast_id,state,agency,title,description,organization(*),estimated_advertisement_date,estimated_advertisement_raw,estimated_value(*),procurement_category,procurement_method,contract_term,contract_number,incumbent_name,mbe_dbe_goal,source_url,first_seen_at,last_seen_at - Detail default: the list fields plus
contact(*),delivery_location,has_documents
| Expand | Leaves | Notes |
|---|---|---|
organization(*) |
state, level, agency |
Narrower than a solicitation's — the forecast feed carries no sub-state jurisdiction. |
contact(*) |
name, email, phone |
As published. |
estimated_value(*) |
min, max, raw |
Parsed from the portal's free-text award band, with the band preserved as raw. A band naming one number is a floor, so max is null — never read a missing max as an unbounded ceiling. |
raw(*) |
wildcard | Small or above, and unstable. |
Pagination¶
Standard page-number pagination on every list, including the nested revisions route:
page(default 1)limit(default 25, max 100)
Responses carry count / next / previous / results. List responses are cached for up to 15 minutes and the cache is scoped by plan, so a Free caller never receives a Small caller's raw(*).
Alerts¶
sled_opportunity is an alertable query type — alerts.sled_opportunity.match, using the same filters documented above. See the webhooks guide.
Unlike exclusions and DIBBS, a SLED solicitation closing does fire an alert. status is a stored column Tango recomputes every fifteen minutes, so a closure is a real change to the record rather than a query-time derivation. sled_forecast is not alertable (no deadline, so no transition); revisions and attachments are not alertable on their own — subscribe to the solicitation and filter on change_seen_after or revision_kind.
What this dataset is not¶
- Not federal, and not joinable to it. No UEI, no PIID, no NAICS/PSC crosswalk, no agency-hierarchy key.
organization(*)here is three strings, not the 7-key federal office payload. - Not a complete picture of any state. See Coverage.
- Not a document repository. Attachment metadata and extraction stats are served; bodies and download URLs are not.
- Not award data.
status=awardedrecords that a portal declared an award; there is no awardee, value or contract record behind it.
Examples¶
# Open solicitations in Texas closing this month
GET /api/sled/opportunities/?state=TX&response_deadline_before=2026-10-01
# Local and education buyers with documents attached
GET /api/sled/opportunities/?jurisdiction=local|education&has_documents=true
# Everything new since your last poll, including dateless standing rosters
GET /api/sled/opportunities/?first_seen_after=2026-09-01&status=open|unknown
# Full-text search, with the matching passage on each row
GET /api/sled/opportunities/?search=environmental%20mitigation&shape=opportunity_id,title,state,snippet,response_deadline
# A category code under any scheme, since scheme tagging is partial
GET /api/sled/opportunities/?category_code=541620
# One solicitation, whatever its status, with its documents
GET /api/sled/opportunities/550e8400-e29b-41d4-a716-446655440000/?shape=opportunity_id,title,status,attachments(*)
# What changed on it, and when we saw it change
GET /api/sled/opportunities/550e8400-e29b-41d4-a716-446655440000/revisions/?kind=deadline_change
# Coverage, before trusting any per-state count
GET /api/sled/opportunities/coverage/
# Maryland forecasts advertising after this quarter
GET /api/sled/forecasts/?state=MD&advertisement_after=2026-10-01
eBuy¶
GSA eBuy requests — the RFQs, RFPs and RFIs agencies post to GSA Schedule holders — are exposed at /api/ebuy/requests/. For field definitions, see the eBuy Data Dictionary.
eBuy is scoped to your own contracts
eBuy is not public data. GSA shows a Schedule holder only the requests posted under the SINs on that holder's own contracts, and Tango keeps the same boundary: you see a request only while your account is linked to a contract it was posted under.
Access needs two things: a paid plan, and a contract link that MakeGov sets up for your account. Reach out to get started. /api/ebuy/access/ tells you which of the two you have.
Authentication: eBuy endpoints require authentication (API key or OAuth2). Unauthenticated requests receive HTTP 401.
Endpoints¶
GET /api/ebuy/access/— whether eBuy is enabled for your account, and through which contractsGET /api/ebuy/requests/— list, with filtering, search, ordering and paginationGET /api/ebuy/requests/{rfq_id}/— one request, with its attachmentsGET /api/ebuy/requests/{rfq_id}/attachments/{doc_seq_num}/download/— download one attachment
The detail path segment is the eBuy request id, e.g. RFQ1835158.
Checking your access¶
Without a contract link, /api/ebuy/requests/ returns an empty list — the same answer as a search with no matches. GET /api/ebuy/access/ tells the two apart:
{
"enabled": false,
"reason": "no_contract_grant",
"contracts": []
}
| Field | Meaning |
|---|---|
enabled |
true when your plan includes eBuy and your account has at least one active contract link. |
reason |
Why enabled is false: tier_required (your plan does not include eBuy) or no_contract_grant (no contract link yet). tier_required wins when both apply. null when enabled. |
contracts |
The GSA Schedule contract numbers linked to your account, sorted. Only ever your own. |
What you can see¶
- A request outside your contracts does not exist, as far as the API is concerned. It is absent from the list, and its detail URL returns 404 — the same answer as an id that was never issued. Nothing in a response tells you that a request exists under someone else's contract.
- Access follows the link, not the moment you fetched. Every call re-checks your contract links. If a link is removed, the requests behind it disappear from the list and their detail and download URLs start returning 404.
- No response names which contract a request was posted under.
?contract_number=narrows to one of your own contracts, but the contract is never a field on the request.
A contract can be linked to you directly or to your account organization. Your access is the union of the two, so everyone in an organization sees the requests under the organization's linked contracts.
status is frozen¶
status is stored exactly as eBuy supplied it — Open or Cancelled — and is never recomputed.
eBuy's feed carries only the requests that are currently active. A request that closes simply stops appearing; it never arrives with a final closed status. So an Open request may have closed months ago.
- Read
last_seen— when Tango last observed the request in eBuy — to judge whether it is still live. A request seen this week is current; one last seen in the spring is not, whatever itsstatussays. close_dateis the buyer's stated deadline, and?close_date_after=is the practical way to ask for requests that have not yet closed.- There is deliberately no
is_openflag and no?open=filter: a flag derived from the clock would call a months-dead request open.
Filtering¶
Most filters accept | (or the word OR) for OR, and match case-insensitively. Date filters require YYYY-MM-DD; invalid dates or inverted ranges return 400 (see Date filters). The _before date filters include the whole of that day.
| Param | What it does |
|---|---|
search |
Ranked full-text search — see Search. Multi-value: use \| for OR. |
rfq_id |
Exact match on the eBuy request id, e.g. RFQ1835158. Multi-value: use \| for OR. |
reference_number |
The buying office's own solicitation or reference number. Matches every spelling variant, dashed and undashed. Multi-value: use \| for OR. |
request_type |
RFQ, RFP or RFI. Multi-value: use \| for OR. |
status |
Open or Cancelled — as supplied, and frozen. Multi-value: use \| for OR. |
sin |
The Special Item Number the request was posted under, e.g. 54151S. Multi-value: use \| for OR. |
schedule |
The GSA schedule the request was posted under. Multi-value: use \| for OR. |
buyer_agency |
The buying department's name as eBuy gives it. Multi-value: use \| for OR. |
agency |
Agency, resolved to the buyer's organization and rolled up its hierarchy, so a department includes the sub-agencies and offices beneath it. Matches by name, abbreviation or code (CGAC, FPDS, organization key or AAC), e.g. ?agency=GSA. Multi-value: use \| for OR. A value that resolves to no organization falls back to an exact match on buyer_agency and never returns 400. buyer_agency alone is the department name as eBuy gives it, with no roll-up. |
contract_number |
Narrow to the requests under one or more of your own linked contracts. A contract your account is not linked to returns nothing, not an error. Multi-value: use \| for OR. |
issue_date_after, issue_date_before |
Issue-date range (YYYY-MM-DD). |
close_date_after, close_date_before |
Close-date range (YYYY-MM-DD). |
Search¶
?search= is a ranked full-text query over each request's title, description, reference number and request id, and the extracted text of its attachments — statements of work, pricing schedules and the like.
A match on the request itself always ranks above a match that is only in an attachment. Attachment text is long, repetitive contracting boilerplate, so a hit there is a weaker signal than a hit in what the buyer wrote; an attachment-only match is still returned, just after every direct match.
Under search= with no explicit ordering, results come back by relevance. An explicit ordering overrides it.
Ordering¶
ordering= allowlist:
issue_date— the default is-issue_date(newest first)close_datelast_seenmodified
Results are tie-broken deterministically, so paging through a large result set never repeats or skips a request.
Sorting descending on close_date puts requests without a close date first. Pair it with close_date_after, which excludes them.
Pagination¶
Standard page-number pagination:
page(default 1)limit(default 25, max 100)
Responses carry count / next / previous / results.
Rate limits¶
eBuy endpoints — list, detail and attachment downloads — count against your account's standard daily and per-minute rate limits, like every other endpoint.
Shaping¶
See Response shaping for the syntax.
- List default:
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 - Detail default: every field, plus
organization(*)andattachments(*)
Two expansions are available:
organization(*)— the buying office resolved to the canonical 7-key office payload (organization_id,office_code,office_name,agency_code,agency_name,department_code,department_name), the same object used on awards and opportunities. Best-effort: some buyers eBuy lists are not federal agencies (the judiciary, Congress, state and local governments), and those resolve tonull.attachments(*)—doc_seq_num,doc_name,doc_type,doc_path,is_link,doc_session_date.attachments(extracted_text)— the text extracted from each document. It must be named:attachments(*)and the detail default do not include it. See Attachment text.
description is long-form and is in the detail default only; name it in ?shape= to get it on a list.
Attachments¶
GET /api/ebuy/requests/RFQ1835158/attachments/3/download/
A request's attachments include both documents and links, told apart by is_link:
- A document (
is_link: false) downloads through the endpoint above, addressed by itsdoc_seq_num. The response is a redirect to a download URL that expires after five minutes — fetch it right away, and request a new one rather than storing it. - A link (
is_link: true) is an outbound URL with no file behind it;doc_pathis the URL. The download endpoint returns 400 for a link, with the URL in the response body.
A download returns 404 when the document has not been captured yet, and — like every eBuy route — when the request is outside your contracts.
Attachment text¶
GET /api/ebuy/requests/RFQ1835158/?shape=rfq_id,title,attachments(doc_seq_num,doc_name,extracted_text)
extracted_text is the text extracted from a document — the same text ?search= matches — so you can read a statement of work or pricing schedule without downloading the file.
- Name it to get it. It is not in the detail default and
attachments(*)does not include it, because a document's text can be long. - It works on the list too. Name it in
?shape=on/api/ebuy/requests/to get the text for every request on the page. - A missing key means there is no text. A link, a scanned document with no readable text, or a document not yet processed omits
extracted_textrather than returningnull.
attachment_count counts every document ever observed on a request, including ones an amendment later removed from eBuy's own page, so it can exceed what eBuy currently shows.
Alerts¶
ebuy_request is an alertable query type — alerts.ebuy_request.match, using the same filters documented above. See the webhooks guide.
- An alert only ever matches requests under your own contracts. Matching uses the contract links of the account that owns the webhook endpoint, re-read on every evaluation, so a removed link stops alerts on the next run.
- An alert fires when a matching request appears or changes. A request closing does not fire, because a closed request simply stops appearing — see
statusis frozen.
Examples¶
# Is eBuy enabled for this key?
GET /api/ebuy/access/
# Open requests under one SIN
GET /api/ebuy/requests/?status=Open&sin=54151S
# Still-open requests closing in the next month
GET /api/ebuy/requests/?close_date_after=2026-10-01&close_date_before=2026-10-31&ordering=close_date
# Search titles, descriptions and attachment text
GET /api/ebuy/requests/?search=cybersecurity%20assessment
# Look a request up by the buyer's own number
GET /api/ebuy/requests/?reference_number=W912DY-26-Q-0012
# Only one of your own contracts
GET /api/ebuy/requests/?contract_number=47QTCA26D0001
# Detail with the buying office and attachments
GET /api/ebuy/requests/RFQ1835158/?shape=rfq_id,title,close_date,organization(*),attachments(doc_seq_num,doc_name,is_link)
# Detail with each document's text
GET /api/ebuy/requests/RFQ1835158/?shape=rfq_id,title,attachments(doc_seq_num,doc_name,extracted_text)
Notes:
- Invalid
shapefields return HTTP 400 with structured validation errors.
Forecasts¶
Forecasts are procurement forecasts exposed at /api/forecasts/. For field definitions, see the Forecasts Data Dictionary. For which agency sources we ingest (HHS, DHS, GSA, COMMERCE, DOE, ED, TREASURY, DOI, DOL, DOT, USDA, VA, NRC, NSF), see Forecasts we track.
Endpoints¶
GET /api/forecasts/(list + filtering + search + ordering)GET /api/forecasts/{pk}/(detail)
Response Shaping¶
Forecasts default to the shaping pipeline — all responses go through shaping even without ?shape=. Use ?shape= to customize which fields are returned.
- List default:
id,source_system,external_id,agency,title,description,anticipated_award_date,fiscal_year,naics_code,is_active,status,primary_contact,place_of_performance,estimated_period,set_aside,contract_vehicle,organization(*) - Detail default: Same as list, plus
raw_dataanddisplay(*)
The organization(*) expand is the canonical 7-key office payload, resolved deterministically from the forecast's agency text against Tango's department-level agency aliases. All 14 agency values currently in the data resolve: HHS, DHS, DOI, GSA, DOE, ED, DOT, VA, DOL, NRC, NSF, COMMERCE, TREASURY, USDA. A forecast whose agency value matches no department alias returns organization: null.
See Response Shaping for full syntax and the Forecasts Data Dictionary for field definitions.
Filtering¶
Core filters (multi-value filters accept | for OR; date filters require YYYY-MM-DD format — invalid dates or inverted ranges return 400, see Date filters):
| Param | What it does |
|---|---|
agency |
Filter by agency acronym. |
source_system |
Filter by source system identifier. |
naics_code |
Filter by exact NAICS. |
naics_starts_with |
Filter by NAICS prefix (e.g. 54). |
fiscal_year |
Filter by exact fiscal year. |
fiscal_year_gte, fiscal_year_lte |
Fiscal year range. |
award_date_after, award_date_before |
Anticipated award date range (YYYY-MM-DD). |
modified_after, modified_before |
Modified-in-Tango date range (YYYY-MM-DD). |
status |
Status (case-insensitive, partial match). |
search |
Full-text search over title/description (vector-backed). |
Ordering¶
Forecasts support ordering= with a strict allowlist:
anticipated_award_datefiscal_yeartitle
Examples:
- Soonest award first:
GET /api/forecasts/?ordering=anticipated_award_date - Most recent fiscal year first:
GET /api/forecasts/?ordering=-fiscal_year
Pagination¶
Forecasts use standard page-number pagination:
page(default 1)limit(max 100)
SDK examples¶
See also: Full SDK method reference — tango-python methods · tango-node methods.
import os
from tango import ShapeConfig, TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
resp = client.list_forecasts(
agency="GSA",
naics_starts_with="54",
ordering="-anticipated_award_date",
limit=10,
shape=ShapeConfig.FORECASTS_MINIMAL,
)
for f in resp.results:
print(f.title, f.anticipated_award_date)
import { ShapeConfig, TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const resp = await client.listForecasts({
agency: "GSA",
naics_starts_with: "54",
ordering: "-anticipated_award_date",
limit: 10,
shape: ShapeConfig.FORECASTS_MINIMAL,
});
for (const f of resp.results) {
console.log(f.title, f.anticipated_award_date);
}
Grants¶
Grant opportunities are exposed at /api/grants/. For field definitions, see the Grants Data Dictionary.
Endpoints¶
GET /api/grants/(list + filtering + search + ordering)GET /api/grants/{grant_id}/(detail)
Response Shaping¶
Grants default to the shaping pipeline — all responses go through shaping even without ?shape=. Use ?shape= to customize which fields are returned.
- List default:
grant_id,opportunity_number,agency_code,title,description,applicant_eligibility_description,funding_activity_category_description,grantor_contact,last_updated, plus expandedorganization(*),status(*),category(*),cfda_numbers(*),applicant_types(*),funding_categories(*),funding_instruments(*),funding_details(*),important_dates(*),additional_info(*) - Detail default: Same as list, plus
synopsis,forecast,opportunity_history
The organization(*) expand is the canonical 7-key office payload, resolved from the grant's agency_code via a segment-walk (e.g. HHS-NIH11 → NIH; USDOJ-OJP-BJA → BJA; trailing fiscal-cycle digits stripped where applicable). Grants from agencies without a canonical organization mapping return organization: null.
See Response Shaping for full syntax and the Grants Data Dictionary for field definitions.
Filtering¶
Core filters (date filters require YYYY-MM-DD format; invalid dates or inverted ranges return 400 — see Date filters):
| Param | What it does |
|---|---|
search |
Full-text search (vector-backed). |
agency |
Filter by agency abbreviation (substring). |
opportunity_number |
Exact opportunity number. |
cfda_number |
CFDA number (substring match). |
status |
Opportunity status (case-insensitive choice). |
applicant_types |
Eligibility/applicant types (case-insensitive choice). |
funding_categories |
Funding category codes (case-insensitive choice). |
funding_instruments |
Funding instruments (case-insensitive choice). |
posted_date_after, posted_date_before |
Posted date range (inclusive, YYYY-MM-DD). |
response_date_after, response_date_before |
Response/deadline date range. |
Ordering¶
Grants support ordering= with a strict allowlist:
posted_date(also acceptslast_updated)deadline_currentrank(relevance ranking; most useful whensearchis present)
Examples:
- Most recently posted first:
GET /api/grants/?ordering=-posted_date - Soonest deadline first:
GET /api/grants/?ordering=deadline_current
Pagination¶
Grants use standard page-number pagination:
page(default 1)limit(max 100)
SDK examples¶
See also: Full SDK method reference — tango-python methods · tango-node methods.
import os
from tango import ShapeConfig, TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
resp = client.list_grants(
agency="HHS",
search="opioid",
ordering="-posted_date",
limit=10,
shape=ShapeConfig.GRANTS_MINIMAL,
)
for g in resp.results:
print(g.grant_id, g.opportunity_number, g.title)
import { ShapeConfig, TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const resp = await client.listGrants({
agency: "HHS",
search: "opioid",
ordering: "-posted_date",
limit: 10,
shape: ShapeConfig.GRANTS_MINIMAL,
});
for (const g of resp.results) {
console.log(g.grant_id, g.opportunity_number, g.title);
}
SBIR/STTR¶
The SBIR/STTR endpoints expose Small Business Innovation Research and Small Business Technology Transfer topics across every participating agency (DoD, NIH, NSF, NASA, DOE, DOT and more), plus the DoD DSIP solicitation cycles they belong to. For field definitions, see the SBIR Data Dictionary.
Authentication: SBIR endpoints require authentication (API key or OAuth2). Unauthenticated requests receive HTTP 401. Available on every plan, including Free — no tier gating on any field.
Endpoints¶
GET /api/sbir/topics/— SBIR/STTR topics (all agencies). Detail lookup bytopic_id.GET /api/sbir/solicitations/— DoD DSIP solicitation cycles. Detail lookup bysolicitation_id.
Each topic belongs to a parent that funds it — a DoD DSIP cycle, a SAM.gov notice, or a Grants.gov announcement — so you can follow a topic through to the opportunity or grant behind it. Topics span every participating agency; solicitations are DoD (DSIP) only.
Topics¶
GET /api/sbir/topics/ — one record per SBIR/STTR topic. Detail: GET /api/sbir/topics/{topic_id}/.
The parent ladder¶
Each topic links to at most one parent, resolved in priority order:
solicitation(*)— the parent DoD DSIP cycle (most DoD topics), joined on the topic'scycle_nameor itssolicitation_number. When both match, thecycle_namematch wins: topics fed from DSIP carry a cycle name, while a cycle'ssolicitation_numbercan be empty.opportunity(*)— the parent SAM.gov notice (e.g. DOT, ARPA-H topics that hang off a notice).grant(*)— the parent Grants.gov announcement (e.g. NIH topics, joined on opportunity number).
A non-DoD topic with no DSIP parent returns solicitation: null and links through opportunity or grant instead. A topic's own close_date reflects its individual schedule and outranks the parent BAA's window — a topic can close on its own date inside an otherwise-open parent.
Lifecycle: activity¶
activity is derived at query time as open, closed, or unknown (topics with no usable date roster). Filter on it with ?activity=open. solicitation_status is sbir.gov's own raw Open/Closed string (source data, not derived) and is exposed separately.
Filtering¶
Date filters require YYYY-MM-DD (invalid dates or inverted ranges return 400, see Date filters). Apart from agency, these filters are single-value (no | OR).
| Param | What it does |
|---|---|
activity |
Lifecycle: open, closed, or unknown. |
agency |
Agency, resolved to an organization and rolled up its hierarchy; accepts several values for OR, separated by a pipe or by OR (?agency=DOD OR NSF). Topics carry department-level values (DOD, NSF, DOE, NASA, HHS, DHS, DOT, EPA, DOC, USDA, ED) and link to that department, so a component agency resolves but matches nothing — NIH topics are filed under HHS, and ?agency=NIH returns none. A value that resolves to no organization falls back to a partial match on the raw agency text, with a warning in meta. |
topic_number |
Topic number, partial match. |
solicitation_number |
Parent solicitation (BAA) number, partial match. |
cycle_name |
DoD DSIP cycle name, exact match (case-sensitive), e.g. DOD_SBIR_2026_P1_CBZ. Only topics fed from DSIP carry one; the rest hold an empty string. This differs from cycle_name on /api/sbir/solicitations/, which is a partial match. |
year |
Solicitation year (exact). |
doc_source |
Document source: grants_gov, dsip, sam_gov, other, none (exact, case-insensitive). |
open_date_after, open_date_before |
Open-date range. |
close_date_after, close_date_before |
Close-date range. |
release_date_after, release_date_before |
Release-date range. |
search |
Full-text search over title, description, and topic number. |
There is no program filter on topics — SBIR/STTR program lives on the parent solicitation. Filter topics by their parent instead (solicitation_number), or query /api/sbir/solicitations/?program=STTR.
Ordering¶
ordering= allowlist: activity, close_date, open_date, release_date, year, modified. Default is newest-modified first.
Shaping¶
- List default:
topic_id,title,agency,year,topic_number,solicitation_number,cycle_name,release_date,open_date,close_date,activity,solicitation_status,topic_url,official_solicitation_url - Detail default: every allowed leaf field plus
solicitation(*),opportunity(*),grant(*),organization(*)
Expandable relations and the keys they resolve to:
solicitation(*)→solicitation_id,solicitation_number,title,program,cycle_name,year,start_date,end_date,out_of_cycle,solicitation_statusopportunity(*)→opportunity_id,title,solicitation_number,response_deadlinegrant(*)→grant_id,opportunity_number,title,response_dateorganization(*)→ the canonical 7-key office payload (organization_id,office_code,office_name,agency_code,agency_name,department_code,department_name), resolved from the topic'sagencytext.nullwhen the value resolves to no organization.
Solicitations¶
GET /api/sbir/solicitations/ — one record per DoD DSIP solicitation cycle. Detail: GET /api/sbir/solicitations/{solicitation_id}/.
Lifecycle: activity¶
activity is derived from end_date as open or closed (no unknown state — solicitations always have a cycle window). solicitation_status is the raw feed status token, exposed separately.
BAA documents¶
The documents(*) expansion returns the solicitation's BAA PDF inventory. Each document resolves to document_id, filename, cycle_name, s3_key, file_size, extraction_status, n_pages, n_chars.
Filtering¶
Date filters require YYYY-MM-DD. Single-value (no | OR).
| Param | What it does |
|---|---|
activity |
Lifecycle: open or closed. |
program |
Program: SBIR or STTR (exact, case-insensitive). |
solicitation_number |
Solicitation number, partial match. |
cycle_name |
DSIP cycle directory name, partial match. (The topics filter of the same name is an exact match.) |
solicitation_status |
Raw feed status token (exact, case-insensitive). |
out_of_cycle |
Boolean. |
year |
Solicitation year (exact). |
start_date_after, start_date_before |
Cycle start-date range. |
end_date_after, end_date_before |
Cycle end-date range. |
search |
Full-text search over title and solicitation number. |
There is no agency filter — DSIP solicitations are DoD only.
Ordering¶
ordering= allowlist: activity, start_date, end_date, year, modified. Default is newest-modified first.
Shaping¶
- List default:
solicitation_id,solicitation_number,title,program,cycle_name,year,start_date,end_date,out_of_cycle,activity,solicitation_status,sol_download_url - Detail default: every allowed leaf field plus
topics(*),documents(*)andorganization(*)
Expandable relations:
documents(*)→document_id,filename,cycle_name,s3_key,file_size,extraction_status,n_pages,n_charstopics(*)→topic_id,title,agency,topic_number,close_date,topic_url(a reduced view; the full topic shape lives at/api/sbir/topics/)organization(*)→ the canonical 7-key office payload (organization_id,office_code,office_name,agency_code,agency_name,department_code,department_name). Always the Department of Defense, since DSIP solicitations are DoD only.
Pagination¶
Both resources use standard page-number pagination:
page(default 1)limit(default 25, max 100)
Examples¶
# Open HHS topics (this is where NIH topics live), timeline fields
GET /api/sbir/topics/?activity=open&agency=HHS&shape=topic_id,title,agency,open_date,close_date,activity
# A topic with its parent solicitation
GET /api/sbir/topics/?solicitation_number=SB251-01&shape=topic_id,title,topic_number,solicitation(solicitation_number,program,cycle_name)
# Open STTR solicitation cycles with their BAA documents
GET /api/sbir/solicitations/?activity=open&program=STTR&shape=solicitation_id,title,cycle_name,documents(filename,s3_key,n_pages)
# Solicitation detail with topics
GET /api/sbir/solicitations/{solicitation_id}/?shape=solicitation_id,title,program,topics(topic_id,title,close_date)
Notes:
*is only valid inside expansions. Use explicit field lists at the root.- Invalid
shapefields return HTTP 400 with structured validation errors.
DIBBS¶
The DLA Internet Bid Board System (DIBBS) endpoints expose small-purchase requests for quote, larger solicitations (RFP/IFB), and awards from the Defense Logistics Agency. For field definitions, see the DIBBS Data Dictionary.
Authentication: DIBBS endpoints require authentication (API key or OAuth2). Unauthenticated requests receive HTTP 401. Available on every plan, including Free — no tier gating on any field.
Endpoints¶
GET /api/dibbs/rfqs/— requests for quote (small purchases)GET /api/dibbs/rfps/— solicitations (RFP/IFB)GET /api/dibbs/awards/— awards (line items)
Each resource supports list + filtering + search + ordering, plus a detail lookup by uuid (GET /api/dibbs/{resource}/{uuid}/). The path segment must be a valid RFC 4122 UUID.
DIBBS is an NSN and part-number world, so nsn, part_number, solicitation, and search filter every resource. All three carry an organization(*) expansion (always the Defense Logistics Agency, resolved to the canonical 7-key office payload).
Read this before you total awards. A DIBBS award record is a line item, not a whole award. A delivery order covering many items appears as one record per item, and
total_contract_priceis the order's total repeated on every one of them. Summing it across records badly overstates spend. To total spend, count each order once. See Awards below.
Shared filters¶
These apply to all three resources. Identifier filters accept | for OR; date filters require YYYY-MM-DD (invalid dates or inverted ranges return 400, see Date filters).
| Param | What it does |
|---|---|
nsn |
National Stock Number (exact, case-insensitive). Multi-value: use | for OR. |
part_number |
Manufacturer part number (exact, case-insensitive). Multi-value: use | for OR. |
solicitation |
DLA solicitation number, e.g. SPE1C126Q0337 (exact, case-insensitive). Multi-value: use | for OR. |
organization |
Filter by the federal-hierarchy key of the buying organization (always DLA). Multi-value: use | for OR. |
search |
Full-text search over nomenclature, NSN, part number, and solicitation number. |
The organization(*) expansion resolves to the canonical 7-key office payload (organization_id, office_code, office_name, agency_code, agency_name, department_code, department_name) — the same object used on awards, opportunities, vehicles, forecasts, grants, and IT Dashboard responses. It is omitted from the default list shape (constant DLA) and included in the default detail shape.
Pagination¶
All three resources use standard page-number pagination:
page(default 1)limit(default 25, max 100)
Default ordering is newest-modified first; uuid is appended to any client ordering to guarantee a stable total order across pages. rank is a valid ordering field on all three but is only meaningful with ?search=.
RFQs¶
GET /api/dibbs/rfqs/ — small-purchase requests for quote.
Open RFQs¶
?open=true returns the RFQs still accepting quotes today, derived from return_by_date (a passed or missing date is not open). ?open=false returns the complement. Every result carries a computed is_open boolean regardless of the filter. Because open/closed is derived at query time, an RFQ simply reaching its return_by_date is not a change to the record.
Filtering¶
Shared filters (above), plus:
| Param | What it does |
|---|---|
open |
Boolean. true returns RFQs still accepting quotes today. |
purchase_request |
Purchase request number (exact, case-insensitive). Multi-value: use | for OR. |
set_aside |
Set-aside flag (Y / N). Multi-value: use | for OR. |
status_code |
11-character composite DIBBS status code, e.g. DLK00L1N000 (exact, case-insensitive). Multi-value: use | for OR. |
quantity_min, quantity_max |
Quantity range. |
return_by_date_after, return_by_date_before |
Close-date range (YYYY-MM-DD). |
issue_date_after, issue_date_before |
Issue-date range (YYYY-MM-DD). |
Ordering¶
ordering= allowlist: rank, return_by_date, issue_date, quantity, modified.
Shaping¶
- List default:
uuid,solicitation,purchase_request,nsn,part_number,nomenclature,quantity,unit_of_issue,set_aside,status_code,return_by_date,issue_date,is_open,document_url - Detail default: the list fields plus
solicitation_formattedandorganization(*)
RFPs¶
GET /api/dibbs/rfps/ — solicitations (RFP/IFB).
Open solicitations¶
?open=true returns the solicitations still accepting offers today, derived from closes_date (a passed or missing date is not open). ?open=false returns the complement. Every result carries a computed is_open boolean.
Filtering¶
Shared filters (above), plus:
| Param | What it does |
|---|---|
open |
Boolean. true returns solicitations still accepting offers today. |
buyer_code |
DLA buyer code (exact, case-insensitive). Multi-value: use | for OR. |
issued_date_after, issued_date_before |
Issue-date range (YYYY-MM-DD). |
closes_date_after, closes_date_before |
Close-date range (YYYY-MM-DD). |
Solicitations do not carry quantity, set_aside, status_code, or purchase_request (those are RFQ-only).
Ordering¶
ordering= allowlist: rank, closes_date, issued_date, modified.
Shaping¶
- List default:
uuid,solicitation,nsn,part_number,nomenclature,buyer_code,issued_date,closes_date,is_open,document_url,tech_docs_url - Detail default: the list fields plus
organization(*)
Awards¶
GET /api/dibbs/awards/ — DIBBS awards, one record per line item.
An award has no open/closed lifecycle, so there is no open filter or is_open field. The resolved awardee entity is included when the awardee's CAGE code matches a registered Tango entity; awardee_cage is always present either way.
The line-item total-price warning¶
total_contract_price is the order's total, replicated verbatim on every line item of that order. Never SUM() it across records — one real order spanning 483 line items reads $9,000,000.00 on all of them, so a naive total reports $4.35 billion for that one order. To total spend, count each order once (dedupe by award_number + delivery_order_number). Filtering and ordering by the per-order price is fine; summing is not. total_contract_price is null on the ~3.5% of rows whose price cell is non-numeric — the raw cell is preserved verbatim in total_contract_price_text (e.g. See Award Doc).
Coverage¶
Awards are complete for those DLA posted to DIBBS on or after 2026-06-09. Before that only a fraction is held, the earliest posted 2026-06-01, so an empty result for an older award number is a coverage gap, not evidence the award does not exist. For those, query the contract by PIID: GET /api/contracts/?piid=SPE7MC26V3036.
award_number holds the award or basic contract. A delivery order's own number (9th character F, the PIID FPDS and USAspending report for the order) is in delivery_order_number, with its parent contract in award_number — so filter orders on delivery_order_number.
Filtering¶
Shared nsn, part_number, solicitation, organization, search (above), plus:
| Param | What it does |
|---|---|
award_number |
Award or basic contract number — for a delivery order, its parent contract (exact, case-insensitive). Multi-value: use | for OR. |
delivery_order_number |
The delivery order's own number (null on basic awards). Multi-value: use | for OR. |
purchase_request |
Purchase request number. Multi-value: use | for OR. |
awardee_cage |
Awardee CAGE code (populated on every award row). Multi-value: use | for OR. |
entity |
UEI of the resolved Tango entity (set only when the CAGE matched a registered entity). Multi-value: use | for OR. |
award_date_after, award_date_before |
Award-date range (YYYY-MM-DD). |
posted_date_after, posted_date_before |
Posted-date range (YYYY-MM-DD). |
total_contract_price_min, total_contract_price_max |
Order price range (per-order, not summable across rows). |
Ordering¶
ordering= allowlist: rank, award_date, posted_date, total_contract_price, modified.
Shaping¶
- List default:
uuid,award_number,delivery_order_number,delivery_order_counter,nsn,part_number,purchase_request,nomenclature,awardee_cage,total_contract_price,total_contract_price_text,award_date,posted_date,last_mod_posting_date,solicitation, plusawardee(uei,cage_code,legal_business_name) - Detail default: the list fields plus
awardee(*)andorganization(*)
The awardee(*) expansion resolves to uei, cage_code, legal_business_name from the linked entity (null when the CAGE is unresolved).
Examples¶
# Open RFQs for a stock number
GET /api/dibbs/rfqs/?open=true&nsn=5310-00-123-4567&shape=uuid,nsn,nomenclature,quantity,return_by_date,is_open
# Solicitations closing in a window
GET /api/dibbs/rfps/?closes_date_after=2026-08-01&closes_date_before=2026-08-31&shape=uuid,solicitation,nsn,closes_date,is_open
# Awards to one vendor by UEI (remember: line items, not orders)
GET /api/dibbs/awards/?entity=ABC123DEF456&shape=uuid,award_number,delivery_order_number,total_contract_price,award_date,awardee(*)
# Detail by uuid
GET /api/dibbs/awards/550e8400-e29b-41d4-a716-446655440000/?shape=uuid,award_number,total_contract_price,awardee(*),organization(*)
Notes:
*is only valid inside expansions. Use explicit field lists at the root.- Invalid
shapefields return HTTP 400 with structured validation errors.
Budget¶
Beta — surface may change
The /api/budget/... endpoints and the /api/entities/{uei}/budget-flows/ action are beta. Data is real and refreshed on the normal cadence, but field names, shape leaves, default fields, filter names, and URL paths may still change based on early-consumer feedback. Pin a specific request shape (?shape=...) in production code, and watch the changelog for the move-to-stable announcement.
Federal-account budget data — what Congress requested, what it enacted, what got apportioned, what's been obligated, and what's been outlayed — joined to FPDS contract activity. Each row is one (federal account × fiscal year).
For task-oriented walkthroughs, see Explore federal budget data. For field definitions, see the Budget Data Dictionary.
Endpoints¶
GET /api/budget/accounts/— list + filter + search.GET /api/budget/accounts/{pk}/— detail.GET /api/budget/accounts/{pk}/recipients/— funding-office × recipient drill-down with hydrated contracts.GET /api/budget/accounts/{pk}/quarters/— TAS-grain SF-133 quarterly flow (FY21+).
Related budget surfaces on other resources:
GET /api/entities/{uei}/budget-flows/— federal accounts that paid a given vendor (the inverse of/recipients/). Documented below.GET /api/organizations/{key}/?shape=...,budget_appropriation(*),budget_spending(*)— org-level rollups. See Organizations.
Filtering¶
| Param | What it does |
|---|---|
search |
Free-text search across account_title, agency_name, bureau_name. |
federal_account_symbol |
Exact match (also __in for multi). E.g. 075-0512. |
fiscal_year |
Exact match, also __in, __gte, __lte. |
data_through_period |
Fiscal period (1–12) the account-year's figures run through. Exact match, also __gte, __lte, __isnull. data_through_period=12 keeps only complete years — see Partial fiscal years. |
agency_code |
CGAC 3-character code. __in for multi-agency queries. |
bureau_name |
Exact, __in, or __icontains for fuzzy matching. |
account_title |
__icontains for fuzzy matching on account title. |
bea_category |
BEA budget enforcement category (Defense, Nondefense, Mandatory, ...). |
on_off_budget |
ON or OFF budget. |
subfunction_code |
OMB budget subfunction code (e.g. 051, 551). |
account_category |
budgetary, credit_financing, request_only or proposed (also __in). Which values to include depends on what you are summing — see Account category. |
Every dollar and ratio field also supports __gte / __lte range filters. The full list of range-filterable fields:
- Dollars:
requested_ba,enacted_ba,apportioned,obligated_total,outlayed_total,unobligated_balance,contract_obligated,contract_outlayed,assistance_obligated,assistance_outlayed - Ratios (capped + raw):
contract_share_of_obligated_capped,obligated_to_apportioned_pct(_capped),apportioned_to_enacted_pct(_capped),obligated_to_enacted_pct(_capped),outlayed_to_obligated_pct(_capped),unobligated_pct - Trends:
enacted_ba_yoy_pct,obligated_yoy_pct,enacted_ba_5yr_cagr,ba_growth_next_year_pct - Request-vs-actual:
actual_vs_requested_contract(_capped)
See Budget Data Dictionary for what each field means and the exploration guide for filter recipes.
Ordering¶
Pass ?ordering=<field> (prefix with - for descending). Allowed sort fields:
fiscal_year, agency_code, federal_account_symbol, enacted_ba, obligated_total, outlayed_total, contract_obligated, contract_share_of_obligated_capped, obligated_to_apportioned_pct_capped, obligated_to_enacted_pct_capped, ba_growth_next_year_pct, actual_vs_requested_contract_capped, enacted_ba_yoy_pct, enacted_ba_5yr_cagr, modified.
Default sort is -fiscal_year, -enacted_ba — the un-filtered list lands on the largest accounts of the most recent fiscal year. Within a fiscal year, accounts with no enacted_ba (null) sort after every other account, and ties break on the account's id, so pages are stable. An explicit ?ordering= also sorts nulls last in both directions, and a field that is not in the list above returns 400 with the allowed fields rather than being ignored.
Pagination¶
Standard page-number pagination: count / next / previous / results.
page(default 1)limit(default 25, max 100)
Response shaping¶
This endpoint supports response shaping via ?shape=.
Default shape (no ?shape= param): identity fields (including data_through_period, right after fiscal_year, and account_category) + lifecycle dollars + the capped ratios that show up in the most common workflows + contract_obligated, contract_share_of_obligated_capped, assistance_obligated, ba_growth_next_year_pct + source_anomalies.
On-demand expansions:
| Expand | Returns | Notes |
|---|---|---|
appendix(*) |
President's Budget Appendix object-class breakdown (16 OMB request buckets — personnel comp, travel, contractual services, grants, etc. — plus granule ID and PDF URL). | null for accounts with no Appendix data (typically large mandatory programs). Coverage: FY08–FY27. |
narratives(*) |
List of Appendix narrative + appropriations text entries for this (FA, FY). | null when no narratives exist. ~5% of accounts have multiple narratives per year (split by subaccount). Text fields can be several KB each — only loaded when explicitly requested. |
# Default shape
/api/budget/accounts/
# Pull everything
/api/budget/accounts/{pk}/?shape=*
# Detail + Appendix breakdown
/api/budget/accounts/{pk}/?shape=*,appendix(*)
# Detail + Appendix breakdown + narrative text
/api/budget/accounts/{pk}/?shape=*,appendix(*),narratives(*)
# Trends cluster
/api/budget/accounts/?shape=federal_account_symbol,account_title,enacted_ba,enacted_ba_yoy_pct,enacted_ba_5yr_cagr,contract_obligated_5yr_cagr
Recipients drill-down¶
GET /api/budget/accounts/{pk}/recipients/
GET /api/budget/accounts/{pk}/recipients/?funding_organization_id={org_uuid}
One row per (funding office × recipient × this account-year) on the contract side, sorted by contract_obligated desc. Each row carries:
| Field | Meaning |
|---|---|
funding_organization_id |
Organization UUID — resolvable via /api/organizations/{key}/. |
funding_office |
Hydrated payload with full Department / Agency / Office hierarchy for the funding org. |
recipient_id |
UEI — resolvable via /api/entities/{uei}/. |
recipient |
Hydrated payload with uei, legal_business_name, dba_name. |
contract_obligated, contract_outlayed |
Dollar flows. |
n_contracts |
Distinct PIID count under this (FA, funding office, recipient). |
contracts[] |
Minimal Contract shape per PIID — key, piid, naics, psc, obligated, award_date. Use key to follow back to the full Contract record. |
The raw PIID array isn't surfaced — each contracts[] row carries its own piid, so the analytics-only consumer can reconstruct the list without bandwidth duplication.
Query params:
funding_organization_id— Narrow to a single funding office (Organization UUID).limit— Page size (default 25, max 100).
Top-level response envelope includes federal_account_symbol and fiscal_year for context.
Quarterly cash flow¶
GET /api/budget/accounts/{pk}/quarters/
GET /api/budget/accounts/{pk}/quarters/?tas=075-2024-2024-0512
One row per (Treasury Account Symbol × quarter × period type) for this account-year. A federal account typically rolls up several TAS (different appropriation-year vintages), so the response is a list rather than a single record.
| Field | Meaning |
|---|---|
tas |
Full Treasury Account Symbol including appropriation year. |
quarter |
1 through 4. |
period |
quarterly or cumulative — both shapes are returned per quarter. |
obligated_quarterly, outlayed_quarterly |
Flow during the quarter. |
obligated_cumulative, outlayed_cumulative |
Running total through end of the quarter. |
quarterly_source |
Provenance flag — which SF-133 snapshot the row came from. |
Coverage: FY21+. Earlier years return an empty page; use the account detail's lifecycle fields (obligated_total, outlayed_total) for FY17–FY20.
Query params:
tas— Narrow to a single TAS.limit— Page size (default 25, max 100).
Entity budget-flows¶
GET /api/entities/{uei}/budget-flows/
GET /api/entities/{uei}/budget-flows/?fiscal_year=2024
The inverse of /recipients/ — given a UEI, list the federal accounts that paid this vendor. One row per (account × funding office × this UEI) flow, sorted by contract_obligated desc. Each row carries surrounding account context (account title, agency, bureau, BEA category, total enacted BA, contract share of obligated) plus the hydrated funding_office and contracts[] payloads (same shape as the recipients drill-down).
Contract-side only — grants and other assistance flows are not in this index.
Query params:
fiscal_year— Restrict to one FY (omit for all years).limit— Page size (default 25, max 100).
This endpoint requires authentication.
Coverage windows¶
| Surface | Years covered |
|---|---|
/api/budget/accounts/ lifecycle |
FY17–FY26 |
appendix(*) shape leaf |
FY08–FY27 |
narratives(*) shape leaf |
FY08–FY27 |
/quarters/ |
FY21+ |
/recipients/, /budget-flows/ |
FY17–FY26 |
Out-of-range requests return empty lists or null-valued leaves — no error.
Capped vs raw ratios¶
Most ratio fields ship in three variants: *_pct, *_pct_capped, and *_pct_capped_flag. The capped variant bounds extreme values (typically at 5.0) so the field sorts and charts cleanly; the flag tells you whether the cap was applied for that row. Use raw when you need exact math, capped when filtering/sorting/visualizing.
Partial fiscal years¶
data_through_period is the fiscal period (1–12) of USAspending File A that an account-year's figures run through, and null when the row has no File A data (for example, an account known only from apportionments or the Budget Appendix). Closed fiscal years read 12. The latest fiscal year is year-to-date and stays below 12 until it closes. The X-Tango-Data-As-Of header (below) names the latest period in the current publication. Until the data pipeline derives the period from the source, rows may carry 12 or null instead of the real period, so treat a value as a hint, not a guarantee.
A partial year is not comparable to a closed one. Beyond the missing months, mid-year figures for indefinite and trust-fund appropriations can run high and settle lower by year-end, so a partial-year enacted_ba can exceed the prior year's final figure without any real increase. Check data_through_period before comparing the latest year with earlier ones, or filter to complete years:
/api/budget/accounts/?fiscal_year__gte=2022&data_through_period=12
Responses also carry an X-Tango-Data-As-Of header with the latest fiscal year and period in the publication (e.g. 2026-P10), and X-Tango-Data-Published-At; both are omitted until a validated publication is live. The header describes the whole response, while data_through_period says it on every row.
Account category¶
account_category is one of four values:
| Value | What it is |
|---|---|
budgetary |
The default: current-law accounts with Treasury File A data. |
credit_financing |
Non-budgetary accounts that record the cash flows of federal direct loans and loan guarantees. They carry apportionments but no obligations, and have no enacted_ba. Their apportionments are not budget authority. |
request_only |
Current-law accounts with a President's Budget / Appendix request (requested_ba) and no File A data, such as NIH's 075-9915. Every execution column (obligations, outlays, apportionments, contract figures) and enacted_ba is null. |
proposed |
Recurring President's Budget requests for programs Congress never created, such as the EV Fund 047-0623. Same shape as request_only, but not current law. |
All four are served and filterable. Which to include when you add rows up depends on the figure:
- Apportionment, obligation and enacted sums: use
account_category=budgetary. Credit financing apportionments are not budget authority, so including them double-counts;request_onlyandproposedrows have null execution figures and add nothing. - A current-law
requested_batotal: useaccount_category__in=budgetary,request_only. Filtering tobudgetaryalone drops the request-only accounts, so the request comes out low. Leavecredit_financingandproposedout.
/api/budget/accounts/?fiscal_year=2025&agency_code=075&account_category=budgetary
/api/budget/accounts/?fiscal_year=2027&agency_code=075&account_category__in=budgetary,request_only
The organization budget_appropriation(*) rollup follows the same rule: it leaves out credit financing and proposed accounts entirely, and adds request_only requested_ba to the department's request (request-only accounts are not counted in its other figures).
Source anomalies¶
source_anomalies is a list in the default shape, [] when the account-year is clean. Each element names one problem found in the source data for that row, and says whether Tango changed the served value (capped) or only reported the problem (flagged).
code |
Condition | action |
|---|---|---|
contract_exceeds_obligations |
USAspending File C contract obligations exceed the account's positive File A total obligations. contract_obligated is capped at obligated_total. |
capped |
assistance_exceeds_obligations |
File C assistance obligations exceed the account's positive File A total obligations. Values are unchanged. | flagged |
contract_without_obligations |
File C reports contract obligations for an account whose File A total obligations are zero or negative. Values are unchanged. | flagged |
The list of codes may grow; treat an unknown code as a reason to look closer rather than an error. There is no filter on anomalies — read the field. Field-by-field definitions of each element are in the data dictionary.
"source_anomalies": [
{
"code": "contract_exceeds_obligations",
"field": "contract_obligated",
"bound_field": "obligated_total",
"action": "capped",
"reported_value": 1250000000.0,
"served_value": 980000000.0,
"likely_cause": "single_row_outlier",
"affected_fields": [
"contract_obligated",
"contract_share_of_obligated_capped",
"contract_obligated_yoy_pct",
"contract_obligated_5yr_cagr",
"actual_vs_requested_contract",
"actual_vs_requested_contract_capped"
],
"message": "USAspending File C contract obligations exceed this account's total obligations (File A). contract_obligated is capped at obligated_total; the excess most likely comes from a misreported File C row.",
"source": {
"dataset": "usaspending_file_c",
"fiscal_year": 2025,
"rows": [
{
"fiscal_period": 9,
"piid": "EXAMPLE0001",
"parent_piid": null,
"tas": "000-X-0000-000",
"reporting_agency_id": "000",
"transaction_obligated_amount": 300000000.0,
"file_c_source": "db_dump"
}
]
}
}
]
Dollar amounts, PIID and TAS in the example are illustrative. Both contract codes carry source.rows: up to three of the largest PIID-linked File C rows on the account and fiscal year, largest first ([] when none were found). assistance_exceeds_obligations carries no rows.
May be empty until the next data refresh
source_anomalies, account_category and data_through_period populate from the budget data publication. Until a publication carrying them is live, source_anomalies is [], account_category is budgetary and data_through_period may be null or 12 on every row.
SDK examples¶
The Python and Node SDKs don't yet expose first-class budget methods (beta). Use raw HTTP — see the exploration guide for curl + Python requests recipes.
IT Dashboard¶
Federal IT investment data from itdashboard.gov exposed at /api/itdashboard/. For field definitions, see the IT Dashboard Data Dictionary.
Endpoints¶
GET /api/itdashboard/(list + filtering + search)GET /api/itdashboard/{uii}/(detail by Unique Investment Identifier)
Filtering¶
Free tier¶
| Param | What it does |
|---|---|
search |
Full-text search over UII, investment title, description, agency name, and bureau name (vector-backed). |
agency_name |
Text search on agency name (e.g., ?agency_name=defense). |
Pro+ tier¶
| Param | What it does |
|---|---|
agency_code |
Filter by numeric agency code (e.g., ?agency_code=21 for the Department of Transportation). |
type_of_investment |
Filter by investment type (case-insensitive, e.g., ?type_of_investment=Major IT Investment). |
updated_time_after, updated_time_before |
Filter by updated time range (e.g., ?updated_time_after=2026-01-01). |
Business+ tier¶
| Param | What it does |
|---|---|
cio_rating |
Filter by exact CIO risk rating: 1=High Risk, 2=Moderately High, 3=Medium, 4=Moderately Low, 5=Low Risk. |
cio_rating_max |
Filter investments at or below a CIO rating threshold (e.g., ?cio_rating_max=2 returns High + Moderately High Risk). |
performance_risk |
Boolean. ?performance_risk=true returns investments with at least one NOT MET performance metric. |
Tier gating
Filters marked Pro+ or Business+ require the corresponding subscription tier. Lower-tier users attempting to use a gated filter receive a 403 response with structured upgrade information.
Pagination¶
IT Dashboard uses page-number pagination (page, limit) and returns count, next, previous, and results.
Response Shaping¶
IT Dashboard supports the ?shape= query parameter. When no ?shape= is provided, the endpoint returns a default shape.
Default shape (list): uii,agency_name,bureau_name,investment_title,type_of_investment,part_of_it_portfolio,updated_time,url,organization(*)
Default shape (detail): adds agency_code and bureau_code (otherwise identical to list).
Available fields (all tiers)¶
| Field | Type | Description |
|---|---|---|
uii |
String | Unique Investment Identifier (e.g., 021-488119819). |
agency_code |
Integer | Numeric agency code. |
agency_name |
String | Agency name. |
bureau_code |
Integer | Numeric bureau code. |
bureau_name |
String | Bureau name. |
investment_title |
String | Investment title. |
type_of_investment |
String | Investment type (e.g., "Major IT Investment"). |
part_of_it_portfolio |
String | IT portfolio classification. |
updated_time |
DateTime | Last updated timestamp from source. |
url |
String | Canonical URL on itdashboard.gov. |
organization_id |
UUID | Underlying foreign key to agencies.Organization; null when no canonical org match. |
Tier-gated leaf field¶
| Field | Tier | Description |
|---|---|---|
business_case_html |
Business+ | Full business case HTML from IT Dashboard. |
Available expansions¶
All shape expansions are available to every tier — the underlying source data is public from itdashboard.gov. Only the business_case_html leaf field requires Business+.
| Expansion | Description |
|---|---|
organization(*) |
Canonical 7-key office payload (organization_id, office_code, office_name, agency_code, agency_name, department_code, department_name). Resolved deterministically from (agency_code, bureau_code). Included in default shape. |
funding(*) |
Fiscal-year funding breakdown (FY2020–FY2025 internal funding and contributions). |
details(*) |
Extended metadata: description, previous/current UII, classification, business case URL, public URLs. |
cio_evaluation(*) |
CIO risk ratings over time (rating, comment, date, latest indicator). |
contracts(*) |
Associated IT contracts. |
projects(*) |
Project details under the investment. |
cost_pools_towers(*) |
Cost pool and tower breakdowns. |
funding_sources(*) |
Funding source details. |
performance_metrics(*) |
Performance metrics with targets and actuals. |
performance_actual(*) |
Historical performance actuals. |
operational_analysis(*) |
Operational analysis data. |
Examples¶
# Free tier: basic search
GET /api/itdashboard/?search=navy
# Pro+ tier: filter by agency + expand funding
GET /api/itdashboard/?agency_code=7&shape=uii,investment_title,url,funding(*)
# Business+ tier: find high-risk investments with full details
GET /api/itdashboard/?cio_rating_max=2&shape=uii,investment_title,agency_name,cio_evaluation(*),performance_metrics(*)
# Business+ tier: underperforming investments
GET /api/itdashboard/?performance_risk=true&shape=uii,investment_title,agency_name,url,performance_metrics(*)
# Detail with everything
GET /api/itdashboard/021-488119819/?shape=uii,investment_title,agency_name,url,funding(*),details(*),cio_evaluation(*),contracts(*),projects(*)
See Response Shaping for syntax and examples.
Protests¶
Protests are bid protest records exposed at /api/protests/. For field definitions, see the Protests Data Dictionary.
A bid protest challenges an award or a solicitation — who should have won. A dispute under a contract already awarded goes to a different forum entirely: see Contract appeals for the Contract Disputes Act decisions of the CBCA and the ASBCA. The two resources share no records.
Authentication: Protests endpoints require authentication (API key or OAuth2). Unauthenticated requests receive HTTP 401.
Coverage: The API covers protest decisions from 2015 onward for GAO and SBA OHA. COFC coverage starts in 2026 and moves forward, and COFC records leave several filterable fields empty — see the Data Dictionary.
Endpoints¶
GET /api/protests/(list + filtering + search + pagination)GET /api/protests/{case_id}/(detail by deterministic case UUID)
Both list and detail return case-level objects identified by case_id (a deterministic UUID derived from source_system + base_case_number). Subordinate dockets are available via ?shape=...,dockets(...) expansion. The path segment must be a valid RFC 4122 UUID (version 1–5, variant 8/9/a/b).
Filtering¶
Core filters:
| Param | What it does |
|---|---|
source_system |
Filter by source system (gao, cofc, or sba_oha). Multi-value: use | for OR. |
outcome |
Filter by protest outcome (for example Denied, Dismissed, Withdrawn, Sustained; SBA OHA also returns Granted, Remanded, Reversed, Vacated). COFC records carry an outcome only once the court issues an opinion. Multi-value: use | for OR. |
case_type |
Filter by case type (for example Bid Protest, Bid Protest: Cost; SBA OHA uses SIZE, NAICS, VSBC, CVE, BDPE, and others). Multi-value: use | for OR. |
agency |
Filter by protested agency text. COFC records carry an agency only once the court issues an opinion. Multi-value: use | for OR. |
case_number |
Filter by case number (B-number, for example b-423274). Multi-value: use | for OR. |
solicitation_number |
Filter by solicitation number. Matches every spelling of the number (W91RUS22R0082 or W91RUS-22-R-0082, any case). Multi-value: use | for OR. |
naics_code |
Filter by the NAICS code at issue, for example 541512. Populated only on SBA OHA size and NAICS appeals. |
protester |
Filter by protester name text. Multi-value: use | for OR. |
filed_date_after, filed_date_before |
Filed date range filters. Bounded by the 2015 coverage floor. |
decision_date_after, decision_date_before |
Decision date range filters. Bounded by the 2015 coverage floor. |
search |
Full-text search over protest searchable fields. |
Ordering¶
Protests do not support custom ordering via ordering=.
Results are returned in a fixed timeline order:
decision_datewhen present, otherwisefiled_date(newest first)- UUID descending as a deterministic tie-breaker
If ordering is provided, the API returns HTTP 400.
Pagination¶
Protests use standard page-number pagination:
page(default 1)limit(max 100)
List response: one result per case¶
The list endpoint returns one result per case (distinct base_case_number), not one per docket. Each result includes case-level fields from the most recent docket in the case. case_id is a deterministic UUID from (source_system, base_case_number) used for grouping and detail lookup. Pagination and filters apply to dockets first; results are then grouped, so count is the total number of matching cases across all pages.
The default (unshaped) response does not include nested dockets. To get dockets, use the dockets expansion via ?shape=...,dockets(...).
Detail response: case-level¶
The detail endpoint (GET /api/protests/{case_id}/) returns a single case-level object, the same structure as a list item. Use ?shape=...,dockets(...) to include nested dockets.
Shaping¶
Protests support shape on both list and detail endpoints:
GET /api/protests/?shape=...GET /api/protests/{case_id}/?shape=...
Both list and detail return case-level objects. You can request case-level fields and the dockets expansion (e.g. shape=case_id,case_number,title,dockets(case_number,docket_number,filed_date)).
Allowed shape fields (case-level for both list and detail):
case_id(deterministic case UUID; use for detail lookup)source_system,case_number(base B-number),title,protester,agencysolicitation_number,case_type,outcomefiled_date,posted_date,decision_date,due_datedocket_url,decision_urldigest(opt-in only; fromraw_data.digest, e.g. decision summary text)challenged_party,naics_code,size_standard,outcome_reason,judge(opt-in only; populated on SBA OHA decisions, null on GAO and COFC)dockets(expand with docket fields, e.g.dockets(case_number,docket_number,filed_date))organization(expand: deterministic resolution of the protested agency to a canonical Tango organization — included in the default response shape; canonical 7-key office payload)resolved_protester(expand: Bayesian entity resolution for the protester name; Pro+ tier)resolved_agency(expand: Bayesian organization resolution for the agency name; Pro+ tier)
Allowed shape fields inside dockets(...):
source_system,case_number(base B-number),docket_number(specific docket, e.g.b-424046.1)title,protester,agency,solicitation_number,case_type,outcomefiled_date,posted_date,decision_date,due_datedocket_url,decision_urldigestchallenged_party,naics_code,size_standard,outcome_reason,judge
The case_number field (e.g. b-424214) identifies the case. The docket_number field (e.g. b-424214.1, b-424214.2) identifies the specific sub-docket within a case and is only available inside dockets(...).
Organization (deterministic): organization(...)¶
The default response shape includes an organization(*) expansion: the canonical 7-key office payload (organization_id, office_code, office_name, agency_code, agency_name, department_code, department_name) resolved deterministically at ingest time to a canonical Tango organization. This is the same payload used on awards, opportunities, vehicles, forecasts, grants, and IT Dashboard responses.
The deterministic organization expand and the Bayesian resolved_agency expand coexist intentionally:
organizationis the canonical "what does our matching pipeline think?" answer — fast, cached, and suitable for joining against the rest of your data.resolved_agency(Pro+) carriesmatch_confidence("confident"/"review") and a human-readablerationale. Useful when you need to know why a particular org was chosen or to surface review-worthy matches to a human.
When the two disagree, prefer organization for canonical use and consult resolved_agency.match_confidence / .rationale to understand the discrepancy.
Entity Resolution: resolved_protester(...)¶
When a protester name has been resolved to a canonical entity in Tango, you can expand the match via resolved_protester(...). Only high-confidence (confident) and medium-confidence (review) matches are returned; unresolved or low-confidence names return null.
Allowed fields inside resolved_protester(...):
uei— Unique Entity Identifier of the matched entityname— Display name of the matched entitymatch_confidence—"confident"(auto-linkable) or"review"(needs human review)rationale— Human-readable explanation of why the match was made
Organization Resolution: resolved_agency(...)¶
When an agency name has been resolved to a canonical organization in Tango, you can expand the match via resolved_agency(...). Same confidence rules as resolved_protester.
Allowed fields inside resolved_agency(...):
key— UUID key of the matched organizationname— Display name of the matched organizationmatch_confidence—"confident"or"review"rationale— Human-readable explanation
Not exposed in the API (internal only):
raw_data,field_provenance,external_id,data_quality,source_last_updated,created,modified- internal search/index fields (for example
search_vector)
Examples:
# Case-level fields and nested dockets
GET /api/protests/?shape=case_id,case_number,title,dockets(case_number,docket_number,filed_date,outcome)
# Case-level only
GET /api/protests/?shape=case_id,source_system,case_number,title,outcome,filed_date
More examples:
# Timeline-focused list payload
GET /api/protests/?source_system=gao&shape=case_id,title,outcome,filed_date,decision_date
# Link-focused payload
GET /api/protests/?shape=case_id,docket_url,decision_url
# Detail by case UUID with shaped response
GET /api/protests/550e8400-e29b-41d4-a716-446655440000/?shape=case_id,source_system,title,agency,protester,filed_date,decision_date
# Detail with nested dockets
GET /api/protests/550e8400-e29b-41d4-a716-446655440000/?shape=case_id,title,dockets(docket_number,filed_date,outcome)
# Entity resolution for protester
GET /api/protests/?shape=case_id,protester,resolved_protester(*)
# Organization resolution for agency
GET /api/protests/?shape=case_id,agency,resolved_agency(*)
# Selective resolution fields
GET /api/protests/?shape=case_id,resolved_protester(uei,match_confidence)
Notes:
- Use the
docketsexpansion to request nested docket fields (e.g.dockets(docket_number,case_number)). *is only valid inside expansions. Use explicit field lists at the root.- Invalid
shapefields return HTTP 400 with structured validation errors.
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 paginationGET /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_numbersis an array, not a string. One decision resolving four consolidated appeals lists all four. - A decision the board stops listing is kept, not deleted.
listedgoes tofalseand the row stays, so a decision you retrieved last month still resolves on itsuuid. Filter?listed=trueif 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_numbersis empty on most ASBCA decisions before then. CBCA's listing gained a decision-type column in 2015, sodecision_typeis 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=5911does not find59116. 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 quoting6682alone does not reach it. - The comma is not a separator. Because a docket can contain commas,
|(orOR) is the only way to ask for several:?docket=59116|59117.
Search¶
?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)appellantfirst_listed_at— when Tango first observed the decision on a board's listing, which is the field to poll onrank— relevance; requires a non-emptysearch, 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_hintsnaming 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_textis 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) andtext_char_counttell 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,
listedflips tofalseand subscribers hear about it. - Newly extracted text fires. A decision gaining its
decision_textis 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
shapefields return HTTP 400 with structured validation errors.
Federal Register¶
Federal Register documents — every rule, proposed rule, notice and presidential document published since 1994 — are exposed at /api/federal_register/. For field definitions, see the Federal Register Data Dictionary.
About 1.01 million documents are available, from January 1994 to the present, and the dataset is updated daily.
Authentication: Federal Register endpoints require authentication (API key or OAuth2). Unauthenticated requests receive HTTP 401.
Plans: every endpoint, every filter and every field — including a document's full_text — is available on every plan, including Free.
Endpoints¶
GET /api/federal_register/— list, with filtering, search, ordering and paginationGET /api/federal_register/{uuid}/— one document
The detail path segment is the document's uuid, and it must be a valid RFC 4122 UUID — anything else is a 404. The uuid is derived from the document number and publication date rather than assigned, so it survives a rebuild of the dataset and is safe to store alongside your own records.
One row is one published document¶
Each row is one document as the Federal Register published it, identified by its document number and publication date together.
Three consequences are worth knowing before your first query:
- A document number is not unique on its own. The Federal Register reused document numbers across genuinely different documents, as late as 2015 —
94-1021is both a trademark-fee rule and a National Library of Medicine meeting notice.?document_number=therefore can return more than one document; add a publication-date filter, or readpublication_dateon each result, to tell them apart. - A correction is its own document. Corrections are published as separate documents with their own numbers (
R1-,C1-, and olderX,ZandEprefixes).correction_ofon the correction andcorrectionson the original carry the link; the two are never merged into one row. typeundercounts 1994. About 32,600 documents carryUncategorized Document, and nearly all of them are from 1994, where it covers 92% of the year.?type=Ruletherefore misses most 1994 rules; usesearch, a CFR citation or an agency filter to reach them.
The agencies, cfr_references, dockets and topics fields are the Federal Register's own structures, served as it publishes them. In particular, the agencies field carries the Federal Register's agency records, not Tango organizations. To filter by the agency code or name you already have from an award or opportunity, use agency — see Filtering by agency.
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 must be at least 2 characters.
| Param | What it does |
|---|---|
search |
Ranked full-text search over the document's title, abstract and action — see Search. Min 2 characters. |
document_number |
Exact match on the Federal Register document number, e.g. 2016-31922. Multi-value: use \| for OR, up to 500 values. |
publication_date_after, publication_date_before |
Publication-date range (YYYY-MM-DD). |
effective_on_after, effective_on_before |
Effective-date range (YYYY-MM-DD). |
comments_close_on_after, comments_close_on_before |
Comment-deadline range (YYYY-MM-DD). |
comments_open |
Boolean — see Open comment periods. |
cfr_title |
A CFR title number, e.g. 40. Matches documents citing any part of that title. |
cfr_part |
A CFR part number, e.g. 52. Requires cfr_title — see Matching a CFR citation. |
agency |
A federal agency or department by name, abbreviation, CGAC or FPDS code, or organization key, e.g. VA, 3600 or HHS. A department includes its sub-agencies. Multi-value: use \| for OR. See Filtering by agency. |
fr_agency |
The Federal Register's agency slug, e.g. environmental-protection-agency. Case-insensitive. Multi-value: use \| for OR. |
type |
Rule, Proposed Rule, Notice, Presidential Document, Correction, Sunshine Act Document or Uncategorized Document. Case-insensitive; any other value returns 400. Multi-value: use \| for OR. |
significant |
Boolean. Documents designated significant under Executive Order 12866. Most documents carry no designation either way, so false returns only documents explicitly marked not significant. |
rin |
A Regulation Identifier Number, e.g. 2060-AV16. Case-insensitive. Multi-value: use \| for OR. |
executive_order_number |
Exact match on an executive order number. |
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.
Filtering by agency¶
There are two agency filters, and they answer different questions.
agency takes the identifier you already hold from the rest of Tango: an abbreviation (VA), a name (Veterans Affairs), a CGAC code (036), an FPDS agency code (3600) or an organization key. It resolves through the same federal hierarchy as agency on awards and opportunities, so the code on a SAM.gov opportunity works here unchanged, and a department includes its sub-agencies (HHS includes NIH and FDA). A document matches when any agency it lists falls inside that organization.
# Everything the VA has published, using the FPDS code from an award or opportunity
GET /api/federal_register/?agency=3600
# VA acquisition-regulation rulemakings (48 CFR)
GET /api/federal_register/?agency=VA&cfr_title=48
fr_agency takes the Federal Register's own slug (veterans-affairs-department) and matches it exactly, with no roll-up.
The response's meta.resolved_filters.agency reports which organization each value matched. Names are matched loosely, so read it whenever you pass a name instead of a code. Two cases are worth checking it for:
- A value that matches no organization is rejected with a 400 when every value misses; when only some miss, the rest still apply and the misses are listed in
meta.warnings. - A value that resolves to an organization with no Federal Register agency mapped into it returns an empty list with a
meta.warningsentry saying so. This happens when the value lands on a single office below the level the Federal Register names; pass the department or agency instead.
Agencies that no longer exist and have no current organization are reachable only through fr_agency.
Matching a CFR citation¶
cfr_title and cfr_part match against the document's cfr_references — the parts of the Code of Federal Regulations the document amends or affects.
GET /api/federal_register/?cfr_title=40
GET /api/federal_register/?cfr_title=40&cfr_part=52
cfr_titlealone finds every document that cites any part of that title.cfr_titlewithcfr_partfinds documents with one reference to that exact title and part —cfr_title=40&cfr_part=52is 40 CFR Part 52. A document citing 40 CFR 60 and 49 CFR 52 does not match.cfr_partwithoutcfr_titlereturns 400: a part number means nothing outside its title.cfr_titlemust be a number; anything else returns 400.
Open comment periods¶
?comments_open=true returns documents whose comment period closes today or later. ?comments_open=false returns documents whose comment period has already closed.
Documents with no comment deadline match neither — false is the closed set, not everything that is not open. Most notices and final rules carry no deadline, so ?comments_open=false is far smaller than the resource.
"Today" is evaluated when you make the request, so the same query returns fewer documents tomorrow. For a fixed window, use comments_close_on_after and comments_close_on_before instead.
Search¶
?search= is a ranked full-text query over each document's title, abstract and action.
Wrap a value in double quotes for a phrase: ?search="regional haze". Without quotes, the terms are matched individually.
Search does not reach the body of the document. full_text is served, but it is not searched; a term that appears only in a document's body will not match. Pair search with a CFR, agency or RIN filter to narrow by subject instead.
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.
Ordering¶
ordering= allowlist:
publication_date— the default is-publication_date(newest first)effective_oncomments_close_ondocument_numberrank— relevance; requires a non-emptysearch, and is the implicit default under one
Results are tie-broken deterministically, so paging through a large result set never repeats or skips a document.
Sorting descending on effective_on or comments_close_on puts documents without that date first. Most documents have neither date, so ?ordering=-comments_close_on alone opens on undated notices. Pair it with the matching range filter, which excludes them: ?comments_close_on_after=2026-01-01&ordering=-comments_close_on.
Pagination¶
Standard page-number pagination:
page(default 1)limit(default 10, max 100)
Responses carry count / next / previous / results.
Shaping¶
Federal Register documents 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,document_number,publication_date,type,subtype,title,abstract,action,agencies,cfr_references,citation,significant,comments_close_on,effective_on,html_url,pdf_url - Detail default: the list fields plus
dates,signing_date,start_page,end_page,volume,docket_ids,dockets,regulation_id_numbers,topics,correction_of,corrections,executive_order_number,presidential_document_number,proclamation_number,comment_url,regulations_dot_gov_url,raw_text_url,body_html_url
Every other field — page_length, toc_doc, toc_subject, disposition_notes, executive_order_notes, not_received_for_publication, public_inspection_pdf_url, regulation_id_number_info, regulations_dot_gov_info and, on the detail endpoint only, full_text — is available by naming it in ?shape=. The full list is in the data dictionary.
full_text — the document body¶
GET /api/federal_register/{uuid}/?shape=uuid,document_number,title,full_text
full_text is the plain-text body of the document, populated for nearly every document in every year from 1994 on. It is available on every plan, and it is detail only, and only when named: it is in neither default shape, and a list request naming it in shape returns 400. A single document can run to several megabytes, so a list page never carries it — find documents on the list endpoint, then fetch each body by uuid.
Alerts¶
federal_register is an alertable query type — alerts.federal_register.match, using the same filters documented above. See the webhooks guide.
An alert fires when a matching document is first published and when a matching document changes. The Federal Register commonly fills in a document's details over the few days after it is published, so expect a document you were alerted to as new to arrive again as modified.
A comment period closing does not fire. comments_open is evaluated at query time from comments_close_on, so a deadline passing is not a change to the document. A comments_open=true alert tells you when a matching document opens for comment, not when its period ends.
Examples¶
# Proposed rules still open for comment
GET /api/federal_register/?type=Proposed%20Rule&comments_open=true
# Everything amending 40 CFR Part 52 since the start of 2025
GET /api/federal_register/?cfr_title=40&cfr_part=52&publication_date_after=2025-01-01
# Significant EPA documents
GET /api/federal_register/?fr_agency=environmental-protection-agency&significant=true
# Proposed rules from one department and its sub-agencies, still open for comment
GET /api/federal_register/?agency=HHS&type=Proposed%20Rule&comments_open=true
# A phrase, ranked by relevance
GET /api/federal_register/?search=%22greenhouse%20gas%22
# Every document under one RIN
GET /api/federal_register/?rin=2060-AV16&ordering=publication_date
# Comment deadlines closing soonest first, within a window
GET /api/federal_register/?comments_close_on_after=2026-10-01&comments_close_on_before=2026-10-31&ordering=comments_close_on
# One document number (may return more than one document before 2016)
GET /api/federal_register/?document_number=2016-31922
# Detail, with the body
GET /api/federal_register/{uuid}/?shape=uuid,document_number,publication_date,title,full_text
Notes:
*is only valid inside expansions; Federal Register documents have none, so use explicit field lists.- Invalid
shapefields return HTTP 400 with structured validation errors.
Lookups
NAICS¶
NAICS codes are exposed at /api/naics/. For field definitions, see the NAICS Data Dictionary.
External references¶
Endpoints¶
GET /api/naics/(list + filtering)GET /api/naics/{code}/(detail)
Metrics: Time-series obligation/award metrics for a NAICS code: GET /api/naics/{code}/metrics/<months>/<period_grouping>/. See Metrics for parameters and behavior.
Filtering¶
| Param | What it does |
|---|---|
search |
Matches code__startswith or description__icontains. |
revenue_limit |
Filter by SBA revenue size standard (in millions of USD). |
revenue_limit_gte, revenue_limit_lte |
Revenue size standard range (in millions). |
employee_limit |
Filter by SBA employee size standard. |
employee_limit_gte, employee_limit_lte |
Employee size standard range. |
Example:
- NAICS starting with 54:
GET /api/naics/?search=54 - NAICS with revenue limit \(\le 15M\):
GET /api/naics/?revenue_limit_lte=15
Ordering¶
/api/naics/ does not support ordering=....
Pagination¶
NAICS uses page-number pagination (static reference pagination defaults to larger pages):
page(default 1)limit(default 1000, max 10000)
Response shaping¶
This endpoint supports response shaping via the shape query parameter.
- Leaves:
code,description - Expansions:
size_standards(employee_limit,revenue_limit)— SBA size standards.revenue_limitis in whole dollars.federal_obligations(total,active)— obligation rollups withawards_obligatedandawards_count.
Default shape (list, no ?shape= param): code,description
Default shape (detail or ?show_limits=1): code,description,size_standards(*),federal_obligations(*)
Deprecation note: The legacy ?show_limits=1 parameter is still supported but produces the same result as ?shape=code,description,size_standards(*),federal_obligations(*). Prefer ?shape= directly.
# List defaults
/api/naics/?shape=code,description
# Full detail equivalent (replaces ?show_limits=1)
/api/naics/?shape=code,description,size_standards(*),federal_obligations(*)
# Size standards only
/api/naics/541330/?shape=code,description,size_standards(*)
# Just the total obligations
/api/naics/541330/?shape=code,federal_obligations(total)
SDK examples¶
See also: Full SDK method reference — tango-python methods · tango-node methods.
The official SDKs don’t yet expose a first-class list_naics() / listNaics() method.
You can still call the endpoint via the SDK’s internal HTTP helper.
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
data = client._get("/api/naics/", params={"search": "5415", "limit": 25})
print("count:", data.get("count"))
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const http = (client as any).http;
const data = await http.get("/api/naics/", { search: "5415", limit: 25 });
console.log("count:", data.count);
Product and Service Code (PSC)¶
Product and Service Code (PSC) reference data is exposed at /api/psc/. For field definitions, see the PSC Data Dictionary.
External references¶
Endpoints¶
GET /api/psc/(list)GET /api/psc/{code}/(detail)
Metrics: Time-series obligation/award metrics for a PSC code: GET /api/psc/{code}/metrics/<months>/<period_grouping>/. See Metrics for parameters and behavior.
Filtering¶
| Param | What it does |
|---|---|
has_awards |
If truthy, restrict to PSCs that appear in award data. |
Response options:
| Param | What it does |
|---|---|
complete |
If truthy, returns a “complete” representation (serializer context-dependent). |
Ordering¶
/api/psc/ does not support ordering=....
Pagination¶
PSC uses page-number pagination (static reference pagination defaults to larger pages):
page(default 1)limit(default 1000, max 10000)
Note: Reference-data endpoints like /api/psc/ allow larger page sizes than transactional award endpoints because the underlying datasets are relatively small and static, and larger pages reduce client round-trips.
Response shaping¶
This endpoint supports response shaping via the shape query parameter.
- Leaves:
code,parent,category,level_1_category,level_1_category_code,level_2_category,level_2_category_code - Expansions:
current(name,active,start_date,end_date,description,includes,excludes)— active or latest descriptionhistorical(name,active,start_date,end_date,description,includes,excludes)— all descriptions
Default shape (no ?shape= param): code,current(*),parent,category,level_1_category,level_1_category_code,level_2_category,level_2_category_code
# Just code and current description name
/api/psc/?shape=code,current(name)
# Code with current and historical descriptions
/api/psc/AD12/?shape=code,current(name,active),historical(name,active)
SDK examples¶
See also: Full SDK method reference — tango-python methods · tango-node methods.
The official SDKs don’t yet expose a first-class list_psc() / listPsc() method.
You can still call the endpoint via the SDK’s internal HTTP helper.
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
data = client._get("/api/psc/", params={"has_awards": "true", "limit": 25})
print("count:", data.get("count"))
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const http = (client as any).http;
const data = await http.get("/api/psc/", { has_awards: "true", limit: 25 });
console.log("count:", data.count);
Business types¶
Business type reference data is exposed at /api/business_types/. For field definitions, see the Business Types Data Dictionary.
Endpoints¶
GET /api/business_types/(list)GET /api/business_types/{code}/(detail)
Value formats¶
code: 2-character alphanumeric, typically uppercase (examples:A6,JT,XX,2R).certifier: In practice you’ll see a small set of values:SBA,AbilityOne,Government,Self.
Filtering¶
None.
Ordering¶
/api/business_types/ does not support ordering=....
Pagination¶
Business types use page-number pagination (static reference pagination defaults to larger pages):
page(default 1)limit(default 1000, max 10000)
Response shaping¶
This endpoint supports response shaping via the shape query parameter.
- Leaves:
name,code - No expansions.
Default shape (no ?shape= param): name,code
# Select a single field
/api/business_types/?shape=name
SDK examples¶
See also: Full SDK method reference — tango-python methods · tango-node methods.
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
resp = client.list_business_types(limit=25)
print("count:", resp.count)
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const resp = await client.listBusinessTypes({ limit: 25 });
console.log("count:", resp.count);
MAS SINs¶
MAS SIN reference data is exposed at /api/mas_sins/. For field definitions, see the MAS SINs Data Dictionary.
Endpoints¶
GET /api/mas_sins/(list)GET /api/mas_sins/{sin}/(detail)
Value formats and normalization¶
sin: String SIN code (examples:334310,541611,561210FS,ANCILLARY).large_category_code: Single-letter category code (example:A).sub_category_code: 3-character subcategory code (example:A01).set_aside_code: One ofY,N,B.service_comm_code: One ofB,C,S,P.naics_codes: List of NAICS code integers. Some SINs may use special values (e.g.0forANCILLARY).expiration_date: Parsed to an ISO date. The source uses aYYYYDDD(year + day-of-year) numeric format in some rows (example:2022181→ 2022-06-30).
Filtering¶
search: matches against SIN code, NAICS code, PSC code, title, and description.
Examples:
- Search by SIN:
GET /api/mas_sins/?search=334310 - Search by NAICS:
GET /api/mas_sins/?search=541611 - Search by PSC:
GET /api/mas_sins/?search=R799 - Search by text:
GET /api/mas_sins/?search=facilities
Ordering¶
/api/mas_sins/ does not support ordering=....
Pagination¶
MAS SINs use page-number pagination (static reference pagination defaults to larger pages):
page(default 1)limit(default 1000, max 10000)
Response shaping¶
This endpoint supports response shaping via the shape query parameter.
- Leaves:
sin,large_category_code,large_category_name,sub_category_code,sub_category_name,psc_code,state_local,set_aside_code,service_comm_code,expiration_date,tdr,olm,naics_codes,title,description - No expansions.
Default shape (no ?shape= param): all 15 fields above.
# Select a subset of fields
/api/mas_sins/?shape=sin,title,description
SDK examples¶
See also: Full SDK method reference — tango-python methods · tango-node methods.
Assistance listings¶
Assistance listings (CFDA) reference data is exposed at /api/assistance_listings/. This is not the assistance transactions endpoint; it’s just the static reference list. For field definitions, see the Assistance Listings Data Dictionary.
Endpoints¶
GET /api/assistance_listings/(list)GET /api/assistance_listings/{number}/(detail;{number}supports digits and.)
Filtering¶
None.
Ordering¶
/api/assistance_listings/ does not support ordering=....
Pagination¶
Assistance listings use standard page-number pagination:
page(default 1)limit(default 1000, max 10000)
Response shaping¶
This endpoint supports response shaping via the shape query parameter.
- Leaves:
number,title,published_date,archived_date,popular_name,objectives,applicant_eligibility,benefit_eligibility - No expansions.
Default shape (list, no ?shape= param): number,title
Note: Detail responses (/api/assistance_listings/{number}/) use the full serializer (includes all fields) when no ?shape= param is provided.
# Include extra fields
/api/assistance_listings/?shape=number,title,objectives
# Detail with specific fields
/api/assistance_listings/10.001/?shape=number,title,objectives,popular_name
SDK examples¶
See also: Full SDK method reference — tango-python methods · tango-node methods.
The official SDKs don’t yet expose a first-class list_assistance_listings() / listAssistanceListings() method.
You can still call the endpoint via the SDK’s internal HTTP helper.
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
data = client._get("/api/assistance_listings/", params={"page": 1, "limit": 25})
print("count:", data.get("count"))
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const http = (client as any).http;
const data = await http.get("/api/assistance_listings/", { page: 1, limit: 25 });
console.log("count:", data.count);
Organizations¶
Organizations are the unified Federal Hierarchy model exposed at /api/organizations/ (departments, agencies, offices, and other intermediate nodes).
If you’re looking for field definitions, see the Organizations Data Dictionary.
Endpoints¶
GET /api/organizations/(list + filtering + search)GET /api/organizations/{identifier}/(detail — accepts either an integer Federal Hierarchy key or a UUID primary key)
Filtering¶
| Param | What it does |
|---|---|
search |
Multi-stage org search (aliases → trigram → full-text → fuzzy). |
type |
Filter by organization type (lenient allowlist; invalid values are ignored + warnings may be returned). |
level |
Filter by hierarchy level (1 = department, 2 = agency, etc.). |
cgac |
Filter by CGAC code. |
parent |
Filter by parent organization (accepts organization key or common names/abbreviations). |
include_inactive |
Include inactive orgs in list results (default false). Detail lookups always return the org regardless of active status. |
Response shaping¶
Organizations support shape=... (see Response Shaping).
Default shape (no ?shape= param):
key, fh_key, name, short_name, type, level, is_active, code, fpds_code, cgac, canonical_code, parent_fh_key, full_parent_path_name
All responses go through the shaping pipeline, even without a ?shape= parameter.
Root fields available via shaping:
| Field | Description |
|---|---|
key |
Tango's internal UUID primary key. |
fh_key |
Zero-padded 9-character string (e.g., "000000001"). Accepted for detail lookups alongside key (UUID). |
name |
Full official name. |
short_name |
Abbreviation or common name (e.g., "DOD"). |
type |
Organization type (DEPARTMENT, AGENCY, OFFICE, etc.). |
level |
Hierarchy depth (1 = department). |
is_active |
Whether the organization is currently active. |
code |
Office or agency code. |
fpds_code |
FPDS 4-digit agency code. |
cgac |
CGAC 3-character code. |
canonical_code |
Level-prefixed canonical code (e.g., "L1:097"). |
fpds_org_id |
FPDS organization identifier. |
aac_code |
Activity Address Code. |
parent_fh_key |
Parent's fh_key (zero-padded). |
full_parent_path_name |
Human-readable hierarchy path. |
l1_fh_key .. l8_fh_key |
Ancestor fh_key at each hierarchy level (zero-padded). |
start_date |
Organization start date from Federal Hierarchy. |
end_date |
Organization end date from Federal Hierarchy. |
logo |
Logo URL, when available. |
summary |
Short description or mission summary. |
mod_status |
Modification status (ACTIVE, INACTIVE). |
description |
Organization description. |
obligations |
Total rolled-up obligations (alias for tree_obligations). |
total_obligations |
Direct obligations for this organization. |
tree_obligations |
Obligations for the entire subtree. |
obligation_rank |
Percentile ranking (1–100) by obligations. |
Expansions:
| Expand | Fields | Notes |
|---|---|---|
parent(...) |
key, fh_key, name, short_name, type, level, is_active, code, cgac |
Immediate parent org. |
ancestors(...) |
fh_key, name, short_name, level |
All ancestors from L1 down to immediate parent. |
children(...) |
key, fh_key, name, short_name, type, level, is_active, code, cgac |
Immediate child organizations. |
department(...) |
code, name, abbreviation |
L1 ancestor as a department. Returns null for level 1 orgs (they are the department). |
agency(...) |
code, name, abbreviation |
L2 ancestor as an agency. Returns null for level 1 and 2 orgs (no agency above them). |
budget_appropriation(...) |
scope, cgac, fiscal_year, n_accounts, summary, top_accounts |
Beta. Department-level appropriation rollup keyed by the org's CGAC. scope is "self" for L1 orgs (the org is the dept) and "department" for L2+ (totals describe the parent dept; sub-orgs inherit the dept's CGAC). When summing across siblings, filter on scope == "self" to avoid double-counting. Supports [fiscal_year=YYYY] bracket param. See Budget. |
budget_spending(...) |
organization_id, fiscal_year, n_orgs_in_rollup, summary, top_accounts |
Beta. Funding-office spending rollup across the org's subtree (self + descendants via FH). Returns null when nothing in the subtree has funding-office activity. Supports [fiscal_year=YYYY] bracket param. See Budget. |
# Default response (no shape param)
/api/organizations/
# Select specific fields
/api/organizations/?shape=fh_key,name,type,level
# Include department and agency expands
/api/organizations/?shape=fh_key,name,department(code,name),agency(code,name,abbreviation)
# Include enriched ancestors
/api/organizations/?shape=fh_key,name,ancestors(fh_key,name,short_name,level)
department and agency expand level behavior
The department expand returns null for level 1 organizations (a department doesn't have a department — it is one). The agency expand returns null for level 1 or 2 organizations (departments and agencies don't have an agency above them). This means list responses with mixed levels work without errors.
Ordering¶
/api/organizations/ does not support ordering=....
(Results are returned in a stable way; searches preserve their own relevance ordering, and non-search lists are returned in a consistent key order.)
Pagination¶
Organizations use standard page-number pagination:
page(default 1)limit(max 100)
SDK examples¶
See also: Full SDK method reference — tango-python methods · tango-node methods.
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
resp = client.list_organizations(
search="Treasury",
type="DEPARTMENT|AGENCY",
include_inactive=False,
limit=25,
)
print("count:", resp.count)
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const resp = await client.listOrganizations({
search: "Treasury",
type: "DEPARTMENT|AGENCY",
include_inactive: false,
limit: 25,
});
console.log("count:", resp.count);
Set-aside codes¶
This page documents the set-aside codes used across Tango’s awards and opportunities APIs (including shaped set_aside(code,description) expansions).
Codes¶
| Code | Meaning |
|---|---|
NONE |
No set aside used |
SBA |
Small Business Set Aside - Total |
8A |
8a Competed |
SBP |
Small Business Set Aside - Partial |
HMT |
HBCU or MI Set Aside - Total |
HMP |
HBCU or MI Set Aside - Partial |
VSB |
Very Small Business |
ESB |
Emerging Small Business Set-Aside |
HZC |
HUBZone Set Aside |
SDVOSBC |
Service-Disabled Veteran-Owned Small Business Set Aside |
BI |
Buy Indian |
IEE |
Indian Economic Enterprise |
ISBEE |
Indian Small Business Economic Enterprise |
HZS |
HUBZone Sole Source |
SDVOSBS |
SDVOSB Sole Source |
8AN |
8(a) Sole Source |
RS |
Reserved for Small Business |
HS3 |
8(a) with HUBZone Preference |
VSA |
Veteran Set Aside |
VSS |
Veteran Sole Source |
WOSB |
Women-Owned Small Business |
EDWOSB |
Economically Disadvantaged Women-Owned Small Business |
WOSBSS |
Women-Owned Small Business Sole Source |
EDWOSBSS |
Economically Disadvantaged Women-Owned Small Business Sole Source |
Source of truth¶
Tango maintains a canonical mapping internally and publishes it here for API consumers.
Utilities
Name Resolution¶
Resolve an entity or organization name to ranked candidates using Tango's Bayesian resolver. This endpoint is useful for data enrichment workflows, entity linking, and research.
Endpoint: POST /api/resolve/
Overview¶
Given a name and target type, the endpoint returns a ranked list of entity or organization candidates. Results are ranked by quality and filtered based on your access tier:
- Free tier: Up to 3 candidates (identifier and display name only)
- Pro tier and above: Up to 5 candidates (includes match quality tier)
The resolver learns from location context (state, city), industry signals (NAICS, PSC codes), and any additional information you provide in the context field. More context typically leads to better matches.
Authentication required: API key or OAuth2 token (all callers must be authenticated; unauthenticated requests receive HTTP 401).
Rate limiting: Standard API rate limits apply (no additional premium throttling for this endpoint).
Request¶
POST /api/resolve/
Content-Type: application/json
X-API-KEY: <key>
{
"name": "Lockheed Martin",
"target_type": "entity",
"state": "MD",
"city": "Bethesda",
"context": "fighter aircraft maintenance, NAICS 336411"
}
Request body fields¶
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Name to resolve (must be non-blank). |
target_type |
string | Yes | Type of target: "entity" or "organization". |
state |
string | No | 2-letter state code (e.g., "MD", "CA"). |
city |
string | No | City name. |
context |
string | No | Freeform text with additional context for better matching. Can include industry references, contract descriptions, agency names, solicitation details, or any other relevant information. More context generally produces more accurate matches. The API does not document which specific signals are extracted from this field. |
Constraints:
namecannot be empty or whitespace-only (400 error)target_typemust be exactly"entity"or"organization"(400 error)stateshould be a valid 2-letter code, but is not validatedcityandcontextare free-form text with no constraints
Response¶
Success (HTTP 200)¶
{
"candidates": [
{
"identifier": "ABC123DEF456",
"display_name": "Lockheed Martin Corporation"
},
{
"identifier": "GHI789JKL012",
"display_name": "Lockheed Martin Aeronautics"
},
{
"identifier": "MNO345PQR678",
"display_name": "LMC Holdings LLC"
}
],
"count": 3
}
Free tier: Returns up to 3 candidates with identifier and display_name fields only.
Pro tier and above: Returns up to 5 candidates with an additional match_tier field:
{
"candidates": [
{
"identifier": "ABC123DEF456",
"display_name": "Lockheed Martin Corporation",
"match_tier": "high"
},
{
"identifier": "GHI789JKL012",
"display_name": "Lockheed Martin Aeronautics",
"match_tier": "medium"
},
{
"identifier": "MNO345PQR678",
"display_name": "LMC Holdings LLC",
"match_tier": "low"
},
{
"identifier": "STU901VWX234",
"display_name": "Lockheed Martin Space",
"match_tier": "low"
},
{
"identifier": "YZA567BCD890",
"display_name": "Lockheed Martin Rotary",
"match_tier": "low"
}
],
"count": 5
}
The match_tier field indicates the quality of the match:
"high"— Confident match, likely the intended target"medium"— Reasonable match, but consider alternatives"low"— Possible match, but verify before using
When fewer candidates are returned than the tier limit, count reflects the actual number returned.
No matches (HTTP 200)¶
{
"candidates": [],
"count": 0
}
Validation errors (HTTP 400)¶
Missing or blank name:
{
"error": "name is required and must not be blank",
"code": "validation_error"
}
Invalid target_type:
{
"error": "target_type must be 'entity' or 'organization'",
"code": "validation_error"
}
Authentication error (HTTP 401)¶
Standard 401 response for unauthenticated requests.
Server error (HTTP 500)¶
If an unexpected error occurs during resolution (rare), the response is:
{
"error": "An unexpected error occurred during resolution",
"code": "server_error"
}
Examples¶
Example 1: Entity resolution with full context¶
curl -X POST https://tango.dev/api/resolve/ \
-H "X-API-KEY: your-api-key" \
-H "Content-Type: application/json" \
-d '{
"name": "Boeing",
"target_type": "entity",
"state": "WA",
"city": "Seattle",
"context": "aerospace defense contractor, NAICS 336414, Department of Defense supplier"
}'
Response (Pro tier):
{
"candidates": [
{
"identifier": "ABC123DEF456",
"display_name": "The Boeing Company",
"match_tier": "high"
},
{
"identifier": "GHI789JKL012",
"display_name": "Boeing Defense, Space and Security",
"match_tier": "high"
},
{
"identifier": "MNO345PQR678",
"display_name": "Boeing Commercial Airplanes",
"match_tier": "medium"
}
],
"count": 3
}
Example 2: Minimal request (free tier)¶
curl -X POST https://tango.dev/api/resolve/ \
-H "X-API-KEY: your-api-key" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Corp",
"target_type": "entity"
}'
Response (Free tier):
{
"candidates": [
{
"identifier": "ABC123DEF456",
"display_name": "Acme Corporation"
}
],
"count": 1
}
Example 3: Organization resolution¶
curl -X POST https://tango.dev/api/resolve/ \
-H "X-API-KEY: your-api-key" \
-H "Content-Type: application/json" \
-d '{
"name": "Naval Sea Systems Command",
"target_type": "organization",
"context": "Department of the Navy"
}'
Response:
{
"candidates": [
{
"identifier": "N6233A00",
"display_name": "Naval Sea Systems Command",
"match_tier": "high"
}
],
"count": 1
}
Best practices¶
-
Provide context: The
contextfield significantly improves match quality. Include any relevant details: industry (NAICS/PSC codes), agency, contract purpose, solicitation details, or other business context. -
Use the right target_type: Use
"entity"for vendors/contractors and"organization"for government agencies or organizational units. -
Location helps: When available, provide
stateand/orcityto disambiguate between entities with similar names in different regions. -
Check match_tier (Pro tier): Use the
match_tierto gauge confidence. High matches are generally safe to use; medium and low matches should be verified. -
Handle no matches: Plan for empty result sets. When no candidates are returned, consider trying alternative names or reducing specificity in the
contextfield.
SDK support¶
See also: Full SDK method reference — tango-python methods · tango-node methods.
Both SDKs expose a typed resolve() method. The Python version returns a ResolveResult dataclass with candidates (list of ResolveCandidate) and count; the Node version returns the same envelope as a typed object.
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
result = client.resolve(
name="Lockheed Martin",
target_type="entity",
state="MD",
context="aerospace defense",
)
print("Found", result.count, "candidates")
for candidate in result.candidates:
print(f" - {candidate.display_name} ({candidate.identifier}) [{candidate.match_tier}]")
# Older SDK versions without client.resolve():
# response = client._post("/api/resolve/", {"name": "...", "target_type": "entity", ...})
# for c in response["candidates"]:
# print(c["display_name"], c["identifier"])
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const response = await client.resolve({
name: "Lockheed Martin",
target_type: "entity",
state: "MD",
context: "aerospace defense",
});
console.log(`Found ${response.count} candidates`);
response.candidates.forEach((candidate) => {
console.log(` - ${candidate.display_name} (${candidate.identifier})`);
});
Tier-based access¶
All requests must be authenticated. Free tier and pro+ tier users receive different response shapes:
| Aspect | Free | Pro+ |
|---|---|---|
| Max candidates | 3 | 5 |
| Fields | identifier, display_name | identifier, display_name, match_tier |
| Rate limit | Standard | Standard |
See Plans & Data Access for tier definitions.
Identifier Validation¶
Validate the format of federal procurement identifiers. This endpoint is useful for intake forms, data cleaning pipelines, or integrations that need to check identifiers before submission.
Endpoint: POST /api/validate/
Overview¶
Given an identifier type and value, the endpoint returns a result of "valid", "not_valid", or "low_confidence". No database lookups are performed — this is pure format validation.
Supported identifier types:
| Type | Description | Rules |
|---|---|---|
piid |
Procurement Instrument Identifier | FAR 4.201: 13–17 alphanumeric chars, valid AAC + fiscal year + instrument type + serial |
solicitation |
Solicitation number | 5–30 chars, contains both letters and digits, matches known procurement ID patterns |
uei |
Unique Entity Identifier | Exactly 12 alphanumeric chars, must not contain letters I or O |
Result values¶
| Result | Meaning |
|---|---|
valid |
Matches a known format with high confidence |
not_valid |
Does not match any recognized format |
low_confidence |
Plausible identifier but doesn't match a named pattern (solicitations only) |
The low_confidence result only applies to solicitation numbers. Solicitation formats vary widely across agencies — some pass basic structural checks (alphanumeric, right length, mixed letters and digits) but don't match any of the ~25 known patterns derived from FPDS data. PIIDs and UEIs have strict formats, so they are always valid or not_valid.
Access: Requires Pro tier (Small+). See Plans & Data Access for tier definitions.
Rate limiting: Standard API rate limits apply.
Request¶
POST /api/validate/
Content-Type: application/json
X-API-KEY: <key>
{
"type": "piid",
"value": "ABCDEF24C1234"
}
Request body fields¶
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | Yes | Identifier type: "piid", "solicitation", or "uei". |
value |
string | Yes | Identifier value to validate (must be non-blank). |
Response¶
Valid identifier (HTTP 200)¶
{
"result": "valid",
"type": "piid",
"value": "ABCDEF24C1234"
}
Invalid identifier (HTTP 200)¶
For PIIDs, the response includes an errors array with specific validation failures:
{
"result": "not_valid",
"type": "piid",
"value": "ABC",
"errors": [
"PIID must be 13-17 characters, got 3"
]
}
For solicitation numbers and UEIs, the response has no errors array:
{
"result": "not_valid",
"type": "uei",
"value": "A1B2C3D4E5I6"
}
Low confidence (HTTP 200, solicitations only)¶
{
"result": "low_confidence",
"type": "solicitation",
"value": "W911NF21C000100000000000000005"
}
Validation errors (HTTP 400)¶
Missing or invalid type:
{
"error": "type must be one of: piid, solicitation, uei",
"code": "validation_error"
}
Missing or blank value:
{
"error": "value is required and must not be blank",
"code": "validation_error"
}
Examples¶
Validate a PIID¶
curl -X POST https://tango.dev/api/validate/ \
-H "X-API-KEY: your-api-key" \
-H "Content-Type: application/json" \
-d '{"type": "piid", "value": "W58RGZ25F1234"}'
Validate a solicitation number¶
curl -X POST https://tango.dev/api/validate/ \
-H "X-API-KEY: your-api-key" \
-H "Content-Type: application/json" \
-d '{"type": "solicitation", "value": "SPE7A619T4321"}'
Validate a UEI¶
curl -X POST https://tango.dev/api/validate/ \
-H "X-API-KEY: your-api-key" \
-H "Content-Type: application/json" \
-d '{"type": "uei", "value": "A1B2C3D4E5F6"}'
Metrics¶
Metrics endpoints return time-series obligation and award counts for a specific entity, NAICS code, or PSC code. They share the same URL pattern and query parameters, so this page describes behavior once, then lists the available endpoints.
How metrics work¶
Each metrics endpoint is:
GET /api/{resource}/{id}/metrics/<months>/<period_grouping>/
- Path parameters (required):
{id}— The resource identifier (entity UEI, NAICS code, or PSC code).months— Lookback window in months (positive integer). Example:24for the last 24 months.period_grouping— How to bucket time:year,quarter, ormonth.
- Query parameters (optional):
group_by— Comma-separated list of dimensions to break out:agency,department. When used, each result row includes the grouped dimension(s). Rolling averages are not supported when grouping; if both are used, rolling is skipped and awarningis returned.fiscal_year— Iftrueor1, buckets use federal fiscal year (Oct–Sep). Default is calendar year.rolling— Iftrueor1, adds a rolling average to each result (when not usinggroup_by).
Response shape (same for all three):
count— Number of result rows.description— Human-readable description of the time range and grouping.warning— Present only when e.g. rolling was requested withgroup_by(rolling skipped).- The resource object —
recipient(entity),naics_code, orpsc_code(the one you queried). results— Array of objects. Each has:year(and optionallymonth,quarterdepending onperiod_grouping),awards_obligated— obligation amount (USD) for the period,subawards_obligated— subaward obligation amount (entity endpoints only),awards_count— number of awards (NAICS and PSC endpoints only),rolling_avg(ifrolling=trueand nogroup_by),department,agency(ifgroup_byincludes them).
Invalid period_grouping, non-positive months, or invalid group_by values return HTTP 400 with an error message.
Available endpoints¶
| Resource | Endpoint | Identifier |
|---|---|---|
| Entity | GET /api/entities/{uei}/metrics/<months>/<period_grouping>/ |
Entity UEI. See Entities. |
| NAICS | GET /api/naics/{code}/metrics/<months>/<period_grouping>/ |
NAICS code. See NAICS. |
| PSC | GET /api/psc/{code}/metrics/<months>/<period_grouping>/ |
PSC code. See PSC. |
All three support the same optional query parameters: group_by, fiscal_year, rolling.
Example¶
Entity obligations for the last 12 months, by month, with rolling average:
GET /api/entities/ABC123DEF456/metrics/12/month/?rolling=true
NAICS code 541512 for the last 24 months by quarter, grouped by department:
GET /api/naics/541512/metrics/24/quarter/?group_by=department
SDK¶
See also: Full SDK method reference — tango-python methods · tango-node methods.
The official SDKs don’t yet expose first-class methods for metrics. Use the HTTP helper with the paths above:
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
data = client._get(
"/api/entities/ABC123DEF456/metrics/12/month/",
params={"rolling": "true"},
)
print("count:", data.get("count"), "results:", len(data.get("results", [])))
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const http = (client as any).http;
const data = await http.get("/api/entities/ABC123DEF456/metrics/12/month/", {
rolling: "true",
});
console.log("count:", data.count, "results:", data.results?.length ?? 0);
Version¶
Inspect the deployed Tango API version and build date. Useful for logging, support tickets, and client-side feature detection.
Endpoint¶
GET /api/version/
No authentication required.
Response¶
| Field | Type | Description |
|---|---|---|
version |
string | Semantic version string (e.g. 4.4.0). Bumped on each release per Keep a Changelog conventions; see the public Changelog. |
date |
string | RFC 2822 build / release date (e.g. "Mon, 08 May 2026 12:17:42 GMT"). |
{
"version": "4.4.0",
"date": "Mon, 08 May 2026 12:17:42 GMT"
}
Example¶
curl -sS https://tango.dev/api/version/
When to use¶
- Bug reports: include the live
versionvalue alongside the request that failed; it speeds up triage. - Client compatibility: an SDK can refuse to start (or warn) when the API is older than its minimum supported version.
- Cache invalidation: a version bump is a good signal to invalidate any client-side cached schemas (e.g. response-shape help retrieved via
?show_shapes=true).
Concepts
Finding Federal Organizations: The Challenge of Incomplete Data Sources¶
When building applications that work with federal procurement and assistance data, one of the first challenges you'll encounter is finding the right organization identifiers. The federal government maintains multiple overlapping but incomplete data sources, each with their own strengths and gaps. In this post, we'll explore why SAM's Federal Hierarchy is authoritative but incomplete, how USAspending fills some gaps but misses others, and how Tango's unified approach helps you find organizations reliably.
The Authoritative Source: SAM Federal Hierarchy¶
The Federal Hierarchy from SAM.gov is the official, authoritative source for federal organization structure. It provides a comprehensive tree of departments, agencies, and offices with unique orgKey identifiers and maintains the canonical parent-child relationships.
However, the Federal Hierarchy has a critical limitation: it's missing entire organizations that appear in transaction data.
For example, USAspending's office file contains thousands of organizations that don't exist in Federal Hierarchy. These include:
- Office
AC6091- "W462 USA AERONAUTICAL SVCS AGG" (Department of Defense office that appears in USAspending financial assistance data but is missing from Federal Hierarchy) - Many DOD offices - Hundreds of Department of Defense offices with codes like
W81YDE,W914J4,W90LDHthat appear in USAspending transaction data but aren't in Federal Hierarchy's structure - Subtier agencies - Organizations like "Center for Nutrition Policy and Promotion" (subtier code
12F3) and "Federal Library and Information Center Committee" (subtier code0363) that exist in USAspending's subtier file but may be missing from Federal Hierarchy
When you're working with contract data from FPDS or financial assistance from USAspending, you'll encounter codes like:
- CGAC codes (like
069for Department of Transportation) - FPDS codes (4-digit agency identifiers used in contract transactions)
- Subtier codes (like
12F3for Center for Nutrition Policy and Promotion) - Office codes (like
15JCRMfor the Criminal Division)
The Federal Hierarchy may have the organization's name and structure, but it often lacks these operational codes that appear in actual transaction data.
USAspending: Filling Some Gaps¶
USAspending's database has data that complement the Federal Hierarchy, divided into top tier, subtier, and offices.
USASpending contains data that are missing from Federal Hierarchy. For example, this includes abbreviations like DOT, DHS, USDA, as well as full names and mission statements.
However, USAspending has a critical gap: it doesn't include all FPDS contract data. FPDS uses its own set of organization identifiers that don't always map cleanly to USAspending's structure. When you're working with contract transactions, you'll encounter organizations that exist in neither Federal Hierarchy nor USAspending:
- Legacy FPDS offices - Historical contract offices that were used in FPDS transactions but have since been reorganized or decommissioned. These appear in FPDS contract data with office codes that don't match any organization in Federal Hierarchy or USAspending's reference files.
- Contract-specific organization IDs - FPDS transaction data includes
fpds_org_idvalues that are specific to contract processing workflows. These identifiers may reference organizations that were valid at the time of the contract but no longer exist in current organization reference data. - Historical agency structures - FPDS contains contract transactions from years past that reference agency codes and department IDs that have changed over time. The organization that awarded a contract in 2010 might have a different code structure today, and the old codes may not appear in either Federal Hierarchy or USAspending's current reference files.
These FPDS identifiers appear in millions of contract transactions but aren't present in USAspending's organization files. Tango addresses this by maintaining legacy organization data from historical FPDS sources, ensuring that even old contract transactions can be properly linked to their awarding organizations.
Tango's Unified Approach¶
Tango consolidates all these sources into a single Organization model with a priority-based field provenance system. Here's how it works:
Data Source Priority¶
Tango loads organizations from multiple sources in priority order:
- Federal Hierarchy (Top Priority) - The authoritative structure
- USAspending (Next Priority) - Fills in missing codes and details
- Legacy models (Historical FPDS) - Backfills from historical data
Higher-priority sources won't be overwritten by lower-priority ones, ensuring that Federal Hierarchy's authoritative structure is preserved while USAspending fills in the operational codes.
Deduplication¶
When multiple Federal Hierarchy records map to the same organizational unit (same canonical code), Tango elects the best representative based on data completeness (name, codes, source priority, freshness) and demotes the others. Demoted records are preserved in the database for historical reference but excluded from API search and list results. Their fh_key values are recorded in the canonical record's history so lookups by any historical identifier still resolve correctly.
Finding Organizations in Tango¶
Tango provides several ways to find organizations, each optimized for different use cases:
1. Search by Name or Alias¶
The /api/organizations/ endpoint supports a search query parameter that uses a multi-stage search strategy:
# Search by abbreviation, acronym, or name
GET /api/organizations/?search=FEMA
GET /api/organizations/?search=Department of Transportation
GET /api/organizations/?search=Treasury OIG # Context-aware search
Or programmatically using the search library:
from agencies.lib.search import search_organizations
# Finds organizations by abbreviation, acronym, or name
results = search_organizations("FEMA")
results = search_organizations("Department of Transportation")
results = search_organizations("Treasury OIG") # Context-aware search
The search handles:
- Exact alias matches - Catches abbreviations like "CIO", "OIG", "FEMA"
- Trigram similarity - Handles typos like "FMEA" → "FEMA"
- Full-text search - Finds organizations by keywords in names
- Context-aware queries - "Treasury OIG" finds the OIG within Treasury
2. Filter or look up by code¶
The list endpoint supports a single code-based filter — cgac (department-level CGAC code). Other codes (FPDS, office, sub-tier) are not exposed as filters; use ?search= or a path lookup instead:
# Filter by CGAC (department-level)
GET /api/organizations/?cgac=069
# Search across aliases / full-text — works for FPDS codes, office codes,
# names, and abbreviations (multi-stage: aliases → trigram → FTS → fuzzy)
GET /api/organizations/?search=2100
GET /api/organizations/?search=15JCRM
GET /api/organizations/?search=FEMA
# Direct lookup by Federal Hierarchy key (zero-padded 9-char string) or UUID
GET /api/organizations/{fh_key}/
GET /api/organizations/{key}/
Note:
?fpds_code=and?code=query parameters are silently ignored (the response includes ameta.warningsentry).fpds_code,fpds_org_id, and the office-levelcodeare available as response leaves, not filters — request them via?shape=...,fpds_code,fpds_org_id,code.
3. Lookup by fh_key¶
The Federal Hierarchy's orgKey (mapped to fh_key in Tango) is used for direct lookups:
GET /api/organizations/{fh_key}/
Note: Tango uses two identifiers:
key(UUID) - The primary key for the Organization model, stable across reloadsfh_key(BigInteger) - The Federal Hierarchy'sorgKeyidentifier, used for API lookups and cross-referencing with SAM.gov
4. Hierarchy Navigation¶
Each organization includes parent relationships and flattened hierarchy paths:
{
"key": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"fh_key": 123456,
"name": "Federal Emergency Management Agency",
"short_name": "FEMA",
"parent_fh_key": 789012,
"l1_name": "Department of Homeland Security",
"l1_short_name": "DHS",
"full_parent_path_name": "Department of Homeland Security > Federal Emergency Management Agency"
}
Field Provenance¶
Every organization tracks which source provided each field and when it was last updated:
{
"field_provenance": {
"name": {
"source": "federal_hierarchy",
"modified_at": "2024-12-01T10:00:00Z"
},
"cgac": {
"source": "usaspending",
"modified_at": "2024-12-01T10:00:00Z"
},
"fpds_code": {
"source": "usaspending",
"modified_at": "2024-12-01T10:00:00Z"
},
"code": {
"source": "legacy",
"modified_at": "2024-12-01T10:00:00Z"
}
}
}
This transparency helps us ensure the reliability of each field and make informed decisions about which identifiers to use.
Practical Examples¶
Example 1: Finding an Organization from a Contract Transaction¶
When processing an FPDS contract transaction, you might see:
agencyID:2100departmentID:097
In Tango, you can find the organization by department CGAC, by searching for the FPDS code (search picks it up), or by name:
# By CGAC (department-level filter)
GET /api/organizations/?cgac=097
# By FPDS code via search (the multi-stage search picks it up via aliases / FTS)
GET /api/organizations/?search=2100
# Or search by name if you know it
GET /api/organizations/?search=Department of the Army
Example 2: Finding an Office from USAspending Data¶
USAspending financial assistance data might reference:
awarding_sub_agency_code:1501awarding_office_code:15JCRM
You can find the office via search:
GET /api/organizations/?search=15JCRM
The response will include the full hierarchy, so you can see it's part of the Department of Justice (CGAC 015).
Example 3: Context-Aware Search¶
If you know you're looking for "Treasury OIG" but aren't sure of the exact code:
GET /api/organizations/?search=Treasury OIG
Tango's contextual search will find the OIG (Office of Inspector General) within the Treasury Department, even if there are multiple OIGs across different departments.
Best Practices¶
- Use
key(UUID) for API references - The UUIDkeyis the primary identifier for storing references to organizations in your application - Use
fh_keyfor Federal Hierarchy lookups - When cross-referencing with SAM.gov or other federal data sources that use the Federal Hierarchy, usefh_key - Use code lookups for transaction matching - When matching transactions, use the specific code type (CGAC, FPDS code, office code) that appears in your data
- Leverage search for user-facing features - The
searchquery parameter handles abbreviations, typos, and context better than exact code matching - Check field_provenance for data quality - Understand which source provided each field to assess reliability
Conclusion¶
Federal organization data is fragmented across multiple sources, each with its own strengths and gaps. SAM's Federal Hierarchy provides the authoritative structure but lacks operational codes. USAspending fills in many codes but doesn't include FPDS contract identifiers. Tango unifies these sources with a priority-based system that preserves authoritative data while filling in the gaps, giving you a single, reliable way to find and reference federal organizations.
Whether you're matching contract transactions, processing financial assistance awards, or building user-facing search features, Tango's unified Organization model and flexible search capabilities help you find the right organization identifiers, regardless of which source your data comes from.
Provenance & auditing¶
Tango ingests data from multiple upstream systems (SAM.gov, USAspending, FPDS, agency feeds, etc.). Those sources overlap, disagree, and update on different cadences.
To keep the dataset consistent (and debuggable), Tango tracks two related concepts:
- Field provenance: “Which source last updated this specific field, and when?”
- Change logs (CDC): “Which fields changed on this row, by what operation (INSERT/UPDATE), and from what source?”
Even though we do not currently expose provenance or changelog tables in the public API, this tracking matters to API consumers because it helps us:
- Explain behavior: why a value changed (or why it didn’t).
- Prevent regressions: avoid overwriting authoritative sources with lower-quality sources.
- Improve reliability: tighten guarantees around “data is ready / stable” moments for downstream consumers.
Terminology¶
- CDC (Change Data Capture): a pattern for recording row-level changes (insert/update/delete) as data is ingested.
change_source: a normalized identifier of the ingest source (e.g."sam","dsbs","usaspending").changed_fields: the list of field names that were changed by an operation.- For UPDATE, this is a true diff.
- For INSERT, this is a best-effort list of fields set by the ingest path.
batch_id/etl_job_id: identifiers used to correlate changes to a specific loader run.
What models have this (so far)¶
Today, Tango tracks provenance/auditing for:
- Organizations
- Entities
- Opportunities
How it works (high level)¶
At ingest time, Tango typically writes incoming data into a staging representation, computes a diff against the current table, records the audit log, and then applies the update.
flowchart LR
A[Upstream source file/API] --> B[Loader parses + normalizes]
B --> C[(Staging / temp representation)]
C --> D[Compute diff vs target]
D --> E[(ChangeLog rows)]
D --> F[(Field provenance updates)]
C --> G[Apply upsert/update to target]
Why this is useful¶
- Multiple sources, different trust levels: provenance lets us define “who wins” per field (and why).
- Better debugging: we can query “what changed the name for UEI X?” or “why did this org’s code change?”
- Future-facing: this lays groundwork for exposing provenance in a controlled way (e.g. support tooling, exports, or opt-in API surfaces).
Example questions we can answer¶
- “What last updated an Organization’s
name(and from which source)?” - “What last updated
legal_business_namefor an entity UEI?” - “Which fields changed on organization FH key 12345 in the last sync?”
- “Did loader X actually change data, or was it a no-op?”
Data Dictionary¶
Tango's Data Dictionary documents the fields you can expect on key resources and what they mean.
If you're looking for how to query/filter/order endpoints, see the API Reference.
Awards¶
Definitive contracts, indefinite-delivery vehicles, other transactions, subawards, and solicitation-level groupings.
- Contracts — Definitive contract awards (consolidated from FPDS and USAspending inputs). Response shaping →
- IDVs — Indefinite delivery vehicles (GWACs, IDIQs, etc.). Response shaping →
- OTAs — Other Transaction Agreements from FPDS. Response shaping →
- OTIDVs — Other Transaction IDVs from FPDS. Response shaping →
- Subawards — Sub-contracts and sub-grants under prime awards; sourced from USAspending/FSRS. Response shaping →
- Vehicles — Solicitation-centric grouping of IDVs. Response shaping →
- GSA eLibrary contracts — Persisted GSA eLibrary contract metadata.
Entities¶
- Entities — Vendors and recipients in federal contracting and assistance; UEI is the canonical identifier (from SAM.gov). Response shaping →
- Exclusions — SAM.gov exclusion records (debarments, suspensions); currency is derived at query time. API reference →
DIBBS¶
- DIBBS — DLA Internet Bid Board System: RFQs, solicitations (RFP/IFB), and awards. Awards are line items — do not sum
total_contract_priceacross rows. API reference →
SBIR/STTR¶
- SBIR/STTR — SBIR/STTR topics and DoD DSIP solicitation cycles; topics link to the opportunity or grant that funds them. API reference →
State & Local — Beta¶
- State & Local — State, local and education solicitations and forecasts;
statusis Tango-derived, and category scheme tagging is partial. API reference →
eBuy¶
- eBuy — GSA eBuy requests scoped to your own Schedule contracts;
statusis frozen as eBuy supplied it, so readlast_seen. API reference →
Forecasts¶
- Forecasts — Upcoming procurement opportunities from agency feeds (e.g., HHS, DHS) before they appear as SAM.gov solicitations. Response shaping →
Grants¶
- Grants — Grant opportunities from Grants.gov (funding opportunities, not assistance transactions). Response shaping →
Budget — Beta¶
- Budget — Federal-account budget data (requested / enacted / apportioned / obligated / outlayed), joined to FPDS contract activity. API reference → · Exploration guide →
IT Dashboard¶
- IT Dashboard — Federal IT investment records from itdashboard.gov, keyed by Unique Investment Identifier (UII). Response shaping →
Protests and appeals¶
Two different proceedings, kept in two resources: a bid protest challenges an award or a solicitation, and a contract appeal disputes a contracting officer's final decision under a contract already awarded.
- Protests — Bid protest cases from GAO, the U.S. Court of Federal Claims, and SBA OHA, with subordinate dockets. API reference →
- Contract appeals — Contract Disputes Act decisions of the CBCA (2007 onward) and the ASBCA (2010 onward); dockets are an array, and a delisted decision is kept. API reference →
Federal Register¶
- Federal Register — Federal Register documents since 1994, keyed by document number and publication date together; agencies and CFR references are served as the Federal Register publishes them. API reference →
Opportunities¶
SAM.gov contract opportunity and notice data.
- Opportunities — Contract opportunities aggregated by parent. Response shaping →
- Notices — Individual SAM.gov notice records (amendments, updates) under an opportunity. Response shaping →
Reference data¶
Lookup tables and codes used across awards and entities.
- Assistance listings (CFDA) — CFDA program numbers and titles for federal grants/assistance. Response shaping →
- Business types — SAM.gov business type codes (e.g., small business, 8(a), HUBZone). Response shaping →
- MAS SINs — MAS Special Item Numbers (SINs) reference data. Response shaping →
- NAICS — North American Industry Classification System codes. Response shaping →
- Organizations — Unified federal hierarchy (departments, agencies, offices in one tree). Response shaping →
- PSC — Product/Service Codes for federal contracting. Response shaping →
Shared¶
Data structures shared across multiple endpoints via response shaping expands.
- Set-aside codes — Set-aside type codes and descriptions.
- Agencies — Agency fields and expansions as they appear in shaped responses. Response shaping →
- Departments — Department fields as they appear in shaped responses. Response shaping →
- Offices — Office fields, flat aliases, and expansions as they appear in shaped responses. Response shaping →
SDKs
SDKs¶
Idiomatic clients for the Tango API in your language of choice.
| SDK | Status | Docs |
|---|---|---|
| Python | Stable | tango-python |
| Node / TypeScript | Stable | tango-node |
Both SDKs cover the full API surface, handle pagination automatically, and share the same response-shaping conventions as the underlying HTTP API. They wrap retries, rate-limit backoff, and webhook signature verification so you don't have to.
Which one should I use?¶
- Python if you're doing data work, scripting, or building backend services in Python.
- Node / TypeScript if you're building a web app, serverless function, or anything in the JS/TS ecosystem.
Both stay in feature parity. If something lands in one, it lands in the other within a release cycle.
More languages?¶
Not on the roadmap yet. The OpenAPI schema at tango.dev/api/ is suitable for generating clients in other languages — open an issue if you want one we should support directly.
Python
Tango Python SDK¶
A modern Python SDK for the Tango API by MakeGov, featuring dynamic response shaping and comprehensive type hints.
Features¶
- Dynamic Response Shaping - Request only the fields you need, reducing payload sizes by 60-80%
- Full Type Safety - Runtime-generated TypedDict types with accurate type hints for IDE autocomplete
- Comprehensive API Coverage - All major Tango API endpoints (contracts, IDVs, OTAs, entities, forecasts, opportunities, notices, grants, protests, webhooks, and more)
- Flexible Data Access - Dictionary-based response objects with validation
- Modern Python - Built for Python 3.12+ using modern async-ready patterns
- Production-Ready - Comprehensive test suite with VCR.py-based integration tests
Installation¶
Requirements: Python 3.12 or higher
pip install tango-python
Or with uv:
uv pip install tango-python
Quick Start¶
from tango import TangoClient, ShapeConfig
# Initialize the client
client = TangoClient(api_key="your-api-key")
# List agencies
agencies = client.list_agencies()
print(f"Found {agencies.count} agencies")
# Get specific agency
agency = client.get_agency("GSA")
print(f"Agency: {agency.name}")
# Search contracts
contracts = client.list_contracts(
limit=10
)
Authentication¶
Most endpoints require an API key. You can obtain one from the Tango API portal.
# With API key
client = TangoClient(api_key="your-api-key")
# From environment variable (TANGO_API_KEY)
client = TangoClient()
Core Concepts¶
Dynamic Response Shaping¶
Response shaping is the most powerful feature of the Tango SDK. It lets you request only the fields you need, dramatically reducing payload sizes and improving performance.
from tango import TangoClient, ShapeConfig
client = TangoClient(api_key="your-api-key")
# Custom shape - only fields you need
contracts = client.list_contracts(
shape="key,piid,recipient(display_name,uei),total_contract_value",
limit=10
)
# Access fields using dictionary syntax OR as an attribute
for contract in contracts.results:
print(f"PIID: {contract['piid']}")
print(f"Recipient: {contract['recipient']['display_name']}")
for contract in contracts.results:
print(f"PIID: {contract.piid}")
print(f"Recipient: {contract.recipient.display_name}")
API Methods¶
Agencies¶
# List all agencies
agencies = client.list_agencies(page=1, limit=25)
# Get specific agency by code
agency = client.get_agency("GSA")
Contracts¶
# List/search contracts with filtering
contracts = client.list_contracts(
page=1,
limit=25,
# Filter parameters
keyword="software",
awarding_agency="4700", # GSA agency code
award_date_gte="2023-01-01",
fiscal_year=2024,
naics_code="541511"
)
# Filter by specific agency
contracts = client.list_contracts(
awarding_agency="4700", # GSA
limit=50
)
Available Filter Parameters:
Text Search:
- keyword - Search contract descriptions (mapped to 'search' API param)
Date Filters:
- award_date_gte, award_date_lte - Award date range
- pop_start_date_gte, pop_start_date_lte - Period of performance start date range
- pop_end_date_gte, pop_end_date_lte - Period of performance end date range
- expiring_gte, expiring_lte - Contract expiration date range
Party Filters:
- awarding_agency, funding_agency - Agency codes, names, abbreviations, or organization UUIDs. Multi-value OR via |.
- recipient_name, recipient_uei - Vendor/recipient filters
Checking how agency filters resolved¶
Agency values are resolved fuzzily, so a token can match an organization you did not intend — which silently scopes the query to that organization's subtree. A short result set is then indistinguishable from "no such records exist". Responses expose what actually happened:
response = client.list_contracts(awarding_agency="HUD|HUDD")
# Tokens that matched nothing and were ignored.
if response.unresolved_agency_tokens:
raise SystemExit(f"dropped: {response.unresolved_agency_tokens}")
# {'awarding_agency': ['HUDD']}
# What the tokens that DID match resolved to — the only way to catch a
# plausible-but-wrong match, where nothing was dropped at all.
for org in response.resolved_agencies.get("awarding_agency", []):
print(org["name"], org["cgac"])
# Department of Housing and Urban Development 086
for warning in response.agency_warnings:
print(warning)
Each entry of response.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) or fuzzy (a looser text match).
A fuzzy match is the one worth checking against the resolved name:
for entry in response.meta["resolved_filters"].get("awarding_agency", []):
if entry.get("matched_by") == "fuzzy":
print(f"{entry['token']!r} loosely matched {entry['resolved']['name']}")
If every token for a filter fails to resolve, the API returns 400 and the SDK raises
TangoValidationError naming the offending value, rather than an empty page.
Classification:
- naics_code, psc_code - Industry/product codes
- set_aside_type - Set-aside type
Type Filters:
- fiscal_year, fiscal_year_gte, fiscal_year_lte - Fiscal year filters
- award_type - Award type code
Identifiers:
- piid - Procurement Instrument Identifier
- solicitation_identifier - Solicitation ID
Sorting:
- sort, order - Sort results (e.g., sort="award_date", order="desc")
Response Options:
- shape, flat, flat_lists - Response shaping options
IDVs, OTAs, OTIDVs¶
# List IDVs (keyset pagination)
idvs = client.list_idvs(limit=25, awarding_agency="4700")
# Get single IDV with shaping
idv = client.get_idv("IDV_KEY", shape=ShapeConfig.IDVS_COMPREHENSIVE)
# OTAs and OTIDVs follow the same pattern
otas = client.list_otas(limit=25)
otidvs = client.list_otidvs(limit=25)
Vehicles¶
vehicles = client.list_vehicles(
search="GSA schedule",
ordering="-vehicle_obligations",
shape=ShapeConfig.VEHICLES_MINIMAL,
)
vehicle = client.get_vehicle("UUID", shape=ShapeConfig.VEHICLES_COMPREHENSIVE)
awardees = client.list_vehicle_awardees("UUID")
orders = client.list_vehicle_orders("UUID", ordering="-obligated")
Entities (Vendors/Recipients)¶
# List entities with filters
entities = client.list_entities(search="Booz Allen", state="VA", limit=25)
# Get specific entity by UEI or CAGE code
entity = client.get_entity("ZQGGHJH74DW7")
Forecasts¶
forecasts = client.list_forecasts(agency="GSA", fiscal_year=2025, limit=25)
Opportunities¶
opportunities = client.list_opportunities(agency="DOD", active=True, limit=25)
Notices¶
notices = client.list_notices(agency="DOD", notice_type="Presolicitation", limit=25)
Grants¶
grants = client.list_grants(agency="HHS", status="F", limit=25) # F = Forecasted
Protests¶
protests = client.list_protests(source_system="gao", outcome="Sustained", limit=25)
protest = client.get_protest("CASE_UUID")
GSA eBuy¶
access = client.get_ebuy_access() # scoped to the schedule contracts linked to your account
requests = client.list_ebuy_requests(status="Open", sin="54151S", limit=25)
request = client.get_ebuy_request("RFQ1835158")
url = client.get_ebuy_attachment_url("RFQ1835158", doc_seq_num=1) # short-lived signed URL
GSA eLibrary Contracts¶
contracts = client.list_gsa_elibrary_contracts(schedule="MAS", limit=25)
contract = client.get_gsa_elibrary_contract("UUID")
Reference Data¶
# Offices, organizations, NAICS, PSC, subawards, business types
offices = client.list_offices(search="acquisitions")
organizations = client.list_organizations(level=1)
naics = client.list_naics(search="software")
get_naics = client.get_naics("541511")
psc = client.list_psc()
subawards = client.list_subawards(prime_uei="UEI123")
business_types = client.list_business_types()
mas_sins = client.list_mas_sins()
assistance = client.list_assistance_listings()
departments = client.list_departments()
Resolve / Validate¶
# Resolve a name to entity/org candidates
result = client.resolve(name="Lockheed Martin", target_type="entity")
for c in result.candidates:
print(c.identifier, c.display_name)
# Validate an identifier
result = client.validate(identifier_type="uei", value="ABCDEF123456")
IT Dashboard¶
investments = client.list_itdashboard_investments(search="cloud", limit=25)
investment = client.get_itdashboard_investment("023-000001234")
Entity Sub-resources¶
contracts = client.list_entity_contracts("ABCDEF123456", limit=25)
idvs = client.list_entity_idvs("ABCDEF123456")
otas = client.list_entity_otas("ABCDEF123456")
metrics = client.get_entity_metrics("ABCDEF123456", months=12, period_grouping="month")
Pagination¶
All list methods return a PaginatedResponse object with metadata:
response = client.list_contracts(limit=25)
print(f"Total results: {response.count}")
print(f"Next page URL: {response.next}")
print(f"Previous page URL: {response.previous}")
# Iterate through results
for contract in response.results:
print(contract['description'])
# Get next page (contracts use keyset/cursor pagination)
if response.next:
next_response = client.list_contracts(cursor=response.cursor, limit=25)
Error Handling¶
The SDK provides specific exception types for different error scenarios:
from tango import (
TangoClient,
TangoAPIError,
TangoAuthError,
TangoNotFoundError,
TangoRateLimitError,
TangoValidationError
)
client = TangoClient(api_key="your-api-key")
try:
contracts = client.list_contracts(limit=10)
except TangoAuthError:
print("Invalid API key or authentication required")
except TangoNotFoundError:
print("Resource not found")
except TangoValidationError as e:
print(f"Invalid parameters: {e.message}")
print(f"Details: {e.response_data}")
except TangoRateLimitError:
print("Rate limit exceeded")
except TangoAPIError as e:
print(f"API error: {e.message}")
Advanced Features¶
Custom Shapes¶
Create custom shapes to request exactly the fields you need:
# Simple fields
contracts = client.list_contracts(
shape="key,piid,description,total_contract_value"
)
# Nested relationships
contracts = client.list_contracts(
shape="key,piid,recipient(display_name,uei),place_of_performance(*))"
)
# Wildcards for all fields
contracts = client.list_contracts(
shape="key,piid,recipient(*)"
)
Flattened Responses¶
The flat=True parameter is passed to the API, which returns dot-notation keys in the raw response. The SDK still wraps the result in a ShapedModel — access nested fields via attribute or dict syntax, not dot-notation string keys:
contracts = client.list_contracts(
shape="key,piid,recipient(display_name,uei)",
flat=True
)
for contract in contracts.results:
# Attribute access
print(contract.recipient.display_name)
# Dict access (nested, not flat string keys)
print(contract['recipient']['display_name'])
Webhook Tooling¶
The SDK ships first-class tooling for building and testing webhook integrations against the Tango API — including signing helpers, a local receiver, and a command-line tool covering the full lifecycle:
pip install 'tango-python[webhooks]'
This adds a tango console script with subcommands for the full webhook lifecycle:
# Discover what's available
tango webhooks list-event-types
tango webhooks fetch-sample --event-type entities.updated
# Local development
tango webhooks listen --port 8011 --secret $SECRET # receiver
tango webhooks simulate --secret $SECRET --event-type entities.updated # sign + print
tango webhooks simulate --secret $SECRET --event-type entities.updated \
--to http://127.0.0.1:8011/tango/webhooks # also POST
# Manage delivery endpoints
tango webhooks endpoints create|list|get|delete
# Force a real test delivery from Tango
tango webhooks trigger
The signing helpers (verify_signature, generate_signature) are pure stdlib and importable from the default install — your receiver code doesn't need the extra:
from tango.webhooks import verify_signature
if not verify_signature(raw_body, secret, request.headers.get("X-Tango-Signature")):
return 401, "invalid signature"
For the full guide — workflow, CLI reference, and programmatic patterns for pytest fixtures — see docs/WEBHOOKS.md.
Type Hints with IDE Support¶
Import TypedDict types for IDE autocomplete:
from tango import TangoClient, ShapeConfig
from tango.shapes import ContractMinimalShaped
client = TangoClient(api_key="your-api-key")
contracts = client.list_contracts(shape=ShapeConfig.CONTRACTS_MINIMAL)
# Type hint enables IDE autocomplete
contract: ContractMinimalShaped = contracts.results[0]
print(contract["piid"]) # IDE knows this field exists
print(contract["recipient"]["display_name"]) # Nested fields too
Development¶
This project uses uv for dependency management and tooling.
Setup¶
# Clone the repository
git clone https://github.com/makegov/tango-python.git
cd tango-python
# Install dependencies with uv
uv sync --all-extras
# Or install dev dependencies only
uv sync --group dev
Testing¶
The SDK includes a comprehensive test suite with: - Unit tests - Fast tests for core functionality - Integration tests - Real API validation using VCR.py cassettes
# Run all tests
uv run pytest
# Run only unit tests
uv run pytest tests/ -m "not integration"
# Run only integration tests
uv run pytest tests/integration/
# Run integration tests with live API (requires TANGO_API_KEY)
export TANGO_API_KEY=your-api-key
export TANGO_USE_LIVE_API=true
uv run pytest tests/integration/
# Refresh cassettes with fresh API responses
export TANGO_API_KEY=your-api-key
export TANGO_REFRESH_CASSETTES=true
uv run pytest tests/integration/
See tests/integration/README.md for detailed testing documentation.
Code Quality¶
# Format code
uv run ruff format tango/
# Lint code
uv run ruff check tango/
# Type checking
uv run mypy tango/
# Run all checks
uv run ruff format tango/ && uv run ruff check tango/ && uv run mypy tango/
Project Structure¶
tango-python/
├── tango/ # Main SDK package
│ ├── __init__.py # Public API exports
│ ├── client.py # TangoClient implementation
│ ├── models.py # Data models and shape configs
│ ├── exceptions.py # Exception classes
│ └── shapes/ # Dynamic model system
│ ├── __init__.py # Shapes package exports
│ ├── parser.py # Shape string parser
│ ├── generator.py # TypedDict generator
│ ├── factory.py # Instance factory
│ ├── schema.py # Schema registry
│ ├── explicit_schemas.py # Predefined schemas (Contract, Entity, Grant, etc.)
│ ├── models.py # Shape specification models
│ └── types.py # TypedDict exports
├── tests/ # Test suite
│ ├── __init__.py
│ ├── conftest.py # Pytest configuration
│ ├── test_client.py # Unit tests for client
│ ├── test_models.py # Model tests
│ ├── test_shapes.py # Shape system tests
│ ├── cassettes/ # VCR.py HTTP cassettes
│ └── integration/ # Integration tests
│ ├── __init__.py
│ ├── README.md # Integration test docs
│ ├── conftest.py # Integration test fixtures
│ ├── validation.py # Validation utilities
│ ├── test_agencies_integration.py
│ ├── test_contracts_integration.py
│ ├── test_entities_integration.py
│ ├── test_forecasts_integration.py
│ ├── test_grants_integration.py
│ ├── test_naics_integration.py
│ ├── test_notices_integration.py
│ ├── test_offices_integration.py
│ ├── test_opportunities_integration.py
│ ├── test_organizations_integration.py
│ ├── test_otas_otidvs_integration.py
│ ├── test_protests_integration.py
│ ├── test_reference_data_integration.py
│ ├── test_subawards_integration.py
│ ├── test_vehicles_idvs_integration.py
│ └── test_edge_cases_integration.py
├── docs/ # Documentation
│ ├── API_REFERENCE.md # Complete API reference
│ ├── DEVELOPERS.md # Developer guide
│ ├── SHAPES.md # Shape system guide
│ └── quick_start.ipynb # Interactive quick start
├── scripts/ # Utility scripts
│ ├── README.md
│ ├── check_filter_shape_conformance.py # Filter + shape conformance (CI)
│ ├── fetch_api_schema.py
│ ├── generate_schemas_from_api.py
│ └── pr_review.py # PR validation (lint, types, tests, conformance)
├── pyproject.toml # Project configuration
├── uv.lock # Dependency lock file
├── LICENSE # MIT License
├── CHANGELOG.md # Version history
└── README.md # This file
Documentation¶
- Shape System Guide - Comprehensive guide to response shaping
- API Reference - Detailed API documentation
- Developer Guide - Technical documentation for developers
- Webhooks Guide - Workflow, CLI reference, and programmatic patterns for webhook integrations
- Quick Start Notebook - Interactive Jupyter notebook with examples
Requirements¶
- Python 3.12 or higher
- httpx >= 0.27.0
License¶
MIT License - see LICENSE for details.
Support¶
For questions, issues, or feature requests:
- Email: [email protected]
- Issues: GitHub Issues
- Documentation: https://docs.makegov.com/tango-python
Contributing¶
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Run lint and format:
uv run ruff format tango/ && uv run ruff check tango/ - Run type checking:
uv run mypy tango/ - Run tests:
uv run pytest - (Optional) Run filter and shape conformance if you have the tango API manifest; CI will run it on push/PR
- Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
For a single command that runs formatting, linting, type checking, and tests (and conformance when the manifest is present), use: uv run python scripts/pr_review.py --mode full
Client Configuration¶
TangoClient is the entry point for every API call. This guide covers the constructor, the rate-limit and response-inspection properties, and how the client handles authentication and transport.
For per-method signatures, see API_REFERENCE.md. For error handling, see ERRORS.md. For shaping responses, see SHAPES.md.
Constructor¶
from tango import TangoClient
client = TangoClient(
api_key="your-api-key", # or set TANGO_API_KEY env var
base_url="https://tango.makegov.com", # default
user_agent="my-app/1.0", # optional custom User-Agent
extra_headers={"X-Custom": "val"}, # optional additional headers
)
| Parameter | Type | Default | Description |
|---|---|---|---|
api_key |
str \| None |
None |
API key. Falls back to TANGO_API_KEY environment variable. |
base_url |
str |
"https://tango.makegov.com" |
Base URL for the Tango API. |
user_agent |
str \| None |
None |
Custom User-Agent string appended to the default. |
extra_headers |
dict[str, str] \| None |
None |
Additional HTTP headers sent with every request. |
The client uses httpx under the hood with a 30-second timeout. The API key is sent as an X-API-KEY header on every request.
Properties¶
rate_limit_info¶
Returns rate limit information from the most recent API response.
resp = client.list_contracts(limit=5)
info = client.rate_limit_info
if info:
print(f"Remaining: {info.remaining}/{info.limit}")
print(f"Resets in: {info.reset}s")
print(f"Daily remaining: {info.daily_remaining}/{info.daily_limit}")
The RateLimitInfo object exposes:
| Field | Type | Description |
|---|---|---|
limit |
int \| None |
Request limit for the current window |
remaining |
int \| None |
Requests remaining in the current window |
reset |
int \| None |
Seconds until the window resets |
daily_limit |
int \| None |
Daily request limit |
daily_remaining |
int \| None |
Daily requests remaining |
daily_reset |
int \| None |
Seconds until the daily limit resets |
burst_limit |
int \| None |
Burst request limit |
burst_remaining |
int \| None |
Burst requests remaining |
burst_reset |
int \| None |
Seconds until the burst limit resets |
last_response_headers¶
Returns the full HTTP headers from the most recent API response, as an httpx.Headers object.
resp = client.list_contracts(limit=5)
headers = client.last_response_headers
print(headers["content-type"])
Retry Semantics¶
The SDK does not include built-in retry or backoff. Each method call maps to exactly one HTTP request. If you need retry-on-429 or retry-on-transient-error behavior, wrap your calls (or catch TangoRateLimitError and use wait_in_seconds).
See the Rate Limits guide for recommended strategies.
API Reference¶
Complete reference for all Tango Python SDK methods and functionality.
Table of Contents¶
- Client Initialization
- Agencies
- Offices
- Organizations
- Contracts
- IDVs
- OTAs
- OTIDVs
- Subawards
- Vehicles
- Entities
- Forecasts
- Opportunities
- Notices
- State \& Local (SLED)
- Grants
- GSA eLibrary Contracts
- Protests
- Contract Appeals
- Federal Register
- GSA eBuy
- Budget
- Business Types
- NAICS
- Webhooks
- Response Objects
- ShapeConfig (predefined shapes)
- Error Handling
Client Initialization¶
TangoClient¶
Initialize the Tango API client.
from tango import TangoClient
# With API key
client = TangoClient(api_key="your-api-key")
# From environment variable (TANGO_API_KEY)
client = TangoClient()
# Custom base URL (for testing or different environments)
client = TangoClient(api_key="your-api-key", base_url="https://custom.api.url")
Parameters:
- api_key (str, optional): Your Tango API key. If not provided, will load from TANGO_API_KEY environment variable.
- base_url (str, optional): Base URL for the API. Defaults to https://tango.makegov.com.
Agencies¶
Government agencies that award contracts and manage programs.
list_agencies()¶
List all federal agencies.
agencies = client.list_agencies(page=1, limit=25)
Parameters:
- page (int): Page number (default: 1)
- limit (int): Results per page (default: 25, max: 100)
- search (str, optional): Search term to filter agencies by name
Returns: PaginatedResponse with Agency dataclass objects
Example:
agencies = client.list_agencies(limit=10)
print(f"Found {agencies.count} total agencies")
for agency in agencies.results:
print(f"{agency.code}: {agency.name}")
get_agency()¶
Get a specific agency by code.
agency = client.get_agency("GSA")
Parameters:
- code (str): Agency identifier. Accepts CGAC ("097"), FPDS code ("4712"), short code ("GSA"), abbreviation, or canonical name. See Federal agency hierarchy for code semantics.
Returns: Agency dataclass with agency details
Example:
gsa = client.get_agency("GSA")
print(f"Name: {gsa.name}")
print(f"Abbreviation: {gsa.abbreviation or 'N/A'}")
if gsa.department:
print(f"Department: {gsa.department.name}")
Agency Fields:
- code - Agency code
- name - Full agency name
- abbreviation - Short name
- department - Parent department (if applicable)
Offices¶
Federal agency offices.
list_offices()¶
List offices with optional search.
offices = client.list_offices(page=1, limit=25, search="acquisitions")
Parameters:
- page (int): Page number (default: 1)
- limit (int): Results per page (default: 25, max: 100)
- search (str, optional): Search term
Returns: PaginatedResponse with office dictionaries
get_office()¶
Get a specific office by code.
office = client.get_office(code="4732XX")
Parameters:
- code (str): Office code
Returns: Dictionary with office details
Organizations¶
Federal organizations (hierarchical agency structure).
list_organizations()¶
List organizations with filtering and shaping.
organizations = client.list_organizations(
page=1,
limit=25,
shape=ShapeConfig.ORGANIZATIONS_MINIMAL,
# Filter parameters
cgac=None,
include_inactive=None,
level=None,
parent=None,
search=None,
type=None,
)
Parameters:
- page (int): Page number (default: 1)
- limit (int): Results per page (default: 25, max: 100)
- shape (str, optional): Response shape string
- flat (bool): Flatten nested objects (default: False)
- flat_lists (bool): Flatten arrays with indexed keys (default: False)
Filter Parameters:
- cgac - Filter by CGAC code
- include_inactive - Include inactive organizations
- level - Filter by organization level
- parent - Filter by parent organization
- search - Search term
- type - Filter by organization type
Returns: PaginatedResponse with organization dictionaries
get_organization()¶
Get a specific organization by fh_key.
org = client.get_organization(fh_key="ORG_KEY", shape=ShapeConfig.ORGANIZATIONS_MINIMAL)
Parameters:
- fh_key (str): Organization key
- shape (str, optional): Response shape string
- flat (bool): Flatten nested objects (default: False)
- flat_lists (bool): Flatten arrays with indexed keys (default: False)
Returns: Dictionary with organization details
Contracts¶
Federal contract awards and procurement data.
list_contracts()¶
Search and filter contracts with extensive options.
contracts = client.list_contracts(
cursor=None, # keyset pagination token (not page number)
limit=25,
shape=None,
flat=False,
flat_lists=False,
# Filter parameters (all optional)
# Text search
keyword=None, # Mapped to 'search' API param
# Date filters
award_date_gte=None,
award_date_lte=None,
pop_start_date_gte=None,
pop_start_date_lte=None,
pop_end_date_gte=None,
pop_end_date_lte=None,
expiring_gte=None,
expiring_lte=None,
# Party filters
awarding_agency=None,
funding_agency=None,
recipient_name=None, # Mapped to 'recipient' API param
recipient_uei=None, # Mapped to 'uei' API param
# Classification
naics_code=None, # Mapped to 'naics' API param
psc_code=None, # Mapped to 'psc' API param
set_aside_type=None, # Mapped to 'set_aside' API param
# Type filters
fiscal_year=None,
fiscal_year_gte=None,
fiscal_year_lte=None,
award_type=None,
# Identifiers
piid=None,
solicitation_identifier=None,
# Sorting
sort=None, # Combined with 'order' into 'ordering' API param
order=None, # 'asc' or 'desc'
)
Common Parameters:
- cursor (str, optional): Keyset pagination token from response.next (contracts use keyset pagination, not page numbers)
- limit (int): Results per page (max: 100)
- shape (str): Fields to return (see Shaping Guide)
- flat (bool): Flatten nested objects to dot-notation keys
- flat_lists (bool): Flatten arrays with indexed keys
Filter Parameters:
Text Search:
- keyword - Search contract descriptions (automatically mapped to API's 'search' parameter)
Date Filters:
- award_date_gte - Awarded on or after date (YYYY-MM-DD)
- award_date_lte - Awarded on or before date (YYYY-MM-DD)
- pop_start_date_gte - Period of performance start date ≥
- pop_start_date_lte - Period of performance start date ≤
- pop_end_date_gte - Period of performance end date ≥
- pop_end_date_lte - Period of performance end date ≤
- expiring_gte - Expiring on or after date
- expiring_lte - Expiring on or before date
Party Filters:
- awarding_agency - Agency code (e.g., "4700" for GSA)
- funding_agency - Funding agency code
- recipient_name - Vendor/recipient name (mapped to 'recipient' API param)
- recipient_uei - Vendor UEI (mapped to 'uei' API param)
Classification:
- naics_code - NAICS industry code (mapped to 'naics' API param)
- psc_code - Product/Service code (mapped to 'psc' API param)
- set_aside_type - Set-aside type (mapped to 'set_aside' API param)
Type Filters:
- fiscal_year - Federal fiscal year (exact match)
- fiscal_year_gte - Fiscal year ≥
- fiscal_year_lte - Fiscal year ≤
- award_type - Award type code
Identifiers:
- piid - Procurement Instrument Identifier (exact match)
- solicitation_identifier - Solicitation ID
Sorting:
- sort - Field to sort by (e.g., "award_date", "obligated")
- order - Sort order: "asc" or "desc" (default: "asc")
Returns: PaginatedResponse with contract dictionaries
Examples:
# Basic search
contracts = client.list_contracts(limit=10)
# Filter by agency
contracts = client.list_contracts(
awarding_agency="4700", # GSA agency code
limit=50
)
# Text search
contracts = client.list_contracts(
keyword="software development",
limit=50
)
# Date range
contracts = client.list_contracts(
award_date_gte="2023-01-01",
award_date_lte="2023-12-31",
limit=100
)
# Expiring contracts
contracts = client.list_contracts(
expiring_gte="2025-01-01",
expiring_lte="2025-12-31",
limit=50
)
# Multiple filters
contracts = client.list_contracts(
keyword="IT services",
awarding_agency="4700", # GSA
fiscal_year=2024,
naics_code="541511",
limit=100
)
# With shaping for performance
contracts = client.list_contracts(
shape="key,piid,recipient(display_name),total_contract_value,award_date",
awarding_agency="4700",
fiscal_year=2024,
limit=100
)
# Sorting results
contracts = client.list_contracts(
sort="award_date",
order="desc",
limit=100
)
Common Contract Fields:
- key - Unique identifier
- piid - Procurement Instrument Identifier
- description - Contract description
- award_date - Date awarded
- fiscal_year - Fiscal year
- total_contract_value - Total value
- total_obligated - Total obligated amount
- recipient - Vendor information (nested)
- awarding_agency - Awarding agency (nested)
- funding_agency - Funding agency (nested)
- naics - Industry classification (nested)
- psc - Product/service code (nested)
- place_of_performance - Location (nested)
OTAs¶
Other Transaction Agreements — non-FAR-based awards.
list_otas()¶
List OTAs with keyset pagination, filtering, and shaping.
otas = client.list_otas(
limit=25,
cursor=None,
shape=ShapeConfig.OTAS_MINIMAL,
# Filter parameters (all optional)
award_date=None,
award_date_gte=None,
award_date_lte=None,
awarding_agency=None,
expiring_gte=None,
expiring_lte=None,
fiscal_year=None,
fiscal_year_gte=None,
fiscal_year_lte=None,
funding_agency=None,
ordering=None,
piid=None,
pop_end_date_gte=None,
pop_end_date_lte=None,
pop_start_date_gte=None,
pop_start_date_lte=None,
psc=None,
recipient=None,
search=None,
uei=None,
)
Notes:
- Uses keyset pagination (cursor + limit) rather than page numbers.
- Filter parameters mirror those on list_contracts.
Returns: PaginatedResponse with OTA dictionaries
get_ota()¶
ota = client.get_ota("OTA_KEY", shape=ShapeConfig.OTAS_MINIMAL)
OTIDVs¶
Other Transaction IDVs — umbrella OT agreements that can have child awards.
list_otidvs()¶
List OTIDVs with keyset pagination, filtering, and shaping.
otidvs = client.list_otidvs(
limit=25,
cursor=None,
shape=ShapeConfig.OTIDVS_MINIMAL,
# Same filter parameters as list_otas()
)
Notes:
- Uses keyset pagination (cursor + limit) rather than page numbers.
- Filter parameters are identical to list_otas().
Returns: PaginatedResponse with OTIDV dictionaries
get_otidv()¶
otidv = client.get_otidv("OTIDV_KEY", shape=ShapeConfig.OTIDVS_MINIMAL)
Subawards¶
Subcontract and subaward data under prime awards.
list_subawards()¶
List subawards with filtering and shaping.
subawards = client.list_subawards(
page=1,
limit=25,
shape=ShapeConfig.SUBAWARDS_MINIMAL,
# Filter parameters (all optional)
award_key=None,
awarding_agency=None,
fiscal_year=None,
fiscal_year_gte=None,
fiscal_year_lte=None,
funding_agency=None,
prime_uei=None,
recipient=None,
sub_uei=None,
)
Filter Parameters:
- award_key - Filter by prime award key
- awarding_agency - Filter by awarding agency code
- fiscal_year - Exact fiscal year
- fiscal_year_gte / fiscal_year_lte - Fiscal year range
- funding_agency - Filter by funding agency code
- prime_uei - Filter by prime awardee UEI
- recipient - Search by subrecipient name
- sub_uei - Filter by subrecipient UEI
Returns: PaginatedResponse with subaward dictionaries
Vehicles¶
Vehicles provide a solicitation-centric way to discover groups of related IDVs and (optionally) expand into the underlying awards via shaping.
list_vehicles()¶
List vehicles with optional vehicle-level full-text search and ordering.
vehicles = client.list_vehicles(
page=1,
limit=25,
search="GSA schedule",
ordering="-vehicle_obligations",
shape=ShapeConfig.VEHICLES_MINIMAL,
flat=False,
flat_lists=False,
)
Parameters:
- page (int): Page number (default: 1)
- limit (int): Results per page (default: 25, max: 100)
- search (str, optional): Vehicle-level search term
- ordering (str, optional): Server-side sort. Allowed: vehicle_obligations, latest_award_date. Prefix with - for descending.
- shape (str, optional): Shape string (defaults to ShapeConfig.VEHICLES_MINIMAL)
- flat (bool): Flatten nested objects in shaped response
- flat_lists (bool): Flatten arrays using indexed keys
- joiner (str): Joiner used when flat=True (default: ".")
Returns: PaginatedResponse with vehicle dictionaries
get_vehicle()¶
Get a single vehicle by UUID.
vehicle = client.get_vehicle(
uuid="00000000-0000-0000-0000-000000000001",
shape=ShapeConfig.VEHICLES_COMPREHENSIVE,
)
Notes:
- On the vehicle detail endpoint, search filters expanded awardees when your shape includes awardees(...) (it does not filter the vehicle itself).
list_vehicle_awardees()¶
List the IDV awardees for a vehicle.
awardees = client.list_vehicle_awardees(
uuid="00000000-0000-0000-0000-000000000001",
shape=ShapeConfig.VEHICLE_AWARDEES_MINIMAL,
)
list_vehicle_orders()¶
List task orders under a vehicle's IDVs (/api/vehicles/{uuid}/orders/). Optimized for fast pagination over large vehicles.
orders = client.list_vehicle_orders(
uuid="00000000-0000-0000-0000-000000000001",
limit=25,
ordering="-obligated",
shape=ShapeConfig.VEHICLE_ORDERS_MINIMAL,
)
Parameters:
- uuid (str): Vehicle UUID
- page (int): Page number (default: 1)
- limit (int): Results per page (default: 25, max: 100)
- ordering (str, optional): Server-side sort. Allowed: award_date (default), obligated, total_contract_value. Prefix with - for descending.
- shape (str, optional): Shape string (defaults to ShapeConfig.VEHICLE_ORDERS_MINIMAL)
- flat, flat_lists, joiner: as on other vehicles methods
Returns: PaginatedResponse with order (Contract) dictionaries
Vehicle response fields¶
The post-cutover (May 2026) vehicle response includes these top-level fields, all addressable via the shape parameter:
| Field | Type | Notes |
|---|---|---|
uuid |
str | Stable identifier. |
solicitation_identifier |
str | Solicitation shared by underlying IDVs. |
is_synthetic_solicitation |
bool | True for GWAC orphans recovered via ACRO: prefix. |
agency_id |
str | From IDV award-key suffix. |
program_acronym |
str | None | New post-cutover field. |
organization_id |
str | None | Awarding organization. |
organization |
dict | None | Live awarding-org snapshot {organization_id, office_code, office_name, agency_code, agency_name, department_code, department_name}. Selected as a leaf field (shape=...,organization); not currently sub-selectable. |
vehicle_type, who_can_use, type_of_idc, contract_type |
dict | None | Returned as {code, description}. |
description |
str | None | Common text across IDV descriptions. |
descriptions |
list[str] | None | Distinct IDV descriptions. |
idv_count, order_count |
int | None | Denormalized rollups. |
holder_count |
int | None | Distinct companies holding one of the vehicle's IDVs. |
order_winner_count |
int | None | Distinct companies that have won a task order under the vehicle. |
awardee_count |
int | None | Deprecated. Same value as order_winner_count; use that instead. Removed at the next major API version. |
total_obligated, vehicle_obligations, vehicle_contracts_value |
Decimal | None | Denormalized rollups. |
award_date, latest_award_date, last_date_to_order |
date | None | |
solicitation_title, solicitation_description, solicitation_date, opportunity_id |
str / date / None | From SAM.gov via the linked Opportunity. |
naics_code, psc_code, set_aside, fiscal_year |
int / str / None |
Vehicle shape expansions¶
awardees(...)— underlying IDV awards. Supports nestedorders(...).metrics(*)— bundled computed metrics:avg_offers_received,award_concentration_hhi,order_concentration_hhi,competed_rate,using_agency_count,avg_order_value,max_order_value,top_recipient_share,recent_obligations_24mo,recent_orders_24mo,days_since_last_order,obligation_to_ceiling_ratio. Defaults included inShapeConfig.VEHICLES_COMPREHENSIVE.organization— live awarding-org snapshot (selected as a leaf field; not sub-selectable).
Deprecated shape fields¶
The following fields and expansions are still served by the API (recomputed at request time from the underlying IDVs) but the API now returns a Deprecation: true response header for them. They will be removed in a future tango API release.
agency_details(top-level field andagency_details(*)expansion)competition_details(top-level field andcompetition_details(*)expansion)opportunity(*)expansion (use the new top-levelsolicitation_*andopportunity_idfields instead)
If you pass any of these in shape=..., the SDK will emit a Python DeprecationWarning. The default shapes (VEHICLES_MINIMAL, VEHICLES_COMPREHENSIVE) no longer include them.
IDVs¶
IDVs (indefinite delivery vehicles) are the parent “vehicle award” records that can have child awards/orders under them.
list_idvs()¶
idvs = client.list_idvs(
limit=25,
cursor=None,
shape=ShapeConfig.IDVS_MINIMAL,
awarding_agency="4700",
)
Notes:
- This endpoint uses keyset pagination (
cursor+limit) rather than page numbers.
get_idv()¶
idv = client.get_idv("SOME_IDV_KEY", shape=ShapeConfig.IDVS_COMPREHENSIVE)
list_idv_awards()¶
Lists child awards (contracts) under an IDV.
awards = client.list_idv_awards("SOME_IDV_KEY", limit=25)
list_idv_child_idvs()¶
Lists child IDVs under an IDV.
children = client.list_idv_child_idvs("SOME_IDV_KEY", limit=25)
list_idv_transactions()¶
tx = client.list_idv_transactions("SOME_IDV_KEY", limit=100)
Entities¶
Vendors, recipients, and organizations doing business with the government.
list_entities()¶
List and search for entities (vendors/recipients).
entities = client.list_entities(
page=1,
limit=25,
shape=None,
flat=False,
flat_lists=False,
# Filter parameters (all optional)
search=None,
cage_code=None,
naics=None,
name=None,
psc=None,
purpose_of_registration_code=None,
socioeconomic=None,
state=None,
total_awards_obligated_gte=None,
total_awards_obligated_lte=None,
uei=None,
zip_code=None,
)
Parameters:
- page (int): Page number
- limit (int): Results per page
- shape (str): Fields to return
- flat (bool): Flatten nested objects
- flat_lists (bool): Flatten arrays with indexed keys
Filter Parameters:
- search - Full-text search
- cage_code - Filter by CAGE code
- naics - Filter by NAICS code
- name - Filter by entity name
- psc - Filter by PSC code
- purpose_of_registration_code - Filter by registration purpose
- socioeconomic - Filter by socioeconomic status; takes SAM business-type codes (e.g. OY Black American Owned, A6 SBA-certified 8(a), A2 Woman Owned — see GET /api/business_types/), not set-aside codes; accepts pipe-separated values for OR semantics, e.g. socioeconomic="OY|A2"
- state - Filter by state
- total_awards_obligated_gte / total_awards_obligated_lte - Obligation amount range
- uei - Filter by UEI
- zip_code - Filter by ZIP code
Returns: PaginatedResponse with entity dictionaries
Example:
entities = client.list_entities(search="Booz Allen", limit=20)
for entity in entities.results:
print(f"{entity['legal_business_name']}")
print(f"UEI: {entity.get('uei', 'N/A')}")
if entity.get('business_types'):
print(f"Types: {', '.join(bt['code'] for bt in entity['business_types'])}")
get_entity()¶
Get a specific entity by UEI or CAGE code.
entity = client.get_entity(key="ZQGGHJH74DW7", shape=None)
Parameters:
- key (str): UEI or CAGE code
- shape (str, optional): Fields to return
Returns: Dictionary with entity details
Example:
entity = client.get_entity("ZQGGHJH74DW7")
print(f"Name: {entity['legal_business_name']}")
print(f"UEI: {entity['uei']}")
if entity.get('physical_address'):
addr = entity['physical_address']
print(f"Location: {addr.get('city')}, {addr.get('state_code')}")
Common Entity Fields:
- uei - Unique Entity Identifier
- cage_code - CAGE code
- legal_business_name - Official business name
- display_name - Display name
- dba_name - Doing Business As name
- business_types - Array of business type codes
- primary_naics - Primary NAICS code
- physical_address - Physical address (nested)
- mailing_address - Mailing address (nested)
- email_address - Contact email
- entity_url - Website
Forecasts¶
Contract forecast and planning information.
list_forecasts()¶
List contract forecasts.
forecasts = client.list_forecasts(
page=1,
limit=25,
shape=None,
flat=False,
flat_lists=False,
# Filter parameters (all optional)
agency=None,
award_date_after=None,
award_date_before=None,
fiscal_year=None,
fiscal_year_gte=None,
fiscal_year_lte=None,
modified_after=None,
modified_before=None,
naics_code=None,
naics_starts_with=None,
search=None,
source_system=None,
status=None,
)
Parameters:
- page (int): Page number
- limit (int): Results per page
- shape (str): Fields to return
- flat (bool): Flatten nested objects
- flat_lists (bool): Flatten arrays with indexed keys
Filter Parameters:
- agency - Filter by agency code
- award_date_after / award_date_before - Expected award date range
- fiscal_year - Exact fiscal year
- fiscal_year_gte / fiscal_year_lte - Fiscal year range
- modified_after / modified_before - Last-modified date range
- naics_code - NAICS code (exact match)
- naics_starts_with - NAICS code prefix
- search - Full-text search
- source_system - Filter by source system
- status - Filter by status
Returns: PaginatedResponse with forecast dictionaries
Example:
forecasts = client.list_forecasts(agency="GSA", fiscal_year=2025, limit=20)
for forecast in forecasts.results:
print(f"{forecast['title']}")
print(f"Anticipated: {forecast.get('anticipated_award_date', 'TBD')}")
print(f"Fiscal Year: {forecast.get('fiscal_year', 'N/A')}")
Common Forecast Fields:
- id - Forecast identifier
- title - Forecast title
- description - Description
- anticipated_award_date - Expected award date
- fiscal_year - Fiscal year
- naics_code - Industry code
- status - Current status
Opportunities¶
Active contract opportunities and solicitations.
list_opportunities()¶
List contract opportunities/solicitations.
opportunities = client.list_opportunities(
page=1,
limit=25,
shape=None,
flat=False,
flat_lists=False,
# Filter parameters (all optional)
active=None,
agency=None,
awarded=None,
awardee_uei=None,
first_notice_date_after=None,
first_notice_date_before=None,
last_notice_date_after=None,
last_notice_date_before=None,
naics=None,
notice_type=None,
place_of_performance=None,
psc=None,
response_deadline_after=None,
response_deadline_before=None,
search=None,
set_aside=None,
solicitation_number=None,
)
Parameters:
- page (int): Page number
- limit (int): Results per page
- shape (str): Fields to return
- flat (bool): Flatten nested objects
- flat_lists (bool): Flatten arrays with indexed keys
Filter Parameters:
- active - Filter by active status (bool)
- agency - Filter by agency code
- awarded - Whether the opportunity has an award, either posted on it or linked to it (bool). Requires Tango API 5.5.0
- awardee_uei - Awardee UEI, case-insensitive; OR several with |. Requires Tango API 5.5.0
- first_notice_date_after / first_notice_date_before - First notice date range
- last_notice_date_after / last_notice_date_before - Last notice date range
- naics - NAICS code
- notice_type - Filter by notice type
- place_of_performance - Filter by place of performance
- psc - PSC code
- response_deadline_after / response_deadline_before - Response deadline range
- search - Full-text search
- set_aside - Set-aside type
- solicitation_number - Solicitation number (exact match)
Returns: PaginatedResponse with opportunity dictionaries
Example:
opportunities = client.list_opportunities(agency="DOD", active=True, limit=20)
for opp in opportunities.results:
print(f"{opp['title']}")
print(f"Solicitation: {opp.get('solicitation_number', 'N/A')}")
print(f"Deadline: {opp.get('response_deadline', 'Not specified')}")
print(f"Active: {opp.get('active', False)}")
Common Opportunity Fields:
- opportunity_id - Unique identifier
- title - Opportunity title
- solicitation_number - Solicitation number
- description - Description
- response_deadline - Response deadline
- active - Is currently active
- naics_code - Industry code
- psc_code - Product/service code
Document roles — attachments(doc_role, doc_role_alt):
On a Pro plan or above, each attachment can say what the document is for.
doc_role is one of requirement, instructions, pricing, terms, reference or unknown; doc_role_alt is a runner-up role, and is null on nearly every attachment.
opp = client.get_opportunity(
opportunity_id,
shape="opportunity_id,title,attachments(name,url,doc_role,doc_role_alt)",
)
for attachment in opp["attachments"]:
print(attachment["name"], attachment.get("doc_role"))
- You have to name them.
attachments(*)does not include either field, and no default shape does. - The keys are absent, not null, on an attachment Tango has not classified. Use
attachment.get("doc_role"). - A Free-plan request that names them gets the response without them, plus an entry in
meta.upgrade_hints. - There is no filter on document role.
Award fields:
Tango API 5.5.0 adds award information to opportunities. None of it is in the SDK's default shape, so name the fields you want.
opp = client.get_opportunity(
opportunity_id,
shape=(
"opportunity_id,title,awarded,award_date,award_amount,awardee,awardee_uei,"
"award_count,solicitation_opportunity_id,"
"awards(opportunity_id,notice_id,award_number,award_date,award_amount,awardee,awardee_uei)"
),
)
awarded = client.list_opportunities(
awardee_uei="ABCDEF123456",
shape="opportunity_id,title,award_date,awardee",
)
award_date,award_amount,awardeeandawardee_ueiare filled only where SAM.gov posted an award notice; elsewhere they are null.award_amountis the text SAM.gov published, not a number.- SAM.gov often posts an award as its own opportunity. When that award notice references its solicitation, the award's
solicitation_opportunity_idpoints back to the solicitation, and the solicitation'sawards(...)lists up to ten of the most recent linked awards;award_countis the full count. An award notice that does not reference its solicitation is not linked. awardedis true when the opportunity has an award of its own or has linked awards.- The
awardedandawardee_ueifilters search every opportunity, not just active ones, so passactive=Trueto narrow to open opportunities. - Notices accept
award_date,award_amount,awardeeandawardee_ueiinshapetoo.
Attachment counts — meta(attachments_count, files_count, links_count):
Tango API 5.9.0 splits an opportunity's attachment count by attachment type.
A file is a document attached to the notice, and only files have extracted text; a link is a URL the notice lists.
opp = client.get_opportunity(
opportunity_id,
shape="opportunity_id,title,meta(attachments_count,files_count,links_count)",
)
files = opp["meta"]["files_count"]
if files is None:
print("not counted yet")
meta.files_countcounts the files andmeta.links_countcounts the links.meta.attachments_countstill counts every attachment, links included. An attachment of any other type counts only there, sofiles_count + links_countneed not equal it.Nonemeans unknown, not zero. Both new fields are null until the record has been counted.- None of the three is in the SDK's default shape, so name the ones you want.
- The opportunity embedded in a vehicle accepts them too:
opportunity(meta(files_count,links_count)).
Notices¶
Contract award notices and modifications.
list_notices()¶
List contract notices.
notices = client.list_notices(
page=1,
limit=25,
shape=None,
flat=False,
flat_lists=False,
# Filter parameters (all optional)
active=None,
agency=None,
naics=None,
notice_type=None,
posted_date_after=None,
posted_date_before=None,
psc=None,
response_deadline_after=None,
response_deadline_before=None,
search=None,
set_aside=None,
solicitation_number=None,
)
Parameters:
- page (int): Page number
- limit (int): Results per page
- shape (str): Fields to return
- flat (bool): Flatten nested objects
- flat_lists (bool): Flatten arrays with indexed keys
Filter Parameters:
- active - Filter by active status (bool)
- agency - Filter by agency code
- naics - NAICS code
- notice_type - Filter by notice type
- posted_date_after / posted_date_before - Posted date range
- psc - PSC code
- response_deadline_after / response_deadline_before - Response deadline range
- search - Full-text search
- set_aside - Set-aside type
- solicitation_number - Solicitation number (exact match)
Returns: PaginatedResponse with notice dictionaries
Example:
notices = client.list_notices(agency="GSA", notice_type="Presolicitation", limit=20)
for notice in notices.results:
print(f"{notice['title']}")
print(f"Solicitation: {notice.get('solicitation_number', 'N/A')}")
print(f"Posted: {notice.get('posted_date', 'N/A')}")
Common Notice Fields:
- notice_id - Notice identifier
- title - Notice title
- solicitation_number - Solicitation number
- description - Description
- posted_date - Date posted
- naics_code - Industry code
Attachment counts — attachment_count, file_count, link_count:
Tango API 5.9.0 splits a notice's attachment count by attachment type.
A file is a document attached to the notice, and only files have extracted text; a link is a URL the notice lists.
notices = client.list_notices(
agency="GSA",
shape="notice_id,title,attachment_count,file_count,link_count",
)
file_countcounts the files andlink_countcounts the links.attachment_countstill counts every attachment, links included. An attachment of any other type counts only there, sofile_count + link_countneed not equal it.Nonemeans unknown, not zero. Both new fields are null until the notice has been counted.- The API returns all three when no
shapeis sent, but the SDK's defaultNOTICES_MINIMALshape does not name them, so ask for them.
Grants¶
Federal grant opportunities and assistance listings.
list_grants()¶
List grant opportunities.
grants = client.list_grants(
page=1,
limit=25,
shape=None,
flat=False,
flat_lists=False,
# Filter parameters (all optional)
agency=None,
applicant_types=None,
cfda_number=None,
funding_categories=None,
funding_instruments=None,
opportunity_number=None,
posted_date_after=None,
posted_date_before=None,
response_date_after=None,
response_date_before=None,
search=None,
status=None,
)
Parameters:
- page (int): Page number
- limit (int): Results per page (max 100)
- shape (str): Response shape string
- flat (bool): Flatten nested objects in shaped response
- flat_lists (bool): Flatten arrays using indexed keys
Filter Parameters:
- agency - Filter by agency code
- applicant_types - Filter by applicant type
- cfda_number - Filter by CFDA number
- funding_categories - Filter by funding category
- funding_instruments - Filter by funding instrument
- opportunity_number - Filter by opportunity number (exact match)
- posted_date_after / posted_date_before - Posted date range
- response_date_after / response_date_before - Response date range
- search - Full-text search
- status - Filter by status
Returns: PaginatedResponse with grant dictionaries
Example:
grants = client.list_grants(agency="HHS", status="F", limit=20) # F = Forecasted, P = Posted
for grant in grants.results:
print(f"{grant['title']}")
print(f"Opportunity: {grant.get('opportunity_number', 'N/A')}")
print(f"Status: {grant.get('status', {}).get('description', 'N/A')}")
Common Grant Fields:
- grant_id - Grant identifier
- opportunity_number - Opportunity number
- title - Grant title
- status - Status information (nested object with code and description)
- agency_code - Agency code
- description - Description
- last_updated - Last updated timestamp
- cfda_numbers - CFDA numbers (list of objects with number and title)
- applicant_types - Applicant types (list of objects with code and description)
- funding_categories - Funding categories (list of objects with code and description)
- funding_instruments - Funding instruments (list of objects with code and description)
- category - Category (object with code and description)
- important_dates - Important dates (list)
- attachments - Attachments (list of objects)
Example with Expanded Fields:
# Get grants with expanded status and CFDA numbers
grants = client.list_grants(
shape="grant_id,title,opportunity_number,status(*),cfda_numbers(number,title)",
limit=10
)
for grant in grants.results:
print(f"Grant: {grant['title']}")
if grant.get('status'):
print(f"Status: {grant['status'].get('description')}")
if grant.get('cfda_numbers'):
for cfda in grant['cfda_numbers']:
print(f"CFDA: {cfda.get('number')} - {cfda.get('title')}")
GSA eLibrary Contracts¶
GSA Schedule contracts from the GSA eLibrary.
list_gsa_elibrary_contracts()¶
List GSA eLibrary contracts with filtering and shaping.
contracts = client.list_gsa_elibrary_contracts(
page=1,
limit=25,
shape=ShapeConfig.GSA_ELIBRARY_CONTRACTS_MINIMAL,
# Filter parameters (all optional)
contract_number=None,
key=None,
piid=None,
schedule=None,
search=None,
sin=None,
uei=None,
)
Filter Parameters:
- contract_number - Filter by contract number
- key - Filter by key
- piid - Filter by PIID
- schedule - Filter by GSA schedule
- search - Full-text search
- sin - Filter by SIN (Special Item Number)
- uei - Filter by UEI
Returns: PaginatedResponse with GSA eLibrary contract dictionaries
get_gsa_elibrary_contract()¶
Get a single GSA eLibrary contract by UUID.
contract = client.get_gsa_elibrary_contract("UUID_HERE")
Protests¶
Bid protest records from three venues: GAO, the U.S. Court of Federal Claims (COFC), and the SBA Office of Hearings and Appeals (SBA OHA).
list_protests()¶
List bid protests with filtering and shaping.
protests = client.list_protests(
page=1,
limit=25,
shape=ShapeConfig.PROTESTS_MINIMAL,
# Filter parameters (all optional)
source_system=None,
outcome=None,
case_type=None,
agency=None,
case_number=None,
solicitation_number=None,
protester=None,
filed_date_after=None,
filed_date_before=None,
decision_date_after=None,
decision_date_before=None,
naics_code=None,
search=None,
)
Filter Parameters:
- source_system - Filter by source system: "gao", "cofc", or "sba_oha"
- outcome - Filter by outcome (e.g., "Denied", "Dismissed", "Withdrawn", "Sustained")
- case_type - Filter by case type
- agency - Filter by protested agency
- case_number - Filter by base case number, matched case-insensitively (e.g., "b-423274" for GAO, "26-1391" for COFC, "SIZ-6100" for SBA OHA)
- solicitation_number - Filter by solicitation number
- protester - Search by protester name
- filed_date_after / filed_date_before - Filed date range
- decision_date_after / decision_date_before - Decision date range
- naics_code - The NAICS code at issue in an SBA OHA size or NAICS appeal (e.g., "541519"). GAO and COFC protests carry no NAICS code, so this filter returns SBA OHA records only
- search - Full-text search
Returns: PaginatedResponse with protest dictionaries
Example:
protests = client.list_protests(
source_system="gao",
outcome="Sustained",
filed_date_after="2024-01-01",
shape="case_id,case_number,title,outcome,filed_date,dockets(docket_number,outcome)",
limit=25,
)
for protest in protests.results:
print(f"{protest['case_number']}: {protest['title']} — {protest['outcome']}")
get_protest()¶
Get a single protest by case_id (UUID).
protest = client.get_protest(
"CASE_UUID",
shape="case_id,case_number,title,source_system,outcome,filed_date,dockets(*)",
)
Notes:
- Use shape=...,dockets(...) to include nested docket records.
Contract Appeals¶
Contract Disputes Act appeal decisions from the Civilian Board of Contract Appeals (CBCA) and the Armed Services Board of Contract Appeals (ASBCA).
These are not bid protests. A protest challenges an award or a solicitation before performance; an appeal here challenges a contracting officer's final decision under an existing contract — a claim, a termination, a differing-site-conditions dispute. Protests live at Protests and share no identifiers with this resource.
One row is one decision as the board's own listing publishes it, so several fields describe the listing rather than the dispute. listed goes false once the board's newest listing stops carrying the decision; the row is kept, never dropped.
list_contract_appeals()¶
List appeal decisions with filtering and shaping.
appeals = client.list_contract_appeals(
page=1,
limit=25,
shape=ShapeConfig.CONTRACT_APPEALS_MINIMAL,
# Filter parameters (all optional)
board=None,
docket=None,
appellant=None,
judge=None,
decision_type=None,
decision_date_after=None,
decision_date_before=None,
listed=None,
document_id=None,
search=None,
ordering=None,
)
Filter Parameters:
- board - "cbca" or "asbca"; OR both with "cbca|asbca"
- docket - Exact docket match, e.g. "3288-R", "59116" or "7092-C(6682, 6765, 6767)". A leading CBCA / ASBCA and No. / Nos. are ignored, so a docket can be pasted as it is cited. Commas are part of a docket, never a separator; OR several with |
- appellant - Case-insensitive substring match on the appellant's name (min 2 characters)
- judge - Case-insensitive exact match on the judge as the listing names them
- decision_type - CBCA only: "Decision", "Dismissal", "Order", "Full Board Order", or the listing's own text. ASBCA rows carry no type
- decision_date_after / decision_date_before - Decision date range (YYYY-MM-DD)
- listed - Whether the board's newest listing still carries the decision
- document_id - The board's own document id
- search - Ranked full-text search over the appellant and the full decision text (min 2 characters); wrap in double quotes for a phrase
- ordering - decision_date (the default, as -decision_date), appellant, first_listed_at or rank. rank requires a non-empty search
Returns: PaginatedResponse with appeal-decision dictionaries
Example:
appeals = client.list_contract_appeals(
board="asbca",
search="differing site conditions",
decision_date_after="2025-01-01",
ordering="rank",
limit=25,
)
for appeal in appeals.results:
dockets = ", ".join(appeal["docket_numbers"])
print(f"{appeal['board'].upper()} {dockets}: {appeal['appellant']} ({appeal['decision_date']})")
get_contract_appeal()¶
Get a single decision by uuid.
appeal = client.get_contract_appeal(
"DECISION_UUID",
shape=ShapeConfig.CONTRACT_APPEALS_COMPREHENSIVE,
)
Notes:
- docket_numbers is a list — the dockets with the board prefix stripped (3288-R, 59116), and a consolidated appeal carries several. docket_raw keeps the listing's own text, and docket_source says where the dockets came from.
- Reading decision_text requires an Enterprise plan, and below that tier the key is absent rather than null — read it with .get(), not [...]. Neither default shape names it: the body runs to roughly 100K characters, so asking for it on every row is rarely what you want.
- Searching that text is free at every plan. search= matches inside the decision body and returns no fragment of it, so buying the body does not change what you can find.
Federal Register¶
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.
list_federal_register_documents()¶
List documents with filtering and shaping.
documents = client.list_federal_register_documents(
page=1,
limit=25,
shape=ShapeConfig.FEDERAL_REGISTER_MINIMAL,
# Filter parameters (all optional)
search=None,
document_number=None,
type=None,
agency=None,
fr_agency=None,
publication_date_after=None,
publication_date_before=None,
effective_on_after=None,
effective_on_before=None,
comments_close_on_after=None,
comments_close_on_before=None,
comments_open=None,
cfr_title=None,
cfr_part=None,
significant=None,
rin=None,
executive_order_number=None,
ordering=None,
)
Filter Parameters:
- search - Ranked full-text search over the title, abstract and action; wrap in double quotes for a phrase
- document_number - Exact FR document number, e.g. "2016-31922"; OR several with |
- type - "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. Documents published before 2008 are mostly Uncategorized Document, so a type filter undercounts that era
- agency - 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 - The Federal Register's own agency slug, e.g. "environmental-protection-agency"; OR several with |
- publication_date_after / publication_date_before - Publication date range (YYYY-MM-DD)
- effective_on_after / effective_on_before - Effective date range (YYYY-MM-DD)
- comments_close_on_after / comments_close_on_before - Comment-deadline range (YYYY-MM-DD)
- comments_open - True for documents whose comment period closes today or later, False for those already closed; documents with no comment deadline match neither
- cfr_title - A CFR title number, e.g. "40"
- cfr_part - 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 - Significant under Executive Order 12866
- rin - A Regulation Identifier Number, e.g. "2060-AV16"; OR several with |
- executive_order_number - Exact executive order number
- ordering - publication_date (the default, as -publication_date), effective_on, comments_close_on, document_number or rank. rank requires a non-empty search
Returns: PaginatedResponse with document dictionaries
Example:
documents = client.list_federal_register_documents(
type="Proposed Rule",
comments_open=True,
agency="EPA",
limit=25,
)
for doc in documents.results:
print(f"{doc['document_number']} ({doc['publication_date']}): {doc['title']}")
print(f" comments close {doc['comments_close_on']}")
get_federal_register_document()¶
Get a single document by uuid. To look one up by its document number, use list_federal_register_documents(document_number=...).
document = client.get_federal_register_document(
"DOCUMENT_UUID",
shape=ShapeConfig.FEDERAL_REGISTER_COMPREHENSIVE,
)
Notes:
- agencies, cfr_references, dockets and topics are the Federal Register's own structures, served as published. agencies carries 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 in shape (e.g. shape="uuid,title,full_text"). It can run to several MB, so neither default shape includes it.
State & Local (SLED)¶
State, local and education procurement — solicitations that never appear on SAM.gov because they were never federal. Beta: coverage is partial and grows one jurisdiction at a time.
This data does not join to the federal data. There is 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.
list_sled_opportunities()¶
List SLED solicitations with filtering and shaping.
solicitations = client.list_sled_opportunities(
page=1,
limit=25,
shape=ShapeConfig.SLED_OPPORTUNITIES_MINIMAL,
# Filter parameters (all optional)
state=None,
jurisdiction=None,
status=None,
active=None,
agency=None,
solicitation_number=None,
solicitation_type=None,
has_documents=None,
revision_kind=None,
naics=None,
nigp=None,
unspsc=None,
category=None,
category_code=None,
posted_after=None,
posted_before=None,
response_deadline_after=None,
response_deadline_before=None,
first_seen_after=None,
first_seen_before=None,
change_seen_after=None,
modified_after=None,
modified_before=None,
search=None,
ordering=None,
)
Two defaults worth knowing 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="unknown"(standing rosters, dateless RFIs) is hidden by that default — reach it withstatus="open|unknown".get_sled_opportunity()returns the 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_statusand is frozen at last capture — most of what it calls open already has a passed deadline. Never filter liveness on it.
Filter Parameters:
- state - Two-letter state or territory code. Multi-value: "TX|OK"
- jurisdiction - "state", "local", "education", or "unknown" for aggregator rows that cannot tell state from local
- status - "open", "closed", "awarded", "cancelled", "unknown"
- active - Sugar for federal-shaped callers: True is status="open"; False is its complement, so it includes unknown
- agency - Substring match on the buyer's published text (min 2 characters); no code resolution behind it
- solicitation_number - The number a human would quote; null on roughly a third of the corpus
- solicitation_type - "rfp", "ifb", "rfq", "rfi", "itb", "sole_source", "grant", "other". "null" (portal states no type) is a distinct answer from "other"
- has_documents - Whether the solicitation advertises at least one document
- revision_kind - Kind of the most recent substantive revision
- naics / nigp / unspsc / category - Exact match within one category scheme. naics is thin on purpose: scheme tagging is mid-migration, so only a small share of entries are tagged NAICS
- category_code - Match a code under ANY scheme, including the untagged pre-migration strings. The escape hatch when a scheme-specific filter returns less than expected
- posted_after / posted_before - Posted-date range
- response_deadline_after / response_deadline_before - Deadline range
- first_seen_after / first_seen_before - When Tango first observed it. The polling primitive
- change_seen_after - When Tango observed the last substantive change. A scrape date, not an amendment date
- modified_after / modified_before - When the Tango row last changed
- platform / native_id / external_id - Support filters for reproducing a record with us. Not in any response shape and not stable values
- search - Ranked full-text search over title, agency, identifiers, category labels and description, widened by the solicitations whose attachment text matched (min 2 characters)
- ordering - rank, response_deadline, posted_date, first_seen_at, last_seen_at, last_change_seen_at, modified. rank requires a non-empty search
Returns: PaginatedResponse with solicitation dictionaries
Example:
# Open Texas solicitations closing this month, newest first
page = client.list_sled_opportunities(
state="TX",
response_deadline_before="2026-10-01",
ordering="response_deadline",
limit=25,
)
for row in page.results:
print(f"{row['state']} {row.get('solicitation_number') or '—'}: {row['title']}")
# Full-text search puts the matching passage on each row that matched on its body
hits = client.list_sled_opportunities(
search="environmental mitigation",
shape="opportunity_id,title,state,snippet,response_deadline",
)
snippet is present only under search=, and only on rows that matched on their description — a title-or-agency match honestly carries none. Attachment matching contributes ids only: a caller learns that a document matched, never what it said.
get_sled_opportunity()¶
Get a single solicitation by opportunity_id, whatever its status.
row = client.get_sled_opportunity(
"OPPORTUNITY_UUID",
shape="opportunity_id,title,status,attachments(*),revisions(*)",
)
Notes:
- meta.attachment_count can be lower than len(row["attachments"]). 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 hold its solicitation package"; the array answers "what files exist".
- size_bytes and char_count only mean something as a pair — 3 MB that yielded no characters is a scan awaiting OCR.
- raw(*) needs a Small plan or above, and is explicitly unstable: its shape varies by portal platform.
- delisted_at is when a portal dropped the solicitation from its listing before its deadline, and is what status_reason="delisted" means. It is null when the solicitation was never delisted or has been seen again since.
- meta.jurisdiction_declared says whether the source stated the jurisdiction level itself. False means Tango classified it from the issuer's name, which is the common case on a state's central portal.
Reading a document body — attachments(extracted_text):
row = client.get_sled_opportunity(
opportunity_id,
shape="opportunity_id,attachments(name,size_bytes,extracted_text)",
)
Needs a Small plan or above, and Tango API 4.25.1 or later (client.get_version() reports what you are calling). Three rules:
- You have to name it.
attachments(*)does not carry the body, and neither default shape 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 (where it is withheld and named in
meta.upgrade_hints), on a contested document, or where the text cannot be resolved. Useattachment.get("extracted_text"). - A contested document never returns text, at any plan — its stored bytes disagree with what the record advertised, so its text is not reliably that record's.
Searching document text and reading it are separate things. search= matches inside attachment text on every plan and returns no fragment of it; extracted_text is a per-record read on Small and above. Buying the body does not change search.
list_sled_opportunity_revisions()¶
List one solicitation's observed revision history.
revisions = client.list_sled_opportunity_revisions(
"OPPORTUNITY_UUID",
kind=None,
source_declared=None,
observed_after=None,
observed_before=None,
)
Notes:
- 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.
- 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; it is left out of the default shape for that reason. changed_fields names what moved at every plan.
get_sled_coverage()¶
Get the per-state coverage rollup. Takes no parameters; not shaped or paginated.
coverage = client.get_sled_coverage()
print(coverage["totals"])
for row in coverage["states"]:
print(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.
list_sled_forecasts()¶
List planned state procurements.
forecasts = client.list_sled_forecasts(
state=None,
agency=None,
procurement_category=None,
procurement_method=None,
contract_number=None,
incumbent_name=None,
advertisement_after=None,
advertisement_before=None,
first_seen_after=None,
first_seen_before=None,
modified_after=None,
modified_before=None,
search=None,
ordering=None,
)
Notes:
- Forecasts carry no liveness at all — no deadline to have passed, so there is no status field, no active filter, and no open-only default. Currency is the caller's call from estimated_advertisement_date.
- estimated_advertisement_date is the start of the published quarter, not a posting date. estimated_advertisement_raw keeps 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, so max is None — never read a missing max as an unbounded ceiling. raw is always there to check the parse against.
- incumbent_name is published text, not a resolved Tango entity.
- ordering: rank, estimated_advertisement_date, first_seen_at, last_seen_at, modified.
get_sled_forecast()¶
forecast = client.get_sled_forecast("FORECAST_UUID")
GSA eBuy¶
GSA eBuy requests for quotes, proposals and information (RFQs, RFPs, RFIs), keyed by rfq_id.
Access is scoped to your account. You see only requests posted under the GSA schedule contracts linked to your account, and the endpoints require the Pro tier or above (below it they return 403). With no linked contract, list_ebuy_requests() returns an empty page rather than an error, and get_ebuy_request() raises TangoNotFoundError for a request outside your scope, the same as for an id that does not exist. Use get_ebuy_access() to tell "no access" from "no matches".
list_ebuy_requests()¶
List requests with filtering and shaping.
requests = client.list_ebuy_requests(
page=1,
limit=25,
shape=ShapeConfig.EBUY_REQUESTS_MINIMAL,
# Filter parameters (all optional)
search=None,
rfq_id=None,
reference_number=None,
request_type=None,
status=None,
sin=None,
schedule=None,
buyer_agency=None,
agency=None,
contract_number=None,
issue_date_after=None,
issue_date_before=None,
close_date_after=None,
close_date_before=None,
ordering=None,
)
Filter Parameters:
- search - Full-text search over the title, description, reference number, request id and attachment text. Results rank by relevance unless ordering is given
- rfq_id - Exact request id, e.g. "RFQ1835158"
- reference_number - The buyer's own solicitation number; dashes are ignored
- request_type - "RFQ", "RFP" or "RFI"
- status - "Open" or "Cancelled", as last seen (see the note below)
- sin - Special Item Number, e.g. "54151S"
- schedule - GSA schedule
- buyer_agency - The buyer agency as eBuy names it (free text)
- agency - A Tango agency name, abbreviation, code or organization key, e.g. "GSA". Matches the whole organization subtree, so a department includes its sub-agencies. Requires Tango API 5.3.0
- contract_number - Narrow to requests posted under one of your own linked contracts. A contract not linked to your account returns an empty page, not an error
- issue_date_after / issue_date_before - Issue date range (YYYY-MM-DD, inclusive)
- close_date_after / close_date_before - Close date range (YYYY-MM-DD, inclusive)
- ordering - issue_date (the default, as -issue_date), close_date, last_seen or modified; prefix - for descending
String filters accept several values joined with | (OR).
Returns: PaginatedResponse with request dictionaries
Example:
requests = client.list_ebuy_requests(status="Open", sin="54151S", ordering="close_date")
for req in requests.results:
print(f"{req['rfq_id']} closes {req['close_date']}: {req['title']}")
print(f" last seen {req['last_seen']}")
Notes:
- status is frozen at the last state the request was seen in. Only currently-active requests are carried, so a request that closes stops appearing rather than getting a final row. Open means "open the last time it was seen", not "open now"; read last_seen for staleness.
- The contract number a request was posted under is never returned in any payload.
- buyer_agency_code and some other buyer and contact fields are sparse on older requests.
get_ebuy_request()¶
Get a single request by rfq_id.
request = client.get_ebuy_request(
"RFQ1835158",
shape=ShapeConfig.EBUY_REQUESTS_COMPREHENSIVE,
)
for attachment in request["attachments"]:
print(attachment["doc_seq_num"], attachment["doc_name"], attachment["is_link"])
The default shape returns every field plus two expands: organization (the buyer office, with the same seven keys as other resources' organization expand) and attachments. Each attachment carries doc_seq_num, doc_name, doc_type, doc_path, is_link and doc_session_date. is_link=True means doc_path is an outbound URL with no stored document behind it. amendments, line_items and addresses are lists of objects, served as eBuy publishes them.
Reading a document's text — attachments(extracted_text) (Tango API 5.9.1):
request = client.get_ebuy_request(
"RFQ1835158",
shape="rfq_id,attachments(doc_seq_num,doc_name,extracted_text)",
)
for attachment in request["attachments"]:
text = attachment.get("extracted_text")
if text:
print(attachment["doc_name"], len(text))
extracted_textis the text extracted from the attachment's document.- It is returned only when you name it. The default shape does not include it, and neither does
attachments(*). - The API omits the key, rather than sending null, for an attachment with no text: an external link, an empty scan, or a document that has not been extracted yet. The SDK's shaped result reads an omitted key as
None, so treatNoneas "no text", not as an error.
get_ebuy_attachment_url()¶
Get a short-lived download URL for one stored attachment.
url = client.get_ebuy_attachment_url("RFQ1835158", doc_seq_num=1)
Returns: The signed URL the API redirects to, as a string. The SDK reads the redirect without following it, so no document is downloaded. The URL expires after about five minutes: fetch it promptly, and call this again rather than storing it.
Raises:
- TangoAttachmentLinkError (a TangoValidationError) - The entry is an external link (is_link), not a stored document. The link is on error.url
- TangoNotFoundError - The request is unknown or outside your scope, the attachment does not exist, or its document has not been captured yet
get_ebuy_access()¶
Check whether your account can read eBuy requests.
access = client.get_ebuy_access()
if not access.enabled:
print(access.reason) # "tier_required" or "no_contract_grant"
print(access.contracts) # your own linked contracts, sorted
Returns: EbuyAccess with enabled (bool), reason ("tier_required", "no_contract_grant" or None; tier_required wins when both apply) and contracts (list of str).
Budget¶
Federal account × fiscal year budget rollups, covering the full budget lifecycle (requested → enacted → apportioned → obligated → outlayed), pre-computed ratios and trends, the contract / assistance / unlinked breakdown, and request-vs-actual spend.
list_budget_accounts()¶
List budget accounts. One row per (federal_account_symbol, fiscal_year).
accounts = client.list_budget_accounts(
page=1,
limit=25,
shape=ShapeConfig.BUDGET_ACCOUNTS_MINIMAL,
# Filter parameters (all optional)
federal_account_symbol=None,
fiscal_year=None,
fiscal_year_gte=None,
fiscal_year_lte=None,
agency_code=None,
bureau_name=None,
account_title=None,
bea_category=None,
on_off_budget=None,
subfunction_code=None,
account_category=None,
account_category_in=None,
data_through_period=None,
data_through_period_gte=None,
data_through_period_lte=None,
data_through_period_isnull=None,
search=None,
ordering=None,
)
Filter Parameters:
- federal_account_symbol - Exact federal account symbol (e.g., "097-0100")
- fiscal_year - Fiscal year (exact)
- fiscal_year_gte / fiscal_year_lte - Fiscal year range
- agency_code - Agency code (exact)
- bureau_name - Bureau name (exact)
- account_title - Account title (case-insensitive substring match)
- bea_category - BEA category (exact)
- on_off_budget - On/off budget flag (exact)
- subfunction_code - Subfunction code (exact)
- account_category - Account category (exact): budgetary or credit_financing today; treat it as an open string
- account_category_in - Comma-separated account categories to match any of (e.g. "budgetary,credit_financing")
- data_through_period - File A period (1-12) the account-year's figures run through (exact); below 12 the year is partial
- data_through_period_gte / data_through_period_lte - data_through_period range
- data_through_period_isnull - True for accounts with no File A data, False for accounts with it
- search - Full-text search over account_title, agency_name, bureau_name
- ordering - Sort field; prefix with - for descending. The default is the latest fiscal year first, then largest enacted_ba first, with accounts that have no enacted_ba last.
Source anomalies: the default shape includes account_category and source_anomalies. source_anomalies is a list of problems found in the source data behind the account, and [] when there are none. Each element is a BudgetAccountSourceAnomaly (a TypedDict; every key is optional and may be None, so read keys with .get()): code, field, bound_field, action (capped or flagged), reported_value, served_value, likely_cause, affected_fields, message, and source (dataset, fiscal_year, and rows of fiscal_period, piid, parent_piid, tas, reporting_agency_id, transaction_obligated_amount, file_c_source). Today code is one of contract_exceeds_obligations, assistance_exceeds_obligations or contract_without_obligations; treat it as an open string. There is no filter on anomalies.
for acct in client.list_budget_accounts(fiscal_year=2025).results:
for anomaly in acct["source_anomalies"] or []:
print(acct["federal_account_symbol"], anomaly.get("code"), anomaly.get("action"))
Returns: PaginatedResponse of BudgetAccount records (see ShapeConfig for the default shape).
Example:
accounts = client.list_budget_accounts(
agency_code="097",
fiscal_year_gte=2023,
ordering="-enacted_ba",
limit=10,
)
for acct in accounts.results:
print(f"{acct.federal_account_symbol} FY{acct.fiscal_year}: "
f"enacted ${acct.enacted_ba:,}")
get_budget_account()¶
Get a single budget account by id.
account = client.get_budget_account(
12345,
shape=ShapeConfig.BUDGET_ACCOUNTS_MINIMAL,
)
Parameters:
- id (str | int): Budget account id.
- shape (str, optional): Response shape. Defaults to BUDGET_ACCOUNTS_MINIMAL.
- flat / flat_lists / joiner: See Shaping Guide.
Returns: A BudgetAccount record.
get_budget_account_quarters()¶
Get quarterly TAS-grain flow for a budget account. FY21+ only.
quarters = client.get_budget_account_quarters(12345, limit=25)
Parameters:
- id (str | int): Budget account id.
- tas (str, optional): Narrow to a single Treasury Account Symbol.
- limit (int): Results per page (max 100).
Returns: PaginatedResponse of quarterly flow records.
get_budget_account_recipients()¶
Get funding-office × recipient contract-flow detail for a budget account.
recipients = client.get_budget_account_recipients(
12345,
funding_organization_id=None,
limit=25,
)
Parameters:
- id (str | int): Budget account id.
- funding_organization_id (str, optional): Narrow to a single funding office (Organization UUID).
- limit (int): Results per page (max 100).
Returns: PaginatedResponse of (funding_office, recipient) flow records.
Business Types¶
Business type classifications.
list_business_types()¶
List available business type codes.
business_types = client.list_business_types(page=1, limit=25)
Parameters:
- page (int): Page number
- limit (int): Results per page
Returns: PaginatedResponse with business type dictionaries
Example:
business_types = client.list_business_types(limit=50)
for biz_type in business_types.results:
print(f"{biz_type.code}: {biz_type.name}")
Business Type Fields:
- code - Business type code
- name - Business type name
- description - Description
NAICS¶
NAICS (North American Industry Classification System) codes.
list_naics()¶
List NAICS codes with optional filtering.
naics = client.list_naics(
page=1,
limit=25,
# Filter parameters (all optional)
employee_limit=None,
employee_limit_gte=None,
employee_limit_lte=None,
revenue_limit=None,
revenue_limit_gte=None,
revenue_limit_lte=None,
search=None,
)
Filter Parameters:
- employee_limit - Exact employee size standard
- employee_limit_gte / employee_limit_lte - Employee limit range
- revenue_limit - Exact revenue size standard
- revenue_limit_gte / revenue_limit_lte - Revenue limit range
- search - Full-text search (code or description)
Returns: PaginatedResponse with NAICS dictionaries
Example:
naics = client.list_naics(search="software", limit=10)
for code in naics.results:
print(f"{code['code']}: {code['description']}")
get_naics()¶
Get a single NAICS code by code string.
naics = client.get_naics("541511")
Returns: Dictionary with NAICS code details.
get_naics_metrics()¶
Get computed metrics for a NAICS code.
metrics = client.get_naics_metrics(code="541511", months=12, period_grouping="month")
PSC¶
Product and Service Codes.
list_psc()¶
psc = client.list_psc(page=1, limit=25)
get_psc()¶
psc = client.get_psc("D302")
get_psc_metrics()¶
metrics = client.get_psc_metrics(code="D302", months=12, period_grouping="month")
MAS SINs¶
GSA Multiple Award Schedule Special Item Numbers.
list_mas_sins()¶
sins = client.list_mas_sins(page=1, limit=25)
get_mas_sin()¶
sin = client.get_mas_sin("54151S")
Assistance Listings (CFDA)¶
Catalog of Federal Domestic Assistance listings.
list_assistance_listings()¶
listings = client.list_assistance_listings(page=1, limit=25)
get_assistance_listing()¶
listing = client.get_assistance_listing("10.310")
Departments¶
list_departments()¶
depts = client.list_departments(page=1, limit=25)
get_department()¶
dept = client.get_department("097")
Business Types (by code)¶
get_business_type()¶
Get a single business type by code.
bt = client.get_business_type("A6")
IT Dashboard¶
Federal IT investments from the OMB IT Dashboard.
list_itdashboard_investments()¶
investments = client.list_itdashboard_investments(
page=1,
limit=25,
search=None,
agency_code=None,
type_of_investment=None,
# Pro/Business+ tier-gated filters available
)
Notes:
- Filter tier-gating: search is free; agency_code, type_of_investment require Pro; agency_name, cio_rating, performance_risk require Business+.
- Shape defaults to ShapeConfig.ITDASHBOARD_INVESTMENTS_MINIMAL.
get_itdashboard_investment()¶
investment = client.get_itdashboard_investment("023-000001234")
Entity Sub-resources¶
list_entity_contracts()¶
contracts = client.list_entity_contracts("ABCDEF123456", limit=25)
list_entity_idvs()¶
idvs = client.list_entity_idvs("ABCDEF123456", limit=25)
list_entity_otas() / list_entity_otidvs()¶
otas = client.list_entity_otas("ABCDEF123456", limit=25)
otidvs = client.list_entity_otidvs("ABCDEF123456", limit=25)
list_entity_subawards()¶
subawards = client.list_entity_subawards("ABCDEF123456", limit=25)
list_entity_lcats()¶
lcats = client.list_entity_lcats("ABCDEF123456", limit=25)
get_entity_metrics()¶
metrics = client.get_entity_metrics("ABCDEF123456", months=12, period_grouping="month")
get_entity_budget_flows()¶
Get budget flows for an entity (/api/entities/{uei}/budget-flows/) — the federal accounts that funded contracts and assistance awarded to this entity.
flows = client.get_entity_budget_flows("ABCDEF123456", fiscal_year=2024)
for row in flows.results:
print(row["federal_account_symbol"], row["contract_obligated"])
# next page
more = client.get_entity_budget_flows("ABCDEF123456", page=2, fiscal_year=2024)
Parameters:
- uei (str): Entity UEI. Required.
- page (int): Page number. Default 1.
- limit (int): Results per page. Default 25, max 100.
- fiscal_year (int | None): Optional fiscal year filter.
Returns: PaginatedResponse[dict[str, Any]] — standard count / next / previous / results. Result rows are raw dicts from the API (not shape-controlled).
IDV LCATs¶
list_idv_lcats()¶
lcats = client.list_idv_lcats("GS-00F-XXXX", limit=25)
Agency Sub-resources¶
list_agency_awarding_contracts()¶
List contracts where the agency is the awarding agency.
contracts = client.list_agency_awarding_contracts("4700", limit=25)
list_agency_funding_contracts()¶
List contracts where the agency is the funding agency.
contracts = client.list_agency_funding_contracts("4700", limit=25)
Resolve / Validate¶
resolve()¶
Resolve a free-text name to ranked entity or organization candidates.
result = client.resolve(
name="Lockheed Martin",
target_type="entity", # or "organization"
state="MD", # optional
city="Bethesda", # optional
context="defense contractor", # optional
)
for candidate in result.candidates:
print(candidate.identifier, candidate.display_name)
Notes:
- Free-tier: up to 3 candidates with identifier and display_name.
- Pro+: up to 5 candidates with additional match_tier field.
validate()¶
Validate the format of a PIID, solicitation number, or UEI.
result = client.validate(identifier_type="uei", value="ABCDEF123456")
# identifier_type is one of: "piid", "solicitation", "uei"
Note: The parameter is named identifier_type (not type) to avoid shadowing the Python builtin.
Opportunities (attachments)¶
search_opportunity_attachments()¶
Semantic search over opportunity attachments. q is required.
results = client.search_opportunity_attachments(
q="cybersecurity",
top_k=10,
include_extracted_text=False,
)
Parameters:
- q (str): Search query (required)
- top_k (int, optional): Number of top results to return
- include_extracted_text (bool, optional): Whether to include extracted text from attachments in results
Returns: dict with search results
Webhook Alerts¶
The Alerts API is the canonical (and only) write surface for webhook subscriptions. Every alert maps to one of the five alerts.*.match event types and delivers when its saved-search filters match new or modified records.
list_webhook_alerts()¶
alerts = client.list_webhook_alerts(page=1, page_size=25)
get_webhook_alert()¶
alert = client.get_webhook_alert("ALERT_UUID")
create_webhook_alert()¶
alert = client.create_webhook_alert(
name="New cloud IT contracts",
query_type="contract",
filters={"naics": "541511"},
)
For multi-endpoint accounts, pin the delivery target with endpoint=:
alert = client.create_webhook_alert(
name="New cloud IT contracts",
query_type="contract",
filters={"naics": "541511"},
endpoint="ENDPOINT_UUID",
)
Notes:
- name and query_type are required. query_type is singular (e.g. "contract", not "contracts").
- endpoint= is optional and only required when the account has multiple webhook endpoints; for single-endpoint accounts the server auto-resolves.
update_webhook_alert()¶
alert = client.update_webhook_alert("ALERT_UUID", name="Updated name")
delete_webhook_alert()¶
client.delete_webhook_alert("ALERT_UUID")
Utility¶
get_version()¶
version = client.get_version()
list_api_keys()¶
keys = client.list_api_keys()
Webhooks¶
Webhook APIs let Large / Enterprise users manage delivery endpoints and discover the supported event-type catalog. Filter subscriptions (alerts) live in the Webhook Alerts section above.
For testing, signing, and a CLI tool, see
docs/WEBHOOKS.md. This section covers SDK method signatures only.
list_webhook_event_types()¶
Discover supported event_type values.
info = client.list_webhook_event_types()
print(info.event_types[0].event_type)
list_webhook_endpoints()¶
List your webhook endpoint(s).
endpoints = client.list_webhook_endpoints(page=1, limit=25)
get_webhook_endpoint()¶
endpoint = client.get_webhook_endpoint("ENDPOINT_UUID")
create_webhook_endpoint() / update_webhook_endpoint() / delete_webhook_endpoint()¶
In production, MakeGov provisions the initial endpoint for you. These are most useful for dev/self-service.
endpoint = client.create_webhook_endpoint("https://example.com/tango/webhooks")
endpoint = client.update_webhook_endpoint(endpoint.id, is_active=False)
client.delete_webhook_endpoint(endpoint.id)
test_webhook_delivery()¶
Send an immediate test webhook to your configured endpoint.
result = client.test_webhook_delivery()
print(result.success, result.status_code)
get_webhook_sample_payload()¶
Fetch Tango-shaped sample deliveries.
sample = client.get_webhook_sample_payload(event_type="alerts.contract.match")
print(sample["event_type"])
Deliveries / redelivery¶
The API does not currently expose a public /api/webhooks/deliveries/ or redelivery endpoint. Use:
test_webhook_delivery()for connectivity checksget_webhook_sample_payload()for building handlers
Receiving webhooks (signature verification)¶
Every delivery includes an HMAC signature header:
X-Tango-Signature: sha256=<hex digest>
Compute the digest over the raw request body bytes using your shared secret.
The SDK ships a stdlib-only verifier that mirrors the Tango server's signing scheme byte-for-byte. Use it instead of hand-rolling — it's importable from a default install (no extras needed):
from tango.webhooks import verify_signature
if not verify_signature(raw_body, secret, request.headers.get("X-Tango-Signature")):
return 401
verify_signature returns False for missing/empty/malformed headers — it never raises. Comparison is constant-time.
Webhook tooling (tango.webhooks)¶
The tango.webhooks subpackage adds testing and developer-tooling primitives on top of the API methods above. Signing helpers ship with the default install; the receiver and CLI ship with pip install 'tango-python[webhooks]'. See docs/WEBHOOKS.md for usage guides; this section is the import-level reference.
Signing (default install)¶
from tango.webhooks import (
verify_signature, # (body: bytes, secret: str, header: str | None) -> bool
generate_signature, # (body: bytes, secret: str) -> str ("sha256=<hex>" wire form)
parse_signature_header, # (header: str | None) -> str | None (strips "sha256=")
SIGNATURE_HEADER, # "X-Tango-Signature"
SIGNATURE_PREFIX, # "sha256="
)
WebhookReceiver (with [webhooks] extra)¶
A stdlib-based local HTTP receiver, useful in tests and during local development.
from tango import WebhookReceiver, Delivery # exported from top-level tango package
# or: from tango.webhooks.receiver import WebhookReceiver, Delivery
with WebhookReceiver(secret="dev").run() as rx:
# ... cause something to POST to rx.url ...
deliveries: list[Delivery] = rx.deliveries
Constructor (all keyword arguments):
| Arg | Default | Meaning |
|---|---|---|
secret |
"" |
Shared secret. Empty means signatures are not verified. |
path |
/tango/webhooks |
URL path to accept POSTs on. |
host |
127.0.0.1 |
Bind address. |
port |
0 |
TCP port. 0 = OS picks a free port. |
forward_to |
None |
Optional URL to mirror each delivery to. |
max_history |
256 |
Cap on the in-memory deliveries deque. |
on_delivery |
None |
Callback fired for every delivery (verified or not). |
require_signature |
None |
Override default (require iff secret is set). |
Each Delivery is a dataclass: received_at, path, signature_header, body_bytes, body_json, verified, remote_addr, forward_status, forward_error.
simulate.sign and simulate.deliver¶
from tango.webhooks import sign, SignedRequest
from tango.webhooks import simulate
# Offline — produce the signed wire form without POSTing:
signed: SignedRequest = sign({"events": [{"event_type": "..."}]}, secret="s")
signed.body # bytes you would put on the wire
signed.signature # bare lowercase hex
signed.headers # {"Content-Type": ..., "X-Tango-Signature": "sha256=..."}
# With delivery — sign and POST to a target URL:
result = simulate.deliver(target_url="http://localhost:8011/tango/webhooks",
payload={...}, secret="s")
result.status_code # status from the receiver
result.signature # bare hex
result.sent_bytes # exact bytes that were POSTed
result.response_body # body the receiver returned
simulate.deliver and simulate.sign accept payloads as dict, list, str, or raw bytes. Dicts/lists are serialized via json.dumps(..., sort_keys=True, separators=(",", ":")) so signatures are reproducible across runs.
CLI entry point¶
The tango[webhooks] extra also installs a tango console script. See docs/WEBHOOKS.md § CLI reference for the full command list.
Response Objects¶
PaginatedResponse¶
All list methods return a PaginatedResponse object with the following attributes:
response = client.list_contracts(limit=25)
# Attributes
response.count # Total number of results
response.next # URL to next page (or None)
response.previous # URL to previous page (or None)
response.results # List of result dictionaries
Example:
contracts = client.list_contracts(limit=25)
print(f"Total contracts: {contracts.count:,}")
print(f"Results on this page: {len(contracts.results)}")
# Iterate through results
for contract in contracts.results:
print(contract['piid'])
# Check for more pages (contracts use keyset pagination via cursor)
if contracts.next:
next_page = client.list_contracts(cursor=contracts.cursor, limit=25)
Pagination Example (contracts use keyset pagination, not page numbers):
cursor = None
all_results = []
page_num = 1
while True:
response = client.list_contracts(cursor=cursor, limit=100)
all_results.extend(response.results)
print(f"Batch {page_num}: {len(response.results)} results")
if not response.next:
break
cursor = response.cursor # use cursor for next page
page_num += 1
print(f"Total collected: {len(all_results)} results")
ShapeConfig (predefined shapes)¶
The SDK provides predefined shape strings as constants on ShapeConfig. Use them as the shape argument for list/get methods when you want a consistent, validated set of fields without building a custom shape string.
from tango import TangoClient, ShapeConfig
client = TangoClient()
# List methods default to the minimal shape when shape is omitted
contracts = client.list_contracts(limit=10) # uses CONTRACTS_MINIMAL
# Or pass the constant explicitly
contracts = client.list_contracts(shape=ShapeConfig.CONTRACTS_MINIMAL, limit=10)
entity = client.get_entity("UEI_KEY", shape=ShapeConfig.ENTITIES_COMPREHENSIVE)
Available constants (by resource):
| Constant | Used by | Description |
|---|---|---|
CONTRACTS_MINIMAL |
list_contracts |
key, piid, award_date, recipient(display_name), description, total_contract_value |
ENTITIES_MINIMAL |
list_entities |
uei, legal_business_name, cage_code, business_types |
ENTITIES_COMPREHENSIVE |
get_entity |
Full entity profile (addresses, naics, psc, obligations, etc.) |
FORECASTS_MINIMAL |
list_forecasts |
id, title, anticipated_award_date, fiscal_year, naics_code, status |
OPPORTUNITIES_MINIMAL |
list_opportunities |
opportunity_id, title, solicitation_number, response_deadline, active |
NOTICES_MINIMAL |
list_notices |
notice_id, title, solicitation_number, posted_date |
GRANTS_MINIMAL |
list_grants |
grant_id, opportunity_number, title, status(*), agency_code |
IDVS_MINIMAL |
list_idvs, list_vehicle_awardees |
key, piid, award_date, recipient(display_name,uei), description, total_contract_value, obligated, idv_type |
IDVS_COMPREHENSIVE |
get_idv |
Full IDV with offices, place_of_performance, competition, transactions, etc. |
VEHICLES_MINIMAL |
list_vehicles |
uuid, solicitation_identifier, is_synthetic_solicitation, program_acronym, organization_id, organization, vehicle_type, description, idv_count, holder_count, order_winner_count, awardee_count, order_count, total_obligated, vehicle_obligations, vehicle_contracts_value, latest_award_date, solicitation_title, solicitation_date |
VEHICLES_COMPREHENSIVE |
get_vehicle |
Full vehicle with competition_details, fiscal_year, set_aside, etc. |
VEHICLE_AWARDEES_MINIMAL |
list_vehicle_awardees |
uuid, key, piid, award_date, title, order_count, idv_obligations, idv_contracts_value, recipient(display_name,uei) |
ORGANIZATIONS_MINIMAL |
list_organizations |
key, fh_key, name, level, type, short_name |
OTAS_MINIMAL |
list_otas |
key, piid, award_date, recipient(display_name,uei), description, total_contract_value, obligated |
OTIDVS_MINIMAL |
list_otidvs |
key, piid, award_date, recipient(display_name,uei), description, total_contract_value, obligated, idv_type |
SUBAWARDS_MINIMAL |
list_subawards |
award_key, prime_recipient(uei,display_name), subaward_recipient(uei,display_name) |
GSA_ELIBRARY_CONTRACTS_MINIMAL |
list_gsa_elibrary_contracts |
uuid, contract_number, schedule, recipient(display_name,uei), idv(key,award_date) |
PROTESTS_MINIMAL |
list_protests |
case_id, case_number, title, source_system, outcome, filed_date |
CONTRACT_APPEALS_MINIMAL |
list_contract_appeals |
uuid, board, docket_numbers, decision_date, appellant, judge, decision_type, url |
CONTRACT_APPEALS_COMPREHENSIVE |
get_contract_appeal |
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 (omits decision_text, which needs an Enterprise plan) |
FEDERAL_REGISTER_MINIMAL |
list_federal_register_documents |
uuid, document_number, publication_date, type, subtype, title, abstract, action, agencies, cfr_references, citation, significant, comments_close_on, effective_on, html_url, pdf_url |
FEDERAL_REGISTER_COMPREHENSIVE |
get_federal_register_document |
The list fields plus dates, signing_date, start_page, end_page, volume, docket_ids, dockets, regulation_id_numbers, topics, correction_of, corrections, executive_order_number, presidential_document_number, proclamation_number, comment_url, regulations_dot_gov_url, raw_text_url, body_html_url (omits full_text) |
EBUY_REQUESTS_MINIMAL |
list_ebuy_requests |
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 |
EBUY_REQUESTS_COMPREHENSIVE |
get_ebuy_request |
Every field, plus the organization and attachments expands (without extracted_text, which must be named) |
BUDGET_ACCOUNTS_MINIMAL |
list_budget_accounts, get_budget_account |
id, federal_account_symbol, fiscal_year, data_through_period, agency_code/name, bureau_name, account_title, bea_category, on_off_budget, subfunction_code, account_category, lifecycle (requested/enacted/apportioned/obligated/outlayed/unobligated), contract & assistance rollups, key ratios, next-year growth, source_anomalies |
VEHICLE_ORDERS_MINIMAL |
list_vehicle_orders |
key, piid, award_date, recipient(display_name,uei), total_contract_value, obligated |
ITDASHBOARD_INVESTMENTS_MINIMAL |
list_itdashboard_investments |
Minimal IT Dashboard investment fields |
ITDASHBOARD_INVESTMENTS_COMPREHENSIVE |
get_itdashboard_investment |
Full investment fields: uii, agency_code, agency_name, bureau_code, bureau_name, investment_title, type_of_investment, part_of_it_portfolio, updated_time, url |
SLED_OPPORTUNITIES_MINIMAL |
list_sled_opportunities |
opportunity_id, solicitation_number, solicitation_type, title, state, jurisdiction, agency, status, status_reason, delisted_at, posted_date, response_deadline, source_url, has_documents, first_seen_at, last_change_seen_at (no description — detail-only on the API) |
SLED_OPPORTUNITIES_COMPREHENSIVE |
get_sled_opportunity |
Full solicitation with description, the raw portal status, the delisting timestamp, both deadlines, bid opening, category codes, and organization / contact / meta / attachments / revisions |
SLED_REVISIONS_MINIMAL |
list_sled_opportunity_revisions |
observed_at, sequence, kind, changed_fields, source_declared (omits changes, which needs a Small plan) |
SLED_FORECASTS_MINIMAL |
list_sled_forecasts |
forecast_id, state, agency, title, estimated_advertisement_date, estimated_advertisement_raw, procurement_category, procurement_method, contract_number, incumbent_name, source_url, estimated_value(*) |
SLED_FORECASTS_COMPREHENSIVE |
get_sled_forecast |
Full forecast with description, contract_term, mbe_dbe_goal, delivery_location, and organization / contact / estimated_value |
All predefined shapes are validated at SDK release time (see Developer Guide). For custom shapes, see the Shaping Guide.
Error Handling¶
The SDK provides specific exception types for different error scenarios.
Exception Types¶
from tango import (
TangoAPIError, # Base exception
TangoAuthError, # 401 - Authentication failed
TangoNotFoundError, # 404 - Resource not found
TangoValidationError, # 400 - Invalid parameters
TangoRateLimitError, # 429 - Rate limit exceeded
)
TangoAPIError¶
Base exception for all Tango API errors.
Attributes:
- message (str): Error message
- status_code (int, optional): HTTP status code
TangoAuthError¶
Raised when authentication fails (401).
Common causes: - Invalid API key - Expired API key - Missing API key for protected endpoint
TangoNotFoundError¶
Raised when a resource is not found (404).
Common causes: - Invalid agency code - Invalid entity key - Resource doesn't exist
TangoValidationError¶
Raised when request parameters are invalid (400).
Attributes:
- message (str): Error message
- status_code (int): HTTP status code (400)
- details (dict): Validation error details from API
TangoRateLimitError¶
Raised when rate limit is exceeded (429).
Error Handling Examples¶
from tango import (
TangoClient,
TangoAPIError,
TangoAuthError,
TangoNotFoundError,
TangoValidationError,
TangoRateLimitError,
)
client = TangoClient(api_key="your-api-key")
# Handle specific errors
try:
agency = client.get_agency("INVALID")
except TangoNotFoundError:
print("Agency not found")
except TangoAuthError:
print("Authentication failed - check your API key")
except TangoAPIError as e:
print(f"API error: {e.message}")
# Handle validation errors with details
try:
contracts = client.list_contracts(
award_date_gte="invalid-date"
)
except TangoValidationError as e:
print(f"Validation error: {e.message}")
if e.response_data:
print(f"Details: {e.response_data}")
# Handle rate limiting
try:
contracts = client.list_contracts(limit=100)
except TangoRateLimitError:
print("Rate limit exceeded - please wait before retrying")
# Implement exponential backoff here
# Catch-all for any API error
try:
result = client.list_contracts()
except TangoAPIError as e:
print(f"An error occurred: {e.message}")
if e.status_code:
print(f"Status code: {e.status_code}")
Best Practices¶
1. Use Response Shaping¶
Always use response shaping for better performance:
# ❌ Without shaping (slow, large response)
contracts = client.list_contracts(limit=100)
# ✅ With shaping (fast, small response)
contracts = client.list_contracts(
shape="key,piid,recipient(display_name),total_contract_value",
limit=100
)
See Shaping Guide for details.
2. Handle Pagination Properly¶
Don't fetch all results at once - paginate responsibly:
# ✅ Good - process batch by batch (contracts use keyset/cursor pagination)
cursor = None
batches = 0
while batches < 10: # Limit to 10 batches
contracts = client.list_contracts(cursor=cursor, limit=100)
process_contracts(contracts.results)
if not contracts.next:
break
cursor = contracts.cursor
batches += 1
3. Use Filters to Narrow Results¶
Filter on the server side instead of client side:
# ❌ Don't do this
all_contracts = client.list_contracts(limit=1000)
gsa_contracts = [c for c in all_contracts.results if c['awarding_agency']['code'] == 'GSA']
# ✅ Do this instead
gsa_contracts = client.list_contracts(
awarding_agency="GSA",
limit=100
)
4. Handle Errors Gracefully¶
Always wrap API calls in try-except blocks:
try:
contracts = client.list_contracts(limit=10)
except TangoAPIError as e:
logger.error(f"Failed to fetch contracts: {e.message}")
# Handle error appropriately
5. Use Environment Variables for API Keys¶
Never hardcode API keys:
# ❌ Don't do this
client = TangoClient(api_key="sk_live_abc123...")
# ✅ Do this instead
import os
client = TangoClient(api_key=os.getenv("TANGO_API_KEY"))
# Or just use the default (loads from environment)
client = TangoClient()
Additional Resources¶
- Shaping Guide - Response shaping syntax, examples, and field reference
- Developer Guide - Dynamic models, predefined shapes, and SDK conformance (maintainers)
- Quick Start - Interactive notebook with examples
- GitHub Repository - Source code and examples
- Tango API Documentation - Full API documentation
Response Shaping Guide¶
Response shaping lets you control which fields the API returns, making your requests faster and more efficient. Instead of receiving hundreds of fields you don't need, you specify exactly what you want.
See also: API Reference for method parameters and ShapeConfig (predefined shapes); Developer Guide for dynamic models and maintainer conformance.
Why Use Response Shaping?¶
Performance Benefits: - 60-80% smaller responses - Faster downloads, lower bandwidth costs - Faster API responses - Less data to process and serialize - Clearer code - Explicitly state what data you're using
Example: A full contract response is ~2.4 MB. With shaping, you can reduce it to 320 KB (87% smaller) while getting all the data you actually need.
Quick Start¶
from tango import TangoClient
client = TangoClient(api_key="your-api-key")
# Without shaping - returns ALL fields (slower, larger)
contracts = client.list_contracts(limit=10)
# With shaping - returns only what you specify (faster, smaller)
contracts = client.list_contracts(
limit=10,
shape="key,piid,recipient(display_name),total_contract_value"
)
# Or use a predefined shape constant
from tango import ShapeConfig
contracts = client.list_contracts(shape=ShapeConfig.CONTRACTS_MINIMAL, limit=10)
# Access the data
for contract in contracts.results:
print(f"{contract['piid']}: {contract['recipient']['display_name']}")
Predefined shapes (ShapeConfig)¶
Instead of writing shape strings by hand, you can use the SDK’s predefined constants. Each list/get method has a default minimal shape when you omit shape; you can also pass a constant explicitly.
from tango import TangoClient, ShapeConfig
client = TangoClient()
# These are equivalent (list_contracts defaults to CONTRACTS_MINIMAL)
contracts = client.list_contracts(limit=10)
contracts = client.list_contracts(shape=ShapeConfig.CONTRACTS_MINIMAL, limit=10)
# Other resources
entities = client.list_entities(shape=ShapeConfig.ENTITIES_MINIMAL)
idvs = client.list_idvs(shape=ShapeConfig.IDVS_MINIMAL)
grants = client.list_grants(shape=ShapeConfig.GRANTS_MINIMAL)
Available constants: Contracts (CONTRACTS_MINIMAL), Entities (ENTITIES_MINIMAL, ENTITIES_COMPREHENSIVE), Forecasts, Opportunities, Notices, Grants, IDVs, Vehicles (VEHICLES_MINIMAL, VEHICLES_COMPREHENSIVE, VEHICLE_AWARDEES_MINIMAL, VEHICLE_ORDERS_MINIMAL), Organizations, OTAs, OTIDVs, Subawards. See API Reference – ShapeConfig for the full table and which method uses which constant.
Vehicles
metrics(*)expansion: The vehicles surface bundles 12 computed metrics under a singlemetrics(*)expansion (e.g.award_concentration_hhi,competed_rate,top_recipient_share). It is included inVEHICLES_COMPREHENSIVEby default. Theagency_details,competition_details, andopportunityshape entries are deprecated and emitDeprecationWarningif requested explicitly.
Basic Shaping¶
Simple Fields¶
List the fields you want, separated by commas:
# Just the basics
contracts = client.list_contracts(
shape="key,piid,description,award_date",
limit=10
)
# Access the fields
for contract in contracts.results:
print(f"{contract['piid']}: {contract['description']}")
print(f"Date: {contract['award_date']}")
Nested Fields¶
Use parentheses to select fields from nested objects:
# Get recipient information
contracts = client.list_contracts(
shape="key,piid,recipient(display_name,uei,cage_code)",
limit=10
)
for contract in contracts.results:
recipient = contract['recipient']
print(f"Recipient: {recipient['display_name']}")
print(f"UEI: {recipient['uei']}")
Multiple Levels¶
You can nest as deeply as needed. Contract location information is on place_of_performance (not nested inside recipient):
# Get place of performance details
contracts = client.list_contracts(
shape="key,recipient(display_name),place_of_performance(city_name,state_code,zip_code)",
limit=10
)
for contract in contracts.results:
location = contract['place_of_performance']
print(f"{location['city_name']}, {location['state_code']} {location['zip_code']}")
Common Use Cases¶
1. Quick Lists and Dropdowns¶
When you just need basic info for a list or dropdown:
# Minimal data for a dropdown
contracts = client.list_contracts(
shape="key,piid,recipient(display_name)",
limit=100
)
# Build a dropdown
for contract in contracts.results:
print(f"{contract['piid']} - {contract['recipient']['display_name']}")
2. Data Analysis¶
When analyzing contracts, focus on the metrics:
# Get financial and timing data
contracts = client.list_contracts(
shape="key,piid,award_date,fiscal_year,total_contract_value,obligated",
awarding_agency="GSA",
limit=1000
)
# Analyze
total_value = sum(c.get('total_contract_value', 0) or 0 for c in contracts.results)
print(f"Total contract value: ${total_value:,.2f}")
3. Geographic Analysis¶
When you need location data:
# Get place of performance details
# Note: use city_name (not city); congressional_district is not a shape field
contracts = client.list_contracts(
shape="key,piid,place_of_performance(city_name,state_code)",
limit=100
)
# Group by state
from collections import Counter
states = Counter(c['place_of_performance']['state_code'] for c in contracts.results if c.get('place_of_performance') and c['place_of_performance'].get('state_code'))
print(f"Top states: {states.most_common(5)}")
4. Vendor Research¶
When researching vendors and recipients:
# Get detailed vendor information
# Note: entity physical_address uses 'city' and 'state_or_province_code'
# business_types is a list of dicts with 'code' and 'description'
entities = client.list_entities(
shape="uei,legal_business_name,dba_name,business_types,physical_address(city,state_or_province_code)",
limit=50
)
for entity in entities.results:
print(f"{entity['legal_business_name']}")
print(f"Business Types: {', '.join(bt['code'] for bt in entity.get('business_types', []))}")
if entity.get('physical_address'):
addr = entity['physical_address']
print(f"Location: {addr.get('city')}, {addr.get('state_or_province_code')}")
5. Agency Research¶
When analyzing agency activity:
# Get agency and classification details
# Note: use awarding_office (not awarding_agency) for agency name/code sub-fields
contracts = client.list_contracts(
shape="key,awarding_office(agency_name,agency_code),naics(code,description),psc(code,description),total_contract_value",
fiscal_year=2024,
limit=500
)
# Analyze by agency
from collections import defaultdict
by_agency = defaultdict(float)
for contract in contracts.results:
if contract.get('awarding_office'):
agency = contract['awarding_office']['agency_name']
value = float(contract.get('total_contract_value', 0) or 0)
by_agency[agency] += value
# Top agencies by value
top_agencies = sorted(by_agency.items(), key=lambda x: x[1], reverse=True)[:10]
for agency, value in top_agencies:
print(f"{agency}: ${value:,.2f}")
Advanced Techniques¶
Using Wildcards¶
Get all fields from a nested object with *:
# Get all recipient fields
contracts = client.list_contracts(
shape="key,piid,recipient(*)",
limit=10
)
# All recipient fields are now available
for contract in contracts.results:
recipient = contract['recipient']
# Has all fields: display_name, uei, cage_code, legal_business_name, etc.
Flattening Responses¶
Convert nested structures to flat keys with dot notation:
# Flatten nested objects
contracts = client.list_contracts(
shape="key,piid,recipient(display_name,uei)",
flat=True,
limit=10
)
# Fields are now flattened
for contract in contracts.results:
print(contract['piid'])
print(contract['recipient.display_name'])
print(contract['recipient.uei'])
Best Practices¶
1. Start Minimal, Add as Needed¶
Start with the minimum fields you need, then add more:
# Start here
shape = "key,piid,recipient(display_name)"
# Add more as you need them
shape = "key,piid,recipient(display_name,uei),total_contract_value"
# Keep adding
shape = "key,piid,recipient(display_name,uei),total_contract_value,award_date,fiscal_year"
2. Use Shapes for Large Queries¶
The bigger the query, the more important shaping becomes:
# Small query - shaping optional
contracts = client.list_contracts(limit=5)
# Medium query - shaping recommended
contracts = client.list_contracts(
shape="key,piid,recipient(display_name),total_contract_value",
limit=100
)
# Large query - shaping highly recommended
contracts = client.list_contracts(
shape="key,piid,recipient(display_name),total_contract_value",
limit=1000
)
3. Reuse Common Shapes¶
Define shapes as constants for reuse:
# Define your common shapes
SHAPES = {
'list': "key,piid,recipient(display_name),total_contract_value",
'detail': "key,piid,description,recipient(*),awarding_office(*),total_contract_value,award_date",
'analysis': "key,fiscal_year,total_contract_value,obligated,award_date",
'geographic': "key,piid,place_of_performance(city_name,state_code)"
}
# Use them
contracts = client.list_contracts(shape=SHAPES['list'], limit=100)
contract_detail = client.list_contracts(shape=SHAPES['detail'], limit=1)
4. Document Your Shapes¶
When using custom shapes in production, document why you chose those fields:
# Dashboard summary shape
# - key: Contract identifier
# - piid: Display to users
# - recipient.display_name: Main label
# - total_contract_value: Summary metric
DASHBOARD_SHAPE = "key,piid,recipient(display_name),total_contract_value"
contracts = client.list_contracts(shape=DASHBOARD_SHAPE, limit=50)
Field Reference¶
Common Contract Fields¶
Identifiers:
- key - Unique contract identifier
- piid - Procurement Instrument Identifier
- award_id - Award identifier
Basic Info:
- description - Contract description
- award_date - Date awarded
- fiscal_year - Fiscal year
Financial:
- total_contract_value - Total contract value
- obligated - Total obligated amount (note: field is obligated, not total_obligated)
- award_amount - Initial award amount
Parties:
- recipient(...) - The vendor/recipient
- awarding_agency(...) - The agency awarding the contract
- funding_agency(...) - The agency funding the contract
Classification:
- naics(code,description) - Industry classification
- psc(code,description) - Product/Service code
Location:
- place_of_performance(...) - Where work is performed
- recipient_location(...) - Vendor location
Common Entity Fields¶
Basic:
- uei - Unique Entity Identifier
- cage_code - Commercial and Government Entity code
- legal_business_name - Official business name
- display_name - Display name
- dba_name - Doing Business As name
Classification:
- business_types - Array of business type codes
- primary_naics - Primary NAICS code
- naics_codes - All NAICS codes
Contact:
- email_address - Email
- entity_url - Website
- physical_address(...) - Physical address
- mailing_address(...) - Mailing address
Financial:
- federal_obligations(*) - Expansion for total/active federal obligations
Performance Comparison¶
Here's what you can expect when using shapes:
| Use Case | Fields Returned | Payload Size | vs. Full Response |
|---|---|---|---|
| Full response | ~200 fields | 2.4 MB | Baseline |
| Dropdown | 3-4 fields | 180 KB | 92% smaller |
| List view | 6-8 fields | 320 KB | 87% smaller |
| Detail view | 20-30 fields | 780 KB | 68% smaller |
| Analysis | 8-10 fields | 250 KB | 90% smaller |
Troubleshooting¶
Fields Not Appearing¶
If fields aren't showing up in the response:
- Check your shape syntax - Make sure parentheses match and commas are correct
- Field doesn't exist - The field might not exist for that record
- Typo - Double-check field names (they're case-sensitive)
# ❌ Wrong
shape = "key,piid recipient(display_name)" # Missing comma
# ✅ Correct
shape = "key,piid,recipient(display_name)"
Unexpected Structure¶
If the structure isn't what you expected:
# Shape specifies nested structure
contracts = client.list_contracts(
shape="key,recipient(display_name,uei)",
limit=1
)
# Access nested fields
contract = contracts.results[0]
print(contract['recipient']['display_name']) # Nested access
print(contract['recipient']['uei'])
# Use .get() for safety
display_name = contract.get('recipient', {}).get('display_name', 'Unknown')
Examples by Resource Type¶
Contracts¶
# Minimal for lists
"key,piid,recipient(display_name),total_contract_value"
# For analysis (use 'obligated', not 'total_obligated')
"key,fiscal_year,award_date,total_contract_value,obligated,naics(code)"
# For geographic analysis (use city_name; congressional_district not available)
"key,piid,place_of_performance(city_name,state_code)"
# Full detail (use awarding_office for agency breakdown)
"key,piid,description,recipient(*),awarding_office(*),total_contract_value,award_date,naics(*),psc(*)"
Entities¶
# Minimal for lookups
"uei,legal_business_name,cage_code,business_types"
# For vendor research (entity physical_address uses state_or_province_code, not state_code)
"uei,legal_business_name,dba_name,business_types,physical_address(city,state_or_province_code),primary_naics"
# Full profile
"uei,legal_business_name,dba_name,cage_code,business_types,physical_address(*),email_address,entity_url"
Forecasts¶
# Minimal
"id,title,anticipated_award_date,fiscal_year"
# With classification
"id,title,anticipated_award_date,fiscal_year,naics_code,status"
Opportunities¶
# Minimal
"opportunity_id,title,solicitation_number,response_deadline"
# With details
"opportunity_id,title,solicitation_number,description,response_deadline,active,naics_code,psc_code"
Next Steps¶
- Try the examples - Copy and paste these examples to get started
- Experiment - Start with minimal shapes and add fields as needed
- Profile your queries - Use network tools to see the size difference
- Define patterns - Create reusable shapes for your common queries
For more help, see: - API Reference - Method parameters, ShapeConfig table, and field context - Developer Guide - Dynamic models, predefined shapes in depth, and SDK conformance (for maintainers) - Quick Start Guide - Interactive examples
Pagination¶
The SDK uses two pagination strategies depending on the endpoint.
For per-method pagination parameters, see API_REFERENCE.md. This guide is the conceptual overview and iteration patterns.
Page-Based Pagination¶
Most endpoints use traditional page-based pagination with page and limit parameters.
from tango import TangoClient
client = TangoClient()
# First page
resp = client.list_entities(search="Booz Allen", limit=25)
print(f"Total: {resp.count}")
print(f"This page: {len(resp.results)}")
print(f"Next: {resp.next}")
# Next page
resp2 = client.list_entities(search="Booz Allen", limit=25, page=2)
Iterating All Pages¶
page = 1
all_results = []
while True:
resp = client.list_entities(search="Booz Allen", limit=100, page=page)
all_results.extend(resp.results)
if not resp.next:
break
page += 1
print(f"Fetched {len(all_results)} of {resp.count} entities")
Endpoints using page-based pagination: entities, forecasts, opportunities, notices, grants, protests, subawards, vehicles, vehicle awardees, organizations, GSA eLibrary contracts, IT Dashboard investments, agencies, offices, business types, NAICS, webhook subscriptions, webhook endpoints.
Cursor-Based Pagination¶
High-volume award endpoints use cursor-based (keyset) pagination for better performance on large datasets. Instead of a page number, you pass a cursor token from the previous response.
from urllib.parse import parse_qs, urlparse
from tango import TangoClient
client = TangoClient()
# First page
resp = client.list_contracts(limit=25, sort="award_date", order="desc")
print(f"Total: {resp.count}")
# Get cursor from the next URL
if resp.next:
qs = parse_qs(urlparse(resp.next).query)
cursor = qs.get("cursor", [None])[0]
# Fetch next page
resp2 = client.list_contracts(
limit=25,
cursor=cursor,
sort="award_date",
order="desc",
)
Iterating All Pages¶
from urllib.parse import parse_qs, urlparse
all_results = []
cursor = None
while True:
resp = client.list_contracts(
keyword="cloud",
limit=100,
cursor=cursor,
sort="award_date",
order="desc",
)
all_results.extend(resp.results)
if not resp.next:
break
qs = parse_qs(urlparse(resp.next).query)
cursor = qs.get("cursor", [None])[0]
print(f"Fetched {len(all_results)} of {resp.count} contracts")
Endpoints using cursor-based pagination: contracts, IDVs, IDV awards, IDV child IDVs, IDV transactions, OTAs, OTIDVs.
PaginatedResponse¶
All list methods return a PaginatedResponse object:
| Field | Type | Description |
|---|---|---|
count |
int |
Total number of results available |
next |
str \| None |
Full URL for the next page, or None |
previous |
str \| None |
Full URL for the previous page, or None |
results |
list[T] |
List of results for this page |
cursor |
str \| None |
Cursor token (cursor-based endpoints only) |
page_metadata |
dict \| None |
Optional additional page metadata |
Webhooks Guide¶
This guide covers everything tango-python provides for building, testing, and operating webhook integrations against the Tango API: signing helpers, a local receiver, a command-line tool, and management commands for the underlying endpoints and alerts.
If you only need the SDK method signatures, see API_REFERENCE.md § Webhooks. For the API-level contract (signing scheme, event taxonomy, retry behavior), see the Tango Webhooks Partner Guide.
Contents¶
- Install
- Concepts in 60 seconds
- Quickstart: zero to receiving
- CLI reference
- Programmatic use
- Common workflows
- Troubleshooting
Install¶
The signing helpers ship with the default install:
pip install tango-python
The CLI (tango webhooks ...) and the local receiver class are gated behind an optional extra:
pip install 'tango-python[webhooks]'
This adds click as a runtime dependency. The base SDK install stays unchanged.
After installing the extra, the tango console script is on your PATH:
tango webhooks --help
Concepts in 60 seconds¶
Tango webhooks have two pieces of state:
| Concept | What it is | Tango term |
|---|---|---|
| Endpoint | The URL Tango POSTs to, plus a generated signing secret | WebhookEndpoint |
| Alert | A saved-search filter saying which matches to deliver | WebhookAlert (filter subscription) |
| Delivery | A single signed POST Tango makes when a matching event fires | (the request itself) |
A typical setup:
- Create an endpoint (
POST /api/webhooks/endpoints/) with the public URL of your handler. Tango returns asecret— save it; it's used to sign every delivery. - Create one or more alerts (
POST /api/webhooks/alerts/) describing the saved-search matches you want delivered (e.g. opportunities matchingnaics=541511). Each alert maps to one of fivealerts.*.matchevent types. - Tango POSTs to your endpoint when matching events fire. The body is JSON; the header
X-Tango-Signature: sha256=<hex>is the HMAC-SHA256 of the raw body bytes keyed by your endpoint's secret. - Your handler verifies the signature, parses the body, and acts on it.
Quickstart: zero to receiving¶
Assumes you have a TANGO_API_KEY and want to receive entity-update webhooks for a specific UEI.
1. See what you can subscribe to¶
export TANGO_API_KEY=...
tango webhooks list-event-types
# alerts.opportunity.match New/updated opportunity matched a saved alert
# alerts.contract.match New/updated contract matched a saved alert
# alerts.entity.match Entity matched a saved alert
# alerts.grant.match Grant matched a saved alert
# alerts.forecast.match Forecast matched a saved alert
# alerts.exclusion.match SAM.gov exclusion matched a saved alert
# alerts.dibbs_rfq.match DLA DIBBS RFQ matched a saved alert
# alerts.dibbs_rfp.match DLA DIBBS RFP matched a saved alert
# alerts.sled_opportunity.match State/local/education solicitation matched a saved alert
# alerts.contract_appeal.match CBCA/ASBCA appeal decision matched a saved alert
# alerts.federal_register.match Federal Register document matched a saved alert
# alerts.ebuy_request.match GSA eBuy request matched a saved alert
This list is served by the API, so it is always current — the SDK does not hardcode
event types anywhere. Always trust list-event-types over any list written down
here.
Two things that surprise people:
- DIBBS awards are not alertable. There is no
alerts.dibbs_award.match; only RFQs and RFPs emit alerts. -
Nothing fires when a record merely lapses — except SLED. An exclusion reaching its termination date, or an RFQ/RFP passing its close date, emits no event: open/closed and in-force are derived at query time, so no stored row changes. Poll with
list_exclusions(active=...)/list_dibbs_rfqs(open=...)if you need to observe expiry.sled_opportunityis the exception, and the only one. A SLED solicitation's liveness is a storedstatuscolumn that Tango recomputes every fifteen minutes rather than deriving per request, so a deadline passing is a write andalerts.sled_opportunity.matchcan follow it. There is nosled_forecastquery type — a forecast has no deadline, so nothing transitions — and SLED revisions and attachments are not separately alertable: subscribe tosled_opportunityand filter onchange_seen_afterorrevision_kind.
2. See what a payload looks like¶
tango webhooks fetch-sample --event-type alerts.entity.match
Prints the canonical JSON shape Tango will deliver. No POST, no signature — just the body.
3. Run a local receiver¶
In one shell, start a listener with a chosen secret:
export TANGO_WEBHOOK_SECRET=dev_secret
tango webhooks listen --port 8011
In another shell, drive it with the canonical sample, signed locally:
tango webhooks simulate \
--secret $TANGO_WEBHOOK_SECRET \
--event-type alerts.entity.match \
--to http://127.0.0.1:8011/tango/webhooks
The listener should print a verified delivery with the alerts.entity.match body. You now have a feedback loop: edit your handler, re-run simulate, see the result.
4. Wire up the real Tango → your handler path¶
When you're ready for end-to-end testing against Tango itself, expose your local listener via a tunnel (ngrok http 8011, cloudflared tunnel, etc.) and register that public URL with Tango, then create an alert via the SDK:
# Use the public URL the tunnel gave you.
tango webhooks endpoints create --name dev --url https://<your-tunnel>.ngrok.io/tango/webhooks
# Save the `secret` from the response — that's what your handler uses to verify.
# Create an alert (filter subscription) via the SDK
from tango import TangoClient
client = TangoClient()
client.create_webhook_alert(
name="watch UEI ABC123",
query_type="entity",
filters={"uei": "ABC123"},
)
To force a real test delivery from Tango (without waiting for an actual event):
tango webhooks trigger
You should see a verified delivery in your local listener with the signature value generated by Tango — not by simulate.
CLI reference¶
All commands live under tango webhooks. Options that talk to Tango's API (--api-key, --base-url) read TANGO_API_KEY and TANGO_BASE_URL if not passed explicitly.
tango webhooks listen¶
Run a local HTTP receiver. Verifies signatures, optionally forwards each delivery downstream, prints a one-line summary plus the JSON body for each delivery.
tango webhooks listen \
--port 8011 \
--host 127.0.0.1 \
--path /tango/webhooks \
--secret $TANGO_WEBHOOK_SECRET \
--forward-to http://127.0.0.1:4242/wh
Options:
--port(default8011)--host(default127.0.0.1— loopback only, by design)--path(default/tango/webhooks)--secret/TANGO_WEBHOOK_SECRET— if empty, signatures are not verified (the listener accepts everything; useful for inspecting payloads when you don't have the right secret yet)--forward-to URL— mirror each delivery to a downstream URL, preserving body bytes and theX-Tango-Signatureheader--require-signature / --allow-unsigned— override the default policy (default: require when--secretis set)
Press Ctrl+C to stop. Rejected (signature-mismatch) deliveries are still printed with the label UNVERIFIED so you can debug what arrived.
tango webhooks simulate¶
Sign a payload locally with the same scheme Tango uses, then either print the signed request or POST it to a receiver.
Without --to — just print the headers + body a real Tango delivery would have:
tango webhooks simulate --secret dev_secret --event-type alerts.entity.match
Output includes delivered: false, the headers (Content-Type, X-Tango-Signature), and the JSON payload.
With --to — also POST the signed body to a receiver:
tango webhooks simulate \
--secret dev_secret \
--event-type alerts.entity.match \
--to http://127.0.0.1:8011/tango/webhooks
Output includes delivered: true, the receiver's status code, and the receiver's response body.
Three sources for the payload (mutually exclusive):
| Flag | Source | When to use |
|---|---|---|
--event-type X |
Fetches the canonical sample for X from Tango |
You want a realistic body without setting up an alert |
--payload-file PATH |
Reads a JSON file | You're testing a specific shape (regression, edge case) |
| (neither) | A built-in placeholder envelope | Smoke-testing the wiring |
tango webhooks trigger¶
Ask Tango to send a real test delivery to your configured endpoint. Wraps POST /api/webhooks/endpoints/test-delivery/. Requires --api-key.
tango webhooks trigger
tango webhooks trigger --endpoint-id <uuid>
Output is JSON: success, status_code (the HTTP code Tango got from your endpoint), response_time_ms, endpoint_url, message, error. Exit code is non-zero if delivery failed.
tango webhooks fetch-sample¶
Print the canonical sample payload for one event type, or the full mapping if --event-type is omitted. Wraps GET /api/webhooks/endpoints/sample-payload/. Read-only.
tango webhooks fetch-sample --event-type alerts.entity.match
tango webhooks fetch-sample # all event types
tango webhooks list-event-types¶
List every event type Tango supports with a one-line description.
tango webhooks list-event-types
tango webhooks endpoints¶
Manage where Tango delivers.
tango webhooks endpoints list [--page N] [--limit N]
tango webhooks endpoints get ENDPOINT_ID
tango webhooks endpoints create --name NAME --url URL [--inactive]
tango webhooks endpoints delete ENDPOINT_ID [--yes]
create returns the generated secret once — save it. --name is required and must be unique per user (uniqueness is enforced on (user, name), so you can have multiple endpoints with distinct names). delete prompts for confirmation; --yes skips. --inactive registers the endpoint disabled (no deliveries until you re-enable it).
Managing alerts¶
Alerts (filter subscriptions) are the canonical way to control what Tango delivers. There is no CLI subgroup for them — use the SDK directly:
from tango import TangoClient
client = TangoClient()
client.list_webhook_alerts()
client.get_webhook_alert("ALERT_UUID")
client.create_webhook_alert(
name="watch UEI ABC123",
query_type="entity",
filters={"uei": "ABC123"},
)
client.update_webhook_alert("ALERT_UUID", name="Renamed")
client.delete_webhook_alert("ALERT_UUID")
For multi-endpoint accounts, pass endpoint=<uuid> to create_webhook_alert to pin which endpoint the alert delivers to.
Programmatic use¶
The CLI is built on top of small importable pieces. You can use them directly in your own code — most usefully, in tests.
Signature verification in your handler¶
verify_signature is pure stdlib (no SDK dependencies, no click). Call it on the raw request body, not on a re-serialized parsed body — the HMAC is computed over exact bytes.
from tango.webhooks import verify_signature
# In your Flask / FastAPI / Django / Starlette / whatever handler:
def handle_webhook(request):
body = request.body # raw bytes
signature = request.headers.get("X-Tango-Signature")
if not verify_signature(body, secret=ENDPOINT_SECRET, signature_header=signature):
return 401, {"error": "invalid_signature"}
payload = json.loads(body)
# ... act on the events ...
return 200, {"ok": True}
verify_signature returns False for missing/empty/malformed headers — it never raises. Comparison is constant-time (hmac.compare_digest).
WebhookReceiver in pytest fixtures¶
The CLI's listen command is a thin wrapper around tango.webhooks.WebhookReceiver, which is a context-manager-friendly local HTTP server. Use it directly in tests to verify your code emits webhook calls correctly, or to drive your handler with realistic deliveries.
from tango import WebhookReceiver # WebhookReceiver is exported from the top-level tango package
from tango.webhooks import generate_signature, verify_signature
import httpx
def test_my_handler_processes_entity_update():
with WebhookReceiver(secret="test_secret").run() as rx:
# Trigger whatever in your code-under-test should send a webhook
# (e.g. a publisher, or in this case a manual POST).
body = b'{"events":[{"event_type":"alerts.entity.match","alert_id":"ABC"}]}'
sig = generate_signature(body, "test_secret")
# generate_signature returns the wire form ("sha256=<hex>") — assign
# directly to the header without wrapping.
httpx.post(rx.url, content=body, headers={"X-Tango-Signature": sig})
assert len(rx.deliveries) == 1
assert rx.deliveries[0].verified
assert rx.deliveries[0].body_json["events"][0]["uei"] == "ABC"
WebhookReceiver options:
secret: str = ""— shared secret. Empty means "don't verify."path: str = "/tango/webhooks"— URL path to accept.host: str = "127.0.0.1"/port: int = 0— bind address.0lets the OS pick a free port.forward_to: str | None = None— mirror each delivery to a downstream URL.max_history: int = 256— cap on the in-memorydeliveriesdeque.on_delivery: Callable[[Delivery], None] | None = None— fires for every recorded delivery, including signature-failed ones.require_signature: bool | None = None— override default (require iffsecretis set).
Each Delivery has: received_at, path, signature_header, body_bytes, body_json, verified, remote_addr, forward_status, forward_error.
simulate.sign and simulate.deliver¶
simulate.sign is the offline counterpart — it produces the exact wire form a Tango delivery would have, so you can drive your handler from a unit test:
from tango.webhooks import sign
signed = sign({"events": [{"event_type": "alerts.entity.match"}]}, secret="s")
assert signed.headers["X-Tango-Signature"].startswith("sha256=")
# Use `signed.body` as the raw bytes and `signed.headers` directly:
response = my_app.test_client().post(
"/webhooks", data=signed.body, headers=signed.headers
)
simulate.deliver does the same but POSTs the result to a URL — WebhookReceiver works as a target:
from tango.webhooks import simulate
from tango import WebhookReceiver
with WebhookReceiver(secret="s").run() as rx:
result = simulate.deliver(target_url=rx.url, payload={...}, secret="s")
assert result.status_code == 200
Common workflows¶
"I'm starting fresh — set me up to receive entity updates"¶
export TANGO_API_KEY=...
# 1. Confirm event types
tango webhooks list-event-types
# 2. Stand up a tunnel so Tango can reach you
ngrok http 8011 &
# 3. Register your endpoint
tango webhooks endpoints create --name dev --url https://<id>.ngrok.io/tango/webhooks
# (save the `secret` from the response into TANGO_WEBHOOK_SECRET)
# 4. Create an alert via the SDK
python -c '
from tango import TangoClient
TangoClient().create_webhook_alert(
name="entities", query_type="entity", filters={"uei": "<UEI>"}
)'
# 5. Run the listener pointed at your downstream handler
tango webhooks listen --port 8011 --secret $TANGO_WEBHOOK_SECRET \
--forward-to http://localhost:4242/wh
# 6. Force a test delivery
tango webhooks trigger
"I want to develop my handler offline"¶
You don't need a Tango account or any tunnel:
# Run the handler however you normally would on, e.g., :4242
tango webhooks listen --port 8011 --secret dev --forward-to http://127.0.0.1:4242/wh
# In another shell, drive it. Use Tango-shaped bodies if you have an API key:
tango webhooks simulate --secret dev --event-type alerts.entity.match \
--to http://127.0.0.1:8011/tango/webhooks
# Or use a custom shape from a file (no API key required):
tango webhooks simulate --secret dev --payload-file ./fixtures/edge.json \
--to http://127.0.0.1:8011/tango/webhooks
"I want to test my handler in CI, no network"¶
In pytest, use WebhookReceiver and simulate.deliver together — both are pure-Python and don't talk to Tango:
from tango.webhooks import simulate
from tango import WebhookReceiver
def test_handler_round_trip():
with WebhookReceiver(secret="s").run() as rx:
result = simulate.deliver(
target_url=rx.url,
payload={"events": [{"event_type": "alerts.entity.match", "alert_id": "X"}]},
secret="s",
)
assert result.status_code == 200
assert rx.deliveries[0].verified
"I need to inspect what bytes Tango actually sends"¶
tango webhooks simulate --secret $TANGO_WEBHOOK_SECRET --event-type alerts.entity.match
# Prints { "delivered": false, "headers": {...}, "sent_payload": {...} }
This is the shape your handler will receive — including the exact X-Tango-Signature value it should verify.
Troubleshooting¶
Signature always fails. Verify on raw bytes, not on a re-serialized parsed body. The HMAC is over exact bytes; reformatting whitespace or reordering keys breaks it. Most web frameworks expose the raw body separately from a parsed JSON shortcut — use the raw one.
tango: command not found. Install the extra: pip install 'tango-python[webhooks]'. The console script is registered only when click is available.
Listener prints WARNING: no --secret provided. You started listen without --secret and without TANGO_WEBHOOK_SECRET set. Every delivery will be accepted with verified=False. Useful for inspecting payloads when you don't have the secret yet, but unsafe in any shared environment.
fetch-sample returns 401. Set TANGO_API_KEY (or pass --api-key). fetch-sample reads from Tango's API.
endpoints create returns 400 or "endpoint already exists". Endpoint names are unique per user — if you've already created one with that --name, either pick a different name or use endpoints list to find the existing one and reuse it.
simulate --event-type X fails with HTTP 4xx. Tango doesn't recognize the event type. Run list-event-types to see the current list.
trigger returns success: false. Tango reached your endpoint but got a non-2xx response. Check endpoint_url and response_body in the output, then look at your handler's logs.
Error Handling¶
The SDK raises typed exceptions for HTTP errors and for shape-related failures. All exceptions are importable from tango.exceptions (and re-exported from the top-level tango package for the API errors).
For a compact reference of each class, see API_REFERENCE.md § Error Handling. This guide covers the hierarchy, recovery patterns, and the shape-error classes that don't have a dedicated section there.
Exception Hierarchy¶
TangoAPIError
├── TangoAuthError (401 Unauthorized)
├── TangoNotFoundError (404 Not Found)
├── TangoValidationError (400 Bad Request)
├── TangoRateLimitError (429 Too Many Requests)
└── ShapeError
├── ShapeValidationError (invalid field names)
├── ShapeParseError (invalid shape syntax)
├── TypeGenerationError (dynamic type generation failure)
└── ModelInstantiationError (model creation failure)
API Errors¶
TangoAPIError (base)¶
All API errors inherit from this class.
| Attribute | Type | Description |
|---|---|---|
status_code |
int \| None |
HTTP status code |
response_data |
dict |
Parsed response body (credentials redacted) |
message |
str |
Human-readable error message |
from tango import TangoClient
from tango.exceptions import TangoAPIError
client = TangoClient()
try:
resp = client.list_contracts(limit=10)
except TangoAPIError as e:
print(f"API error {e.status_code}: {e.message}")
TangoAuthError (401)¶
Raised when the API key is missing, invalid, or expired.
from tango.exceptions import TangoAuthError
try:
client = TangoClient(api_key="invalid-key")
client.list_contracts(limit=1)
except TangoAuthError:
print("Check your API key")
TangoNotFoundError (404)¶
Raised when a resource doesn't exist.
from tango.exceptions import TangoNotFoundError
try:
entity = client.get_entity("INVALID_UEI")
except TangoNotFoundError:
print("Entity not found")
TangoValidationError (400)¶
Raised for invalid request parameters (bad date format, unknown filter, etc.).
TangoRateLimitError (429)¶
Raised when you exceed rate limits. Includes retry information.
| Attribute | Type | Description |
|---|---|---|
wait_in_seconds |
int \| None |
Seconds to wait before retrying |
detail |
str \| None |
Human-readable rate limit message |
limit_type |
str \| None |
"burst" or "daily" |
import time
from tango.exceptions import TangoRateLimitError
try:
resp = client.list_contracts(limit=10)
except TangoRateLimitError as e:
if e.wait_in_seconds:
print(f"Rate limited ({e.limit_type}). Retrying in {e.wait_in_seconds}s...")
time.sleep(e.wait_in_seconds)
resp = client.list_contracts(limit=10)
Note: The SDK does not include built-in retry or backoff. You are responsible for handling rate limit errors. See the Rate Limits guide for strategies.
Shape Errors¶
These are raised when there's a problem with the response shaping configuration, not the API itself. See SHAPES.md for shape syntax.
ShapeValidationError¶
Raised when a shape string references field names that don't exist on the model.
from tango.exceptions import ShapeValidationError
try:
resp = client.list_contracts(shape="key,piid,nonexistent_field", limit=1)
except ShapeValidationError as e:
print(f"Invalid shape: {e}")
print(f"Shape string: {e.shape}")
ShapeParseError¶
Raised when the shape string has invalid syntax (unbalanced parentheses, etc.).
| Attribute | Type | Description |
|---|---|---|
shape |
str |
The invalid shape string |
position |
int \| None |
Character position where parsing failed |
TypeGenerationError¶
Raised when the SDK fails to generate a dynamic TypedDict for a shaped response.
ModelInstantiationError¶
Raised when the SDK fails to create a model instance from API data.
| Attribute | Type | Description |
|---|---|---|
field_name |
str \| None |
Field that caused the failure |
expected_type |
type \| None |
Expected Python type |
actual_value |
Any |
Value that couldn't be coerced |
Catching Everything¶
To handle any SDK-raised error in one place, catch TangoAPIError and ShapeError (or just Exception at the outermost boundary):
from tango.exceptions import TangoAPIError, ShapeError
try:
resp = client.list_contracts(shape="key,piid", limit=10)
except TangoAPIError as e:
# HTTP-layer problems (auth, rate limit, validation, etc.)
print(f"API error {e.status_code}: {e.message}")
except ShapeError as e:
# Shape-string or model-construction problems
print(f"Shape error: {e}")
Tango Python SDK – Dynamic Models Guide¶
This document explains how the Python dynamic shaping system works.
It mirrors the Node.js DYNAMIC_MODELS.md guide for the Python SDK.
Overview¶
Tango's dynamic modeling allows you to:
- Request exactly the fields you want
- Validate the shape string against Tango's schemas
- Generate a typed model descriptor at runtime
- Materialize shaped objects using correct:
- date parsing
- datetime parsing
- decimal handling
- list vs scalar logic
- nested structure
Components¶
ShapeParser¶
Parses shape strings into a ShapeSpec.
from tango.shapes import ShapeParser
parser = ShapeParser()
spec = parser.parse("key,piid,recipient(display_name)")
SchemaRegistry¶
Holds the field schemas for all models.
from tango.shapes import SchemaRegistry
from tango.models import Contract
registry = SchemaRegistry()
schema = registry.get_schema(Contract)
award_date_field = schema["award_date"]
# FieldSchema(name='award_date', type=date | None)
TypeGenerator¶
Builds a dynamic TypedDict-backed type from (shape_spec, base_model).
from tango.shapes import ShapeParser, TypeGenerator
from tango.models import Contract
parser = ShapeParser()
spec = parser.parse("key,piid,recipient(display_name)")
gen = TypeGenerator()
dynamic_type = gen.generate_type(
shape_spec=spec,
base_model=Contract,
type_name="ContractShaped",
)
ModelFactory¶
Takes a dynamic type + raw API JSON and produces typed ShapedModel instances.
The TangoClient uses this pipeline automatically after fetching data.
from tango import TangoClient
client = TangoClient(api_key="your-api-key")
contracts = client.list_contracts(
shape="key,award_date,recipient(display_name)",
)
# contracts.results are ShapedModel instances materialized by ModelFactory:
# - date/datetime strings parsed to date/datetime objects
# - decimals normalized via Decimal
# - nested structures are themselves ShapedModel instances
Example: Full Shaping Pipeline (manual)¶
from tango.shapes import ShapeParser, TypeGenerator, ModelFactory, create_default_parser_registry
from tango.models import Contract
parser = ShapeParser()
spec = parser.parse("key,award_date,recipient(display_name)")
gen = TypeGenerator()
dynamic_type = gen.generate_type(
shape_spec=spec,
base_model=Contract,
type_name="ContractShaped",
)
parsers = create_default_parser_registry()
factory = ModelFactory(gen, parsers)
shaped = factory.create_instance(
data={
"key": "C-1",
"award_date": "2024-01-15",
"recipient": {"display_name": "Acme"},
},
shape_spec=spec,
base_model=Contract,
dynamic_type=dynamic_type,
)
shaped becomes:
ContractShaped(key='C-1', award_date=datetime.date(2024, 1, 15), recipient=ContractShaped_Recipient(display_name='Acme'))
Attribute Access¶
ShapedModel is a dict subclass with __getattr__ so fields are accessible
both as dictionary keys and as attributes:
# Both styles work
shaped["key"] # "C-1"
shaped.key # "C-1"
# Nested models are also ShapedModel instances
shaped.recipient["display_name"] # "Acme"
shaped.recipient.display_name # "Acme"
Accessing a field that was not included in your shape raises a descriptive
AttributeError with suggestions:
shaped.award_amount
# AttributeError: Field 'award_amount' not found in ContractShaped.
# Available fields: 'key', 'award_date', 'recipient'
# This field may not be included in your shape specification.
# To include this field, add it to your shape parameter.
Type Safety¶
The Python SDK enforces shape correctness at parse time via ShapeParser.validate().
Nested structures are recursively materialized as ShapedModel instances, guaranteeing
the same access patterns at every depth. No static class generation happens at build time;
shapes are resolved at runtime.
Caching¶
TypeGenerator caches descriptors using a thread-safe LRU cache (default: 100 entries).
ShapeParser also caches parse results keyed on the raw shape string.
Nested Models¶
If a field is nested in the schema (e.g. "recipient" → RecipientProfile),
the generator recursively builds the nested descriptor, naming it
{ParentType}_{FieldName} (e.g. ContractShaped_Recipient). Each nested object
is also a ShapedModel, so attribute access and repr work uniformly at every level.
Predefined Shape Constants¶
ShapeConfig provides opinionated defaults for each resource's list and detail methods.
Each TangoClient method applies its corresponding default automatically; pass shape=
to override.
from tango import TangoClient, ShapeConfig
client = TangoClient(api_key="your-api-key")
# These are equivalent — list_contracts defaults to CONTRACTS_MINIMAL
contracts = client.list_contracts(limit=10)
contracts = client.list_contracts(shape=ShapeConfig.CONTRACTS_MINIMAL, limit=10)
# Other resources
entities = client.list_entities(shape=ShapeConfig.ENTITIES_MINIMAL)
idvs = client.list_idvs(shape=ShapeConfig.IDVS_MINIMAL)
See API Reference – ShapeConfig for the full table of constants.
Changelog¶
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]¶
[1.16.0] - 2026-10-10¶
Added¶
- Extracted text on eBuy request attachments (Tango API 5.9.1).
attachments(extracted_text)on eBuy requests returns the text extracted from an attachment's document. It is returned only when named: the defaultget_ebuy_request()shape andattachments(*)leave it out. An attachment with no text (an external link, an empty scan, or a document not yet extracted) is sent by the API without the key rather than with a null; the SDK's shaped result reads that asNone.
Changed¶
- Re-vendored
contracts/filter_shape_contract.json(Tango API 5.9.1).
[1.15.0] - 2026-10-06¶
Added¶
matched_byon agency-filter diagnostics (Tango API 5.9.0). Each entry ofPaginatedResponse.meta["resolved_filters"][<filter name>]that resolved now says how its token matched: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) orfuzzy(a looser text match, worth checking against the resolved name). An entry that did not resolve has nomatched_by.metais passed through as the API sends it, so no code change was needed to receive the field; this release documents it and pins it with a test.agency_warnings,unresolved_agency_tokensandresolved_agenciesare unchanged.- File and link counts on opportunities and notices (Tango API 5.9.0). An attachment's
typeisfile(a document attached to the notice; only files have extracted text) orlink(a URL the notice lists), and the new fields count each:meta.files_countandmeta.links_counton opportunities,file_countandlink_counton notices. The existingmeta.attachments_countandattachment_countstill count every attachment, links included, and an attachment of any other type counts only there, so files plus links need not equal the total. The new fields areNoneuntil a record has been counted, so treatNoneas unknown, not zero. All four are accepted in custom shapes, as isopportunity(meta(files_count,links_count))on vehicles; no default SDK shape names them.
Changed¶
- Re-vendored
contracts/filter_shape_contract.json(Tango API 5.9.0) and regeneratedtango/shapes/generated_overlay.py.
[1.14.0] - 2026-10-02¶
Added¶
PaginatedResponse.count_type:"exact"or"approximate", read from the API'sX-Results-CountTypeheader on every list method (Nonewhen the API doesn't send it). Past 1,000 matches the contract, IDV, opportunity and notice lists may return a query-planner estimate ascount, which can be well off; this is how a caller tells the two apart. It is taken from each response itself, so concurrent calls on one client can't see each other's value the waylast_response_headerscan.
[1.13.0] - 2026-10-02¶
Added¶
holder_countandorder_winner_counton vehicles (Tango API 5.8.0).holder_countis the number of distinct companies holding one of the vehicle's IDVs, andorder_winner_countis the number of distinct companies that have won a task order under it. Both are in the defaultVEHICLES_MINIMALandVEHICLES_COMPREHENSIVEshapes and accepted in custom shapes.data_through_periodon budget accounts (Tango API 5.8.0). The File A period (1-12) an account-year's figures run through; below 12 the year is partial, andNonemeans there is no File A data. It is in the defaultBUDGET_ACCOUNTS_MINIMALshape and accepted in custom shapes.list_budget_accounts(data_through_period=..., data_through_period_gte=..., data_through_period_lte=..., data_through_period_isnull=...)filters by that period, exactly, by range, or by whether it is set.
Changed¶
- Re-vendored
contracts/filter_shape_contract.json(Tango API 5.8.0) and regeneratedtango/shapes/generated_overlay.py.
Deprecated¶
awardee_counton vehicles. It is the same value asorder_winner_count; use that instead. The API removes it at its next major version, and the SDK's default vehicle shapes will drop it then.
[1.12.0] - 2026-10-02¶
Added¶
account_categoryandsource_anomalieson budget accounts (Tango API 5.7.0). Both are in the defaultBUDGET_ACCOUNTS_MINIMALshape and accepted in custom shapes.account_categoryisbudgetaryorcredit_financingtoday.source_anomalieslists the problems found in the source data behind an account, and is[]when there are none. Each element is typed as the newBudgetAccountSourceAnomalyTypedDict(withBudgetAccountAnomalySourceandBudgetAccountAnomalySourceRowfor its nestedsource), and every key is optional. Treataccount_categoryand an anomaly'scodeas open strings, since new values may appear.list_budget_accounts(account_category=..., account_category_in=...)filters by account category, exactly or against a comma-separated list.
Changed¶
list_budget_accounts()documents the API's default ordering: the latest fiscal year first, then largestenacted_bafirst, with accounts that have noenacted_balast.- Re-vendored
contracts/filter_shape_contract.json(Tango API 5.7.0) and regeneratedtango/shapes/generated_overlay.py.
Fixed¶
list_idvs(),list_idv_awards(),list_idv_child_idvs()andlist_idv_transactions()now return the API's paginationcursor. They dropped it, soresponse.cursorwas alwaysNoneand a caller following the cursor stopped after the first page.
[1.11.0] - 2026-09-29¶
Added¶
- Award fields on opportunities and notices (Tango API 5.5.0) — new
awarded/awardee_ueifilters onlist_opportunities(), plus award leaves and anawards(...)expand in the opportunity and notice shapes (seedocs/API_REFERENCE.md).
[1.10.0] - 2026-09-29¶
Added¶
list_sbir_topics(cycle_name=...)filters topics by their DSIP solicitation cycle (e.g.DOD_SBIR_2026_P1_CBZ), andSbirTopicgains acycle_namefield (Tango API 5.3.1). The vendored API contract is refreshed to 5.3.1.
[1.9.0] - 2026-09-28¶
Added¶
-
GSA eBuy requests (Tango API 5.1.0). Four methods over the
/api/ebuy/resource:list_ebuy_requests(),get_ebuy_request(),get_ebuy_attachment_url()andget_ebuy_access(), plusEbuyRequest/EbuyAttachmentschemas, anEbuyAccessresult type, and theEBUY_REQUESTS_MINIMAL/EBUY_REQUESTS_COMPREHENSIVEdefaults. These are the RFQs, RFPs and RFIs posted to GSA eBuy. All fourteen of the API's filters are explicit named parameters, includingsearch(which also matches attachment text),reference_number,sin,agencyandcontract_number.Access is scoped to your account: you see only requests posted under the GSA schedule contracts linked to it, and the endpoints require the Pro tier or above. With no linked contract,
list_ebuy_requests()returns an empty page rather than an error, soget_ebuy_access()reports whether access is enabled, why not (tier_requiredorno_contract_grant), and which contracts are linked.statusis frozen at the last state a request was seen in, because a request that closes stops appearing rather than getting a final row; readlast_seenfor staleness.get_ebuy_attachment_url()returns the short-lived signed URL the API redirects to without downloading the document, and raises the newTangoAttachmentLinkError(aTangoValidationError) with the link on.urlwhen the entry is an external link rather than a stored document. Theagencyfilter requires Tango API 5.3.0. -alerts.ebuy_request.matchis an alertable event type.create_webhook_alert(query_type="ebuy_request", ...)works.
[1.8.0] - 2026-09-28¶
Added¶
-
Federal Register documents (Tango API 5.3.0). Two methods over the new
/api/federal_register/resource:list_federal_register_documents()andget_federal_register_document(), plus aFederalRegisterDocumentschema and theFEDERAL_REGISTER_MINIMAL/FEDERAL_REGISTER_COMPREHENSIVEdefaults. These are the rules, proposed rules, notices and presidential documents published in the Federal Register since 1994. All seventeen of the API's filters are explicit named parameters, includingcomments_open,cfr_title/cfr_part,rin, and bothagency(a Tango organization, including its sub-agencies) andfr_agency(the Federal Register's own agency slug).get_federal_register_document()takes the document'suuid, not itsdocument_number, because a document number is not unique on its own: the Federal Register reused some before 2016. Look a document up by number withlist_federal_register_documents(document_number=...).full_textis available on the detail method only when named inshape, and neither default shape includes it, since it can run to several MB.ordering="rank"requires a non-emptysearch, matching the API. -alerts.federal_register.matchis an alertable event type.create_webhook_alert(query_type="federal_register", ...)works. -agencyfilter onlist_budget_accounts(),list_exclusions()andlist_itdashboard_investments()(Tango API 5.3.0). It takes a Tango agency name, abbreviation, code or organization key (e.g.EPA) and matches the whole organization subtree, so a department includes its sub-agencies; OR several with|. On exclusions it scopes by the excluding agency. On IT Dashboard it is available at every plan. - Theorganizationexpand is accepted on budget accounts, exclusions, SBIR topics and SBIR solicitations, along with theorganization_idfield on budget accounts and exclusions.organization(...)carriesagency_code,agency_name,department_code,department_name,office_code,office_nameandorganization_id. The SDK rejected these shapes client-side before. The default shapes are unchanged.
Fixed¶
list_itdashboard_investments()documents the API's current filter tiers.agency_nameis available at every plan, not Business+ only.
[1.7.0] - 2026-09-23¶
Added¶
-
Contract Disputes Act appeals (Tango API 4.26.0). Two methods over the new
/api/contract_appeals/resource:list_contract_appeals()andget_contract_appeal(), plus aContractAppealschema and theCONTRACT_APPEALS_MINIMAL/CONTRACT_APPEALS_COMPREHENSIVEdefaults. These are decisions of the Civilian Board of Contract Appeals (CBCA) and the Armed Services Board of Contract Appeals (ASBCA) — appeals of a contracting officer's final decision under an existing contract, which is a different dispute from a bid protest and shares no identifiers withlist_protests(). All ten of the API's filters are explicit named parameters.Three behaviors are documented on the methods and the model, because each is otherwise a support question.
decision_textneeds an Enterprise plan, and below that tier the key is absent rather than null — read it with.get(). Searching that text is free at every plan:search=matches inside the decision body and returns no fragment of it, so buying the body does not change what you can find. Anddocketaccepts a docket as it is cited — a leadingCBCA/ASBCAandNo./Nos.are ignored, while commas stay part of a docket (7092-C(6682, 6765, 6767)) rather than separating two.Neither default shape names
decision_text: the body runs to roughly 100K characters, so putting it in a default would make every fetch ask for something most callers cannot read.ordering="rank"requires a non-emptysearch, matching the API. -alerts.contract_appeal.matchis an alertable event type.create_webhook_alert(query_type="contract_appeal", ...)works. That method'squery_typedocstring now names the full current set and points atlist_webhook_event_types()as the authority, since a list written into a docstring can only go stale.
Fixed¶
attachments(doc_role, doc_role_alt)on opportunities and notices no longer raisesShapeValidationError. The Tango API serves both fields on a Pro plan or above, but the SDK rejected them client-side after the request had already been made.doc_rolesays what an attachment is for (requirement,instructions,pricing,terms,referenceorunknown) anddoc_role_altis a runner-up role that is usually null. Both must be named in the shape, sinceattachments(*)does not include them, and an attachment that has not been classified omits both keys, so read them with.get().- The shape overlay generator no longer lets one resource's view of a model hide fields from another's. An opportunity is reachable both from
list_opportunities()and as theopportunityexpand on vehicles, and the vehicles embed exposes fewer attachment fields. Whichever tree was generated last replaced the other, so the opportunity schema lost fields its own endpoint serves. The generator now adds the missing fields instead of replacing the schema, and keeps every existing field's type as it was. - SLED solicitations accept
delisted_atandmeta(jurisdiction_declared). Both were rejected client-side even though the API returns them.delisted_atis when a portal dropped a solicitation from its listing before its deadline, which is whatstatus_reason="delisted"means.meta.jurisdiction_declaredsays whether the source stated the jurisdiction level itself or Tango classified it from the issuer's name.delisted_atis now in bothSLED_OPPORTUNITIES_MINIMALandSLED_OPPORTUNITIES_COMPREHENSIVE, matching the API's own list default.
Changed¶
- SLED response values now carry the types the API returns. The SDK's SLED schema had guessed several types from field names.
posted_date,response_deadline,response_deadline_originalandbid_opening_dateonSledOpportunitynow parse to timezone-awaredatetimevalues (before,posted_dateandbid_opening_datewere truncated to adateand the two deadlines stayed strings), as doesestimated_advertisement_dateonSledForecast.estimated_value(min, max)on forecasts now parses toDecimal.category_codesis typed as a list of{scheme, code}objects,attachmentsandrevisionsas lists,meta(last_change_source_declared)andrevisions(source_declared)as booleans, andattachments(size_bytes, pages)andrevisions(sequence)as integers. If you compared any of the date fields as strings, compare them asdatetimevalues instead. - Re-vendored
contracts/filter_shape_contract.json(Tango API 5.1.0) and regeneratedtango/shapes/generated_overlay.py. The one resource the SDK does not wrap yet,ebuy/requests, is recorded incontracts/shape_coverage_baseline.jsonas a known gap. - The
ContractAppealshape schema is hand-curated intango/shapes/explicit_schemas.pyrather than left to the generated overlay. The generator falls back to name heuristics for a resource the live-API type probe has not sampled yet, and those heuristics get four of this resource's fields wrong:docket_numbersis a list,listedanddecision_date_repairedare booleans, andlisting_yearis an integer.
Documentation¶
list_protests()anddocs/API_REFERENCE.mdnow describe all three protest venues: GAO, the U.S. Court of Federal Claims (COFC) and the SBA Office of Hearings and Appeals (SBA OHA).naics_codeis documented as the NAICS code at issue in an SBA OHA size or NAICS appeal, which GAO and COFC protests never carry, rather than the solicitation's code.case_numberexamples now cover each venue's format, andnaics_codeis listed among the filter parameters.docs/API_REFERENCE.mddocuments the document-role fields on opportunity and notice attachments, anddelisted_atandmeta.jurisdiction_declaredon SLED solicitations.- New Contract Appeals section in
docs/API_REFERENCE.md, leading with what separates an appeal from a protest, plus the two newShapeConfigconstants in the shapes table. - Added
alerts.contract_appeal.matchto the event-type listing indocs/WEBHOOKS.md.
[1.6.0] - 2026-09-10¶
Added¶
attachments(extracted_text)— SLED document bodies on the Small plan and above (Tango API 4.25.1). Re-vendored the contract and regenerated the shape overlay, soshape="...,attachments(name,extracted_text)"now validates instead of being rejected client-side. Three properties are documented on the model and indocs/API_REFERENCE.md, because each is otherwise a support question: the leaf must be named (neitherSLED_OPPORTUNITIES_MINIMALnorSLED_OPPORTUNITIES_COMPREHENSIVEincludes it, andattachments(*)does not carry it, because the API only resolves the body for a caller who asked); the key is absent rather than null when the text is not being served, so read it with.get(); and a contested document never returns text at any plan. Searching document text stays ungated on every plan and returns no fragment of it — buying the body does not change search.-
State, local and education (SLED) procurement (Tango API 4.25.0). Six methods over the new
/api/sled/namespace:list_sled_opportunities(),get_sled_opportunity(),list_sled_opportunity_revisions(),get_sled_coverage(),list_sled_forecasts(),get_sled_forecast(), plusSledOpportunity/SledOpportunityRevision/SledForecastschemas and fiveShapeConfigdefaults. Every one of the API's 27 solicitation filters and 13 forecast filters is an explicit named parameter.Four behaviors are documented on the methods themselves because each misleads a caller who assumes federal semantics. Passing neither
statusnoractivereturns open solicitations only — that default is the API's, and the SDK deliberately does not synthesize astatusparam, since doing so would makeactive=Falseunreachable (pinned by a test).statusis Tango-derived and refreshed every fifteen minutes; the portal's own word is served assource_status, is frozen at last capture, and is not a liveness filter. Category scheme tagging is mid-migration, sonaics=matches only the small tagged share andcategory_code=is the escape hatch. Andmeta.attachment_countcan be lower thanlen(attachments), because an auto-generated portal cover sheet is listed and flaggedis_generated_summarybut excluded from the count.SLED_REVISIONS_MINIMALomitschangeson purpose: the per-field before/after needs a Small plan, so naming it by default would 403 a Free caller.changed_fieldsis in the default and available at every plan. -previous_uiionlist_itdashboard_investments(). The Tango API has accepted this filter for a while — it resolves a retired UII forward to whatever superseded it — and the SDK never picked it up, so it was a silent no-op for anyone who passed it through. Ungated, unlike its analytical neighbours on that endpoint. Found by the SDK's own filter-conformance check, not by SLED work.
Changed¶
- Re-vendored
contracts/filter_shape_contract.json(schema_version 2, 48 resources) and regeneratedtango/shapes/generated_overlay.pyfrom it — 535 fields across 33 containers, 84 nested schemas. Besides the SLED expands this closes three entity gaps the older contract could not see:relationships(confidence, verification_method)and the wholeworkforce(*)expand were reachable on the API and rejected client-side by the SDK's schema. - Baselined 14 reverse-shape-coverage gaps in
contracts/shape_coverage_baseline.json. All 14 are nested sub-resource routes the newer contract publishes for the first time (entities_contracts,idvs_awards,vehicles_orders, and similar); none is SLED, and none is a regression — they are pre-existing SDK gaps that only became visible with the re-vendored contract. The gate reports 0 new drift.
Documentation¶
- New State & Local (SLED) section in
docs/API_REFERENCE.mdcovering all six methods, both defaults that surprise people, and the five newShapeConfigconstants. - Corrected
docs/WEBHOOKS.md. It stated flatly that "nothing fires when a record merely lapses"; that is now false for exactly one query type.alerts.sled_opportunity.matchdoes fire on a closing, because SLED liveness is a stored column a fifteen-minute sweep writes rather than a query-time derivation. Also documented that there is nosled_forecastquery type and that SLED revisions and attachments are not separately alertable.
[1.5.1] - 2026-08-10¶
Documentation¶
- Documented (and pinned with tests) the OR syntax
list_entities(socioeconomic=...)has always supported: pipe-separated values match entities holding any of the listed designations, e.g.socioeconomic="OY|A2"; values are SAM business-type codes (e.g.OYBlack American Owned,A6SBA-certified 8(a),A2Woman Owned), not set-aside codes.
Changed¶
- The sdist no longer packages
.github/workflows, so CI-only commits stop changing the sdist hash. (#58)
[1.5.0] - 2026-08-04¶
Added¶
relationships(type, source)on entities. Tango API 4.20.0 added two keys to each entry in the entityrelationshipsexpand:type, the stable relationship-type code (prime_sub,parent_subsidiary,ultimate_parent,predecessor), andsource, where the tie came from (sam,subawards). Both are shape-selectable, soshape="uei,relationships(type,source,uei)"now resolves against the SDK's schema. Re-vendored the contract and regenerated the shape overlay; the coverage gate reports 0 gaps.PaginatedResponse.metanow carries the response-levelmetablock the API returns, with three accessors over it:unresolved_agency_tokens(agency tokens that matched nothing, per filter),resolved_agencies(the organization each token did match), andagency_warnings(the API's human-readable notes). Agency values resolve fuzzily, so a token can match an organization the caller did not intend and quietly scope the query to that subtree — and a token that resolves to nothing was previously dropped with no signal at all. Both cases were indistinguishable from "no such records exist". The API now reports both; until now the SDK read the envelope key-by-key and droppedmetaon the floor, so SDK users were the one group that could not see it.resolved_agenciesis the accessor that matters for the wrong-organization case: nothing is dropped there, so an unresolved-token check cannot detect it. All accessors return empty rather than raising whenmetais absent (most responses) or malformed.- A fully-unresolvable agency filter now raises
TangoValidationErrornaming the offending value instead of returning an empty page. This needed no SDK change — the existing 400 handler already reads the API'serrorkey — but it is new behavior for callers oflist_subawards(),list_opportunities(),list_notices(), andlist_vehicles(), which previously returned an empty result set. Contracts, IDVs, OTAs, and OTIDVs already behaved this way.
Changed¶
-
The
relationvalue vocabulary changed upstream — match ontypeinstead. In Tango API 4.20.0 therelationlabel stopped collapsing toaffiliatefor subcontracting and corporate-succession ties and now names the partner's role:subcontractor/prime,predecessor/successor, anddescendant(rather thanchild) on the far side of an ultimate-parent tie.affiliatesurvives only as a fallback for a type the API doesn't recognize. This affects the large majority of relationship entries.No SDK code change is required —
relationwas and remains astr. But if you have application code branching on the stringaffiliate, switch it totype, which is stable and won't churn again. See the entities data dictionary for the full vocabulary table.
Notes¶
PaginatedResponse.page_metadatais documented as alwaysNone: the API has never emitted apage_metadatakey, so the field has only ever read a value that does not exist. It is retained so existing attribute access keeps working. Usemeta.
[1.4.0] - 2026-07-20¶
Added¶
list_protests()now acceptsnaics_code, filtering protests by the solicitation's NAICS code. The API has always supported it (verified live: 15,027 protests unfiltered, 12 for541519, 2 for336411), butprotestswas absent from the conformance checker's resource-to-method map, so the gap was never reported.-
list_psc()now acceptshas_awards, restricting results to codes with contract award history (2,526 PSC codes unfiltered, 2,243 with awards).Falseis sent explicitly rather than dropped, since the API distinguishes the two. This param has always worked but was invisible to the contract until makegov/tango#2948 added theview_handled_paramsdeclaration for params a viewset reads straight fromquery_params. That PR also fixed a truthiness bug wherehas_awards=falsefiltered identically totrue, and is deployed — verified live at 2,526 forFalseand 2,243 forTrue.One caveat that is not an SDK issue:
/api/psc/responses are cached for 30 days per query-param key, so any?has_awards=key requested before that deploy still serves the old awards-only result until its entry expires or is invalidated.
Fixed¶
-
The reverse shape-coverage gate was blind to seven resources, and the SDK's schemas had silently drifted behind four of them. The gate skipped any resource whose contract shape tree was falsy (
if not shape: continue). Tango published"shape": nullforentities,opportunities,notices,protests,itdashboard,events, andnews— five of them because its contract generator crashed on tier-aware viewsets (makegov/tango#2944) — so the gate reported full coverage while checking nothing for them. Re-vendoring the fixed contract surfaced 69 real gaps (36 fields, 20 expands, 13 flattened expands) across entities, notices, protests, and itdashboard, all now closed by a regenerated overlay.The user-visible consequence was worse than missing schema entries. Because
ShapeParservalidates client-side, the SDK rejected 13 fields the API accepts, before ever issuing a request —list_entities(shape="display_name")raisedShapeValidationErroragainst an endpoint that returns 200 for it. Affected:Entity'sdisplay_name,entity_type,entity_structure,organization_structure,profit_structure,purpose_of_registration,country_of_incorporation,past_performance,registered,sba_business_types, andProtest'sdecisions,resolved_agency,resolved_protester. All 13 now validate. Note thatregisteredandsba_business_typesregressed in 1.3.0 — they validated under 1.1.1 — so the overlay generation had narrowedEntitywhile nothing was watching.The gate no longer skips silently: a resource declaring
shape_supported: truewith no shape tree is now a hard finding (contract_missing_shape) reported as an upstream contract defect rather than an SDK one. Contracts predatingschema_version: 2omit the key, where a null tree stays genuinely ambiguous and skipping remains correct. - Seven resources with working SDK methods were never conformance-checked at all.RESOURCE_TO_METHODomittedprotests,psc,mas_sins,departments,business_types, andassistance_listingsentirely, and mappedofficestoNoneas "not yet implemented" even thoughlist_officesexists. Their filter coverage had never been validated. Wiring them up found one real gap (protests.naics_code, above); the other six were already complete.eventsandnewsstay mapped toNone— genuinely absent from the SDK, now with a note explaining thatlist_webhook_event_typesis unrelated. -refresh_contract.py+probe_shape_types.pyre-run against the corrected contract.contracts/observed_shape_types.jsongained live-sampled types for the five newly-visible resources (entities alone contributes 136 observed paths), so the regenerated overlay resolves their types from real API responses rather than name heuristics.
Changed¶
CONTRACT_OMITTED_PARAMSnow warns when an entry is no longer needed, mirroring how baseline entries are burned down, and the list is now empty — which is the goal state. It briefly heldmas_sins: search, added because the API applies it (330 results unfiltered, 0 for a nonsense term, 32 foroffice) while the contract recordedfilter_params: [], so the checker would otherwise have flagged a working parameter as a silent no-op and invited its removal. Both that entry andbudget/accounts: searchwere retired by the new warning once makegov/tango#2944 and #2948 taught the contract generator to publish them. The carve-out mechanism stays for the next such case.
[1.3.0] - 2026-07-19¶
Added¶
-
DIBBS, exclusions, and SBIR/STTR endpoint support. Six endpoint families Tango shipped in v4.16–v4.18 had no SDK support at all — no models, no methods. Added
list_dibbs_rfqs/get_dibbs_rfq,list_dibbs_rfps/get_dibbs_rfp,list_dibbs_awards/get_dibbs_award,list_exclusions/get_exclusion,list_sbir_topics/get_sbir_topic, andlist_sbir_solicitations/get_sbir_solicitation, with all 85 filter params, shape schemas, andShapeConfigdefaults. New models:DibbsRfq,DibbsRfp,DibbsAward,Exclusion,SbirTopic,SbirSolicitation.Two API behaviors are worth knowing.
is_open(DIBBS) andis_currently_excluded(exclusions) are derived at query time, so filter with theopen/activekwargs rather than shaping on those fields. And DIBBStotal_contract_priceis the order total repeated on every line item — never sum it across rows; deduplicate on award + delivery-order number first. - Reverse shape-coverage: the SDK now captures every field and expand the API returns. The conformance check only validated one direction — that the SDK's shape constants reference allowed fields. Nothing checked the reverse, so the hand-maintainedtango/shapes/explicit_schemas.pyhad silently fallen ~200+ fields behind the API: 209 leaf fields, 41 whole nested expand branches, and 3 resources (naics, psc, mas_sins) the typed shape API could not request at all. A new generated overlay (tango/shapes/generated_overlay.py, produced byscripts/generate_shape_overlay.pyand merged over the base bySchemaRegistry) closes all of them, with types resolved from live-API sampling rather than guessed. Notable now-shapeable data: contract/IDV acquisition attributes (fair_opportunity_limited_sources,subcontracting_plan, …),contracts.officersandperiod_of_performance, the fullorganizationshierarchy (obligation_rank,l1..l8_fh_key,budget_appropriation,children/parent),otas/otidvstransactions, and the deepvehicles.awardees[.orders]tree. Code-object expands (set_aside,award_type,idv_type, …), previously modeled inconsistently asstr/baredict, now uniformly resolve to{code, description}. - Reverse shape-coverage gate.scripts/check_shape_coverage.pywalks Tango's shape trees (from the vendored contract) against the SDK schemas and fails when the SDK misses anything the API exposes and it isn't incontracts/shape_coverage_baseline.json. Offline against the vendored contract — no token, runs on forks — and wired into thelint.ymlconformance job. Regenerate the overlay withscripts/generate_shape_overlay.py(from the vendored contract +contracts/observed_shape_types.json, no API key); refresh the type observations withscripts/probe_shape_types.py(maintainer-run, needs a key). - Contract-first conformance system. The canonical API filter/shape contract is now vendored atcontracts/filter_shape_contract.json(refresh with the newscripts/refresh_contract.py), so the conformance check runs out of the box — locally, in CI, and on forks — with no tango checkout or access token.scripts/check_filter_shape_conformance.pygained three new checks on top of filter coverage: staleness (an SDK filter argument the API no longer accepts is an error — it would silently no-op), types (each argument's annotation is validated against the contract'sschema_version: 2per-filter type metadata), and a known-gaps baseline (contracts/conformance_baseline.json) that downgrades accepted missing params from errors to warnings so backlog is tracked instead of silent. A new--suggestflag prints ready-to-paste typed parameter scaffolds for any missing filters. The first run against the current API surface found 7 real coverage gaps, now baselined:keyon contracts/IDVs/OTAs/OTIDVs,cageon entities,idon forecasts, andopportunity_idon opportunities. -TangoValidationErrornow exposes the API's structured validation details directly:.issues(the list of{"path": ..., "reason": ...}entries the server returns for shape errors) and.available_fields(the endpoint's valid field set, when included). Both were previously reachable only by digging through.response_data. (#45)
Changed¶
- The CI conformance job (
lint.yml) now runs unconditionally against the vendored contract instead of silently skipping whenTANGO_API_REPO_ACCESS_TOKENis absent; the token is only used for a best-effort staleness notice comparing the vendored contract to tango HEAD.scripts/pr_review.pylikewise defaults its conformance step to the vendored contract (override withTANGO_CONTRACT_MANIFEST). - 400 error messages now name the rejected field(s) and reason when the API
returns structured
issues— e.g.Invalid request parameters: Invalid shape: tradeoff_process (unknown_field)instead of justInvalid request parameters: Invalid shape. (#45)
Fixed¶
docs/WEBHOOKS.mdlisted only five of the eight webhook alert types. It was missingalerts.exclusion.match,alerts.dibbs_rfq.match, andalerts.dibbs_rfp.match. No SDK change was needed — event types are served by the API and never hardcoded, so the new types already worked — but the hand-written list had gone stale. Also documents two things that surprise people: DIBBS awards are not alertable, and nothing fires when a record merely lapses (an exclusion hitting its termination date, or an RFQ/RFP passing its close date, emits no event). A production smoke test now asserts every live event type appears in the doc, so the list cannot silently fall behind again.- Refreshed the vendored API contract, which had gone stale by seven
resources. It tracked 25 resources against Tango's current 32, so the
conformance and coverage checks were validating against out-of-date truth and
could not see DIBBS, exclusions, SBIR, or
budget/accountsat all. Refreshing it also surfaced 72 additional fields on existing resources, now covered. Nested routes are keyed with a slash (budget/accounts), which silently broke the oldbudget_accountsmapping — both keys are now accepted. check_filter_shape_conformance.pyno longer reports false stale params. It resolved SDK arguments to API params only through an explicitapi_param_mappingdict, so methods that express the translation as tuple tables —("account_title__icontains", account_title)andrange_filters = (("apportioned", apportioned, apportioned_gte, ...))— looked like they exposed dozens of params the API rejects. They do not:list_budget_accountscorrectly sendsapportioned__gte,fiscal_year__lte, andaccount_title__icontains. The checker now understands both tuple forms (inAssign,AnnAssign, and inlineforiterables), with an explicitapi_param_mappingstill taking precedence. This letbudget/accountsbe conformance-checked for the first time; its genuinely missing lookup variants (agency_code__in,bureau_name__icontains, …) are now baselined.
[1.2.0] - 2026-06-05¶
Added¶
list_budget_accounts()now exposes the full range-filter surface that the REST endpoint has always supported. Every numeric metric on/api/budget/accounts/— the 26 fields in the backend'sRANGE_NUMERIC_FIELDSlist (enacted_ba,apportioned,obligated_total,unobligated_balance,contract_obligated,contract_share_of_obligated_capped,ba_growth_next_year_pct,actual_vs_requested_contract, all the ratio fields and their_cappedvariants, etc.) — is now accepted in three forms: exact match (field=), greater-or-equal (field_gte=), and less-or-equal (field_lte=). Previously only the identity / taxonomy filters were exposed, which forced callers to discover accounts by exact symbol lookup; the new surface makes pipeline-style queries (e.g. "all FY24 accounts where contract share ≥ 60%, unobligated balance ≥ $200M, and next-year growth ≥ 15%, sorted by largest headroom first") a single SDK call. The new params map to the API'sfield__gte/field__lteform;ordering=already accepted any of these fields and continues to.
[1.1.3] - 2026-06-04¶
Added¶
- Reference-data list/get methods now accept
shape(and the associatedflat/flat_lists) parameters, matching the underlying API which has always supported the shape system viaShapeOnDemandMixin. Affected:list_naics/get_naics,list_psc/get_psc,list_assistance_listings/get_assistance_listing,list_business_types/get_business_type,list_mas_sins/get_mas_sin. Whenshapeis omitted, behavior is unchanged — the API applies its own per-resource default.list_business_typesreturns raw dicts (instead ofBusinessTypeinstances) when ashapeis supplied so the caller gets exactly the shape requested.
[1.1.2] - 2026-06-04¶
Changed¶
get_entity_budget_flows()now exposes the backend's standard page/limit pagination andfiscal_yearfilter, and returnsPaginatedResponse[dict[str, Any]]instead of a raw dict. The backend has always paginated this endpoint viaStandardResultsSetPagination; the previous signature gave callers no way to reach pages beyond the first or to narrow by fiscal year. Callers that were indexingresult["results"]on the old return value should switch toresult.results(and can now useresult.next/page=to walk further pages).- Completed the strict-
mypyburn-down acrosstango/shapes/(parser, generator, factory, schema). All changes are type-annotation/typing corrections with no runtime behavior change, except:FieldSchema.nested_modelis now typedtype | str | None(it always accepted string model names from the explicit schemas; the annotation was wrong).ModelFactory.validate_dataandShapeParser._validate_field_speclikewise accepttype | strfor the model argument.- Removed two dead
elif field_spec.is_wildcard:branches (inTypeGenerator.generate_typeandModelFactory.create_instance) and the now-orphaned_parse_nested_wildcardhelper. These were unreachable — wildcard field specs are fully handled by the top-of-loop branch thatcontinues before reaching them — so removal is behavior-preserving.
Docs¶
docs/API_REFERENCE.mdnow documents the Budget surface that shipped in v1.1.0: a new## Budgetsection coveringlist_budget_accounts,get_budget_account,get_budget_account_quarters, andget_budget_account_recipients; aget_entity_budget_flows()entry under Entity Sub-resources; aBUDGET_ACCOUNTS_MINIMALrow in the ShapeConfig table; and a Budget entry in the table of contents.
CI¶
- Bumped GitHub Actions off the deprecated Node 20 runtime (forced off
2026-06-02):
actions/checkoutv4→v6,astral-sh/setup-uvv4→v8.1.0 (pinned exact — no floatingv8major tag is published yet), andcodecov/codecov-actionv3→v5 (with the renamedfiles:input). mypyis now a hard gate inlint.yml(no longer advisory). Thetango/package type-checks cleanly under strict mypy.
[1.1.1] - 2026-05-29¶
Removed¶
- The
notebooksoptional extra (jupyter,ipykernel). It only powered local editing ofdocs/quick_start.ipynb, which still renders on GitHub/PyPI without it, and its transitive tree (jupyter-server, jupyterlab, nbconvert, tornado, etc.) accounted for the bulk of the repo's open Dependabot alerts. Contributors who want to re-run the notebook canpip install jupyterdirectly. The shipped SDK is unaffected — its only runtime dependency remainshttpx.
Changed¶
- Refreshed the dev/test dependency lock to clear the remaining Dependabot
alerts (patched
idna,pygments,pytest,python-dotenv; droppedurllib3/requeststransitives). Dev-tool majors moved forward (mypy1.18→2.1,pytest8→9,ruff0.14→0.15,vcrpy7→8); all lint, type, and test gates stay green. No change to the shipped SDK's runtime deps.
[1.1.0] - 2026-05-29¶
Fixed¶
_parse_webhook_alert: aligned the parser output with theWebhookAlerttype contract. Sparse server payloads now hydratequery_typeandfiltersas""/{}(matching their declared non-null types) rather thanNone, andstatusis typed as theLiteral["active", "paused"]the model promises. Clears the four type-check errors this introduced; no change for full payloads.Contractmodel: removed dead fields (id,award_id,recipient_name,award_amount,awarding_agency,funding_agency) and added the real API fields (key,piid,obligated,total_contract_value,base_and_exercised_options_value,awarding_office,funding_office,naics_code,psc_code,set_aside,solicitation_identifier,parent_award,legislative_mandates,subawards_summary,place_of_performance). The deprecated fields remain declared withNonedefaults for one minor cycle (they never returned data) and will be removed in2.0.0. NewOrganizationOfficePayloaddataclass for the office fields. (Contractis a documentation-only dataclass — not instantiated or exported — so there is no runtime impact.)list_contracts: no longer sendspage=1to the cursor-only/api/contracts/endpoint. When no cursor is supplied, neitherpagenorcursoris sent and the API returns the first page by default.- Shape validation: registered the
ContractOrIDVCompetitionnested schema (alias ofCompetition) so nested selections likecompetition(extent_competed,number_of_offers_received)on contract / IDV shapes validate instead of raisingShapeValidationError.
Added¶
- Budget accounts surface (tango v4.6.8):
list_budget_accounts,get_budget_account,get_budget_account_quarters,get_budget_account_recipients. NewBudgetAccountdataclass exported from the top-level package, plusShapeConfig.BUDGET_ACCOUNTS_MINIMAL. - Singleton detail GETs:
get_contract,get_contract_subawards,get_contract_transactions,get_forecast,get_grant,get_notice,get_opportunity,get_subaward. get_entity_budget_flows(uei)for/api/entities/{uei}/budget-flows/.list_otidv_awards(key)for/api/otidvs/{key}/awards/(parity with Node).grant_idfilter kwarg onlist_grants(supports multi-value OR via|).
CI¶
- Re-enabled
lint.ymlas a PR + push-to-main gate.ruff formatandruff checkare hard gates;mypyruns advisory (continue-on-error) pending burn-down of ~28 pre-existing type errors. The filter/shape conformance check is a separate job that skips cleanly until aTANGO_API_REPO_ACCESS_TOKENsecret for the private manifest repo is configured, at which point it becomes a hard gate.
[1.0.0] - 2026-05-13¶
First stable release.
tango-pythonis now at full API parity with the Tango HTTP surface, the legacy subject-based webhook subscription mechanism has been removed in favor of filter alerts, the shape parser agrees byte-for-byte with the server's expand-alias handling, and the SDK's docs are auto-published todocs.makegov.com/sdks/python/via the composer pipeline (makegov/docs#15 / makegov/docs#16). From1.xon, we'll only do breaking changes on a major bump.Originally tracked as: API parity (PR #25), subject-based webhook removal (PR #27 / issue #2275), shape-validator alias support (PR #28 / issue #2266), and the docs-only content port (makegov/docs#16).
Added¶
orderingparameter onlist_forecasts,list_grants,list_subawards,list_gsa_elibrary_contracts, andlist_opportunities. Prefix with-for descending. Closes a parity gap with the API surface (these endpoints all accept?ordering=server-side).create_webhook_endpointacceptsname=(keyword-only) and now requires it. The Tango API enforces unique(user, name)on endpoints; omittingnamereturns a 400 server-side, so the SDK raisesTangoValidationErrorclient-side instead of round-tripping. (0.7.0 — never publicly released — emitted aDeprecationWarninginstead.)update_webhook_endpointacceptsname=for renaming an endpoint.- Webhook alerts (filter subscriptions):
list_webhook_alerts,get_webhook_alert,create_webhook_alert,update_webhook_alert,delete_webhook_alert— the canonical write surface over/api/webhooks/alerts/. NewWebhookAlertdataclass exported from the top-level package. resolve(name, target_type, ...)— POST/api/resolve/to rank entity / organization candidates from a free-text name. ReturnsResolveResultwithResolveCandidateentries (both exported).validate(identifier_type, value)— POST/api/validate/to validate the format of a PIID, solicitation number, or UEI. ReturnsValidateResult(exported).- Reference data:
list_departments,get_department,list_psc,get_psc,get_psc_metrics,get_naics,get_naics_metrics,get_business_type,list_assistance_listings,get_assistance_listing,list_mas_sins,get_mas_sin. - Entity sub-resources:
list_entity_contracts,list_entity_idvs,list_entity_otas,list_entity_otidvs,list_entity_subawards,list_entity_lcats,get_entity_metrics. All shape-aware where the underlying endpoint supports shaping. - IDV sub-resources:
list_idv_lcats. - Agency sub-resources:
list_agency_awarding_contracts,list_agency_funding_contracts. - Misc:
search_opportunity_attachments(q, top_k, include_extracted_text)for/api/opportunities/attachment-search/;get_version()for/api/version/;list_api_keys()for/api/api-keys/.
Changed¶
create_webhook_alertacceptsendpoint=(keyword-only). Required for accounts with multiple webhook endpoints; auto-resolves for single-endpoint accounts. Closes the multi-endpoint smoke-test gap (tango#2256).test_webhook_deliverynow sends the canonicalendpointbody key instead of the deprecatedendpoint_idalias (tango#2252). The Python kwarg name staysendpoint_id=for backwards compatibility; the wire payload is what changed.generate_signature(body, secret)now returns the full wire form"sha256=<hex>"instead of bare hex. Callers can assign the return value directly to theX-Tango-Signatureheader without wrapping in a format string. This is a breaking change for code that relied on the bare-hex return; pass it throughparse_signature_header()to recover the previous form.verify_signatureaccepts both prefixed and bare-hex inputs (unchanged), so receivers continue to work either way.
Removed¶
- Subject-based webhook subscription surface (tango#2275). Migrate to
create_webhook_alert(...)and the alerts API.- Methods:
list_webhook_subscriptions,get_webhook_subscription,create_webhook_subscription,update_webhook_subscription,delete_webhook_subscription. - Dataclasses:
WebhookSubscription,WebhookSubjectTypeDefinition. Both are no longer exported from the top-leveltangopackage — importing them raisesImportError. - Fields:
default_subject_typeremoved fromWebhookEventType;subject_typesandsubject_type_definitionsremoved fromWebhookEventTypesResponse. The server's/api/webhooks/event-types/response no longer carries these. - CLI: the entire
tango webhooks subscriptionsClick subgroup (list/get/create/delete). Use the SDK'sclient.create_webhook_alert(...)etc. directly — there is no CLI subgroup for alerts.
- Methods:
orderingkwarg fromlist_noticesandlist_protests. The notices and protests viewsets reject every?ordering=value at runtime (tango#2254); the kwarg silently sent unsupported values. Other five list methods retainordering.
Fixed¶
TangoClient._post()and_patch()accept bothjson_data=(positional) andjson=(keyword) for backward compatibility. Internal callers and docs examples that usejson=no longer fail withTypeError. Passing both now raisesTangoValidationErrorrather than silently preferring one — that ambiguity would hide caller bugs.get_psc_metrics/get_naics_metrics/get_entity_metricsdocstrings —period_groupingvalues are"month"/"quarter"/"year"(the path-segment values the API accepts), not"monthly"/"quarterly".docs/API_REFERENCE.md#get_agency— example usesclient.get_agency("GSA")consistently and notes the parameter accepts CGAC / FPDS / short code / abbreviation / canonical name.README.mdQuick Start —get_agency()returns anAgencydataclass, so the example uses attribute access (agency.name) instead ofagency['name']which wouldTypeError.scripts/smoke_api_parity.py—list_business_types(limit=1)is now wrapped in therun(...)helper so a failure on that call records FAIL instead of aborting the smoke run.tango webhooks endpoints createCLI now accepts and requires--name(passed through tocreate_webhook_endpoint(name=...)). Previously the option was absent, meaning the CLI could never set a custom endpoint name and every call would 400 server-side (the server enforcesunique(user, name)).WebhookAlert.query_typeandWebhookAlert.filterstightened fromOptionalto non-optional (stranddict[str, Any]respectively). Legacy nullable rows were purged by the tango#2275 migration; the server model and serializer guarantee non-null values for all current data.WebhookAlert.statusnarrowed fromstrtoLiteral["active", "paused"]— the server serializer produces exactly those two values.- Shape validator agrees with server on
naics(...)/psc(...)expansions. The client-sideShapeParser.validate()previously rejected the canonicalshape=naics(code,description)form (which the server has always accepted) and also rejected the aliasshape=naics_code(code,description). The parser now mirrors the server's_EXPAND_ALIASES(introduced in Tango PR makegov/tango#2259) and rewritesnaics_code(...)/psc_code(...)to their canonicalnaics(...)/psc(...)form at parse time. Bare scalar leaves (shape=naics_code/shape=psc_code) are left untouched and still return the raw column value, matching the server. Schemas forContract,Forecast,Opportunity,Notice, andVehiclegained explicitnaics/pscexpand entries backed by the existingCodeDescriptionnested model. Fixes makegov/tango#2266. Subawardschema matches the server'sSubawardSerializer. The previousSUBAWARD_SCHEMAdeclared two fields the server has never exposed (id,amount) and was missing every real field on the resource — includingpiid,key,awarding_office/funding_office/place_of_performance/subaward_details/fsrs_details/highly_compensated_officers/usaspending_permalink, and the denormalizedprime_awardee_*/recipient_*lookup columns. Shape strings that referenced any real field (e.g.shape="piid") would fail client-side validation withunknown_field, and conversely the SDK happily passedshape="id"/shape="amount"through to the server, where they were rejected.SUBAWARD_SCHEMAis now derived directly fromawards.serializers.subawards.SubawardSerializerand the resource's runtimeavailable_fields. TheSubawarddataclass intango/models.pywas updated to match. New nested schemasSubawardDetails,FsrsDetails,SubawardPlaceOfPerformance, andHighlyCompensatedOfficerare registered so the corresponding shape expansions validate end-to-end.
Documentation¶
- New
docs/ERRORS.md— full exception hierarchy, recovery patterns, and the shape-error classes (ShapeValidationError,ShapeParseError,TypeGenerationError,ModelInstantiationError). Ported fromdocs.makegov.com/sdks/python/errors.mdahead of the docs-site auto-pull cutover (makegov/docs#15 / makegov/docs#16). - New
docs/PAGINATION.md— page-based vs cursor-based strategies, iteration patterns, and thePaginatedResponsefield reference. Ported fromdocs.makegov.com/sdks/python/pagination.md. - New
docs/CLIENT.md—TangoClientconstructor reference,rate_limit_info/last_response_headersproperties, and retry-semantics note (the SDK has no built-in retry). Ported fromdocs.makegov.com/sdks/python/client.md.
CI¶
- New
.github/workflows/docs-dispatch.yml— fires on push tomainwhendocs/**,README.md, orCHANGELOG.mdchanges and dispatchesexternal_updatedatmakegov/docsso the public docs site rebuilds with the latest SDK content. Required for the makegov/docs#15 auto-pull pipeline.
[0.6.0] - 2026-05-07¶
Added¶
- Vehicles: new top-level fields
program_acronym,idv_count,total_obligated,is_synthetic_solicitation,latest_award_date,description,opportunity_id. - Vehicles: new
metrics(*)shape expansion bundling 12 computed metrics:avg_offers_received,award_concentration_hhi,order_concentration_hhi,competed_rate,using_agency_count,avg_order_value,max_order_value,top_recipient_share,recent_obligations_24mo,recent_orders_24mo,days_since_last_order,obligation_to_ceiling_ratio. Backed by a newVehicleMetricsschema. list_vehicle_orders(uuid, ...)for the new/api/vehicles/{uuid}/orders/endpoint, returning task orders under the vehicle's IDVs with two-phase pagination.list_vehiclesgained 21 explicit filter parameters per API 4.3.0:vehicle_type,type_of_idc,contract_type,set_aside(multi-value via|),who_can_use,naics_code,psc_code,program_acronym,agency,organization_id,total_obligated_min/max,idv_count_min/max,order_count_min/max,fiscal_year,award_date_after/before,last_date_to_order_after/before.list_vehicle_awardeesgained asearchparameter for entity-aware full-text search across IDV fields and recipient entity details (API 4.3.0).orderingparameter onlist_vehicles(whitelist:vehicle_obligations,latest_award_date,total_obligated,award_date,last_date_to_order,fiscal_year,idv_count,order_count) and onlist_vehicle_orders(whitelist:award_date,obligated,total_contract_value). Prefix with-for descending.ShapeConfig.VEHICLE_ORDERS_MINIMALdefault for the new orders endpoint.- Shaping: New
organization(*)expand onVehicle,Forecast,Grant,ITDashboardInvestment, andProtestschemas — returns the canonical 7-key office payload (organization_id,office_code,office_name,agency_code,agency_name,department_code,department_name). Selectable as the bare leaf (shape=...,organization) or as a sub-selectable expansion (shape=...,organization(office_code,...)). - Shaping: New
vehicle(*)expand onContract— request the parent vehicle inline from/api/contracts/(API 4.2.0). VehicleandVehicleMetricsare now exported from the top-leveltangopackage.tango.webhookssubpackage with HMAC-SHA256 signing helpers (verify_signature,generate_signature,parse_signature_header) that mirror the canonical Tango server scheme byte-for-byte. Importable from a defaultpip install tango-python(pure stdlib).WebhookReceiver: a stdlib-based local HTTP listener for development and integration tests. Verifies signatures, optionally forwards each delivery to a downstream URL, and records deliveries in memory for inspection. Usable as a context manager (with WebhookReceiver(secret=...).run() as rx: ...).tango.webhooks.simulate.deliver(...): locally sign and POST a payload to any URL — no Tango involvement. Useful for offline iteration on receiver code.- New
tango[webhooks]extra (addsclick) ships atangoconsole script covering the full webhook lifecycle for developer integrations:listen— local receiversimulate— sign a payload locally; with--to, also POST ittrigger— ask Tango to send a real test deliveryfetch-sample— print the canonical payload Tango emits for an event typelist-event-types— discover what's subscribableendpoints list|get|create|delete— manage delivery endpointssubscriptions list|get|create|delete— manage what events you receive Together these let a developer go from zero to receiving real Tango webhooks without leaving the shell or dropping into Python.
Changed¶
ShapeConfig.VEHICLES_MINIMALandVEHICLES_COMPREHENSIVEnow include the new top-level fields and theorganizationexpansion.VEHICLES_COMPREHENSIVEdefaults tometrics(*)and no longer pulls the deprecatedcompetition_details(*)blob.
Deprecated¶
- Vehicles shape fields
agency_details,competition_details, and theopportunityexpansion. The upstream API now sends aDeprecation: trueheader for these and recomputes them at request time. Explicit use inshape=...emits a PythonDeprecationWarning. Sunset timeline TBD upstream.
Notes¶
- Console script name
tangomay be revisited in a future release if it conflicts with sibling tooling (tango-scriptsreuses the bare name).
Documentation¶
- New
docs/WEBHOOKS.md— comprehensive guide covering install, concepts, a zero-to-receiving quickstart, full CLI reference, and programmatic patterns forWebhookReceiver/simulate.sign/simulate.deliverin pytest fixtures. docs/API_REFERENCE.md: filled inget_webhook_subscription, replaced the hand-rolled signature-verification snippet with a pointer totango.webhooks.verify_signature, and added a new "Webhook tooling (tango.webhooks)" section that documents every importable from the new subpackage.README.md: new "Webhook Tooling" section under Advanced Features, plus the new guide is linked from the Documentation index.
[0.5.0] - 2026-04-08¶
Added¶
- IT Dashboard investments:
list_itdashboard_investments,get_itdashboard_investment(/api/itdashboard/) with shaping and filter params (search,agency_code,agency_name,type_of_investment,updated_time_after,updated_time_before,cio_rating,cio_rating_max,performance_risk). Tier-gated by the API: free tier getssearch, pro adds structured filters, business+ adds CIO/performance analytics. NewITDashboardInvestmentmodel andShapeConfig.ITDASHBOARD_INVESTMENTS_MINIMAL/ITDASHBOARD_INVESTMENTS_COMPREHENSIVEdefaults.
[0.4.4] - 2026-03-25¶
Added¶
parent_piidfilter parameter onlist_contractsfor filtering orders under a specific parent IDV PIID.user_agentandextra_headersparameters onTangoClientfor custom request headers.TangoClient.last_response_headersproperty for accessing full HTTP headers from the most recent API response.
[0.4.3] - 2026-03-21¶
Added¶
TangoRateLimitErrornow exposeswait_in_seconds,detail, andlimit_typeproperties parsed from the API's 429 response body.RateLimitInfodataclass for structured access to rate limit headers (X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset, and per-window daily/burst variants).TangoClient.rate_limit_infoproperty returns rate limit info from the most recent API response.
Changed¶
_requestnow passes the full 429 response body toTangoRateLimitError(previously discarded), enabling callers to accesswait_in_secondsand the specific limit type that was exceeded.
[0.4.2] - 2026-03-04¶
Added¶
- Protests endpoints:
list_protests,get_protestwith shaping and filter params (source_system,outcome,case_type,agency,case_number,solicitation_number,protester,filed_date_after,filed_date_before,decision_date_after,decision_date_before,search).
Changed¶
- Lint CI workflow disabled for push/PR (runs only on manual trigger) until the private
makegov/tangorepo is accessible to the workflow. - Updated documents to reflect changes since v0.4.0
- Entities:
ENTITIES_COMPREHENSIVEnow usesfederal_obligations(*)expansion; the API treats federal obligations as an expansion rather than a plain shape field. - Docs:
SHAPES.mddocumentsfederal_obligations(*)as an expansion for entity shaping. - Integration tests:
test_parsing_nested_objects_with_missing_dataaccepts award office fields (office_code,agency_code,department_code) and empty nested objects when the API returns partial data.
Removed¶
- Assistance:
list_assistanceendpoint and all related tests, docs, and references. - IDV summaries:
get_idv_summaryandlist_idv_summary_awardsendpoints and related integration tests, cassettes, and API reference section.
[0.4.1] - 2026-03-03¶
Added¶
- GSA eLibrary contracts:
list_gsa_elibrary_contracts,get_gsa_elibrary_contractwith shaping and filter params (contract_number,key,piid,schedule,search,sin,uei).
Changed¶
- Conformance: replaced
**kwargs/**filterswith explicit filter parameters onlist_contracts,list_idvs,list_entities,list_forecasts,list_grants,list_notices,list_opportunitiesfor full filter/shape conformance. Backward compatibility preserved forlist_contracts(filters=SearchFilters(...)).
[0.4.0] - 2026-02-24¶
Added¶
- Offices, Organizations, OTAs, OTIDVs, Subawards, NAICS, and Assistance endpoints.
- Filter/shape conformance tooling and documentation.
Changed¶
- CI lint workflow runs filter/shape conformance when the manifest is available.
[0.3.0] - 2026-02-09¶
Added¶
- Vehicles endpoints:
list_vehicles,get_vehicle, andlist_vehicle_awardees(supports shaping + flattening). (refsmakegov/tango#1328) - IDV endpoints:
list_idvs,get_idv,list_idv_awards,list_idv_child_idvs,list_idv_transactions,get_idv_summary,list_idv_summary_awards. (refsmakegov/tango#1328) - Webhooks v2 client support: event type discovery, subscription CRUD, endpoint management, test delivery, and sample payload helpers. (refs
makegov/tango#1274)
Changed¶
- Expanded explicit schemas to support common IDV shaping expansions (award offices, officers, period of performance, etc.).
- HTTP client now supports PATCH/DELETE helpers for webhook management endpoints.
[0.2.0] - 2025-11-16¶
- Entirely refactored SDK
Node
Tango Node.js SDK¶
A modern Node.js SDK for the Tango API, featuring dynamic response shaping, strong TypeScript types, and full coverage of the core Tango endpoints.
This is the Node.js/TypeScript port of the official Tango Python SDK.
Features¶
- Dynamic Response Shaping – Ask Tango for exactly the fields you want using a simple shape syntax.
- Type-Safe by Design – Shape strings are validated against Tango schemas and mapped to generated TypeScript types.
- Full Tango API surface – Awards (contracts, IDVs, OTAs, OTIDVs, subawards, vehicles, GSA eLibrary), opportunities + notices, forecasts, grants, protests, IT Dashboard, budget accounts, exclusions, SBIR/STTR, DIBBS, entities (with sub-resources), agencies/organizations/offices/departments, lookups (NAICS, PSC, MAS SINs, assistance listings, business types), metrics, resolve/validate, webhooks. See
## API Methodsbelow for the full list. - Flexible Data Access – Plain JavaScript objects backed by runtime validation and parsing, materialized via the dynamic model pipeline.
- Modern Node.js – Built for Node.js 20+ with native
fetchand ESM-first design. - Tested Against the Real API – Integration tests (mirroring the Python SDK) keep behavior aligned.
Installation¶
Requirements: Node.js 20 or higher.
npm install @makegov/tango-node
# or
yarn add @makegov/tango-node
# or
pnpm add @makegov/tango-node
Quick Start¶
Initialize the client¶
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({
apiKey: process.env.TANGO_API_KEY,
// baseUrl: "https://tango.makegov.com", // default
});
List agencies¶
const agencies = await client.listAgencies();
for (const agency of agencies.results) {
console.log(agency.code, agency.name);
}
Get a specific agency¶
const treasury = await client.getAgency("2000"); // Treasury
console.log(treasury.name, treasury.department?.name);
Search contracts with a minimal shape¶
import { TangoClient, ShapeConfig } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const contracts = await client.listContracts({
shape: ShapeConfig.CONTRACTS_MINIMAL,
keyword: "cloud services",
awarding_agency: "4700",
fiscal_year: 2024,
limit: 10,
});
// Each contract is shaped according to CONTRACTS_MINIMAL
for (const c of contracts.results) {
console.log(c.piid, c.award_date, c.recipient.display_name);
}
Get a fully-shaped entity¶
import { TangoClient, ShapeConfig } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const entity = await client.getEntity("ABC123DEF456", {
shape: ShapeConfig.ENTITIES_COMPREHENSIVE,
});
console.log(entity.uei, entity.legal_business_name, entity.primary_naics);
Authentication¶
The Node.js SDK uses the same model as the Python one: you can either pass the API key directly or read it from TANGO_API_KEY.
With API key¶
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({
apiKey: "your-api-key-here",
});
From environment variable (TANGO_API_KEY)¶
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient();
// If apiKey is omitted, the client will look for process.env.TANGO_API_KEY
Core Concepts¶
Dynamic Response Shaping¶
Response shaping is the core feature of Tango. Instead of always receiving huge objects with every field, you describe the fields you want with a compact shape string:
const contracts = await client.listContracts({
shape: "key,piid,award_date,recipient(display_name),total_contract_value",
keyword: "software",
limit: 5,
});
Shapes:
- Reduce payload size (often massively).
- Keep responses focused on what your app actually uses.
- Drive type safety – the SDK maps the shape to a TypeScript type.
The Node.js SDK includes:
- A shape parser that validates shape strings.
- A schema registry that knows what fields exist on each resource.
- A type generator and model factory that convert raw API JSON into strongly-typed objects.
Flat vs nested responses¶
By default, nested fields are returned as nested objects:
// shape:
"key,piid,recipient(display_name,uei)";
//
contract.recipient.display_name;
contract.recipient.uei;
You can request a "flat" representation that uses dotted keys and then unflattens into nested objects on the client:
const contracts = await client.listContracts({
shape: ShapeConfig.CONTRACTS_MINIMAL,
flat: true,
});
The Node.js SDK mirrors the Python client's behavior for shape, flat, and flat_lists.
API Methods¶
The Node.js client mirrors the Python SDK's high-level API. Selected highlights:
Agencies / Offices / Organizations / Departments
listAgencies(options)/getAgency(code)listOffices(options)/getOffice(code)listOrganizations(options)/getOrganization(identifier)listDepartments(options)/getDepartment(code)
Contracts / IDVs / OTAs / OTIDVs / Subawards
listContracts(options)/getContract(key, options)/getContractSubawards(key, options)/getContractTransactions(key, options)listIdvs(options)/getIdv(key, options)listIdvAwards(key, options)/listIdvChildIdvs({key, ...options})/listIdvTransactions(key, options)listOtas(options)/getOta(key)/listOtidvs(options)/getOtidv(key)/listOtidvAwards(key, options)listSubawards(options)/getSubaward(key)
Vehicles
listVehicles(options)/getVehicle(uuid, options)/listVehicleAwardees(uuid, options)/listVehicleOrders(uuid, options)
Entities
listEntities(options)/getEntity(ueiOrCage, options)listEntityContracts(uei, options)/listEntityIdvs(uei, options)/listEntityOtas(uei, options)listEntityOtidvs(uei, options)/listEntitySubawards(uei, options)/listEntityLcats(uei, options)getEntityMetrics(uei, months, periodGrouping)/getEntityBudgetFlows(uei)
Forecasts / Opportunities / Notices / Grants
listForecasts(options)/listOpportunities(options)/listNotices(options)/listGrants(options)getForecast(id, options)/getOpportunity(opportunityId, options)/getNotice(noticeId, options)/getGrant(grantId, options)searchOpportunityAttachments(options)
GSA eLibrary / Protests / Contract Appeals / IT Dashboard / LCATs
listGsaElibraryContracts(options)/getGsaElibraryContract(uuid, options)listProtests(options)/getProtest(caseId)listContractAppeals(options)/getContractAppeal(uuid, options)listItDashboard(options)/getItDashboard(uii)listLcats(options)/listIdvLcats(key, options)
Budget Accounts
listBudgetAccounts(options)/getBudgetAccount(id, options)getBudgetAccountQuarters(id, options)/getBudgetAccountRecipients(id, options)
Exclusions / SBIR / DIBBS
listExclusions(options)/getExclusion(exclusionKey, options)listSbirTopics(options)/getSbirTopic(topicId, options)listSbirSolicitations(options)/getSbirSolicitation(solicitationId, options)listDibbsRfqs(options)/getDibbsRfq(uuid, options)listDibbsRfps(options)/getDibbsRfp(uuid, options)listDibbsAwards(options)/getDibbsAward(uuid, options)
Reference / Lookups
listBusinessTypes(options)/getBusinessType(code)listNaics(options)/getNaics(code)/getNaicsMetrics(code, months, periodGrouping)listPsc(options)/getPsc(code)/getPscMetrics(code, months, periodGrouping)listMasSins(options)/getMasSin(sin)listAssistanceListings(options)/getAssistanceListing(number)listMetrics(options)/listAgencyAwardingContracts(code, options)/listAgencyFundingContracts(code, options)
Resolve / Validate
resolve(input)— resolve a free-text name to ranked entity/org candidatesvalidate(input)— validate a PIID, solicitation number, or UEI
Webhooks
listWebhookEventTypes()listWebhookEndpoints(options)/getWebhookEndpoint(id)createWebhookEndpoint(...)/updateWebhookEndpoint(id, patch)/deleteWebhookEndpoint(id)testWebhookEndpoint(endpointId)(preferred) /testWebhookDelivery(options?)(legacy alias)getWebhookSamplePayload(options?)listWebhookAlerts(options)/getWebhookAlert(id)/createWebhookAlert(input)updateWebhookAlert(id, patch)/deleteWebhookAlert(id)
Async iteration helpers
iterate(method, options)— generic async iterator over any supported list methoditerateContracts/iterateEntities/iterateOpportunities/iterateNoticesiterateGrants/iterateForecasts/iterateIdvs/iterateVehiclesiterateDibbsRfqs/iterateDibbsRfps/iterateDibbsAwardsiterateExclusions/iterateSbirTopics/iterateSbirSolicitations
Utility
getVersion()/listApiKeys()
See docs/API_REFERENCE.md for full signatures and parameters.
All list methods return a paginated response:
interface PaginatedResponse<T> {
count: number;
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[];
}
meta surfaces response-level metadata from the API, and the three agency* fields are parsed views of its agency-filter diagnostics — see API Reference § Pagination for how to use them to catch silently-narrowed agency filters.
cursor is extracted from next on keyset-paginated endpoints so you can pass it straight back as the next request's cursor option.
Error Handling¶
Errors are surfaced as typed exceptions, aligned with the Python SDK:
TangoAPIError– Base error for unexpected issues.TangoAuthError– Authentication problems (e.g., invalid API key, 401).TangoNotFoundError– Resource not found (404).TangoValidationError– Invalid request parameters (400). Exposes the API's structured 400 payload viaissuesandavailableFields(see the API Reference).TangoRateLimitError– Rate limit exceeded (429).TangoTimeoutError– Request exceeded the configuredtimeoutMs.
Shape-related errors:
ShapeErrorShapeValidationErrorShapeParseErrorTypeGenerationErrorModelInstantiationError
Use them in your code:
import { TangoClient, TangoAPIError, TangoValidationError } from "@makegov/tango-node";
try {
const resp = await client.listContracts({ keyword: "cloud", limit: 5 });
} catch (err) {
if (err instanceof TangoValidationError) {
console.error("Bad request:", err.message);
} else if (err instanceof TangoAPIError) {
console.error("Tango API error:", err.message);
} else {
console.error("Unexpected error:", err);
}
}
Project Structure¶
tango-node/
├── src/ # Source TypeScript
│ ├── client.ts # TangoClient implementation
│ ├── config.ts # Default base URL + shape presets
│ ├── errors.ts # Error classes (API, auth, validation, etc.)
│ ├── index.ts # Public API exports
│ ├── types.ts # Shared types (options, PaginatedResponse)
│ ├── models/ # Lightweight model interfaces (Contract, Entity, etc.)
│ ├── shapes/ # Shape system (parser, generator, factory)
│ │ ├── explicitSchemas.ts # Predefined schemas for resources
│ │ ├── generatedOverlay.ts # Machine-generated schema overlay (see scripts/)
│ │ ├── factory.ts # Instantiate typed models from data
│ │ ├── generator.ts # Type generation from shape specs
│ │ ├── index.ts # Shapes exports
│ │ ├── parser.ts # Shape string parser
│ │ ├── schema.ts # Schema registry + validation
│ │ ├── schemaTypes.ts # Schema data structures
│ │ └── types.ts # Shape spec types
│ ├── utils/ # Helpers
│ │ ├── dates.ts # Date/time parsing utilities
│ │ ├── http.ts # HTTP client wrapper
│ │ ├── number.ts # Numeric parsing/formatting
│ │ └── unflatten.ts # Unflatten dotted-key responses
│ └── webhooks/ # Signing, receiver, simulator, CLI
├── contracts/ # Vendored API contract + conformance baselines
├── scripts/ # Conformance gates, overlay generator, live smoke scripts
├── docs/ # Documentation
│ ├── API_REFERENCE.md
│ ├── CLIENT.md
│ ├── DEVELOPERS.md
│ ├── DYNAMIC_MODELS.md
│ ├── SHAPES.md
│ └── WEBHOOKS.md
├── tests/ # Test suite (Vitest)
│ ├── unit/ # Offline unit tests (fetchImpl mocks)
│ ├── integration/ # Cassette-replayed integration tests
│ ├── cassettes/ # Recorded API interactions (JSON)
│ ├── production/ # Env-gated live smoke suite (TANGO_LIVE_TESTS)
│ ├── scripts/ # Tests for the conformance gates
│ └── webhooks/ # Receiver / simulator / CLI tests
├── dist/ # Build output (compiled JS + d.ts) from `npm run build`
├── eslint.config.js # ESLint flat config
├── .prettierrc # Prettier config
├── package.json # Package metadata/scripts
├── tsconfig.json # TypeScript config
├── README.md # Usage docs
├── CHANGELOG.md # Version history
└── LICENSE # MIT license
Development¶
After cloning the repo:
npm install
npm run build
npm test
Useful scripts:
npm run build– Compile TypeScript todist/.npm test– Run unit and integration tests (integration replays committed cassettes offline).npm run coverage– Get test coverage report.npm run lint– Run ESLint.npm run format– Run Prettier.npm run typecheck– TS type checking without emit.npm run check-conformance– SDK filters/shapes vs the vendored API contract.npm run check-shape-coverage– Reverse gate: every contract shape field is captured by the SDK.npm run generate-shape-overlay– Regeneratesrc/shapes/generatedOverlay.tsfrom the vendored contract.
See docs/DEVELOPERS.md for the conformance architecture and the cassette record/replay workflow.
Requirements¶
- Node.js 20 or higher.
- A valid Tango API key.
Documentation¶
- API Reference - Detailed API documentation
- Shape System Guide - Comprehensive guide to response shaping
- Dynamic Models Guide - How the dynamic shaping system works.
License¶
MIT License - see LICENSE for details.
Support¶
For questions, issues, or feature requests:
- Email: [email protected]
- Issues: GitHub Issues
- Documentation: https://docs.makegov.com/sdks/node/
Contributing¶
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Run tests (
npm run test) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Client Configuration¶
TangoClient is the entry point for every API call. This guide covers the constructor, environment variables, retry semantics, error handling, and how to plug in a custom fetch.
For per-method signatures, see API_REFERENCE.md. For webhook receivers, see WEBHOOKS.md. For response shaping, see SHAPES.md.
Constructor¶
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({
apiKey: process.env.TANGO_API_KEY, // optional if TANGO_API_KEY is set in env
baseUrl: "https://tango.makegov.com", // default
timeoutMs: 30_000, // default
retries: 3, // default
retryBackoffMs: 250, // default
});
Options¶
| Option | Type | Default | Description |
|---|---|---|---|
apiKey |
string |
reads TANGO_API_KEY env |
Tango API key. Sent as X-API-KEY header. |
baseUrl |
string |
https://tango.makegov.com |
API base URL. Override for staging/local. |
timeoutMs |
number |
30000 |
Per-request timeout in milliseconds. Aborts with TangoTimeoutError. |
timeout |
number |
— | Ergonomic shorthand for timeoutMs (also in ms). If both are supplied, timeoutMs wins. |
retries |
number |
3 |
Number of retry attempts on retryable failures. Set to 0 to disable. Total attempts = retries + 1. |
retryBackoffMs |
number |
250 |
Initial backoff between retries (ms). Doubles each retry, capped at 10s. |
fetchImpl |
typeof fetch |
global fetch |
Custom fetch implementation. Useful for proxies, instrumentation, or runtimes without a global fetch. |
Environment variables¶
| Env var | Purpose |
|---|---|
TANGO_API_KEY |
Default API key when apiKey is not passed to the constructor. |
TANGO_BASE_URL |
Default base URL when baseUrl is not passed. Falls through to https://tango.makegov.com if neither is set. |
Retry semantics¶
The client retries failed requests automatically. A request is retried when:
- The HTTP status is
5xx(any server error), - The status is
408(Request Timeout) or429(Too Many Requests), - Or the request fails at the network layer (DNS, connection refused, fetch network error, abort due to client-side timeout).
Other 4xx statuses (400, 401, 403, 404, …) are not retried — they're surfaced as the appropriate Tango* error immediately.
Backoff math¶
- First retry:
retryBackoffMs(default 250ms) - Second retry:
retryBackoffMs * 2 - Third retry:
retryBackoffMs * 4 - Each wait is capped at 10 seconds.
If the response includes a Retry-After header (typical on 429 and 503), the client honors that value instead of computing its own backoff:
- A delta-seconds value (
Retry-After: 5) → waits 5 seconds. - An HTTP-date value (
Retry-After: Wed, 21 Oct 2026 07:28:00 GMT) → waits until that time. - The honored wait is still capped at 10 seconds.
To disable retries entirely (e.g. for smoke tests or one-shot scripts where you'd rather see the raw failure):
const client = new TangoClient({
apiKey: process.env.TANGO_API_KEY,
retries: 0,
});
Error handling¶
All Tango errors extend TangoAPIError. Import them from the package root:
import {
TangoClient,
TangoAPIError,
TangoAuthError,
TangoNotFoundError,
TangoValidationError,
TangoRateLimitError,
TangoTimeoutError,
} from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
try {
const entity = await client.getEntity("ABCDEF123456");
console.log(entity);
} catch (err) {
if (err instanceof TangoNotFoundError) {
console.warn("No such entity");
} else if (err instanceof TangoValidationError) {
console.error("Bad request:", err.message, err.responseData);
} else if (err instanceof TangoAuthError) {
console.error("Check your API key");
} else if (err instanceof TangoRateLimitError) {
// The client already retried `retries` times before giving up.
console.error("Rate limited; back off harder");
} else if (err instanceof TangoTimeoutError) {
console.error("Request timed out; consider raising timeoutMs");
} else if (err instanceof TangoAPIError) {
console.error("API error:", err.statusCode, err.message);
} else {
throw err;
}
}
Every TangoAPIError carries:
message: string— human-readable; for 400s, the SDK extracts the firstdetail/message/error/ field-error from the response body when present.statusCode?: number— HTTP status (or408for client-side timeouts).responseData?: unknown— parsed JSON body of the error response (when the server returned JSON).
The client also throws TangoAPIError when an HTTP 200 response body contains { "error": "..." } — the Tango API occasionally signals errors in 200 payloads.
Custom fetch¶
Pass a custom fetchImpl to instrument requests, route through a proxy, or run in a non-Node runtime:
import { TangoClient } from "@makegov/tango-node";
const tracedFetch: typeof fetch = async (input, init) => {
const start = Date.now();
const res = await fetch(input, init);
console.log(`${init?.method ?? "GET"} ${input} → ${res.status} (${Date.now() - start}ms)`);
return res;
};
const client = new TangoClient({
apiKey: process.env.TANGO_API_KEY,
fetchImpl: tracedFetch,
});
Targeting staging or local¶
Point baseUrl at the host you want:
const client = new TangoClient({
apiKey: process.env.TANGO_API_KEY,
baseUrl: "http://localhost:8000",
});
The trailing slash on baseUrl is optional; the client normalizes it.
Tango Node SDK – API Reference¶
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.
Importing¶
import { TangoClient, ShapeConfig } from "@makegov/tango-node";
// Models (optional)
import type { Contract } from "@makegov/tango-node/models";
All methods are async and return Promises.
Agencies¶
listAgencies(options?)¶
List federal departments and subagencies.
const resp = await client.listAgencies({ page: 1, limit: 25 });
Parameters¶
| Name | Type | Description |
|---|---|---|
page |
number |
Page number (default 1). |
limit |
number |
Max results per page (default 25, max 100). |
Returns (Agencies)¶
PaginatedResponse<AgencyLike>
getAgency(code)¶
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).
Contracts¶
listContracts(options)¶
Search and list contract records.
const resp = await client.listContracts({
keyword: "cloud",
naics_code: "541511",
shape: ShapeConfig.CONTRACTS_MINIMAL,
flat: true,
});
Search / Filter Parameters¶
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.
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 ignored
Contracts 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.
Returns (Contracts)¶
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¶
Vehicles provide a solicitation-centric grouping of related IDVs.
listVehicles(options)¶
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
getVehicle(uuid, options?)¶
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".").
listVehicleAwardees(uuid, options?)¶
const awardees = await client.listVehicleAwardees("00000000-0000-0000-0000-000000000001", {
shape: ShapeConfig.VEHICLE_AWARDEES_MINIMAL,
});
IDVs¶
IDVs (indefinite delivery vehicles) are the parent “vehicle award” records that can have child awards/orders under them.
listIdvs(options)¶
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.
getIdv(key, options?)¶
const idv = await client.getIdv("SOME_IDV_KEY", {
shape: ShapeConfig.IDVS_COMPREHENSIVE,
});
listIdvAwards(key, options?)¶
Lists child awards (contracts) under an IDV.
const awards = await client.listIdvAwards("SOME_IDV_KEY", { limit: 25 });
listIdvChildIdvs({ key, ...options })¶
const children = await client.listIdvChildIdvs({ key: "SOME_IDV_KEY", limit: 25 });
listIdvTransactions(key, options?)¶
const tx = await client.listIdvTransactions("SOME_IDV_KEY", { limit: 100 });
Entities¶
listEntities(options)¶
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
getEntity(uei, options?)¶
Fetch a single entity by UEI or CAGE.
Returns a shaped entity object with nested addresses/fields based on the shape.
Forecasts¶
listForecasts(options)¶
Forecast search, with optional shaping.
id is a typed filter for fetching specific forecast records through the list endpoint.
Opportunities¶
listOpportunities(options)¶
Search SAM.gov opportunities with shaping.
opportunity_id is a typed filter for fetching specific opportunities through the list endpoint.
Notices¶
listNotices(options)¶
Grants¶
listGrants(options)¶
Organizations / Offices / Departments¶
listOrganizations(options?)¶
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,
});
getOrganization(identifier)¶
const org = await client.getOrganization("ORG_KEY");
listOffices(options?)¶
const offices = await client.listOffices({ search: "acquisitions" });
getOffice(code)¶
const office = await client.getOffice("4732XX");
listDepartments(options?)¶
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 });
getDepartment(code)¶
const dept = await client.getDepartment("097");
OTAs¶
Other Transaction Agreements — non-FAR-based awards.
listOtas(options?)¶
Uses keyset pagination (cursor + limit).
const otas = await client.listOtas({ limit: 25, awarding_agency: "4700" });
getOta(key)¶
const ota = await client.getOta("OTA_KEY");
OTIDVs¶
Other Transaction IDVs — umbrella OT agreements with child awards.
listOtidvs(options?)¶
Uses keyset pagination (cursor + limit).
const otidvs = await client.listOtidvs({ limit: 25 });
getOtidv(key)¶
const otidv = await client.getOtidv("OTIDV_KEY");
listOtidvAwards(key, options?)¶
const awards = await client.listOtidvAwards("OTIDV_KEY", { limit: 25 });
Subawards¶
listSubawards(options?)¶
const subs = await client.listSubawards({ prime_uei: "ABC123DEF456", limit: 25 });
GSA eLibrary Contracts¶
listGsaElibraryContracts(options?)¶
const contracts = await client.listGsaElibraryContracts({ schedule: "MAS", limit: 25 });
getGsaElibraryContract(uuid, options?)¶
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,
});
Protests¶
listProtests(options?)¶
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).
getProtest(caseId)¶
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 Appeals¶
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.
listContractAppeals(options?)¶
const appeals = await client.listContractAppeals({
board: "asbca",
decision_date_after: "2026-01-01",
limit: 25,
});
Parameters (Contract Appeals)¶
| 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.
getContractAppeal(uuid, options?)¶
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.
IT Dashboard¶
listItDashboard(options?)¶
const investments = await client.listItDashboard({ search: "cloud", limit: 25 });
getItDashboard(uii)¶
const investment = await client.getItDashboard("023-000001234");
listItDashboard also accepts previous_uii as a typed filter, for tracing an investment across UII renumbering.
Budget Accounts¶
OMB budget appendix accounts with lifecycle amounts (requested → enacted → apportioned → obligated → outlayed), derived ratios, and trends.
listBudgetAccounts(options?)¶
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_code__in, bureau_name__icontains, bea_category, 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.
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.
getBudgetAccount(id, options?)¶
const account = await client.getBudgetAccount("ACCOUNT_ID");
getBudgetAccountQuarters(id, options?) / getBudgetAccountRecipients(id, options?)¶
Quarterly lifecycle history and top recipients for one account.
const quarters = await client.getBudgetAccountQuarters("ACCOUNT_ID");
const recipients = await client.getBudgetAccountRecipients("ACCOUNT_ID");
DIBBS¶
DLA DIBBS solicitations and awards: RFQs, RFPs, and award history.
listDibbsRfqs(options?) / getDibbsRfq(uuid, options?)¶
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.
listDibbsRfps(options?) / getDibbsRfp(uuid, options?)¶
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.
listDibbsAwards(options?) / getDibbsAward(uuid, options?)¶
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 (SLED) — Beta¶
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.
listSledOpportunities(options?) / getSledOpportunity(opportunityId, options?)¶
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.
listSledOpportunityRevisions(opportunityId, options?)¶
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.
getSledCoverage()¶
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.
listSledForecasts(options?) / getSledForecast(forecastId, options?)¶
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.
Exclusions¶
SAM.gov exclusion records (debarments, suspensions, and other ineligibility actions).
listExclusions(options?) / getExclusion(exclusionKey, options?)¶
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, 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.
SBIR / STTR¶
SBIR/STTR topics and DoD DSIP solicitation cycles.
listSbirTopics(options?) / getSbirTopic(topicId, options?)¶
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, open_date_after / open_date_before, close_date_after / close_date_before, release_date_after / release_date_before, search, ordering.
listSbirSolicitations(options?) / getSbirSolicitation(solicitationId, options?)¶
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.
LCATs¶
listLcats(options)¶
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" });
listIdvLcats(key, options?)¶
Labor Categories (/api/idvs/{key}/lcats/) attached to an IDV.
const lcats = await client.listIdvLcats("GS-00F-XXXX", { limit: 25 });
Metrics¶
listMetrics(options)¶
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",
});
getNaicsMetrics(code, months, periodGrouping)¶
const m = await client.getNaicsMetrics("541511", 12, "month");
getPscMetrics(code, months, periodGrouping)¶
const m = await client.getPscMetrics("D302", 12, "month");
getEntityMetrics(uei, months, periodGrouping)¶
const m = await client.getEntityMetrics("ABCDEF123456", 12, "month");
Reference Lookups¶
listNaics(options?) / getNaics(code)¶
const naics = await client.listNaics({ search: "software" });
const code = await client.getNaics("541511");
listPsc(options?) / getPsc(code)¶
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.
listMasSins(options?) / getMasSin(sin)¶
const sins = await client.listMasSins();
const sin = await client.getMasSin("54151S");
listAssistanceListings(options?) / getAssistanceListing(number)¶
const listings = await client.listAssistanceListings();
const listing = await client.getAssistanceListing("10.310");
listBusinessTypes(options?) / getBusinessType(code)¶
const types = await client.listBusinessTypes();
const bt = await client.getBusinessType("A6");
Resolve / Validate¶
resolve(input)¶
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.count
Required fields: name, target_type ("entity" | "organization").
validate(input)¶
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.
Entity Sub-resources¶
listEntityContracts(uei, options?)¶
const contracts = await client.listEntityContracts("ABCDEF123456", { limit: 25 });
listEntityIdvs(uei, options?) / listEntityOtas(uei, options?) / listEntityOtidvs(uei, options?)¶
const idvs = await client.listEntityIdvs("ABCDEF123456");
listEntitySubawards(uei, options?) / listEntityLcats(uei, options?)¶
const subawards = await client.listEntitySubawards("ABCDEF123456");
Agency Sub-resources¶
listAgencyAwardingContracts(code, options?)¶
const contracts = await client.listAgencyAwardingContracts("4700", { limit: 25 });
listAgencyFundingContracts(code, options?)¶
const contracts = await client.listAgencyFundingContracts("4700", { limit: 25 });
Opportunities (attachments)¶
searchOpportunityAttachments(options)¶
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. |
Async Iteration¶
All list methods can be iterated page-by-page via the generic iterate() helper or the named convenience wrappers.
iterate(method, options?)¶
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.
Utility¶
getVersion()¶
const v = await client.getVersion();
listApiKeys()¶
const keys = await client.listApiKeys();
Webhooks (v2)¶
Webhook APIs let Large / Enterprise users manage subscription filters for outbound Tango webhooks.
listWebhookEventTypes()¶
Discover supported event_type values.
const info = await client.listWebhookEventTypes();
Webhook endpoints¶
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);
testWebhookEndpoint(endpointId)¶
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);
testWebhookDelivery(options?) (legacy alias)¶
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" });
getWebhookSamplePayload(options?)¶
Fetch Tango-shaped sample deliveries.
const sample = await client.getWebhookSamplePayload({ eventType: "alerts.contract.match" });
Webhook Alerts¶
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.
Deliveries / redelivery¶
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
Receiving webhooks (signature verification)¶
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.
Error Types¶
All thrown by async methods:
TangoAPIErrorTangoAuthErrorTangoNotFoundErrorTangoRateLimitErrorTangoTimeoutErrorTangoValidationErrorShapeErrorShapeParseErrorShapeValidationErrorTypeGenerationErrorModelInstantiationError
Structured validation details on TangoValidationError¶
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);
}
}
Pagination¶
All list endpoints return:
interface PaginatedResponse<T> {
count: number;
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.
Response meta and agency-filter diagnostics¶
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}`);
}
}
Tango Node SDK – Shaping Guide¶
A complete translation of the Python SHAPES.md document for Node.
Why Shapes?¶
Tango resources can have hundreds of fields. Shapes let you request:
- Only what you need
- In nested form
- With aliases
- With wildcards
- With flattening options
Shape Grammar¶
shape := field_list
field_list := field ("," field)*
field := field_name [alias] [nested]
field_name := identifier | "*"
alias := "::" identifier
nested := "(" field_list ")"
identifier := [a-zA-Z_][a-zA-Z0-9_]*
Examples¶
Simple¶
shape: "key,piid,award_date";
Nested¶
shape: "recipient(display_name,uei)";
Aliases¶
shape: "recipient::vendor(display_name)";
Wildcard¶
shape: "*";
Wildcard nested¶
shape: "recipient(*)";
ShapeConfig Presets¶
The SDK ships with a ShapeConfig object of ready-made shape strings for common patterns. Import from the main entry point:
import { TangoClient, ShapeConfig } from "@makegov/tango-node";
| Constant | Intended use |
|---|---|
ShapeConfig.CONTRACTS_MINIMAL |
listContracts() |
ShapeConfig.ENTITIES_MINIMAL |
listEntities() |
ShapeConfig.ENTITIES_COMPREHENSIVE |
getEntity() |
ShapeConfig.FORECASTS_MINIMAL |
listForecasts() |
ShapeConfig.OPPORTUNITIES_MINIMAL |
listOpportunities() |
ShapeConfig.NOTICES_MINIMAL |
listNotices() |
ShapeConfig.PROTESTS_MINIMAL |
listProtests() |
ShapeConfig.GRANTS_MINIMAL |
listGrants() |
ShapeConfig.IDVS_MINIMAL |
listIdvs() |
ShapeConfig.IDVS_COMPREHENSIVE |
getIdv() |
ShapeConfig.VEHICLES_MINIMAL |
listVehicles() |
ShapeConfig.VEHICLES_COMPREHENSIVE |
getVehicle() |
ShapeConfig.VEHICLE_AWARDEES_MINIMAL |
listVehicleAwardees() |
ShapeConfig.VEHICLE_ORDERS_MINIMAL |
listVehicleOrders() |
ShapeConfig.ORGANIZATIONS_MINIMAL |
listOrganizations() |
ShapeConfig.OTAS_MINIMAL |
listOtas() |
ShapeConfig.OTIDVS_MINIMAL |
listOtidvs() |
ShapeConfig.SUBAWARDS_MINIMAL |
listSubawards() |
ShapeConfig.GSA_ELIBRARY_CONTRACTS_MINIMAL |
listGsaElibraryContracts() / getGsaElibraryContract() |
ShapeConfig.ITDASHBOARD_INVESTMENTS_MINIMAL |
listItDashboard() |
ShapeConfig.ITDASHBOARD_INVESTMENTS_COMPREHENSIVE |
getItDashboard() |
ShapeConfig.DIBBS_RFQS_MINIMAL |
listDibbsRfqs() |
ShapeConfig.DIBBS_RFPS_MINIMAL |
listDibbsRfps() |
ShapeConfig.DIBBS_AWARDS_MINIMAL |
listDibbsAwards() |
ShapeConfig.EXCLUSIONS_MINIMAL |
listExclusions() |
ShapeConfig.SBIR_TOPICS_MINIMAL |
listSbirTopics() |
ShapeConfig.SBIR_SOLICITATIONS_MINIMAL |
listSbirSolicitations() |
These are plain strings — you can use them directly or as a starting point:
const contracts = await client.listContracts({
shape: ShapeConfig.CONTRACTS_MINIMAL,
limit: 10,
});
Flat Responses¶
shape: ShapeConfig.CONTRACTS_MINIMAL,
flat: true
When flat: true is passed, the Tango API returns dotted key names instead of nested objects. The SDK automatically unflattens them back into nested objects on the client side:
// API returns: { "recipient.display_name": "Acme" }
// SDK unflattens to: { recipient: { display_name: "Acme" } }
You can override the separator character (default ".") with the joiner option.
Validation¶
ShapeParser enforces syntax.
TypeGenerator enforces semantic model rules (existence of fields, nested models).
Performance Tips¶
- Use minimal shapes in production.
- Avoid full-wildcard unless you need all fields.
- Prefer shallow nested shapes for large nested structures.
Webhooks Guide¶
This guide covers everything @makegov/tango-node provides for building, testing, and operating webhook integrations against the Tango API: signing helpers, signature verification, and management commands for endpoints and alerts.
If you only need the SDK method signatures, see API_REFERENCE.md § Webhooks. For the API-level contract (signing scheme, event taxonomy, retry behavior), see the Tango Webhooks Partner Guide.
Breaking change in v0.4.0: subject-based webhook subscriptions have been removed. Use the Alerts API for filter-based delivery. Mirrors makegov/tango#2267.
Contents¶
- Install
- Concepts in 60 seconds
- Quickstart: zero to receiving
- Programmatic use
- Webhook write API
- Common workflows
- Troubleshooting
Install¶
npm install @makegov/tango-node
# or
yarn add @makegov/tango-node
# or
pnpm add @makegov/tango-node
The signing helpers and full webhook write API are included in the default install — no extras needed.
import { TangoClient, generateSignature, verifySignature, parseSignatureHeader, SIGNATURE_HEADER, SIGNATURE_PREFIX } from "@makegov/tango-node";
Concepts in 60 seconds¶
Tango webhooks have three pieces of state:
| Concept | What it is | Tango term |
|---|---|---|
| Endpoint | The URL Tango POSTs to, plus a generated signing secret | WebhookEndpoint |
| Alert | A saved query-filter that fires deliveries when matching records appear | WebhookAlert |
| Delivery | A single signed POST Tango makes when a matching event fires | (the request itself) |
A typical setup:
- Create an endpoint with the public URL of your handler. Tango returns a
secret— save it; it's used to sign every delivery. - Create one or more alerts describing the records your handler cares about (e.g. new IT-services contracts).
- Tango POSTs to your endpoint when matching records appear. The body is JSON; the header
X-Tango-Signature: sha256=<hex>is the HMAC-SHA256 of the raw body bytes keyed by your endpoint's secret. - Your handler verifies the signature, parses the body, and acts on it.
Quickstart: zero to receiving¶
Assumes you have a TANGO_API_KEY and want to receive webhooks for new IT-services contracts.
1. See what you can subscribe to¶
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const info = await client.listWebhookEventTypes();
console.log(info.event_types);
// [{ event_type: "alerts.contract.match", description: "...", schema_version: 1 }, ...]
2. See what a payload looks like¶
const sample = await client.getWebhookSamplePayload({ eventType: "alerts.contract.match" });
console.log(JSON.stringify(sample, null, 2));
Fetches the canonical JSON shape Tango will deliver for that event type. No alert needed.
3. Register your endpoint and alert¶
When you're ready for end-to-end testing, expose your local handler via a tunnel (ngrok http 3000, cloudflared tunnel, etc.) and register that public URL with Tango:
// Endpoint names are unique per user. Tango returns a secret — save it.
const endpoint = await client.createWebhookEndpoint({
callbackUrl: "https://<your-tunnel>.ngrok.io/tango/webhooks",
name: "dev",
});
console.log("Secret:", endpoint.secret); // save this!
// Create an alert — fires when matching records appear
const alert = await client.createWebhookAlert({
name: "New IT cloud contracts",
query_type: "contract", // singular — required
filters: { naics: "541511" }, // any /api/contracts/ filter
});
4. Force a test delivery¶
const result = await client.testWebhookEndpoint(endpoint.id);
console.log(result.success, result.status_code);
You should see a signed delivery hit your handler with the X-Tango-Signature header generated by Tango.
Programmatic use¶
Signature verification in your handler¶
verifySignature is the only function you need in production. It takes (body, header, secret) — note the arg order.
Call it on the raw request body bytes — not on a re-serialized parsed body. The HMAC is computed over the exact bytes Tango sent; reformatting or reordering keys breaks it.
import { verifySignature } from "@makegov/tango-node";
// Express example
app.post("/tango/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body; // Buffer — express.raw() gives you bytes
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"));
// ... act on payload.events ...
res.json({ ok: true });
});
verifySignature signature:
function verifySignature(body: string | Buffer, header: string | null | undefined, secret: string): boolean;
- Returns
falsefor missing, empty, malformed, or mismatched headers — never throws on mismatch. - Uses
timingSafeEqualfromnode:cryptointernally. - Accepts both the canonical
sha256=<hex>header form and a bare hex string (legacy compatibility).
Fastify example:
import { verifySignature } from "@makegov/tango-node";
fastify.addContentTypeParser("application/json", { parseAs: "buffer" }, (req, body, done) => {
done(null, body);
});
fastify.post("/tango/webhooks", async (request, reply) => {
const rawBody = request.body as Buffer;
const signatureHeader = request.headers["x-tango-signature"] as string | undefined;
if (!verifySignature(rawBody, signatureHeader ?? null, process.env.TANGO_WEBHOOK_SECRET!)) {
reply.code(401).send({ error: "invalid_signature" });
return;
}
const payload = JSON.parse(rawBody.toString("utf8"));
// ... handle payload ...
reply.send({ ok: true });
});
Generating signatures (for testing)¶
generateSignature produces the exact header value Tango sends. Use it in tests to sign synthetic payloads before POSTing to your handler.
import { generateSignature, SIGNATURE_HEADER } from "@makegov/tango-node";
const body = Buffer.from(
JSON.stringify({
event_type: "alerts.entity.match",
alert_id: "alert_123",
query_type: "entity",
filters: { uei: "ABC123" },
matches: { new: [], modified: [], new_count: 0, modified_count: 0 },
checked_at: "2024-01-01T00:00:00Z",
}),
);
const signatureHeader = generateSignature(body, "test_secret");
// → "sha256=<lowercase hex>"
// Drive your handler directly (e.g. in a test):
const response = await fetch("http://localhost:3000/tango/webhooks", {
method: "POST",
headers: {
"Content-Type": "application/json",
[SIGNATURE_HEADER]: signatureHeader,
},
body,
});
generateSignature signature:
function generateSignature(body: string | Buffer, secret: string): string;
// Returns: "sha256=<lowercase hex HMAC-SHA256>"
The return value is always the prefixed form sha256=<hex> — pass it directly as the X-Tango-Signature header value.
Parsing the signature header¶
parseSignatureHeader breaks a raw X-Tango-Signature header value into its component parts. Mainly useful for debugging or building custom verification logic.
import { parseSignatureHeader } from "@makegov/tango-node";
const parsed = parseSignatureHeader("sha256=abc123def456");
// → { algorithm: "sha256", signature: "abc123def456" }
const bad = parseSignatureHeader("sha256=");
// → null (empty digest)
const missing = parseSignatureHeader(null);
// → null
Returns null for absent, empty, or malformed values (non-hex digest, empty digest). Never throws.
Webhook write API¶
All methods are on TangoClient. They're async and return Promises. Webhook APIs require Large / Enterprise tier access.
Endpoints¶
An endpoint is the URL Tango POSTs to, paired with a signing secret.
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
// List
const list = await client.listWebhookEndpoints({ page: 1, limit: 25 });
// Get one
const endpoint = await client.getWebhookEndpoint("ENDPOINT_UUID");
// Create — `name` is required and must be unique per user; save the returned `secret`
const created = await client.createWebhookEndpoint({
callbackUrl: "https://example.com/tango/webhooks",
name: "Production handler",
isActive: true, // default true
});
console.log("Secret:", created.secret); // only returned on create — save it
// Update
await client.updateWebhookEndpoint(created.id, { isActive: false });
// Delete
await client.deleteWebhookEndpoint(created.id);
createWebhookEndpoint also accepts the snake_case canonical API form directly (callback_url, is_active). CamelCase is preferred for new code.
WebhookEndpoint shape:
interface WebhookEndpoint {
id: string;
name: string;
callback_url: string;
secret?: string; // present on create response only
is_active: boolean;
created_at: string;
updated_at: string;
}
Alerts (filter-subscription API)¶
Alerts are the SDK's interface for telling Tango "deliver me records matching this filter." Subject-based subscriptions (match by event type + specific subject IDs) were removed in v0.4.0 — alerts are now the only way to subscribe.
// Create
const alert = await client.createWebhookAlert({
name: "New IT cloud contracts",
query_type: "contract", // singular — required
filters: { naics: "541511" }, // required, non-empty
frequency: "daily", // optional
});
// List
const alerts = await client.listWebhookAlerts({ page: 1, pageSize: 25 });
// Get
const alert = await client.getWebhookAlert("ALERT_UUID");
// Update
await client.updateWebhookAlert("ALERT_UUID", { name: "Updated name" });
// Delete
await client.deleteWebhookAlert("ALERT_UUID");
WebhookAlert shape:
interface WebhookAlert {
alert_id: string;
name: string;
query_type: string;
filters: Record<string, unknown>;
frequency: string;
cron_expression: string | null;
status: "active" | "paused";
created_at: string;
last_checked_at: string | null;
match_count: number;
}
Event types and sample payloads¶
// List all supported event types
const info = await client.listWebhookEventTypes();
// info.event_types → [{ event_type, description, schema_version }]
// Fetch a canonical sample payload for one event type
const sample = await client.getWebhookSamplePayload({ eventType: "alerts.contract.match" });
// Or fetch sample payloads for all event types at once
const all = await client.getWebhookSamplePayload();
getWebhookSamplePayload wraps GET /api/webhooks/endpoints/sample-payload/. When eventType is omitted, returns all event types. The response includes a signature_header field showing what the X-Tango-Signature header will look like — useful for understanding the wire format.
Test delivery¶
Force Tango to POST a real test delivery to a registered endpoint:
const result = await client.testWebhookEndpoint("ENDPOINT_UUID");
console.log(result.success); // boolean
console.log(result.status_code); // HTTP code Tango got from your endpoint
console.log(result.response_time_ms);
console.log(result.message);
console.log(result.error); // set on failure
WebhookTestDeliveryResult shape:
interface WebhookTestDeliveryResult {
success: boolean;
status_code?: number;
response_time_ms?: number;
endpoint_url?: string;
message?: string;
error?: string;
response_body?: string;
test_payload?: Record<string, unknown>;
}
testWebhookDelivery(options?) is a legacy alias that accepts { endpointId?: string }. If endpointId is omitted, the API auto-resolves the caller's only endpoint (404 if none, 400 if multiple). Prefer testWebhookEndpoint for new code.
Common workflows¶
"Set me up to receive contract-match alerts from scratch"¶
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
// 1. Confirm available event types
const { event_types } = await client.listWebhookEventTypes();
console.log(event_types.map((e) => e.event_type));
// 2. Create endpoint — expose your handler via ngrok/cloudflared first
const endpoint = await client.createWebhookEndpoint({
callbackUrl: "https://<id>.ngrok.io/tango/webhooks",
});
// Save endpoint.secret — you need it to verify incoming deliveries
process.env.TANGO_WEBHOOK_SECRET = endpoint.secret!;
// 3. Create an alert — fires when matching records appear
await client.createWebhookAlert({
name: "New IT cloud contracts",
query_type: "contract",
filters: { naics: "541511" },
});
// 4. Force a test delivery to verify your handler is reachable
const result = await client.testWebhookEndpoint(endpoint.id);
console.log("Delivery success:", result.success, "Status:", result.status_code);
"Verify a Tango delivery in any HTTP framework"¶
import { verifySignature } from "@makegov/tango-node";
// The pattern is the same in Express, Fastify, Hono, Next.js API routes, etc.:
// 1. Get raw body bytes BEFORE any JSON parsing middleware
// 2. Get the X-Tango-Signature header
// 3. Call verifySignature(rawBody, header, secret)
function handleTangoWebhook(rawBody: Buffer, signatureHeader: string | undefined) {
if (!verifySignature(rawBody, signatureHeader ?? null, process.env.TANGO_WEBHOOK_SECRET!)) {
throw new Error("invalid_signature");
}
return JSON.parse(rawBody.toString("utf8"));
}
"Test my handler in a unit test (no network)"¶
Use generateSignature to sign synthetic payloads and POST them directly to your handler — no Tango account or live endpoint needed:
import { generateSignature, SIGNATURE_HEADER } from "@makegov/tango-node";
const secret = "test_secret";
const payload = {
timestamp: "2024-01-01T00:00:00Z",
events: [{ event_type: "alerts.contract.match", record: { piid: "ABC123" } }],
};
const rawBody = Buffer.from(JSON.stringify(payload));
const sig = generateSignature(rawBody, secret);
// Drive your handler directly — e.g. with supertest or a test fetch:
const res = await request(app).post("/tango/webhooks").set("Content-Type", "application/json").set(SIGNATURE_HEADER, sig).send(rawBody);
expect(res.status).toBe(200);
"Inspect what bytes Tango actually sends"¶
const sample = await client.getWebhookSamplePayload({ eventType: "alerts.contract.match" });
// sample.signature_header shows the X-Tango-Signature format
// sample.sample_delivery shows the exact JSON body shape
console.log(JSON.stringify(sample.sample_delivery, null, 2));
Troubleshooting¶
Signature always fails. Verify on raw bytes, not on a re-serialized parsed body. The HMAC is over exact bytes; reformatting whitespace or reordering keys breaks it. Most frameworks expose the raw body separately from a parsed JSON shortcut — use the raw one. In Express, use express.raw({ type: "application/json" }) before your route handler.
verifySignature returns false even with the right secret. Check argument order: it's (body, header, secret) — the header is second, secret is third. This differs from some other webhook libraries.
createWebhookEndpoint returns 400 or "endpoint already exists". Endpoint names are unique per user — if you've already created one with that name, either pick a different name or use listWebhookEndpoints() to find the existing one and reuse its ID.
createWebhookAlert throws TangoValidationError: query_type is required. The query_type field is singular — "contract", not "contracts".
An alert never fires when a record just reaches its date — except sled_opportunity. An exclusion passing its termination date, or a DIBBS RFQ/RFP passing its close date, emits nothing: open/closed and in-force are derived at query time, so no stored row changes. Poll listExclusions({ active: true }) / listDibbsRfqs({ open: true }) if you need to observe expiry.
sled_opportunity is the one exception. A state solicitation's liveness is a stored status column Tango recomputes every fifteen minutes rather than deriving per request, so a deadline passing is a write and alerts.sled_opportunity.match can follow it. There is no sled_forecast query type — a forecast has no deadline, so nothing transitions — and SLED revisions and attachments are not separately alertable: subscribe to sled_opportunity and filter on change_seen_after or revision_kind.
testWebhookEndpoint returns success: false. Tango reached your endpoint but got a non-2xx response. Check result.status_code and result.response_body in the result, then look at your handler's logs.
getWebhookSamplePayload throws with 401. Set TANGO_API_KEY (or pass apiKey to TangoClient). This endpoint requires authentication.
listWebhookAlerts returns an empty array unexpectedly. Check your API key — alerts are scoped to the authenticated user. Also confirm the endpoint UUID associated with your alerts matches your current endpoint (alerts pointing at a deleted endpoint aren't automatically cleaned up).
Tango Node SDK – Dynamic Models Guide¶
This document explains how the Node.js dynamic shaping system works.
It is a full translation of the Python DEVELOPERS.md shaping guide.
Overview¶
Tango’s dynamic modeling allows you to:
- Request exactly the fields you want
- Validate the shape string against Tango’s schemas
- Generate a typed model descriptor at runtime
- Materialize shaped objects using correct:
- date parsing
- datetime parsing
- decimal handling
- list vs scalar logic
- nested structure
Components¶
ShapeParser¶
Parses shape strings into a ShapeSpec.
import { ShapeParser } from "@makegov/tango-node/shapes";
const parser = new ShapeParser();
const spec = parser.parse("key,piid,recipient(display_name)");
SchemaRegistry¶
Holds the field schemas for all models.
import { SchemaRegistry } from "@makegov/tango-node/shapes";
const registry = new SchemaRegistry();
registry.getField("Contract", "award_date");
TypeGenerator¶
Builds a GeneratedModel descriptor from (baseModel, shapeSpec).
import { TypeGenerator } from "@makegov/tango-node/shapes";
const gen = new TypeGenerator();
const model = gen.generateModelDescriptor("Contract", spec);
ModelFactory¶
Takes a descriptor + raw API JSON and produces typed shaped objects. The TangoClient now uses this pipeline automatically after fetching data.
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const contracts = await client.listContracts({
shape: "key,award_date,recipient(display_name)",
});
// contracts.results are materialized via ModelFactory:
// - date/datetime parsed to Date
// - decimals normalized to string
// - nested structures enforced
Example: Full Shaping Pipeline (manual)¶
const parser = new ShapeParser();
const spec = parser.parse("key,award_date,recipient(display_name)");
const gen = new TypeGenerator();
const descriptor = gen.generateModelDescriptor("Contract", spec);
const factory = new ModelFactory();
const shaped = factory.createOne("Contract", spec, {
key: "C-1",
award_date: "2024-01-15",
recipient: { display_name: "Acme" },
});
shaped becomes:
{
key: "C-1",
award_date: Date("2024-01-15"),
recipient: { display_name: "Acme" }
}
Type Safety¶
Node SDK enforces shape correctness at runtime and guarantees nested structures. The client materializes responses through ModelFactory, so the shape schema is applied automatically. TypeScript interfaces are not codegenerated per shape at build time; the SDK exports lightweight model interfaces in @makegov/tango-node/models for convenience.
Caching¶
TypeGenerator caches descriptors with FIFO eviction.
ShapeParser also caches parse results.
Nested Models¶
If a field is nested in the schema (e.g. "recipient" → RecipientProfile),
the generator recursively builds the nested descriptor.
Changelog¶
All notable changes to @makegov/tango-node will be documented in this file.
This project follows Semantic Versioning.
[Unreleased]¶
Added¶
matched_byon agency-filter diagnostics (Tango API 5.9.0; parity with tango-python). Each entry ofPaginatedResponse.meta.resolved_filters[<filter name>]that resolved now says how its token matched: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) orfuzzy(a looser text match, worth checking against the resolved name). An entry that did not resolve has nomatched_by.metais passed through as the API sends it, so no code change was needed to receive the field; it is now documented on the interface and indocs/API_REFERENCE.mdand pinned by a test.agencyWarnings,unresolvedAgencyTokensandresolvedAgenciesare unchanged.
-
Boards-of-contract-appeals decisions (Tango API 4.26.0).
listContractAppeals(options)andgetContractAppeal(uuid, options)over/api/contract_appeals/, with every filter the API accepts declared as a typed option onListContractAppealsOptions(search,board,docket,appellant,judge,decision_type, thedecision_date_after/_beforepair,listed,document_id,ordering), the newContractAppealRecordreturn type, and a registeredContractAppealshape schema so the typed shape API resolves the resource's fields.These are Contract Disputes Act decisions from the CBCA (civilian) and the ASBCA (defense) — a dispute under an existing contract, not a challenge to an award. Bid protests remain the separate
listProtests()resource, and the two do not overlap.Two properties are documented on the record type and in
docs/API_REFERENCE.md. An unshaped list returns only a core subset of the decision, so every property onContractAppealRecordis optional and anything past the subset has to be named in ashape. Anddecision_textis Enterprise-only, with the key absent rather than null below that tier — a caller checks for presence, not for a nullish value.
attachments(extracted_text)— SLED document bodies on the Small plan and above (Tango API 4.25.1; parity with tango-python). The leaf joinsSledAttachmentPayloadand the explicit shape schema, and the contract was re-vendored so it validates instead of being rejected client-side. Three properties are documented on the interface and indocs/API_REFERENCE.md: the leaf must be named (neitherShapeConfigdefault includes it, andattachments(*)does not carry it, because the API only resolves the body for a caller who asked); the key is absent rather than null when the text is not being served; and a contested document never returns text at any plan. Searching document text stays ungated on every plan and returns no fragment of it.
-
State, local and education (SLED) procurement support (Tango API 4.25.0; parity with tango-python). Six methods over the new
/api/sled/namespace:listSledOpportunities/getSledOpportunity,listSledOpportunityRevisions,getSledCoverage,listSledForecasts/getSledForecast, plusiterateSledOpportunities/iterateSledForecastsand theirIterableListMethodentries. New model interfacesSledOpportunity,SledOpportunityRevision,SledForecastand the six nested payload types; explicit shape schemas for all of them; fiveShapeConfigdefaults. Every one of the API's 27 solicitation filters and 13 forecast filters is a typed option.Four behaviors are documented on the options interfaces because each misleads a caller who assumes federal semantics. Passing neither
statusnoractivereturns open solicitations only — that default is the API's, and the SDK deliberately does not synthesize astatusparam, since doing so would makeactive: falseunreachable (pinned by a test).statusis Tango-derived and refreshed every fifteen minutes; the portal's own word is served assource_status, is frozen at last capture, and is not a liveness filter. Category scheme tagging is mid-migration, sonaicsmatches only the small tagged share andcategory_codeis the escape hatch. Andmeta.attachment_countcan be lower thanattachments.length, because an auto-generated portal cover sheet is listed and flaggedis_generated_summarybut excluded from the count.SLED_REVISIONS_MINIMALomitschangeson purpose: the per-field before/after needs a Small plan, so naming it by default would 403 a Free caller.
- Fourteen nested sub-resource routes now mapped in the conformance gate.
agencies_contracts_awarding,agencies_contracts_funding,contracts_subawards,entities_contracts,entities_idvs,entities_lcats,entities_otas,entities_otidvs,entities_subawards,idvs_awards,idvs_idvs,idvs_lcats,otidvs_awardsandvehicles_ordersall have SDK methods and are published by the newer contract for the first time; unmapped, each was a hard conformance error. Mapping them turns those into the honest finding, which is that most of their options interfaces accept filters through an index signature rather than declaring them.
Changed¶
- Re-vendored
contracts/filter_shape_contract.json(schema_version 2, 48 resources) and regeneratedsrc/shapes/generatedOverlay.tsfrom it — 359 fields across 25 containers, 73 nested schemas. - Re-vendored the contract for Tango API 5.1.0 and regenerated the overlay, which now merges a model's expand when two resources embed it instead of letting the narrower copy win. eBuy requests are in the contract without an SDK method yet, so it is baselined as a tracked gap.
- Baselined 14 reverse-shape-coverage gaps in
contracts/shape_coverage_baseline.json, matching tango-python. All 14 are the nested sub-resource routes above, which reuse the parent resource's model rather than carrying one of their own; none is SLED, and none is a regression — they became visible only with the re-vendored contract.
Fixed¶
attachments(name,doc_role,doc_role_alt)no longer throwsShapeValidationErrorongetOpportunity,listOpportunities,getNoticeandlistNotices; the API serves both fields on the Pro plan and above when they are named. NewOpportunityAttachmentandAttachmentDocRoletypes cover the six roles and are used onOpportunityandNotice.- SLED solicitations accept
delisted_atandmeta(jurisdiction_declared), which were rejected client-side.SledOpportunityandSledMetaPayloadcarry the new fields, andstatus_reasonnow documentsdelisted. ProtestRecordtypesagencyandprotesteras the strings the API returns, and gainstitle,docket_url,decision_url,organization,docketsanddecisions. The never-serveddocketproperty is deprecated in favor ofdockets.getProtesttakes the case'scase_idUUID, which is all the route accepts; a case number returns 404. The parameter is renamedcaseIdand the docs say how to look a case up by number, andlistProtestsis documented as covering GAO, COFC and SBA OHA.
Documentation¶
- New Contract Appeals section in
docs/API_REFERENCE.mdcovering both methods, the full filter table, and the two properties that catch people out (the core-subset default and the tier-gated, absent-rather-than-nulldecision_text).README.md's method list gained both methods. - New State & Local (SLED) — Beta section in
docs/API_REFERENCE.mdcovering all six methods, both defaults that surprise people, and the newShapeConfigconstants. docs/WEBHOOKS.mdtroubleshooting gained the date-lapse rule and its one exception. An exclusion or a DIBBS solicitation reaching its date fires nothing, because open/closed is derived at query time — butalerts.sled_opportunity.matchdoes fire on a closing, since SLED liveness is a stored column a fifteen-minute sweep writes.
[1.3.0] - 2026-08-14¶
Changed¶
- Node.js 20 is now the minimum supported version (
engines.node >= 20); the CI matrix moves from 18/20/22 to 20/22/24. Node 18 has been end-of-life since April 2025, and the security-patched test toolchain below requires 20+. Nothing in the published code changed — existing installs on Node 18 keep working, but they are no longer tested or supported. - Dev-dependency security upgrade clearing all six open Dependabot alerts (vitest 2.1.9 → 4.x, @vitest/coverage-v8 to match, tsx to the esbuild-0.28 line — the published package is unaffected, its only runtime dependency remains
commander). One test-only fix for vitest 4's stricter mocks: constructable mocks now use regular-function implementations.
[1.2.0] - 2026-08-14¶
Added¶
-
DIBBS, exclusions, and SBIR/STTR endpoint support (parity with tango-python v1.3.0). Six endpoint families had no SDK support at all — no models, no methods. Added
listDibbsRfqs/getDibbsRfq,listDibbsRfps/getDibbsRfp,listDibbsAwards/getDibbsAward,listExclusions/getExclusion,listSbirTopics/getSbirTopic, andlistSbirSolicitations/getSbirSolicitation, with every filter param in the API contract exposed as a typed option, explicit shape schemas (including the nested organization/awardee/topic/document expands),ShapeConfigdefaults, and async iterators (iterateDibbsRfqs,iterateDibbsRfps,iterateDibbsAwards,iterateExclusions,iterateSbirTopics,iterateSbirSolicitations, plus the matchingIterableListMethodentries for the genericiterate()). New model interfaces:DibbsRfq,DibbsRfp,DibbsAward,Exclusion,SbirTopic,SbirSolicitation.Two API behaviors are worth knowing.
is_open(DIBBS) andis_currently_excluded(exclusions) are derived at query time, so filter with theopen/activeoptions rather than shaping on those fields. And DIBBStotal_contract_priceis the order total repeated on every line item — never sum it across rows; deduplicate on award + delivery-order number first.
- Full typed filter surface on
listBudgetAccounts(parity with tango-python and the API contract).ListBudgetAccountsOptionsnow declares everybudget/accountsfilter param — the exact /__gte/__ltetriplet for all 26 numeric lifecycle, ratio, and trend fields (requested_ba,enacted_ba,apportioned,obligated_total,outlayed_total,unobligated_balance, the contract/assistance breakdowns, the*_pct/*_cappedratios, YoY + 5-year-CAGR trends, andactual_vs_requested_contract), plus the__in/__icontainsvariants of the categorical filters (federal_account_symbol,fiscal_year,agency_code,bureau_name,bea_category,subfunction_code,account_title__icontains). getGsaElibraryContract(uuid, options)for/api/gsa_elibrary_contracts/{uuid}/(parity with tango-python), with the standardshape/flat/flatLists/joineroptions and theGSA_ELIBRARY_CONTRACTS_MINIMALdefault shape.- Typed filter options that previously worked only through the index-signature escape hatch:
keyonlistContracts/listIdvs/listOtas/listOtidvs,cageonlistEntities,idonlistForecasts,opportunity_idonlistOpportunities,previous_uiionlistItDashboard,naics_codeonlistProtests(sent verbatim, not remapped tonaics), andhas_awardsonlistPsc. The filter-shape conformance gate now reports zero index-signature warnings. - Generated shape-coverage overlay (parity with tango-python v1.4.0):
src/shapes/generatedOverlay.ts, machine-generated by the newscripts/generate-shape-overlay.tsfrom the vendored contract pluscontracts/observed_shape_types.json(live-API type observations vendored from tango-python).SchemaRegistrymerges the overlay over the curated explicit schemas, so the typed shape API now accepts every field and expand the API returns — including entityrelationships(type, source), previously-unmapped models (Naics,PSC,MasSin,BudgetAccount,AssistanceListing,BusinessType), and all the code/description expands that were flattened to scalars. The reverse shape-coverage gate now reports zero gaps andcontracts/shape_coverage_baseline.jsonis empty (416 → 0). - Agency-filter diagnostics on
PaginatedResponse(parity with tango-python v1.5.0). Every list method now surfaces the API'smetapayload, plus three parsed views:agencyWarnings(human-readable notes about dropped or loosely-matched agency tokens),unresolvedAgencyTokens(tokens that matched no organization, keyed by filter name), andresolvedAgencies(the organizations each token actually resolved to — the only way to catch a token fuzzy-matching an agency you did not intend). All three are total: absent or malformedmetayields empty values, never a throw. On thePaginatedResponsetype,metaand the three parsed views are declared as optional properties — responses built by the client always populate them, but existing code that constructs the type without them keeps compiling. - Structured shape errors on
TangoValidationError(parity with tango-python's.issues/.available_fields): newissuesandavailableFieldsgetters expose the API's structured 400 payload — entries like{"path": "tradeoff_process", "reason": "unknown_field"}and the endpoint's valid field set — instead of leaving callers to parseresponseDataby hand. - Vendored the canonical API filter/shape contract at
contracts/filter_shape_contract.json(API 4.22.0), so conformance checking is fully offline — no token, no sibling checkout. - New reverse shape-coverage gate
scripts/check-shape-coverage.ts(npm scriptcheck-shape-coverage): walks every resource's shape tree in the vendored contract against the SDK's explicit schema registry and fails on any field or expand the SDK does not capture, unless recorded incontracts/shape_coverage_baseline.jsonas tracked backlog. - Accepted-gaps baselines:
contracts/conformance_baseline.json(missing filters + unimplemented resources) andcontracts/shape_coverage_baseline.json(known shape-coverage gaps). Baselined gaps report as warnings; anything new is an error. - Recorded integration test layer (parity with tango-python's VCR-cassette suite): a record/replay harness (
tests/integration/harness.ts) around the SDK's injectablefetchImpl, with JSON cassettes intests/cassettes/recorded against the live API. Default runs replay offline — a missing cassette is a hard failure so drift is loud, while an absent cassettes directory (a fork without the corpus) skips the suite with a warning.TANGO_REFRESH_CASSETTES=truere-records serially against the live API;TANGO_USE_LIVE_API=truebypasses cassettes. Cassettes never store request headers, keep only an allowlisted response-header subset, and the recorder throws rather than serialize API-key material anywhere in an interaction (asserted by unit tests intests/unit/integration-harness.test.ts). - Per-resource integration tests (
tests/integration/*.test.ts, 44 tests / 44 cassettes) covering contracts (including cursor pagination and shaping), entities, IDVs, vehicles, opportunities, notices, grants, forecasts, agencies + organizations, protests, budget accounts (fiscal-year range round-trip), DIBBS, exclusions, SBIR, NAICS/PSC reference data, subawards, and edge cases (404, invalid ordering, empty result page). - Env-gated production smoke suite (
tests/production/smoke.test.ts, the node port of tango-python'stests/production/): runs only withTANGO_LIVE_TESTS=trueplusTANGO_API_KEY, asserting light live-API invariants (pagination shape, shaping, rate-limit header parsing). Excluded from default runs and CI byvitest.config.ts.
Changed¶
- Both conformance baselines shrank with the new resources:
dibbs/*,exclusions, andsbir/*leftunimplemented_resourcesincontracts/conformance_baseline.json, and theirunmapped_resourceentries leftcontracts/shape_coverage_baseline.json(422 → 416 known gaps, then 416 → 0 with the generated overlay above). scripts/check-filter-shape-conformance.tsnow defaults to the vendored contract instead of a checked-out tango API repo (TANGO_CONTRACT_PATHor--manifeststill point it at one), covers every resource in the 4.22.0 contract in its resource map, and treats an unimplemented resource as an error unless baselined.- Removed the dead legacy
.eslintrc.cjs— the flateslint.config.jshas been the operative ESLint config since the flat-config migration, and the leftover file only invited divergent edits.
Fixed¶
- The six new list methods (
listDibbsRfqs,listDibbsRfps,listDibbsAwards,listExclusions,listSbirTopics,listSbirSolicitations) leaked a caller-suppliedjoinerto the server as a bare query param and ignored it when unflatteningflat: trueresponses (always unflattening on the default.).joineris now threaded the same way aslistIdvs: sent only alongsideflat=true, and used as the unflatten separator. listBudgetAccounts: thefiscal_year_gte,fiscal_year_lte, andaccount_titleoptions were sent verbatim, which the API silently ignores. They are kept as legacy aliases and now remapped to the forms the API understands (fiscal_year__gte,fiscal_year__lte,account_title__icontains); an explicitly passed dunder param wins over its alias.- Docs: removed the stale
getIdvSummary/listIdvSummaryAwardssections fromREADME.mdanddocs/API_REFERENCE.md— those methods were removed from the SDK in 1.1.0. Documented the full new surface (DIBBS/exclusions/SBIR, budget accounts and their filter surface,getGsaElibraryContract, the newly typed filters,PaginatedResponsemeta diagnostics, structuredTangoValidationErrordetails), completed theShapeConfigpreset table indocs/SHAPES.md, and rewrote the maintainer half ofdocs/DEVELOPERS.mdaround the conformance gates and the cassette record/replay workflow (the old text still claimed the SDK had no cassette mechanism).
CI¶
package-lock.jsonis now committed, and the CI + publish workflows install withnpm ci --ignore-scriptsinstead ofnpm install— installs are reproducible from the lockfile instead of re-resolving dependency ranges on every run.- The test job's Node 20 leg runs the suite once with
--coverage(instead of a second full pass), the defaultnpx vitest runnow includes the integration suite replayed offline from the committed cassettes, and both setup-node steps cache the npm store off the committed lockfile. No coverage fail-under gate, matching tango-python. - The
conformancejob is now a hard gate that runs both conformance directions offline against the vendored contract on every PR — it no longer needsTANGO_API_REPO_ACCESS_TOKENand no longer skips silently without it. When the token is configured, both checks additionally run as hard gates against the fresh contract at makegov/tango HEAD, with a re-vendor warning when the vendored copy has drifted. - The
conformancejob also regeneratessrc/shapes/generatedOverlay.tsand fails on any diff, so a contract or curated-schema change that alters generator output can't land without a regenerated overlay.scripts/generate-shape-overlay.tshonors the sameTANGO_CONTRACT_PATH/--contractoverride as the two check scripts.
[1.1.0] - 2026-05-29¶
Changed (breaking)¶
- Removed
getIdvSummaryandlistIdvSummaryAwards. These called/api/idvs/{id}/summary/and/api/idvs/{id}/summary/awards/, which have never existed in the Tango API (no OpenAPI backing), so no consumer could have been using them successfully. UsegetIdv+listIdvAwardsinstead.
Fixed¶
Contractinterface: removed dead fields (id,award_id,recipient_name,award_amount,awarding_agency,funding_agency) and added the real API fields fromContractListSerializer(key,piid,obligated,total_contract_value,base_and_exercised_options_value,awarding_office,funding_office,naics_code,psc_code,set_aside,solicitation_identifier,parent_award,legislative_mandates,subawards_summary,place_of_performance). All fields optional (the endpoint is shape-on-demand). The deprecated fields remain declared (marked@deprecated) for one minor cycle and will be removed in2.0.0. New exported types:OrganizationOfficePayload,PlaceOfPerformance,SubawardsSummary,LegislativeMandates,ParentAwardReference,EntityBasic.listContracts: no longer sendspage=1to the cursor-only/api/contracts/endpoint. When no cursor is supplied, neitherpagenorcursoris sent and the API returns the first page by default. The stale code comment claiming page-based pagination support has been corrected.- Shape validation: registered the
ContractOrIDVCompetitionnested schema (alias ofCompetition) so nested selections likecompetition(extent_competed,number_of_offers_received)on contract / IDV shapes validate instead of raising a shape-validation error.
Added¶
- Budget accounts surface (tango v4.6.8):
listBudgetAccounts,getBudgetAccount,getBudgetAccountQuarters,getBudgetAccountRecipients. New exportedBudgetAccountinterface andListBudgetAccountsOptions. - Singleton detail GETs:
getContract,getContractSubawards,getContractTransactions,getForecast,getGrant,getNotice,getOpportunity,getSubaward. getEntityBudgetFlows(uei)for/api/entities/{uei}/budget-flows/.listVehicleOrders(uuid, options)for/api/vehicles/{uuid}/orders/(parity with Python).grantIdfilter onlistGrants(camelCase alias mapped to thegrant_idAPI param;grant_idalso accepted directly).
CI¶
- Added
ci.ymlPR + push-to-main gate (lint, typecheck, build, test on Node 18/20/22). The filter/shape conformance check is a separate job that skips cleanly until aTANGO_API_REPO_ACCESS_TOKENsecret for the private manifest repo is configured, at which point it becomes a hard gate. - Gave the webhooks real-HTTP-server round-trip tests an explicit 20s timeout (the default 5s was tight on the slower Node 18 CI runtime).
[1.0.0] - 2026-05-13¶
First stable release. tango-node is now at full feature parity with both the Tango API and tango-python for the surface that remains after the subject-based webhook removal (see "Removed" below). Every read method and every endpoint/alert/signing helper available on tango_python.TangoClient has an idiomatic camelCase counterpart on TangoClient, the SDK's docs are auto-published to docs.makegov.com/sdks/node/ via the composer pipeline (makegov/docs#15 / makegov/docs#16), and from 1.x on we'll only ship breaking changes on a major bump.
Changed (breaking)¶
createWebhookEndpoint({ name, ... })—nameis now required. Previously the SDK silently fell back to the callback URL's host whennamewas omitted, which masked the server'sunique(user, name)constraint until the second duplicate endpoint. Raising client-side gives a clearer error and matchestango-python1.0.0's behavior.createWebhookAlert({ filters, ... })—filtersis now validated as a non-empty plain object. Previously{}and arrays passed the check; the error message claimed "non-empty object" but didn't enforce it. Matches the server-side validation.
Fixed¶
tests/unit/client.iterate.test.ts— corrected the comment above the firstpageassertion; previous text claimed the first call should NOT carry a page, but the assertion (correctly) expectspage=1sincelistContractsdefaults to it.docs/DEVELOPERS.md—listContracts({ offset: 25 })example replaced with{ page: 2 }and "manual offset tracking" → "manual page/cursor tracking" (the method has never acceptedoffset).CHANGELOG.md— corrected "4 new unit test files" to "5"; the parenthesized list always contained 5 paths.- Lint cleanup ahead of the npm publish — 44 redundant
as AnyRecord/as <T>type-assertion casts dropped acrosssrc/(@typescript-eslint/no-unnecessary-type-assertionfires under the newer plugin minor that CI resolves).src/webhooks/receiver.ts: dropped the unusedAddressInfoimport, simplifiedDelivery.bodyJson: unknown | null→unknown(the latter already includesnull), and restructuredWebhookReceiver.run()to avoidconst receiver = this(replaced with arrow-function closures overgetUrl/getDeliveries/stop). No behavior change — tests still pass 220/220. eslint.config.js— disabled the coreno-undefrule for TS files. TypeScript itself does undefined-symbol checking with knowledge of TS-only types (AsyncDisposable,Disposable, etc.); the core rule double-fires and false-flags those. Matches the typescript-eslint upstream guidance.
Internal¶
- Pinned
devDependenciesto exact versions (dropped^frompackage.json). The previous unpinned ranges + gitignoredpackage-lock.jsonmeant CI re-resolved deps on every run and could pick up minor versions with stricter behavior that local installs hadn't seen yet — that's how the lint mismatch above slipped past local checks.
Docs¶
- README updated for the docs-review sweep:
- Added
TangoTimeoutErrorto the documented error class list (it has been exported fromsrc/errors.tssince v0.4 but the README omitted it). - Replaced the "(Coming Soon!)" marker on the docs link with the live
https://docs.makegov.com/sdks/node/URL. - Rewrote the "Comprehensive API Coverage" feature bullet — the old enumeration listed fewer than half of the actually-implemented domains. New bullet points at the canonical "API Methods" section for the full surface.
- Added
- New
docs/CLIENT.md—TangoClientconstructor reference, environment variables, full retry/backoff semantics (includingRetry-Afterhandling), error-handling patterns,fetchImplinjection, and staging/local targeting. Ported fromdocs.makegov.com/sdks/node/client.mdahead of the docs-site auto-pull cutover (makegov/docs#15 / makegov/docs#16). docs/API_REFERENCE.mdenriched with notes from the docs-sitemethods.mdthat hadn't been folded in yet:listContractspage/cursor mutual exclusion,getIdvSummary/listIdvSummaryAwardsdeprecation (server returns 404),listIdvLcatsclarification,listOrganizationslevelsemantics,createWebhookEndpointsnake_case canonical vs camelCase legacy aliases (nameis required either way per the 1.0.0 change above),testWebhookEndpointpost-#2252 cleanup ({ endpoint: <id> }is canonical), andcreateWebhookAlertfield-rename notes (namevssubscription_name,filtersvsfilter_definition, singularquery_type, update-writable field list).
CI¶
- New
.github/workflows/docs-dispatch.yml— fires on push tomainwhendocs/**,README.md, orCHANGELOG.mdchanges and dispatchesexternal_updatedatmakegov/docsso the public docs site rebuilds with the latest SDK content. Required for the makegov/docs#15 auto-pull pipeline.
Parity closure — all previously-tracked gaps addressed¶
Every gap surfaced in the May 2026 parity audit is now closed:
Typed filter Options interfaces¶
listForecasts,listOpportunities,listNotices,listGrants— previouslyListOptionsBase & Record<string, unknown>with zero typed filters; now ship fullOptionsinterfaces (ListForecastsOptions,ListOpportunitiesOptions,ListNoticesOptions,ListGrantsOptions) enumerating every filter kwarg from the Python signatures.ListNoticesOptionsdeliberately omitsordering(server rejects it).ListContractsOptionsexpanded to enumerate all 27+ Python kwargs (award_date*,awarding_agency,funding_agency,obligated_gte/lte,pop_*,expiring_*,keyword/recipient_name/recipient_uei/set_aside_type/naics_code/psc_codealiases,sort+order→ordering, etc.).ListEntitiesOptionsexpanded with the 12 Python kwargs (cage_code,naics,name,psc,purpose_of_registration_code,socioeconomic,state,total_awards_obligated_gte/lte,uei,zip_code).ListVehiclesOptionsexpanded with all 21 filter kwargs (vehicle_type,type_of_idc,contract_type,who_can_use,total_obligated_min/max, etc.).ListIdvsOptionsexpanded with the full IDV filter surface.ListOtasOptions/ListOtidvsOptionsexpanded with the missing_gte/_lteranges (award_date_gte/lte,fiscal_year_gte/lte,expiring_gte/lte,pop_start_date_gte/lte,pop_end_date_gte/lte).ListAgenciesOptionsadded (was inline{ page?, limit? }).
All Options interfaces keep a [key: string]: unknown index signature for forward-compatibility with new server-side filters that haven't been ported yet — typed fields give autocomplete and typo-protection on known filters; unknown fields still pass through.
ShapeConfig preset parity¶
Added: PROTESTS_MINIMAL, OTAS_MINIMAL, OTIDVS_MINIMAL, SUBAWARDS_MINIMAL, GSA_ELIBRARY_CONTRACTS_MINIMAL, ORGANIZATIONS_MINIMAL, VEHICLE_ORDERS_MINIMAL, ITDASHBOARD_INVESTMENTS_MINIMAL, ITDASHBOARD_INVESTMENTS_COMPREHENSIVE.
Updated to match Python field lists: ENTITIES_COMPREHENSIVE (now uses federal_obligations(*) expansion), VEHICLES_MINIMAL (extended to mirror Python's larger field list), VEHICLES_COMPREHENSIVE (dropped competition_details(*) per SDK v0.6.0; added program_acronym, is_synthetic_solicitation, metrics(*), organization expansion).
Explicit schemas¶
Added 11 schemas + registry entries: ORGANIZATION_OFFICE_SCHEMA, VEHICLE_METRICS_SCHEMA, ORGANIZATION_SCHEMA, OTA_SCHEMA, OTIDV_SCHEMA, SUBAWARD_SCHEMA, PROTEST_DOCKET_SCHEMA, PROTEST_SCHEMA, GSA_ELIBRARY_IDV_REF_SCHEMA, GSA_ELIBRARY_CONTRACT_SCHEMA, ITDASHBOARD_INVESTMENT_SCHEMA. Wired into the EXPLICIT_SCHEMAS registry under canonical model names.
Also extended VEHICLE_SCHEMA with the fields the new VEHICLES_* presets reference: is_synthetic_solicitation, program_acronym, organization (expandable to OrganizationOffice), metrics (expandable to VehicleMetrics), idv_count, total_obligated, latest_award_date, opportunity_id, description.
Typed return models¶
client.resolve(input)→Promise<ResolveResult>with typedResolveCandidate[](wasPromise<{ candidates: AnyRecord[]; count: number }>).client.validate(input)→Promise<ValidateResult>(wasPromise<AnyRecord>).client.getAgency(code)→Promise<AgencyRecord>(wasPromise<AnyRecord>).client.getProtest(caseNumber)→Promise<ProtestRecord>with typeddocket,resolved_*, etc.
All new types exported from package root.
Observability — rateLimitInfo + lastResponseHeaders¶
Two new instance properties on TangoClient mirroring Python's rate_limit_info / last_response_headers:
await client.listContracts({ limit: 5 });
console.log(client.rateLimitInfo);
// { remaining: 98, limit: 100, resetIn: 60, retryAfter: null, limitType: "per_minute" }
console.log(client.lastResponseHeaders?.["x-request-id"]);
Both null until the first request completes; populated after every successful request. HttpClient parses X-RateLimit-{Remaining,Limit,Reset,Type} + Retry-After headers into a RateLimitInfo snapshot.
listContracts cursor pagination (non-breaking)¶
ListContractsOptions now accepts cursor alongside page. When cursor is supplied, the request omits page and uses keyset pagination (Python parity); otherwise falls back to page-based. PaginatedResponse gained a cursor field auto-extracted from next so callers can client.listContracts({ cursor: resp.cursor }) for the next page without parsing the URL themselves.
WebhookReceiver framework¶
New src/webhooks/receiver.ts. WebhookReceiver dataclass-style class with start(), stop(), url, deliveries, onDelivery callback, optional forwardTo mirror, history cap, signature verification (via existing verifySignature). Two run patterns shipped:
await using rx = await new WebhookReceiver(opts).run();(modern,Symbol.asyncDispose).await WebhookReceiver.withRunning(opts, async (rx) => { ... });(portable callback scope).
Pure node:http + native fetch — no new dependencies.
Webhook simulator¶
New src/webhooks/simulate.ts. sign(payload, secret) returns a SignedRequest (body bytes, signature header value, content-type). deliver({ targetUrl, payload, secret, ... }) signs and POSTs via native fetch with AbortSignal.timeout(). Includes a stableStringify helper that matches Python's json.dumps(sort_keys=True, separators=(",", ":")) byte-for-byte so signatures are reproducible across languages.
Webhook CLI — tango-node webhooks¶
New bin/tango-node (entry in src/bin/tango-node.ts) backed by commander. Subcommands: listen, simulate, trigger, fetch-sample, list-event-types, endpoints {list, get, create, delete} — mirroring the Python tango webhooks CLI. New runtime dep: commander@^12.1.0.
Conformance gate¶
New scripts/check-filter-shape-conformance.ts + npm run check-conformance script. Walks src/client.ts with the TypeScript compiler AST, extracts each list* method's Options interface, and validates against the canonical manifest at tango/contracts/filter_shape_contract.json. JSON output + exit codes match the Python conformance script. Current state: 0 errors, 0 warnings — parity verified.
Test coverage¶
Net +74 tests across the new work (220 total now passing, up from 146 pre-audit):
- ShapeConfig preset parity (
config.shapes.parity) - Explicit schema parity (
shapes.schema.parity, 12 tests) - WebhookReceiver lifecycle, signature paths, forwarding, history cap,
withRunning+Symbol.asyncDispose(webhooks/receiver, 21 tests) - Webhook simulator stable-stringify, sign-verify round-trip, deliver against a real
http.createServer, timeout (webhooks/simulate, 17 tests) - CLI subcommand wiring (
webhooks/cli, 7 tests) - Conformance script (
scripts/conformance, 5 tests) - Observability + cursor pagination + typed return models (
client.observability, 11 tests)
Internal¶
tsxadded as a devDependency to run the new conformance script.HttpClientconstructor body unchanged; just adds two readonly fields (lastResponseHeaders,rateLimitInfo) populated on every successful response.
Removed¶
- Subject-based webhook subscriptions are gone. The Tango API is dropping the
/api/webhooks/subscriptions/surface for subject delivery (see makegov/tango#2267);tango-nodemirrors that here. Removed methods:listWebhookSubscriptions,getWebhookSubscription,createWebhookSubscription,updateWebhookSubscription,deleteWebhookSubscription. Removed types:WebhookSubscription,WebhookSubscriptionCreateInput,WebhookSubscriptionUpdateInput,WebhookSubscriptionPayload,WebhookSubscriptionPayloadRecord,WebhookSubjectTypeDefinition,WebhookSampleSubject,ListWebhookSubscriptionsOptions.WebhookEventTypesResponseno longer carriessubject_types/subject_type_definitions;WebhookEventTypeno longer carriesdefault_subject_type; sample-payload responses no longer carrysample_subjects/sample_subscription_requests. UsecreateWebhookAlert(filter-based delivery via/api/webhooks/alerts/) — that's the only remaining subscription path.
SemVer-major (0.3.0 → 0.4.0).
Added¶
API parity — read methods¶
- Lookups:
listNaics,getNaics,listPsc,getPsc,listMasSins,getMasSin,listAssistanceListings,getAssistanceListing,listOrganizations,getOrganization,listOffices,getOffice,listDepartments(@deprecatedJSDoc),getDepartment,getBusinessType. - Awards completeness:
listOtas,getOta,listOtidvs,getOtidv,listOtidvAwards,listSubawards,listGsaElibraryContracts,listLcats(accepts{ uei }or{ idvKey }). - Other resources:
listProtests,getProtest,listItDashboard,getItDashboard,listMetrics(parameterized overownerTypesince the API exposes metrics only under owner-scoped paths). - Utility endpoints:
resolve(input)(POST/api/resolve/— returns{ candidates, count }),validate(input)(POST/api/validate/).
API parity — typed wrappers for Python's get_*_metrics helpers¶
getEntityMetrics(uei, months, periodGrouping)getNaicsMetrics(code, months, periodGrouping)getPscMetrics(code, months, periodGrouping)
API parity — entity, IDV, and agency sub-resources¶
listEntityContracts,listEntityIdvs,listEntityOtas,listEntityOtidvs,listEntitySubawards,listEntityLcatslistIdvLcats(key, options?)— typed sibling of the genericlistLcats({ idvKey })listAgencyAwardingContracts,listAgencyFundingContracts
Webhook write API¶
- Endpoints:
createWebhookEndpoint(nownameis first-class; defaults to URL host if omitted),updateWebhookEndpoint,deleteWebhookEndpoint.testWebhookEndpoint(endpointId)is the canonical method;testWebhookDeliveryis kept as an auto-resolving variant (omitendpointIdto let the API pick the sole endpoint). - Alerts (filter-subscription API):
listWebhookAlerts,getWebhookAlert,createWebhookAlert,updateWebhookAlert,deleteWebhookAlert.WebhookAlertCreateInputnow has an optionalendpointfield — required for multi-endpoint accounts, optional for single-endpoint accounts (the API auto-resolves). Server support landed in makegov/tango#2256.
New typed input interfaces exported from the package root: WebhookEndpointCreateInput, WebhookEndpointUpdateInput, WebhookAlertCreateInput, WebhookAlert, plus options types for the new sub-resources.
Webhook signature helpers (parity with tango_python.webhooks.signing)¶
verifySignature(body, header, secret)— constant-time HMAC-SHA256 verification. Accepts"sha256=<hex>"and bare-hex forms. Returnsboolean, never throws.generateSignature(body, secret)— emits"sha256=<hex>"matching the dispatcher format.parseSignatureHeader(header)— returns{ algorithm, signature } | nullfor cleaner branching in receivers.
All exported from the package root; receivers don't need to instantiate TangoClient.
Async iterator pagination¶
For convenience, list methods now have async-iterator wrappers that handle next / cursor for you:
for await (const contract of client.iterateContracts({ awarding_agency: "9700" })) {
console.log(contract.piid, contract.total_contract_value);
}
Typed iterators: iterateContracts, iterateEntities, iterateOpportunities, iterateNotices, iterateGrants, iterateForecasts, iterateIdvs, iterateVehicles. Iteration is sequential (no concurrent requests) to respect API rate limits.
Retry with exponential backoff¶
HttpClient now automatically retries failed requests:
- Retries on 5xx, 408 (Request Timeout), 429 (Too Many Requests), network errors, and client-side timeouts.
- Does not retry on other 4xx — those surface as the appropriate
Tango*error immediately. - Exponential backoff: base
retryBackoffMs(default 250ms), doubled per attempt, capped at 10s. - Honors
Retry-Afterheaders (delta-seconds and HTTP-date) on 429/503.
Constructor surface¶
retries(default3) andretryBackoffMs(default250) options onTangoClientOptions. Setretries: 0to disable.timeoutaccepted as a shorthand alias fortimeoutMs(both in ms;timeoutMswins if both are supplied).
Environment variable fallback¶
TANGO_BASE_URLenv var is now read whenbaseUrlis not passed to the constructor — parity withTANGO_API_KEY.
Misc¶
searchOpportunityAttachments,getVersion,listApiKeysround out parity with the Python SDK's introspection / search surface.- Shape generator now accepts
naics(code,description)/psc(code,description)as canonical expands on Contract, Opportunity, Notice, Forecast, and Vehicle (IDV already had them). Mirrorsmakegov/tango#2259. (refsmakegov/tango#2265)
Changed¶
createWebhookEndpointand related write methods accept the canonical Tango API payload shape in addition to the previous camelCase wrappers — see the new typed input interfaces.testWebhookEndpoint/testWebhookDeliverynow send the canonical{ endpoint }body key instead of the deprecated{ endpoint_id }(server still accepts both as aliases). Tracks makegov/tango#2252.ListSubawardsOptions.orderingnarrowed fromstringto the literal union"last_modified_date" | "-last_modified_date", matching the server-side enum (no other values are accepted; others 400). Tracks makegov/tango#2254.- Shape generator rewrites legacy
naics_code(...)/psc_code(...)expand spellings to canonicalnaics(...)/psc(...)before validation, matching the server's_EXPAND_ALIASESmap. Scalarnaics_code/psc_code(no parens) is untouched and still returns the raw column value. (refsmakegov/tango#2265,makegov/tango#2259)
Fixed¶
ShapeConfig.IDVS_COMPREHENSIVEno longer includesbase_and_exercised_options_value, which is not a valid IDV shape field — the API was returning400 Invalid shapeon this preset. Now aligned withtango_python.IDVS_COMPREHENSIVE. Also reconciledrecipient.cage_code→recipient.cageto match the Python preset exactly.createWebhookAlertnow plumbs an explicitendpointUUID through to the API. Multi-endpoint accounts can now create alerts directly instead of relying on the server's single-endpoint auto-resolution. Tracks makegov/tango#2256.Subawardschema matches the server'sSubawardSerializer. The previousSUBAWARD_SCHEMA(ported from the broken Python schema) declared two fields the server has never exposed (id,amount) and was missing every real field — includingpiid,key,awarding_office/funding_office/place_of_performance/subaward_details/fsrs_details/highly_compensated_officers/usaspending_permalink, and the denormalizedprime_awardee_*/recipient_*lookup columns. Shape strings that referenced any real field (e.g.shape: "piid") would fail client-side validation withunknown_field.SUBAWARD_SCHEMAis now derived directly fromawards.serializers.subawards.SubawardSerializerand the resource's runtimeavailable_fields. New nested schemasSubawardDetails,FsrsDetails,SubawardPlaceOfPerformance, andHighlyCompensatedOfficerare registered so the corresponding shape expansions validate end-to-end.
Internal¶
- Live smoke harnesses at
scripts/smoke-{reads,writes,extras,parity}.tsexercise every new method against a running Tango instance. All four requireTANGO_API_KEYin the environment (hard-fail if unset — no fallback). - 5 new unit test files (
tests/unit/{client.parity,client.iterate,client.baseurl,webhooks.signing,config.shapes}.test.ts) added; total suite is now 16 files / 111 tests / 82% line coverage. - ESM build (
tsc -p tsconfig.json) clean.
[0.3.0] - 2026-02-09¶
Added¶
- Vehicles endpoints:
listVehicles,getVehicle, andlistVehicleAwardees(supports shaping + flattening). (refsmakegov/tango#1327) - IDV endpoints:
listIdvs,getIdv,listIdvAwards,listIdvChildIdvs,listIdvTransactions,getIdvSummary,listIdvSummaryAwards. (refsmakegov/tango#1327) - Webhooks v2 client support: event type discovery, subscription CRUD, endpoint management, test delivery, and sample payload helpers. (refs
makegov/tango#1275)
Changed¶
- HTTP client now supports PATCH/PUT/DELETE for non-GET endpoints.
joineris now respected when unflatteningflat=trueresponses on supported endpoints.
[0.1.0] - 2025-11-21¶
- Initial Node.js port of the Tango Python SDK.
- Basic project scaffolding for client, models, and shapes.
- ESM + TypeScript build configuration.
[0.1.4] - 2025-11-21¶
- Added tests and cleaned up formatting and structure of SDK.
Tango MCP (AI agents)¶
The Tango MCP server gives AI agents access to federal procurement competitive intelligence via the Tango API. The server is hosted at https://tango.dev/mcp and uses HTTP transport — you connect your MCP client to the remote endpoint; no local install required.
https://govcon.dev/mcp is the original endpoint and keeps working; it is not deprecated and we are not retiring it. Both addresses reach the same server. Use tango.dev/mcp for new setups, and see Which endpoint should I use? if you are already connected to govcon.dev.
Tango is listed in both major assistant directories, and you can also point any MCP-compatible client — Cursor, the OpenAI Responses API, your own — at the endpoint directly.
Install from a directory¶
Installing from a directory is one click plus a Tango sign-in. You never handle an API key and there is nothing to configure:
- Claude —
tango.dev/claudeopens the Tango connector in Anthropic's directory. You can also find it in Claude under Settings → Connectors. Works on web, desktop, and mobile. - ChatGPT —
tango.dev/chatgptopens the Tango app in the ChatGPT app directory.
Both connect to the same hosted server and authenticate through Tango's OAuth flow, so the assistant sees exactly the data your account and plan already allow. Manage the connection — or revoke it — from your account at tango.dev.
The rest of this page covers connecting other MCP clients, where you supply the endpoint and credentials yourself.
What it does¶
The server exposes 5 tools that let an AI agent research government contracting data on your behalf:
- Discover what's in the data — Use
resolveto find entities, agencies, vehicles, NAICS/PSC codes, contracts, opportunities, OTAs, OTIDVs, subawards, organizations, protests, and IT Dashboard investments by name or keyword; get identifiers and previews to use with other tools. - Search awards and related data — Use
searchto query contracts, IDVs, vehicles, OTAs, OTIDVs, subawards, organizations, GSA eLibrary contracts, CALC labor rates (LCATs), bid protests, IT Dashboard investments, grants, and federal budget accounts. Filter by vendor, agency, NAICS, PSC, dates, and many other criteria; optionally profile a capped sample of the matches (obligated amounts, set-aside breakdown, etc.) — seeinclude_summaryis a sample, not a market total. - Search active opportunities — Use
search_opportunitiesto find SAM.gov opportunities and agency forecasts by agency, NAICS, set-aside type, response deadline, and keyword. - Get full details for any item — Use
get_detailsto retrieve detailed information for a specific entity, contract, IDV, vehicle, opportunity, OTA, OTIDV, organization, subaward, protest, MAS SIN, GSA eLibrary contract, or IT Dashboard investment by ID, with optional related-data enrichment. - Read curated API documentation — Use
fetch_api_docsto pull short, LLM-friendly documentation for any major Tango resource (contracts, opportunities, vehicles, IT Dashboard, etc.).
Quick start¶
Skip this section if you installed from the Claude or ChatGPT directory — those handle the endpoint and the sign-in for you.
Prerequisites¶
- A Tango API key (from the Tango web interface), or for OAuth clients: access tokens via
Authorization: Bearer <access_token> - An MCP-compatible client (e.g. Cursor, Claude Desktop)
Connect via HTTP¶
The MCP endpoint is https://tango.dev/mcp. Configure your client to connect to this URL over HTTP and send your API key (see Using with MCP clients below).
Which endpoint should I use?¶
| New setups | https://tango.dev/mcp |
Already connected to govcon.dev/mcp |
Stay there. Nothing is changing and there is no migration deadline. |
The two addresses are the same server, but they are distinct OAuth protected resources — each advertises its own resource identifier, and access tokens are bound to the one you authorized against. Switching an existing OAuth connection from govcon.dev/mcp to tango.dev/mcp therefore means re-authorizing. That is the only reason to think twice; if you connect with an API key rather than OAuth, you can switch freely.
Using with MCP clients¶
Remote MCP (HTTP) — recommended¶
Connect your client to the hosted server over HTTP using mcp-remote. The server expects your Tango API key in the X-Tango-API-Key header (or an OAuth bearer token in Authorization: Bearer).
Cursor / Claude with remote URL (API key):
{
"mcpServers": {
"tango-remote": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://tango.dev/mcp",
"--header",
"X-Tango-API-Key: your_api_key_here"
]
}
}
}
Cursor / Claude with remote URL (OAuth bearer token):
{
"mcpServers": {
"tango-remote-oauth": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://tango.dev/mcp",
"--header",
"Authorization: Bearer your_oauth_token_here"
]
}
}
}
Use a fresh access token from your Tango OAuth flow; refresh the token when it expires.
Streamable HTTP (JSON mode) — The server uses HTTP transport and returns JSON responses rather than Server-Sent Events (SSE). If you see errors like "Unexpected content type" or "Failed to open SSE stream", ensure the client uses JSON mode.
API key security — Prefer setting the API key via environment variable or a secrets manager. When using mcp-remote with --header X-Tango-API-Key: ..., avoid committing the key to config; use a local env var or secret so the key is not logged or stored in plain text.
Claude¶
The Tango connector in Anthropic's directory is the recommended path — one click, sign in with Tango, no API key to manage. See Install from a directory above.
To supply your own API key instead of signing in with OAuth, configure Claude Desktop with the remote-MCP JSON above.
ChatGPT¶
Enable the Tango app from the ChatGPT app directory. Authentication is the same Tango OAuth sign-in; no API key required.
To call the server from your own OpenAI integration instead, see OpenAI Responses API below.
OAuth flow¶
When connecting over HTTP, you can authenticate with a bearer token instead of an API key. This is useful for clients that support OAuth or when you obtain access tokens from Tango's OAuth server. The Claude and ChatGPT directory listings run this flow for you — the steps below are for clients you wire up yourself.
-
Obtain an access token from the Tango OAuth server at tango.makegov.com. Use the authorization code or client credentials flow; supported grant types include
authorization_code,client_credentials, andrefresh_token. Required scope is typicallyread. -
Send the token on each request via the
Authorizationheader:Authorization: Bearer <your_access_token>. -
Auth precedence — The server resolves auth in this order: (1)
Authorization: Bearerif present, (2)X-Tango-API-Keyif present, (3)TANGO_API_KEYfrom the environment (stdio/local only).
OAuth discovery — The server exposes standard discovery endpoints so clients can find the Tango authorization server and required scopes:
- Authorization server metadata:
GET /.well-known/oauth-authorization-serverandGET /mcp/.well-known/oauth-authorization-server - Protected resource metadata:
GET /.well-known/oauth-protected-resource
These return JSON (issuer, authorization_endpoint, token_endpoint, scopes_supported, etc.). The default issuer is https://tango.makegov.com.
OpenAI Responses API¶
OpenAI's MCP integration requires a publicly accessible server. Use the hosted MCP endpoint https://tango.dev/mcp in your tools configuration. See the OpenAI MCP documentation for details.
Available tools¶
| Tool | Description |
|---|---|
resolve |
Find entities, vehicles, organizations, contracts, opportunities, OTAs, OTIDVs, subawards, protests, IT Dashboard investments, NAICS / PSC / SIN codes by name or keyword. Use first when you have a string and need to discover what's in the data; returns identifiers and short previews for use with other tools. |
search |
Search across 13 record types: contract (default), idv, vehicle, ota, otidv, subaward, organization, protest, gsa_elibrary, lcat, itdashboard, grant, and budget_account — or all. Filter by vendor, agency, NAICS, PSC, dates, keyword, and many other criteria. Set include_summary=true to profile a capped sample of the matches (see below). |
search_opportunities |
Search open opportunities and agency forecasts. Filter by agency, NAICS, PSC, set-aside type, response deadline, notice type, keyword, and other criteria. Type defaults to opportunity; pass type="forecast" for forecast data. |
get_details |
Get detailed information for a single item by ID and type. Supported types: entity, contract, idv, vehicle, opportunity, ota, otidv, organization, subaward, protest, sin, gsa_elibrary_contract, itdashboard. Use after search or resolve to drill into a specific record. Optional include_related=True enriches the response (see "Enrichment matrix" below). IT Dashboard business_case_html content requires Business+. |
fetch_api_docs |
Fetch curated, LLM-friendly markdown documentation for a single Tango resource. Sections: budget_accounts, contracts, entities, forecasts, gsa-elibrary, idvs, itdashboard, lcats, metrics, opportunities, otas, otidvs, protests, resolve, response-shaping, subawards, vehicles. |
Enrichment matrix (get_details(..., include_related=True))¶
| Type | Adds when include_related=True |
|---|---|
entity |
Socioeconomic status + 10 most recent contracts |
contract |
Full detail + subawards |
idv |
Full detail + child awards + transactions + CALC labor rates |
vehicle |
Full detail + awardees + task orders |
opportunity |
Full detail (attachments, notice history, primary contact) |
ota / otidv |
Full detail + child awards / OTAs + transactions |
organization |
Detail + child organizations |
protest |
Detail + dockets + related opportunities / contracts |
sin |
Detail + GSA eLibrary contracts (Schedule holders) |
gsa_elibrary_contract |
Detail (no further enrichment) |
itdashboard |
Detail + nested CIO evaluation, contracts, projects, funding, performance metrics. The business_case_html HTML blob requires Business+ tier. |
Enrichment failures degrade gracefully — if a related-data fetch fails, the main object is still returned with empty related blocks.
include_summary is a sample, not a market total¶
search(..., include_summary=true) returns a statistical profile — total_obligated, top_vendors, set_aside_distribution, naics_distribution, org_distribution — computed over at most 250 matching records. That is a hard cap, not a ceiling a narrow filter escapes: the server fetches up to 250 rows and aggregates those. When more records match, the response sets is_capped_sample and includes a _note naming how many of the total were analyzed.
Two limits worth knowing before you use it:
- Don't report
total_obligatedas market size. It is the sum over the sampled rows. Narrow the filters until the match count falls under the cap if you need the sample to be the whole population. - The distributions are awarding-side.
org_distributionis keyed onawarding_office, so a summary cannot break spending down by funding organization even when you filtered onfunding_org.
For true full-population aggregates, computed server-side over every matching record:
| You want | Use |
|---|---|
| One vendor's entire award history | get_details(type="entity", fields="past_performance(summary,top_agencies)") |
| An organization's obligations, including its whole subtree | search(type="organization", fields="fh_key,name,total_obligations,tree_obligations") |
| Appropriations-level totals | search(type="budget_account") — see Budget |
Resources¶
The server also exposes static MCP resources. Clients that support resource fetching can pull curated reference content without a tool call:
| URI | Content |
|---|---|
tango://guides/federal-procurement |
Overview guide: how federal procurement data fits together. |
tango://guides/api-reference |
Quick reference: enums, sorting fields, response shaping, recipes. |
tango://reference/set-asides |
Set-aside type codes and meanings. |
tango://reference/protests |
Background on bid protests (GAO / SBA OHA). |
tango://reference/naics/{code} |
NAICS code lookup. |
tango://reference/psc/{code} |
PSC (Product / Service Code) lookup. |
tango://reference/sin/{code} |
GSA MAS SIN lookup. |
Configuration¶
- API key vs OAuth: Send your Tango API key in the
X-Tango-API-Keyheader, or useAuthorization: Bearer <token>with an OAuth access token from tango.makegov.com. - Endpoint: The MCP endpoint is
https://tango.dev/mcp(HTTP transport).https://govcon.dev/mcpreaches the same server and continues to work. - Response format: The hosted server returns responses in TOON (Token-Oriented Object Notation) by default — a compact serialization that is roughly 30–60% smaller than JSON for typical responses. To force standard JSON, set
TANGO_RESPONSE_FORMAT=json(relevant when self-hosting; the hosted server returns TOON).
Self-hosting / local environment variables¶
If you run the server locally (e.g. via stdio for development), the following environment variables are recognized:
| Variable | Purpose |
|---|---|
TANGO_API_KEY |
Default API key (used when no X-Tango-API-Key / Authorization header is supplied; stdio mode only). |
TANGO_RESPONSE_FORMAT |
json to force JSON output. Default is TOON. |
TRANSPORT |
http to switch from stdio to streamable-HTTP transport. |
TANGO_TELEMETRY |
1 to log per-tool duration / token counts / status to stderr as JSON. |
TANGO_LOG_USER_FINGERPRINT |
1 to log a stable hash of the API key (not the key itself) for correlation. |
SENTRY_ENABLED |
1 to enable optional Sentry error monitoring. |
Notable behaviors¶
The server includes several robustness features worth knowing about when integrating:
- Strict argument validation. Unknown tool arguments are rejected with a structured error that lists the valid arg names — no silent drops. Pass
typenotrecord_type, etc. - Zero-result hints. When a search returns no results, the response includes a
_hintwith the active filters and broaden-query guidance — helpful for LLM agents that would otherwise guess randomly. - Ordering-alias rewrite. LLM-friendly sort field names like
expiring,date,value, andamountare rewritten to API-valid values (award_date,obligated,total_contract_value). - Parameter coercion. Bare strings for array fields (
naics_codes,psc_codes,set_aside_types) are wrapped automatically.activeaccepts"true"/"false".notice_typeaccepts both code letters (p,k,o,a,m,r,s,u,i,g) and full names ("Solicitation","Sources Sought", etc.). - Circuit breaker. After 3 transient upstream failures (502 / 503 / 504 / timeout) within 60 seconds, the server short-circuits with
UpstreamUnavailableError. A probe is allowed every 10 seconds during the cooldown. - Tier-aware errors. Tier-gated endpoints (e.g. CALC labor rates) return a typed error with an upgrade hint rather than a raw HTTP exception.
Troubleshooting¶
424 error from OpenAI — OpenAI can only reach publicly accessible servers. Use the hosted endpoint https://tango.dev/mcp.
Empty results — Verify your API key is valid. Check that the UEI, NAICS code, or other identifiers you are using exist in the Tango database.
502 Bad Gateway / "Failed to open SSE stream" — The server uses HTTP transport and JSON responses, not SSE. If the client expects SSE, use a client that supports JSON mode.
403 when calling the server URL — A proxy or firewall may be blocking the request to https://tango.dev/mcp. Ensure your client sends the API key or Bearer token as described above.
"Invalid API key or authentication required" — When connecting to https://tango.dev/mcp, the server must receive either your Tango API key in the X-Tango-API-Key header or an OAuth bearer token in the Authorization: Bearer <token> header. Ensure your MCP client is configured to send one of these.
Get help¶
For API keys, account issues, or MCP connection problems:
- Email: [email protected]
- Tango web interface: tango.dev — sign in to manage your account and API keys.
Privacy¶
Data handling and privacy are described in our Privacy policy.
References¶
- Tango connector in Anthropic's directory — one-click install for Claude
- Tango app in the ChatGPT app directory — one-click install for ChatGPT
https://tango.dev/mcp— Hosted Tango MCP server (HTTP transport).https://govcon.dev/mcpis an alias for the same server.- Model Context Protocol Specification
- MCP Inspector — Interactive testing and debugging for MCP servers
- OpenAI MCP Documentation
- Tango API
Webhooks
Tango – Webhooks Partner Guide¶
Welcome! This guide walks you through enabling and consuming outbound webhooks from Tango so your application can react to fresh federal-spending data without polling our API.
Jump to:
1. What you get¶
Tango pushes a JSON payload to your server whenever new data matches a saved filter — a filter alert. Today this includes:
- Awards / contracts
- Opportunities
- Entities
- Grants
- Forecasts
- Near–real-time: you'll be notified minutes after Tango's ingestion cycle finishes.
- Lightweight: one payload lists the IDs that changed and a small summary per match — pull full details from the existing REST API as needed.
- Filter-based: define a saved query (the same filters you'd pass to the API) and receive an event whenever new or updated rows match.
- Reliability features: automatic circuit breaker pattern for endpoint health management and intelligent retry strategies.
Note (Awards): For correctness, awards webhooks are published after award bundling/materialization so a webhook will not arrive before the corresponding award is queryable via the API.
1.1 When events are published ("data is ready")¶
We intentionally publish events only once the underlying data is queryable via the API.
- Contracts (
alerts.contract.match): published once new contracts are queryable via the API. - Opportunities (
alerts.opportunity.match): published after each opportunities refresh. - Entities (
alerts.entity.match): published after each entities refresh (including DSBS updates). - Grants (
alerts.grant.match): published after each grants refresh. - Forecasts (
alerts.forecast.match): published after each forecasts refresh. - Exclusions (
alerts.exclusion.match): published when a matching SAM.gov exclusion is newly issued, revised, or lifted. - DIBBS RFQs (
alerts.dibbs_rfq.match): published when a matching DIBBS request for quote is newly posted or updated. - DIBBS solicitations (
alerts.dibbs_rfp.match): published when a matching DIBBS solicitation (RFP/IFB) is newly posted or updated. - eBuy requests (
alerts.ebuy_request.match): published when a matching GSA eBuy request under your own linked Schedule contracts is newly posted or updated. Matching uses the contract links of the account that owns the webhook endpoint, so an alert never surfaces a request outside them. A request closing does not fire — eBuy stops listing it rather than marking it closed. - State & local solicitations (
alerts.sled_opportunity.match): published when a matching SLED solicitation is newly posted, revised, or closes. Unlike the resources above, a SLED closing is a change to the record —statusis a stored column that Tango recomputes every fifteen minutes — so a subscription can follow a deadline passing. - Contract appeals (
alerts.contract_appeal.match): published when a matching CBCA or ASBCA decision is newly listed or changes. Two changes worth knowing: a decision leaving a board's listing is a write (listedflips tofalse), and so is a decision gaining its extracted text — so a subscription can follow either, including on plans that cannot readdecision_textitself. - Federal Register (
alerts.federal_register.match): published when a matching Federal Register document is newly published or changes. A comment period closing is not a change —comments_openis evaluated at query time — so acomments_open=truealert tells you when a document opens for comment, not when its period ends.
A very large refresh can delay alerts by hours. When one ingestion run changes millions of records at once (a bulk reload of award history, for example), Tango works through the change in bounded slices instead of all at once, so alerts behind it arrive hours late rather than minutes. While an alert catches up, its last_checked_at trails the current time, and a broad alert can receive several events in a row for the same alert_id — one per slice that matched, each carrying up to 500 new and 500 modified matches. A daily or custom alert only runs on its schedule, so a large enough backlog can take more than one of its scheduled runs to clear.
1.2 Filter subscriptions (alerts)¶
Filter subscriptions let you create persistent monitoring rules based on query filters. For example, "alert me when new opportunities matching NAICS 541512 appear at HHS" or "notify me of new contracts awarded to small businesses in Virginia."
You define a query type (the resource to monitor) and a set of filters (the same query parameters you would use on the corresponding API endpoint). Tango periodically evaluates your filters against new and updated data; when matches are found, Tango delivers a webhook to your endpoint with alerts.<query_type>.match events.
Available query types¶
| Query type | Resource monitored | Filters from |
|---|---|---|
opportunity |
Contract opportunities | /api/opportunities/ filters |
contract |
Contracts | /api/contracts/ filters |
idv |
IDVs | /api/idvs/ filters |
ota |
OTAs | /api/otas/ filters |
otidv |
OTIDVs | /api/otidvs/ filters |
entity |
Entities | /api/entities/ filters |
grant |
Grants | /api/grants/ filters |
forecast |
Forecasts | /api/forecasts/ filters |
exclusion |
SAM.gov exclusions | /api/exclusions/ filters |
dibbs_rfq |
DIBBS requests for quote | /api/dibbs/rfqs/ filters |
dibbs_rfp |
DIBBS solicitations (RFP/IFB) | /api/dibbs/rfps/ filters |
ebuy_request |
GSA eBuy requests under your own linked contracts | /api/ebuy/requests/ filters |
sled_opportunity |
State, local and education solicitations (Beta) | /api/sled/opportunities/ filters |
contract_appeal |
CBCA and ASBCA contract appeal decisions | /api/contract_appeals/ filters |
federal_register |
Federal Register documents | /api/federal_register/ filters |
Alert event types¶
When a filter subscription matches, the delivered event uses the alerts.* namespace. There are twelve:
alerts.opportunity.matchalerts.contract.match— also emitted foridv,ota, andotidvquery typesalerts.entity.matchalerts.grant.matchalerts.forecast.matchalerts.exclusion.matchalerts.dibbs_rfq.matchalerts.dibbs_rfp.matchalerts.ebuy_request.matchalerts.sled_opportunity.matchalerts.contract_appeal.matchalerts.federal_register.match
If you subscribe to idv, ota, or otidv, matches arrive as alerts.contract.match events; inspect query_type on the event to distinguish.
Award alerts follow the government's change date, not Tango's. A contract, idv, ota or otidv alert matches an award only when FPDS last modified it on or after the moment you created the alert. When Tango reprocesses older awards on its side, such as during a data reload, those awards are not reported to you. An award with no FPDS last-modified date, which is true of some records from before 1998, never matches. FPDS publishes a change about a day after it is made, so a change made shortly before you created the alert is not reported even if it reaches Tango afterwards; query the matching /api/<resource>/ endpoint for anything older.
A record simply reaching a date is usually not a change. An exclusion passing its termination date, or a DIBBS RFQ/RFP passing its closing date, does not emit an alert — open/closed and in-force/lifted are derived at query time, so no underlying record changes. Alerts fire only when a matching record is newly posted, revised, or lifted. DIBBS awards are not alertable (there is no dibbs_award query type); subscribe to dibbs_rfq or dibbs_rfp to follow solicitations.
sled_opportunity is the exception. A state solicitation's liveness is a stored status column that Tango recomputes every fifteen minutes, not a query-time derivation, so a deadline passing is a write and a subscription can follow it. There is no sled_forecast query type (a forecast has no deadline, so nothing transitions), and SLED revisions and attachments are not separately alertable — subscribe to sled_opportunity and filter on change_seen_after or revision_kind.
contract_appeal is the other exception, for a different reason. A board removing a decision from its listing, and a decision gaining its extracted text, are both writes to the record rather than derivations, so a subscription follows either. Nothing here expires on a clock.
Frequency options¶
| Frequency | Description | Tier requirement |
|---|---|---|
realtime |
Evaluated after each ingestion cycle | All tiers |
daily |
Evaluated once per day | All tiers |
custom |
Custom cron expression (5-field) | Pro+ (Small and above) |
For a weekly cadence, use a custom cron expression (e.g. 0 8 * * 1 for every Monday at 08:00 UTC).
Tier limits¶
Filter subscriptions are available to all tiers, including Free:
| Tier | Max filter subscriptions |
|---|---|
| Free | 1 |
| Small | 5 |
| Medium | 10 |
| Large | 25 |
| Enterprise | custom |
Convenience API: /api/webhooks/alerts/¶
The alerts API provides a simpler shape for creating and managing filter subscriptions:
Create an alert:
POST /webhooks/alerts/
Content-Type: application/json
{
"name": "HHS IT services opportunities",
"query_type": "opportunity",
"filters": {
"agency": "7500",
"naics": "541512"
},
"frequency": "daily",
"endpoint": "<your-endpoint-id>"
}
Returns 201 Created with the alert details. If a subscription with the same query_type + filters already exists, returns 200 OK with the existing alert (dedup).
Filters are checked when you create an alert. If Tango can tell a filter would never fire, or would match more than you asked for, the create fails with 400 instead of saving the alert. Three things are rejected:
- Unknown filter keys. Every key must be a filter the alert's
query_typesupports, even when it sits next to valid ones — an unsupported key would be ignored when the alert runs, so the alert would match more than you intended. Date-range filters take their_after/_beforeforms:response_deadline_afteris accepted, a bareresponse_deadlineis not. - An agency that matches nothing. For
agencyonopportunityandfederal_registeralerts, andawarding_agency/funding_agencyoncontract,idv,otaandotidvalerts, at least one|-separated value must match an organization. Each value is resolved on its own (see Agency search), so a value that partly matches is accepted.forecast,grantandexclusionalerts fall back to matching the agency text on each record, so any agency name passes this check. - An empty or unmatchable agency value. On the agency filters above, the
forecast,grantandexclusionones included, a value longer than 150 characters or one with no agency in it (|||, an empty string,null) is rejected.sled_opportunity'sagencyis a plain substring match on the buyer's text, so neither agency check applies to it.
The body's filter_definition list carries one message per problem:
{
"filter_definition": [
"Unknown filter key(s) for query_type 'opportunity': naics_cod. Unknown keys are ignored when the alert runs, so it would match more than intended.",
"Agency filter 'agency': No agency found matching 'ZZZQX'. Please check the agency name and try again."
]
}
The other two agency messages read Agency filter '<name>': longer than 150 characters, so it can never match. and Agency filter '<name>': no agency value to match. A filter where no key is recognized at all gets a single message listing some of the filters that query_type does support.
These checks run only when an alert is created. Its filters can't be changed afterwards, so renaming, pausing, resuming or changing the schedule is never blocked by them, including on an alert saved before the checks existed.
The agency check only catches a value that matches nothing. Agency resolution is fuzzy, so a misspelling can still resolve to a different agency — run the filter against the matching /api/<resource>/ endpoint before creating the alert (see Recipes).
When endpoint is required
The endpoint field is required if your account has more than one webhook endpoint configured (the server returns 400 otherwise so deliveries can't go to the wrong receiver). Accounts with a single endpoint may omit it — Tango routes to that endpoint automatically. List your endpoints with GET /api/webhooks/endpoints/ or client.list_webhook_endpoints() in the SDKs.
Tip — multi-value filters: Use
|(pipe) to match any of several values. The same syntax works on every filter that accepts multiple values (naics,psc, agencies, set-asides, etc.) — including the resource-ID filters described in Following specific resources below."filters": {"naics": "541511|541512"}One known exception:
/api/contracts/'sueifilter is single-value only. To watch multiple vendors, create one alert per vendor — see the vendor watchlist recipe.
Following specific resources¶
Every resource you can GET by its detail endpoint can also be followed by that same identifier in an alert. Pass the detail-endpoint ID as a filter and you'll be notified when that resource (or any of a set of them) is created or modified.
query_type |
Filter key | Example value |
|---|---|---|
opportunity |
opportunity_id |
"<uuid-1>\|<uuid-2>\|<uuid-3>" |
contract / idv / ota / otidv |
key |
"<key-1>\|<key-2>\|<key-3>" |
grant |
grant_id |
"123\|456\|789" |
forecast |
id |
"12\|34\|56" |
The filter name matches the detail-endpoint lookup field for each resource — GET /api/contracts/{key}/ → "key": "...", GET /api/opportunities/{opportunity_id}/ → "opportunity_id": "...", and so on. Single-value or pipe-OR (id1|id2|id3) both work; up to 500 IDs per filter.
{
"name": "Follow my watchlist",
"query_type": "contract",
"filters": {"key": "<key-1>|<key-2>|<key-3>"},
"frequency": "realtime"
}
Prefer one alert with many IDs over many alerts with one ID each. A grouped alert is one cron evaluation, one indexed query, and one delivery per tick — and it counts as a single subscription against your tier limit (see Tier limits). One-alert-per-thing burns through your subscription budget quickly and produces more inbound traffic to your endpoint.
Note —
entity: theueifilter onentityalerts is still single-value (see the multi-value tip above). To watch several vendors, create one entity alert per UEI, or use the vendor watchlist recipe.
List alerts:
GET /webhooks/alerts/
Response:
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"alert_id": "e4c4...",
"name": "HHS IT services opportunities",
"query_type": "opportunity",
"filters": {"agency": "7500", "naics": "541512"},
"frequency": "daily",
"cron_expression": null,
"status": "active",
"created_at": "2026-03-14T12:00:00Z",
"last_checked_at": null,
"match_count": 0
}
]
}
Update an alert:
PATCH /webhooks/alerts/{alert_id}/
You can update name, frequency, cron_expression, and is_active. Filters and query type cannot be changed after creation (delete and recreate instead).
Resuming a paused alert starts it from the moment you resume. Set is_active to false to pause and true to resume. Records created or changed while the alert was paused are not delivered when it comes back. If you need that period, query the matching /api/<resource>/ endpoint with the same filters.
Delete an alert:
DELETE /webhooks/alerts/{alert_id}/
Dedup behavior¶
If you create a filter subscription with the same query_type and filter parameters as an existing one (for the same endpoint), the API returns the existing subscription instead of creating a duplicate. The dedup is based on a SHA-256 hash of the canonical filter representation.
Keeping filters focused¶
Filter matching runs against Tango's data-lake change feed — only the records that changed since your alert's last check are evaluated, so a broad filter never overloads the platform and alerts are never paused, throttled, or rejected for matching too many records.
A broad filter can still deliver more events to your endpoint than you want to process. To keep deliveries focused:
- Add NAICS or PSC constraints if monitoring broad agencies (e.g. don't monitor "all of DOD" — try "DOD IT services", i.e. DOD + NAICS 541512).
- Split one broad alert into several focused ones (e.g. "all opportunities" → "opportunities by agency").
- Add keyword constraints (e.g.
keywords=cybersecurityto reduce noise). - Lower the frequency from
realtimetodaily(fewer, larger deliveries over the same underlying data).
Current status¶
The filter subscription API is fully functional for creating, listing, updating, and deleting alerts. A background task evaluates filter subscriptions on their configured schedule (realtime, daily, or custom cron) and delivers matches automatically.
2. Access requirements¶
Filter subscriptions are available to all tiers, including Free, with per-tier caps on how many you can create simultaneously (see the table in §1.2 — Tier limits). No tier upgrade is required to start using webhooks.
3. On-boarding checklist¶
- Provide a callback URL — a publicly reachable
https://endpoint that accepts HTTP POSTs. Recommended path:/tango/webhooks. - Receive your shared secret — Tango generates a 32-byte hex secret (64 hex chars) and shares it out-of-band. You'll use this to verify signatures.
- Test your endpoint — use the test delivery endpoint (§5.5) to verify connectivity.
- Create one or more filter alerts — define a
query_type+filtersper saved query you want to monitor. See §1.2 (Convenience API) and the recipes.
Note We create the initial Webhook Endpoint record for you. All subsequent management of subscriptions/alerts is done via the Subscription API below.
4. Security & authenticity¶
Every POST from Tango includes an HMAC-SHA-256 signature:
X-Tango-Signature: sha256=<hex digest>
The digest is computed over the raw request body using your secret. Verify it like so (Python snippet):
import hmac, hashlib, os, flask, json
SECRET = os.environ["TANGO_WEBHOOK_SECRET"]
app = flask.Flask(__name__)
@app.post("/tango/webhooks")
def recv():
body = flask.request.get_data()
sig = flask.request.headers.get("X-Tango-Signature", "")[7:] # strip "sha256="
if not hmac.compare_digest(
hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest(), sig):
return "Invalid signature", 401
payload = json.loads(body)
# ... handle events ...
return "ok", 200
If you respond with any 2xx status, Tango marks the delivery as successful. Non-2xx responses and time-outs are retried (at-least-once delivery). Current retry limits are error-aware:
- 4xx: up to 2 attempts total
- 5xx: up to 5 attempts total
- network errors/timeouts: up to 7 attempts total
5. Subscription API¶
Base URL: https://tango.dev/api/webhooks/
Auth: API Key – send X-API-KEY: <your-key> (or an OAuth2 bearer token via Authorization: Bearer <token>)
5.0 Discover supported event types¶
Tango exposes a discovery endpoint so clients can validate configurations without hard-coding event types:
GET /webhooks/event-types/
Response:
{
"event_types": [
{
"event_type": "alerts.contract.match",
"description": "A new or modified contract matched a saved filter alert.",
"schema_version": 1
},
{
"event_type": "alerts.opportunity.match",
"description": "A new or modified opportunity matched a saved filter alert.",
"schema_version": 1
},
{
"event_type": "alerts.entity.match",
"description": "A new or modified entity matched a saved filter alert.",
"schema_version": 1
},
{
"event_type": "alerts.grant.match",
"description": "A new or modified grant matched a saved filter alert.",
"schema_version": 1
},
{
"event_type": "alerts.forecast.match",
"description": "A new or modified forecast matched a saved filter alert.",
"schema_version": 1
},
{
"event_type": "alerts.exclusion.match",
"description": "A new, revised, or lifted SAM.gov exclusion matched a saved filter alert.",
"schema_version": 1
},
{
"event_type": "alerts.dibbs_rfq.match",
"description": "A new or modified DIBBS request for quote matched a saved filter alert.",
"schema_version": 1
},
{
"event_type": "alerts.dibbs_rfp.match",
"description": "A new or modified DIBBS solicitation (RFP/IFB) matched a saved filter alert.",
"schema_version": 1
},
{
"event_type": "alerts.ebuy_request.match",
"description": "A new or modified GSA eBuy request visible under your linked contracts matched a saved filter alert.",
"schema_version": 1
},
{
"event_type": "alerts.sled_opportunity.match",
"description": "A new, revised, or newly closed state, local or education solicitation matched a saved filter alert.",
"schema_version": 1
},
{
"event_type": "alerts.contract_appeal.match",
"description": "A new or modified CBCA or ASBCA contract appeal decision matched a saved filter alert.",
"schema_version": 1
},
{
"event_type": "alerts.federal_register.match",
"description": "A new or modified Federal Register document matched a saved filter alert.",
"schema_version": 1
}
]
}
Note: Only some event types may be actively emitted at any given time. If you create an alert for a query type that has no matching new data, you simply won't receive events until matches appear.
5.1 List current alerts / filter subscriptions¶
GET /webhooks/alerts/
Response
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"alert_id": "e4c4…",
"name": "Track DOD IT services > $1M",
"query_type": "contract",
"filters": {"funding_agency": "DOD", "naics": "541512", "obligated_gte": "1000000"},
"frequency": "realtime",
"cron_expression": null,
"status": "active",
"created_at": "2024-06-01T12:00:00Z",
"last_checked_at": "2024-06-01T18:00:00Z",
"match_count": 14
}
]
}
5.3 Update an existing alert¶
PATCH /webhooks/alerts/{alert_id}/
Updatable fields: name, frequency, cron_expression, is_active. filters and query_type are immutable — delete and recreate to change them. A resumed alert (is_active: true) matches from the moment it resumes and skips the paused period (see §1.2).
5.4 Delete an alert¶
DELETE /webhooks/alerts/{alert_id}/
If you delete every alert, you will stop receiving webhooks until you create at least one new one.
Note: You must maintain at least one active alert to receive webhooks.
5.5 Test webhook delivery¶
POST /webhooks/endpoints/test-delivery/
Sends a test webhook to your endpoint to verify connectivity. Returns detailed information about the delivery attempt.
Response (success):
{
"success": true,
"status_code": 200,
"response_time_ms": 145,
"message": "Test delivery successful! Response time: 145ms. Your webhook endpoint is configured correctly."
}
Response (failure):
{
"success": false,
"status_code": 500,
"message": "Test delivery failed with status 500. Please check your endpoint implementation and ensure it returns 2xx status codes.",
"response_body": "<first 1000 chars of response>"
}
5.6 Get sample payload¶
GET /webhooks/endpoints/sample-payload/?event_type=alerts.contract.match
Returns a sample webhook payload for testing your handler implementation.
Notes:
GET /webhooks/endpoints/sample-payload/(no params) returns samples for all supported event types.GET /webhooks/endpoints/sample-payload/?event_type=<event_type>returns a single sample.
Response:
{
"event_type": "alerts.contract.match",
"sample_delivery": {
"timestamp": "2026-05-13T00:36:46.157804",
"events": [
{
"event_type": "alerts.contract.match",
"created_at": "2026-05-13T00:36:46.157779",
"subscription_id": "11111111-2222-3333-4444-555566667777",
"new_ids": ["MATCH_001", "MATCH_002"],
"modified_ids": []
}
]
},
"signature_header": "X-Tango-Signature: sha256=<hmac_sha256_signature>",
"note": "The signature is generated using HMAC-SHA256 with your endpoint's secret key."
}
Preview vs production payload shape
The /webhooks/endpoints/sample-payload/ endpoint emits a simplified preview — no delivery_id, no matches.new[] summary objects, just new_ids / modified_ids. Real deliveries from the dispatcher carry the richer shape documented in §6, including delivery_id, the matches.new / matches.modified arrays with per-record summary fields, and the echoed filters / query_type. Build your receiver against §6, not against this sample. (Server-side reconciliation of the two shapes is tracked separately.)
6. Payload format¶
A single delivery looks like:
{
"timestamp": "2026-05-11T18:20:14Z",
"delivery_id": "8c5e3f6a-...-9b21",
"events": [
{
"event_type": "alerts.contract.match",
"created_at": "2026-05-11T18:20:12.482Z",
"alert_id": "e4c4...-...-...",
"query_type": "contract",
"filters": {"funding_agency": "DOD", "naics": "541512", "obligated_gte": "1000000"},
"matches": {
"new_count": 2,
"modified_count": 0,
"new": [
{"id": "CONT_AWD_...", "piid": "W15QKN24C1234", "total_contract_value": 1450000, "recipient_legal_business_name": "ACME CORP"},
{"id": "CONT_AWD_...", "piid": "FA8773-24-C-...", "total_contract_value": 2300000, "recipient_legal_business_name": "BETA INDUSTRIES LLC"}
],
"modified": [],
"new_ids": ["CONT_AWD_...", "CONT_AWD_..."],
"modified_ids": []
},
"checked_at": "2026-05-11T18:20:12.000Z"
}
]
}
The new_ids / modified_ids arrays are deprecated but still sent; prefer matches.new / matches.modified which carry summary objects per match.
6.1 Per-event fields¶
| Field | Type | Notes |
|---|---|---|
event_type |
string | One of the twelve alerts.<query_type>.match types listed in §1.1 |
alert_id |
UUID | Stable id of the alert that produced this match |
query_type |
string | contract / idv / ota / otidv / opportunity / entity / grant / forecast / exclusion / dibbs_rfq / dibbs_rfp / ebuy_request / sled_opportunity / contract_appeal / federal_register |
filters |
object | Echo of the alert's saved filters |
matches.new |
array | Newly-matched records (summary objects, not full API rows) |
matches.modified |
array | Existing records whose key fields changed |
matches.new_count / matches.modified_count |
int | Counts of the above |
checked_at |
ISO8601 | When the evaluator scanned for matches |
Match summary objects always key the resource's primary id as id — for every query_type, including entity (where the value is the UEI) — plus a small set of high-signal fields that vary by query_type. Pull the full record from the corresponding /api/<resource>/ endpoint when you need more.
Key facts:
- Batched — events are grouped per endpoint per dispatch run.
- At-least-once — retries can cause duplicates; your handler must be idempotent. If you need a de-dupe key, prefer the top-level
delivery_id(UUID, stable across retries of the same dispatch). - Server-side filtered — only events matching your alerts are sent.
Examples¶
These are copy/paste alert payloads for common use cases.
For full how-tos with receiver code and pitfalls, see the recipes.
Track DOD IT-services contracts over $1M¶
{
"name": "DOD IT services > $1M",
"query_type": "contract",
"filters": {
"funding_agency": "DOD",
"naics": "541512",
"obligated_gte": "1000000"
},
"frequency": "realtime"
}
Watch HHS opportunities in NAICS 541512¶
{
"name": "HHS opportunities — IT consulting (541512)",
"query_type": "opportunity",
"filters": {
"agency": "7500",
"naics": "541512"
},
"frequency": "realtime"
}
Match summaries carry id, latest_notice_title, solicitation_number, naics_code, response_deadline and awardee (null unless SAM has posted an award notice for the opportunity); fetch the rest from /api/opportunities/{opportunity_id}/.
Track entity registrations changing in your pipeline¶
{
"name": "Pipeline entities — registration changes",
"query_type": "entity",
"filters": {
"uei": "ABC123XYZ456"
},
"frequency": "daily"
}
(Entities currently take a single uei per alert — for multi-entity tracking, create one alert per UEI.)
Watch cybersecurity grants by CFDA¶
{
"name": "DHS cybersecurity grants",
"query_type": "grant",
"filters": {
"agency": "DHS",
"cfda_number": "97.067"
},
"frequency": "daily"
}
Monitor upcoming Air Force IT acquisitions in the forecast¶
{
"name": "Air Force forecasts — IT (541512 + nearby)",
"query_type": "forecast",
"filters": {
"agency": "AF",
"naics_starts_with": "5415"
},
"frequency": "custom",
"cron_expression": "0 8 * * 1"
}
Follow board decisions on a theme you litigate¶
{
"name": "ASBCA — differing site conditions",
"query_type": "contract_appeal",
"filters": {
"board": "asbca",
"search": "differing site conditions"
},
"frequency": "daily"
}
Match summaries carry id, board, docket_numbers, appellant, decision_date and decision_type; fetch the rest from /api/contract_appeals/{uuid}/.
Follow proposed rules under a CFR part¶
{
"name": "40 CFR 52 — proposed rules open for comment",
"query_type": "federal_register",
"filters": {
"type": "Proposed Rule",
"cfr_title": "40",
"cfr_part": "52",
"comments_open": "true"
},
"frequency": "daily"
}
Match summaries carry id, document_number, publication_date, type, title and comments_close_on; fetch the rest from /api/federal_register/{uuid}/.
Follow eBuy requests under one SIN¶
{
"name": "eBuy — 54151S",
"query_type": "ebuy_request",
"filters": {
"sin": "54151S",
"status": "Open"
},
"frequency": "daily"
}
Match summaries carry id (the eBuy request id), title, request_type, buyer_agency, issue_date and close_date; fetch the rest from /api/ebuy/requests/{rfq_id}/. The alert only matches requests under the contracts linked to the account that owns the webhook endpoint.
8. Best practices on your side¶
- Make your handler idempotent — Tango guarantees at-least-once delivery, so your endpoint may receive the same event more than once. Deduplicate on
delivery_id(see Delivery guarantee). - Return 200 quickly — enqueue the work in your own job queue and respond; do not block processing.
- Test your endpoint first — use the test delivery endpoint to verify connectivity before going live.
- Harden the endpoint — HTTPS only, accept
POSTonly, max-payload 256 KB. - Store the last successful timestamp — helps spot missed deliveries.
- Use exponential back-off when pulling details — Tango's public API has rate limits; stagger follow-up fetches if you receive a large batch.
- Tighten your filters — broad filters generate large match batches; narrow
filtersto what you actually act on. - Monitor endpoint health — Tango uses circuit breaker patterns to protect both systems from cascading failures.
9. Reliability features¶
Delivery guarantee¶
Tango webhooks provide at-least-once delivery. This means:
- Every event that matches one of your alerts will be delivered to your endpoint (assuming it is reachable).
- In rare cases (e.g., a transient failure between sending the HTTP request and recording its success), the same event may be delivered more than once.
Each webhook request includes a stable delivery_id (UUID) that uniquely identifies the dispatch. Use this as an idempotency key on your side to safely deduplicate:
# Example: skip already-processed deliveries
delivery_id = payload["delivery_id"]
if already_processed(delivery_id):
return HttpResponse(status=200)
process(payload)
mark_processed(delivery_id)
Why not exactly-once? Guaranteeing exactly-once delivery over HTTP is not possible without cooperation from the consumer. Tango prevents duplicate internal delivery records via database constraints, but the HTTP POST itself can be retried if acknowledgment is lost. Designing your webhook handler to be idempotent ensures correct behavior regardless of delivery count.
Circuit Breaker Pattern¶
Tango implements automatic circuit breaker protection for webhook endpoints:
- Automatic failure detection — After repeated failures (currently: 5 consecutive failures), the circuit opens
- Cool-down — While open, deliveries are skipped for a short period (currently: 5 minutes)
- Recovery testing — After cool-down, Tango allows a single probe delivery (half-open)
- Automatic recovery — Upon success, normal operation resumes; upon failure, the circuit re-opens (currently: 10 minute cool-down)
- Resource protection — Prevents wasting resources on consistently failing endpoints
This ensures both Tango and your systems remain stable even during outages.
Retry Strategy¶
Failed webhooks are retried with:
- Error-aware retry limits — Retry caps differ by error type:
- Client errors (4xx): up to 2 attempts total
- Server errors (5xx): up to 5 attempts total
- Network errors/timeouts: up to 7 attempts total
10. Troubleshooting¶
| Symptom | Most likely cause | Next steps |
|---|---|---|
| Receive 401 from Tango on the alerts API | Missing/invalid API key | Ensure X-API-KEY: <your-key> header (or Authorization: Bearer <token> for OAuth2) |
| Webhook payloads stop arriving | Circuit breaker activated due to failures | Fix endpoint issues; circuit will auto-recover after cool-down |
| Test delivery fails | Endpoint connectivity issues | Check HTTPS cert, firewall rules, response time |
| Signature mismatch | Using wrong secret or modified body | Re-sync secret; ensure you hash the raw bytes exactly |
| Not receiving expected events | Filter doesn't match upstream data | Run the same filter against /api/<resource>/ first — if it returns nothing there, no events will fire |
| Receiving too many events | Filter too broad | Tighten filters; lower frequency (e.g. daily instead of realtime); split into multiple narrower alerts |
Creating an alert returns 400 Unknown filter key(s) … |
A key the alert's query_type doesn't support, often a typo or a date-range filter without _after / _before |
Use the key exactly as the matching /api/<resource>/ endpoint takes it (§1.2) |
Creating an alert returns 400 No agency found matching '…' |
An agency filter value matches no organization | Check the spelling, or look the agency up with Agency search; at least one pipe-separated value must match |
Creating an alert returns 400 longer than 150 characters or no agency value to match |
The agency value is over 150 characters, or holds no agency (only separators, empty, or null) |
Shorten the list (split it across alerts if needed), or drop the empty filter |
| Alerts arrive hours late after a big data refresh | A single ingestion run changed millions of records, and alerts work through it in slices (§1.1) | Nothing to do; last_checked_at on the alert shows how far it has caught up |
| Nothing arrived for the period an alert was paused | Resuming starts the alert from the moment it resumes | Query the matching /api/<resource>/ endpoint for that period |
| An award alert skipped an award that matches its filters | FPDS last modified the award before the alert was created (§1.2) | Query /api/contracts/ (or /api/idvs/, /api/otas/, /api/otidvs/) with the same filters |
Need help? Email [email protected] with your endpoint URL & the approximate timestamp of the last delivery you saw.
Happy shipping! 🚀
Recipes
Recipes — webhook alerts¶
Filter-based webhook alerts let Tango re-evaluate a saved query on a schedule and POST you the matches as alerts.<query_type>.match events. These recipes are end-to-end how-tos for the most common monitoring use cases.
What's in this section¶
| Recipe | Goal |
|---|---|
| Vendor watchlist | Get notified when specific UEIs win new contracts. |
| Awards by NAICS | Stream new contracts in one or more NAICS codes (with optional agency / set-aside / dollar-floor refinements). |
| Track entity changes | Monitor SAM.gov registration updates — status flips, address moves, NAICS reassignment, socioeconomic re-cert. |
| Grants by agency | Track new grant opportunities at one or more agencies, optionally filtered by CFDA + applicant type. |
| Forecast pipeline | Monitor upcoming opportunities in agency procurement forecasts (HHS, DHS, GSA, COMMERCE, DOE, TREASURY, DOI, DOL, DOT, VA, NRC). |
Prerequisites¶
All recipes assume:
- An active Tango account on any tier (filter alerts are available on Free)
- An API key (
X-API-KEY) — see Authentication - A configured webhook endpoint — Tango provisions the endpoint record for you; ask support if you don't have one yet
- Your shared signing secret stored as
TANGO_WEBHOOK_SECRETserver-side
SDK quick reference¶
Each recipe shows three side-by-side variants. The snippets below are templates — substitute your own name, query_type, and filters. See webhooks-user-guide.md for the full canonical schema, including when the optional endpoint field is required.
# Template — replace the placeholders with your alert spec.
curl -X POST -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/alerts/" \
-d '{
"name": "<your alert name>",
"query_type": "<opportunity|contract|idv|ota|otidv|entity|grant|forecast>",
"filters": { "<filter_name>": "<filter_value>" },
"frequency": "realtime"
}'
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
alert = client.create_webhook_alert(
name="...",
query_type="...",
filters={"...": "..."},
frequency="realtime",
)
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const alert = await client.createWebhookAlert({
name: "...",
query_type: "...",
filters: { "...": "..." },
frequency: "realtime",
});
OR vs AND filter syntax¶
- Within a single filter, OR is
|—naics: "541511|541512"matches either. - Across multiple filters, the API takes the AND —
agency: "DHS"+naics: "541512"matches only DHS opportunities in 541512. - One known exception: the
ueifilter on/api/contracts/is single-value only. Multi-vendor watchlists need one alert per UEI — see the vendor-watchlist recipe.
Test the filter against the API first¶
Always verify your filter returns the data you expect against the underlying API before turning it into an alert. If /api/<resource>/?<filters> returns nothing, the alert will never fire.
# Same filters you'd pass to the alert
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/contracts/?funding_agency=DOD&naics=541512"
Related¶
- Webhooks user guide — protocol details, payload format, retry/circuit-breaker, security
- Stream contract awards in real time — the canonical contract-monitoring how-to with full receiver code
- Search opportunities by NAICS — opportunity filtering walkthrough
Vendor watchlist — track new contracts from specific UEIs¶
Get a webhook every time a vendor on your watchlist wins a new contract.
The 1-line answer¶
Create one filter alert per vendor UEI on query_type=contract. Tango re-evaluates each filter against /api/contracts/ and POSTs alerts.contract.match events when matches appear.
curl -X POST -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/alerts/" \
-d '{
"name": "Vendor watchlist — ACME Corp",
"query_type": "contract",
"filters": { "uei": "ABC123XYZ4567" },
"frequency": "realtime"
}'
That's it. Repeat for each vendor in your watchlist.
One alert per vendor — uei is single-value
The /api/contracts/ API's uei filter is exact-match, single-value only. There is no | multi-value support on this filter today. To watch N vendors, create N alerts — one per UEI.
Future: If multi-value uei lands on /api/contracts/, this recipe collapses to a single alert per watchlist with uei: "UEI1|UEI2|UEI3". Tracked as a backlog enhancement; no ETA.
Step 1 — Verify each UEI returns data¶
For each vendor on your list, prove the filter works against the contracts API first.
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/contracts/?uei=ABC123XYZ4567&ordering=-award_date&limit=5"
If this returns the recent contracts you expect, the alert will fire on new ones. If it returns nothing, double-check the UEI is correct and the vendor has FPDS history.
Step 2 — Create one alert per vendor¶
for uei in ABC123XYZ4567 DEF456UVW7890 GHI789RST0123; do
curl -X POST -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/alerts/" \
-d "{
\"name\": \"Vendor watchlist — $uei\",
\"query_type\": \"contract\",
\"filters\": { \"uei\": \"$uei\" },
\"frequency\": \"realtime\"
}"
done
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
vendors = [
("ACME Corp", "ABC123XYZ4567"),
("Beta Industries", "DEF456UVW7890"),
("Gamma LLC", "GHI789RST0123"),
]
for label, uei in vendors:
alert = client.create_webhook_alert(
name=f"Vendor watchlist — {label}",
query_type="contract",
filters={"uei": uei},
frequency="realtime",
)
print(alert.alert_id, alert.status, label)
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY! });
const vendors = [
{ label: "ACME Corp", uei: "ABC123XYZ4567" },
{ label: "Beta Industries", uei: "DEF456UVW7890" },
{ label: "Gamma LLC", uei: "GHI789RST0123" },
];
for (const { label, uei } of vendors) {
const alert = await client.createWebhookAlert({
name: `Vendor watchlist — ${label}`,
query_type: "contract",
filters: { uei },
frequency: "realtime",
});
console.log(alert.alert_id, alert.status, label);
}
Each alert returns 201 Created on first run, 200 OK on subsequent runs (dedup on query_type + filters).
Step 3 — Receive alerts.contract.match events¶
Tango POSTs a signed JSON batch to your endpoint. The match summary uses the delivered shape (richer than the API's sample-payload echo):
{
"timestamp": "2026-05-12T18:20:14Z",
"delivery_id": "8c5e3f6a-...-9b21",
"events": [
{
"event_type": "alerts.contract.match",
"alert_id": "e4c4...-...-...",
"query_type": "contract",
"filters": { "uei": "ABC123XYZ4567" },
"matches": {
"new_count": 1,
"modified_count": 0,
"new": [
{
"id": "CONT_AWD_W15QKN24C1234_9700_-NONE-_-NONE-",
"piid": "W15QKN24C1234",
"total_contract_value": 2450000,
"recipient_legal_business_name": "ACME CORP"
}
],
"modified": []
},
"checked_at": "2026-05-12T18:20:12.000Z"
}
]
}
Use matches.new and matches.modified for the per-record summary. Pull full contract details from /api/contracts/{id}/ when you need more than the summary fields.
For the full receiver implementation (signature verification, idempotency, fast 2xx, error handling), see Stream contract awards in real time.
Step 4 — Manage the watchlist¶
# List your alerts
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/webhooks/alerts/"
# Pause a vendor without deleting
curl -X PATCH -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/alerts/<alert_id>/" \
-d '{"is_active": false}'
# Remove a vendor permanently
curl -X DELETE -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/webhooks/alerts/<alert_id>/"
name, frequency, cron_expression, and is_active are updatable. query_type and filters (which include uei) are immutable — to change the watched UEI, delete and recreate.
Limitations¶
ueiis single-value on/api/contracts/. One alert per vendor. See the warning at the top of this page.- Tier caps apply. Free tier gets 1 alert total; Small 5; Medium 10; Large 25. A 5-vendor watchlist requires Small or higher.
- Only contracts. This recipe covers
query_type=contract. To watch a vendor's registration changes (DSBS updates, address changes, etc.) instead, usequery_type=entitywithuei— same N-alerts pattern applies. - Following specific contracts (not vendors)? If your watchlist is a fixed set of contracts — not a vendor's whole contract history — use the
keyfilter instead:{"query_type": "contract", "filters": {"key": "<key-1>|<key-2>|<key-3>"}}. One alert, up to 500 contracts per filter. See Following specific resources in the user guide. - No multi-vendor consolidation. All alerts deliver to the same endpoint, but each vendor's matches arrive in their own event (one per alert per dispatch). Group on the receiver side via
alert_idif you need to fan out by vendor in your downstream system.
Related¶
- Webhooks user guide — full protocol reference
- Stream contract awards in real time — receiver implementation, signature verification, common pitfalls
- Contracts API reference — full filter list for
query_type=contract
Awards by NAICS — stream new contracts in your industry codes¶
Get a webhook whenever new contracts land in one or more NAICS codes you care about. Optionally narrow by agency, set-aside, or dollar floor.
The 1-line answer¶
Create a single filter alert on query_type=contract with the NAICS codes you want — use | to OR multiple codes into one alert.
curl -X POST -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/alerts/" \
-d '{
"name": "IT consulting NAICS — 541511 / 541512 / 541519",
"query_type": "contract",
"filters": { "naics": "541511|541512|541519" },
"frequency": "realtime"
}'
That's it. One alert covers any number of NAICS codes.
Step 1 — Verify the filter against the contracts API¶
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/contracts/?naics=541511|541512|541519&ordering=-award_date&limit=5"
If this returns recent contracts in your codes, you're good.
Step 2 — Create the alert¶
curl -X POST -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/alerts/" \
-d '{
"name": "IT consulting NAICS",
"query_type": "contract",
"filters": {
"naics": "541511|541512|541519",
"obligated_gte": "100000"
},
"frequency": "realtime"
}'
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
alert = client.create_webhook_alert(
name="IT consulting NAICS",
query_type="contract",
filters={
"naics": "541511|541512|541519",
"obligated_gte": "100000",
},
frequency="realtime",
)
print(alert.alert_id, alert.status)
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY! });
const alert = await client.createWebhookAlert({
name: "IT consulting NAICS",
query_type: "contract",
filters: {
naics: "541511|541512|541519",
obligated_gte: "100000",
},
frequency: "realtime",
});
console.log(alert.alert_id, alert.status);
Step 3 — Refine with optional filters¶
Stack any of these on top of naics to narrow the alert. All filters are AND-combined.
| Filter | Notes |
|---|---|
funding_agency / awarding_agency |
Vector-backed agency match — "DOD", "Navy", "NAVSEA" all work. Pick the role you actually care about. |
psc |
Six-digit PSC code; same \| multi-value syntax as naics. |
set_aside |
SBA, 8A, WOSB, SDVOSB, etc. Multi-value via \|. |
obligated_gte / obligated_lte |
Dollar thresholds. |
recipient_state / place_of_performance |
Geographic filters. |
ordering |
Doesn't apply to alerts (the evaluator processes new matches as they arrive). |
Example with all the dials turned:
{
"name": "DOD IT services > $1M, small business",
"query_type": "contract",
"filters": {
"funding_agency": "DOD",
"naics": "541511|541512|541519",
"obligated_gte": "1000000",
"set_aside": "SBA|8A|WOSB|SDVOSB"
},
"frequency": "realtime"
}
Step 4 — Receive alerts.contract.match events¶
{
"timestamp": "2026-05-12T18:20:14Z",
"delivery_id": "8c5e3f6a-...-9b21",
"events": [
{
"event_type": "alerts.contract.match",
"alert_id": "e4c4...-...-...",
"query_type": "contract",
"filters": {
"naics": "541511|541512|541519",
"obligated_gte": "100000"
},
"matches": {
"new_count": 3,
"modified_count": 0,
"new": [
{"id": "CONT_AWD_...", "piid": "W15QKN24C1234", "total_contract_value": 1450000, "recipient_legal_business_name": "ACME CORP"},
{"id": "CONT_AWD_...", "piid": "FA8773-24-C-...", "total_contract_value": 230000, "recipient_legal_business_name": "BETA INDUSTRIES LLC"},
{"id": "CONT_AWD_...", "piid": "N00033-24-D-...", "total_contract_value": 5400000, "recipient_legal_business_name": "GAMMA SOLUTIONS INC"}
],
"modified": []
},
"checked_at": "2026-05-12T18:20:12.000Z"
}
]
}
For the full receiver implementation (signature verification, idempotency, fast 2xx), see Stream contract awards in real time.
Limitations¶
- One alert per saved query. If you want different downstream routing per NAICS, create separate alerts and dispatch on
alert_idin your receiver. - Tier caps apply. Free 1 / Small 5 / Medium 10 / Large 25 simultaneous alerts.
- Realtime is "after each ingestion cycle" — FPDS ingest runs roughly twice daily. Realtime delivers within minutes of each refresh, not within minutes of upstream FPDS publication.
naics_starts_withis not a contracts filter. Contracts only support exactnaics. If you need a NAICS prefix, list the codes explicitly with|.
Related¶
- Webhooks user guide — protocol reference
- Stream contract awards in real time — full how-to including receiver code
- Contracts API reference — full filter list
Track entity changes — monitor SAM.gov registration updates¶
Get a webhook when a vendor in your pipeline updates their SAM.gov registration — status flips (Active ↔ Inactive), address moves, NAICS reassignment, socioeconomic re-certification, CAGE updates. These changes can make or break proposal eligibility, set-aside qualification, and partner-vetting workflows, and they're invisible to award-side monitoring until a contract actually posts.
This is the entity-record companion to the vendor watchlist recipe. Vendor-watchlist tracks new contracts for a known UEI (query_type=contract); this recipe tracks changes to the entity record itself (query_type=entity).
The 1-line answer¶
Create a filter alert on query_type=entity. Filters are the same query parameters you'd pass to /api/entities/.
curl -X POST -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/alerts/" \
-d '{
"name": "Pipeline vendor — ACME Corp registration",
"query_type": "entity",
"filters": { "uei": "ABC123XYZ4567" },
"frequency": "daily"
}'
That's it. Tango re-evaluates daily and POSTs alerts.entity.match events when the entity record changes (or first appears, if the UEI is brand-new in SAM).
One alert per vendor — uei is single-value
The /api/entities/ uei filter is exact-match, single-value only (lookup_expr="iexact"). To watch N vendors by UEI, create N alerts — one per UEI. Same constraint as the vendor-watchlist recipe.
For attribute-based watches (e.g. "all small businesses in 541512 in Maryland"), see the second example below — that pattern uses naics / socioeconomic / state instead of uei and only needs one alert.
Step 1 — Verify the filter against the entities API¶
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/entities/?uei=ABC123XYZ4567"
If this returns the entity record you expect, the alert will fire on changes. If it returns nothing, double-check the UEI — Tango only ingests entities present in SAM.gov.
Step 2 — Create the alert (track a portfolio by UEI)¶
for uei in ABC123XYZ4567 DEF456UVW7890 GHI789RST0123; do
curl -X POST -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/alerts/" \
-d "{
\"name\": \"Pipeline entity — $uei\",
\"query_type\": \"entity\",
\"filters\": { \"uei\": \"$uei\" },
\"frequency\": \"daily\"
}"
done
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
pipeline = [
("ACME Corp", "ABC123XYZ4567"),
("Beta Industries", "DEF456UVW7890"),
("Gamma LLC", "GHI789RST0123"),
]
for label, uei in pipeline:
alert = client.create_webhook_alert(
name=f"Pipeline entity — {label}",
query_type="entity",
filters={"uei": uei},
frequency="daily",
)
print(alert.alert_id, alert.status, label)
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY! });
const pipeline = [
{ label: "ACME Corp", uei: "ABC123XYZ4567" },
{ label: "Beta Industries", uei: "DEF456UVW7890" },
{ label: "Gamma LLC", uei: "GHI789RST0123" },
];
for (const { label, uei } of pipeline) {
const alert = await client.createWebhookAlert({
name: `Pipeline entity — ${label}`,
query_type: "entity",
filters: { uei },
frequency: "daily",
});
console.log(alert.alert_id, alert.status, label);
}
daily is the right frequency for entity tracking — SAM.gov refreshes nightly, so re-checking more often just adds deliveries without surfacing more changes. Use realtime only if you specifically need same-cycle delivery once SAM data lands in Tango.
Step 3 — Filter by attribute, not identity¶
Sometimes you want to discover new entities matching a profile, not watch a known list. For example: "alert me when a new small business registers in NAICS 541512 in Maryland." That's one alert with no uei:
curl -X POST -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/alerts/" \
-d '{
"name": "New MD small businesses in 541512",
"query_type": "entity",
"filters": {
"naics": "541512",
"socioeconomic": "A2",
"state": "MD"
},
"frequency": "daily"
}'
alert = client.create_webhook_alert(
name="New MD small businesses in 541512",
query_type="entity",
filters={
"naics": "541512",
"socioeconomic": "A2", # SBA Small Business
"state": "MD",
},
frequency="daily",
)
const alert = await client.createWebhookAlert({
name: "New MD small businesses in 541512",
query_type: "entity",
filters: {
naics: "541512",
socioeconomic: "A2", // SBA Small Business
state: "MD",
},
frequency: "daily",
});
Available EntityFilter keys (see the Entities API reference for the full list):
| Filter | Notes |
|---|---|
uei |
Exact UEI (iexact, single value). |
cage_code (alias cage) |
CAGE code (iexact). Pick one or the other — sending both is rejected. |
name |
Substring match on legal_business_name (min length 2). |
naics |
NAICS code; matches against the entity's registered NAICS list. |
psc |
PSC code; matches against the entity's registered PSC list. |
socioeconomic |
Business-type code (e.g. A2 small business, 27 woman-owned, XX 8(a)). |
state |
Physical-address state code (e.g. MD, VA). |
zip_code |
Physical-address ZIP (exact). |
purpose_of_registration_code |
SAM purpose-of-registration code (e.g. Z2 All Awards). |
total_awards_obligated_gte / _lte |
Lifetime contract+subaward total (USD); IDV/OTA excluded. |
search |
Full-text search across name + UEI + CAGE (min length 2). |
Step 4 — Receive alerts.entity.match events¶
Tango POSTs a signed JSON batch to your endpoint. Entity events split matches into new (first-time-registered UEIs that match your filter) and modified (existing records whose SAM data changed since the last check):
{
"timestamp": "2026-05-12T06:05:14Z",
"delivery_id": "8c5e3f6a-...-9b21",
"events": [
{
"event_type": "alerts.entity.match",
"alert_id": "e4c4...-...-...",
"query_type": "entity",
"filters": { "uei": "ABC123XYZ4567" },
"matches": {
"new_count": 0,
"modified_count": 1,
"new": [],
"modified": [
{
"id": "ABC123XYZ4567",
"legal_business_name": "ACME Corporation",
"registration_status": "Active"
}
]
},
"checked_at": "2026-05-12T06:05:12.000Z"
}
]
}
The summary fields delivered for query_type=entity are: id (the UEI), legal_business_name, and registration_status. To see what changed (which field flipped), pull the full record from /api/entities/{uei}/ (or client.get_entity(uei) / client.getEntity(uei)) and diff against your last-known copy on the receiver side — Tango doesn't ship a field-level diff in the alert payload.
For the full receiver implementation (signature verification, idempotency, fast 2xx, error handling), see Stream contract awards in real time — the receiver shape is identical, only the event type and summary fields change.
Step 5 — Manage the alert¶
# List your alerts
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/webhooks/alerts/"
# Pause a vendor without deleting
curl -X PATCH -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/alerts/<alert_id>/" \
-d '{"is_active": false}'
# Remove a vendor permanently
curl -X DELETE -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/webhooks/alerts/<alert_id>/"
name, frequency, cron_expression, and is_active are updatable. query_type and filters are immutable — to change the watched UEI, delete and recreate.
Troubleshooting¶
| Symptom | Cause | What to do |
|---|---|---|
Alert re-fires every day with the same modified UEI and nothing visibly changed |
SAM bumps modified timestamps on full-record refresh even when no field flipped. The evaluator picks up modified >= last_checked_at regardless of what changed. |
Diff the full record against your last-known copy on the receiver side; if no fields you care about changed, drop the event. |
| First-ever evaluation fires for a UEI you've watched before in another tool | The matches.new bucket on the first run reflects records created in Tango since the lookback cutoff (default 24h on first eval) — not "first time you've ever seen this entity." A UEI freshly ingested into Tango lands in new even if it's been in SAM for years. |
Treat matches.new on a brand-new alert as "Tango first saw this," not "SAM first saw this." For the latter, check entity.created_date from the full record. |
| Status flip happens twice within one check window (Active → Inactive → Active) | The evaluator sees the net state at evaluation time, not the intermediate flip. Two flips in 24h with frequency=daily look like no change at all. |
If you need every transition, use frequency=realtime (5-minute eval cadence) and accept the higher delivery volume. |
cage and cage_code rejected together |
The filter accepts cage as an alias for cage_code; sending both at once is rejected with 400. |
Pick one. They filter the same column. |
state=MD returns nothing for a vendor you know is in Maryland |
state filters on the physical address state, not the mailing address. Some entities register the two separately. |
Verify with curl ".../api/entities/?uei=<uei>" and check physical_address.state_or_province_code. |
| Alert never fires even though SAM clearly updated | Check last_checked_at on the alert (GET /api/webhooks/alerts/<alert_id>/). If it's recent but no events were delivered, the SAM ingestion pipeline may not have refreshed yet — Tango ingests SAM nightly. |
Wait one full ingestion cycle, then re-check. If still nothing, ping support with the alert_id. |
Limitations¶
ueiis single-value. One alert per vendor when watching by UEI. See the warning at the top.- No field-level diff in the payload. You get the post-change summary; compute the diff on your side from the full record.
- Tier caps apply. Free 1 / Small 5 / Medium 10 / Large 25 simultaneous alerts across all alert types combined.
- Daily is usually the right cadence. SAM refreshes nightly.
realtimeworks but won't surface changes faster than the underlying data refresh.
Related¶
- Vendor watchlist — track new contracts for known UEIs (the contract-side complement to this recipe)
- Webhooks user guide — protocol details, payload format, retry/circuit-breaker, security
- Entities API reference — full filter list for
query_type=entity - Recipes index
Grants by agency — track new grant opportunities¶
Get a webhook when new grant opportunities post at agencies you care about, optionally narrowed by CFDA number, applicant type, or response window.
The 1-line answer¶
Create a filter alert on query_type=grant. Filters are the same query parameters you'd pass to /api/grants/.
curl -X POST -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/alerts/" \
-d '{
"name": "DHS cybersecurity grants",
"query_type": "grant",
"filters": {
"agency": "DHS",
"cfda_number": "97.067"
},
"frequency": "daily"
}'
That's it. Tango re-evaluates daily and POSTs alerts.grant.match events when new grants match.
Step 1 — Verify the filter against the grants API¶
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/grants/?agency=DHS&cfda_number=97.067&ordering=-posted_date&limit=5"
If this returns the grants you expect, the alert will fire when new ones land.
Step 2 — Create the alert¶
curl -X POST -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/alerts/" \
-d '{
"name": "DHS cybersecurity grants — open in next 60d",
"query_type": "grant",
"filters": {
"agency": "DHS",
"cfda_number": "97.067",
"applicant_types": "00",
"response_date_after": "2026-05-12",
"response_date_before": "2026-07-12"
},
"frequency": "daily"
}'
import os
from datetime import date, timedelta
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
today = date.today()
sixty_days = today + timedelta(days=60)
alert = client.create_webhook_alert(
name="DHS cybersecurity grants — open in next 60d",
query_type="grant",
filters={
"agency": "DHS",
"cfda_number": "97.067",
"applicant_types": "00", # State governments
"response_date_after": today.isoformat(),
"response_date_before": sixty_days.isoformat(),
},
frequency="daily",
)
print(alert.alert_id, alert.status)
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY! });
const today = new Date().toISOString().slice(0, 10);
const sixtyDays = new Date(Date.now() + 60 * 24 * 3600 * 1000)
.toISOString()
.slice(0, 10);
const alert = await client.createWebhookAlert({
name: "DHS cybersecurity grants — open in next 60d",
query_type: "grant",
filters: {
agency: "DHS",
cfda_number: "97.067",
applicant_types: "00", // State governments
response_date_after: today,
response_date_before: sixtyDays,
},
frequency: "daily",
});
console.log(alert.alert_id, alert.status);
Step 3 — Available grant filters¶
These are the keys accepted by /api/grants/ and therefore by query_type=grant alerts.
| Filter | Notes |
|---|---|
search |
Full-text search across title and description (vector-backed). |
agency |
Substring match on agency abbreviation. "DHS" matches DHS-FEMA, DHS-CISA, etc. |
opportunity_number |
Exact opportunity number. |
cfda_number |
CFDA number, substring match (e.g. "97.067"). |
status |
Opportunity status (case-insensitive choice). |
applicant_types |
Eligibility / applicant type code (case-insensitive choice). |
funding_categories |
Funding category codes. |
funding_instruments |
Funding instrument codes. |
posted_date_after / posted_date_before |
Posted date range (YYYY-MM-DD). |
response_date_after / response_date_before |
Application deadline range (YYYY-MM-DD). |
Filter names matter
The filter is cfda_number (not cfda) and applicant_types (not eligibility). Using the wrong key returns 400.
For full filter docs, see the Grants API reference.
Step 4 — Receive alerts.grant.match events¶
{
"timestamp": "2026-05-12T08:00:14Z",
"delivery_id": "8c5e3f6a-...-9b21",
"events": [
{
"event_type": "alerts.grant.match",
"alert_id": "e4c4...-...-...",
"query_type": "grant",
"filters": {
"agency": "DHS",
"cfda_number": "97.067"
},
"matches": {
"new_count": 2,
"modified_count": 1,
"new": [
{"id": "...", "opportunity_number": "DHS-26-CISA-067-001", "title": "Cybersecurity Grant Program FY26", "agency_code": "DHS-CISA"},
{"id": "...", "opportunity_number": "DHS-26-CISA-067-002", "title": "State and Local Cybersecurity Pilot", "agency_code": "DHS-CISA"}
],
"modified": [
{"id": "...", "opportunity_number": "DHS-25-CISA-067-014", "title": "...", "agency_code": "DHS-CISA"}
]
},
"checked_at": "2026-05-12T08:00:12.000Z"
}
]
}
Pull full grant details from /api/grants/{grant_id}/ (or via the SDK's client.get_grant(grant_id) / client.getGrant(grantId)) when you need more than the summary fields.
Limitations¶
- No multi-value
agencyshortcut.agencyis a substring match; if you need to watch DHS and HHS, create two alerts (or usesearch=with a broader term). - Polling-friendly cadences. Grants don't post every minute —
daily(or acustomweekly cron on Small+) is usually plenty.realtimeworks but most agencies refresh on a daily cycle anyway. - Tier caps apply. Free 1 / Small 5 / Medium 10 / Large 25 simultaneous alerts.
Related¶
- Webhooks user guide — protocol reference
- Grants API reference — full filter list
- Recipes index
Forecast pipeline — monitor upcoming agency opportunities¶
Get a webhook when new procurement forecasts post that match your industry / agency / fiscal-year window. Forecasts are agency-published lists of opportunities they plan to release — useful for capture planning months ahead of the actual solicitation.
Tango ingests forecasts from HHS, DHS, GSA, COMMERCE, DOE, TREASURY, DOI, DOL, DOT, VA, and NRC. See the Forecasts data dictionary for the current list.
The 1-line answer¶
Create a filter alert on query_type=forecast. Filters are the same query parameters you'd pass to /api/forecasts/.
curl -X POST -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/alerts/" \
-d '{
"name": "VA IT services forecast — FY26",
"query_type": "forecast",
"filters": {
"agency": "VA",
"naics_starts_with": "5415",
"fiscal_year": "2026"
},
"frequency": "custom",
"cron_expression": "0 8 * * 1"
}'
That's it. The 0 8 * * 1 cron re-evaluates every Monday at 08:00 UTC and POSTs alerts.forecast.match events when new forecasts match.
Step 1 — Verify the filter against the forecasts API¶
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.dev/api/forecasts/?agency=VA&naics_starts_with=5415&fiscal_year=2026&ordering=anticipated_award_date&limit=5"
If this returns the forecasts you expect, the alert will fire when new ones land.
Step 2 — Create the alert¶
curl -X POST -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.dev/api/webhooks/alerts/" \
-d '{
"name": "VA + DHS — IT consulting forecasts FY26",
"query_type": "forecast",
"filters": {
"agency": "VA",
"naics_starts_with": "5415",
"fiscal_year": "2026",
"award_date_after": "2026-04-01",
"award_date_before": "2026-12-31"
},
"frequency": "custom",
"cron_expression": "0 8 * * 1"
}'
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
alert = client.create_webhook_alert(
name="VA — IT consulting forecasts FY26",
query_type="forecast",
filters={
"agency": "VA",
"naics_starts_with": "5415", # NAICS prefix — matches 541511, 541512, 541519, etc.
"fiscal_year": "2026",
"award_date_after": "2026-04-01",
"award_date_before": "2026-12-31",
},
frequency="custom",
cron_expression="0 8 * * 1", # every Monday at 08:00 UTC
)
print(alert.alert_id, alert.status)
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY! });
const alert = await client.createWebhookAlert({
name: "VA — IT consulting forecasts FY26",
query_type: "forecast",
filters: {
agency: "VA",
naics_starts_with: "5415", // NAICS prefix
fiscal_year: "2026",
award_date_after: "2026-04-01",
award_date_before: "2026-12-31",
},
frequency: "custom",
cron_expression: "0 8 * * 1", // every Monday at 08:00 UTC
});
console.log(alert.alert_id, alert.status);
Step 3 — Available forecast filters¶
These are the keys accepted by /api/forecasts/ and therefore by query_type=forecast alerts.
| Filter | Notes |
|---|---|
agency |
Agency acronym (e.g. VA, DHS, HHS, GSA). |
source_system |
Source system identifier — useful when an agency publishes forecasts via multiple feeds. |
naics_code |
Exact NAICS match. |
naics_starts_with |
NAICS prefix match (e.g. 5415 for any 5415xx). |
fiscal_year |
Exact fiscal year. |
fiscal_year_gte / fiscal_year_lte |
Fiscal year range. |
award_date_after / award_date_before |
Anticipated award date range (YYYY-MM-DD). |
modified_after / modified_before |
Date the forecast was last modified in Tango. |
status |
Forecast status (case-insensitive partial match). |
title |
Substring match on title. |
search |
Full-text search over title/description (vector-backed). |
Filter names matter
NAICS filtering uses naics_code (exact) or naics_starts_with (prefix) — there is no plain naics filter on /api/forecasts/. There is also no set_aside filter on forecasts — that's a SAM/FPDS-side concept not present in agency forecast publications.
For full filter docs, see the Forecasts API reference.
Step 4 — Receive alerts.forecast.match events¶
{
"timestamp": "2026-05-12T08:00:14Z",
"delivery_id": "8c5e3f6a-...-9b21",
"events": [
{
"event_type": "alerts.forecast.match",
"alert_id": "e4c4...-...-...",
"query_type": "forecast",
"filters": {
"agency": "VA",
"naics_starts_with": "5415",
"fiscal_year": "2026"
},
"matches": {
"new_count": 2,
"modified_count": 0,
"new": [
{"id": "...", "title": "VistA Modernization Phase IV", "source_system": "VA", "anticipated_award_date": "2026-09-30"},
{"id": "...", "title": "Health Data Lake Refresh", "source_system": "VA", "anticipated_award_date": "2026-08-15"}
],
"modified": []
},
"checked_at": "2026-05-12T08:00:12.000Z"
}
]
}
Pull full forecast details from /api/forecasts/{id}/ (or via client.get_forecast(id) / client.getForecast(id)) when you need more than the summary fields.
Limitations¶
- No
set_asidefilter on forecasts. Forecasts don't expose a set-aside dimension; that's an FPDS/SAM concept that only shows up downstream when the actual opportunity / award posts. - Forecast cadences are slow. Most agencies refresh forecasts monthly or quarterly. A weekly
customcron (e.g.0 8 * * 1) is typically the right cadence;realtimeworks but won't actually fire more often than the underlying refresh. Note:customcron requires Small tier or above — on Free, usedaily. - Coverage varies by agency. Only the 11 source agencies listed above are ingested today. Filtering on
agency=DOJreturns nothing because Tango doesn't ingest DOJ forecasts. - Tier caps apply. Free 1 / Small 5 / Medium 10 / Large 25 simultaneous alerts.
Related¶
- Webhooks user guide — protocol reference
- Forecasts API reference — full filter list
- Forecasts data dictionary — field-by-field reference, including which agencies are tracked
- Recipes index
Changelog¶
This is the most up-to-date change log for Tango and the Tango API.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]¶
Added¶
Changed¶
Deprecated¶
Breaking¶
Fixed¶
Internal / Infra¶
[5.9.1-7] - 2026-10-12¶
Fixed¶
- A list's
countno longer lags the data behind it — a count cached before new records loaded could be served for up to a day, so a search could return rows undercount: 0. Counts are now recomputed once a load lands.
[5.9.1-5] - 2026-10-12¶
Fixed¶
- More complete entity obligation totals —
federal_obligations.total.awards_obligatedcounts all years, including awards before FY2008 or without a funding agency, and thetotal_awards_obligated_gte/_ltefilters follow;total.subawards_countdrops subawards the source has removed.
[5.9.1-1] - 2026-10-10¶
Added¶
- eBuy attachments can return their extracted text — ask for it by name with
?shape=attachments(extracted_text)on/api/ebuy/requests/. It is never part of a default response orattachments(*), and an attachment with no text omits the key.
Changed¶
- IDV
gsa_elibraryno longer returnsextracted_textorsource_dataunless named —gsa_elibrary(*),gsa_elibrary()and the default IDV list and detail shapes drop both keys; ask for them withgsa_elibrary(extracted_text).
Fixed¶
- Intermittent 502 Bad Gateway responses — about 1 in 2,300 requests got a 502 from our load balancer before reaching the API; a connection-reuse mismatch between the load balancer and the API servers is fixed.
[5.9.0-6] - 2026-10-08¶
Fixed¶
- Account usage includes today — the Overview and Usage pages count today's requests as they happen instead of leaving them out until the next night's rollup.
[5.9.0-5] - 2026-10-07¶
Fixed¶
- With
search, an agency filter scopes to the organizationmeta.resolved_filtersnames — a token matching both an inactive and a current organization could filter by one while reporting the other.
[5.9.0-3] - 2026-10-07¶
Fixed¶
- More agency names resolve to the agency you meant.
Air Force,Coast Guard,ArmyandStateresolved to an office or an unrelated body;VA - U.S. Department of Veterans AffairsandOffice — Departmentvalues returned a400. agency=ATFreturns ATF's Federal Register documents.- Attachment counts on new opportunities —
meta.attachments_count,meta.files_countandmeta.links_countare set when an opportunity first syncs, instead of stayingnulluntil one of its attachments next changes.
[5.9.0-2] - 2026-10-06¶
Fixed¶
- A few opportunities were missing attachments — an attachment linked to a notice but not to the notice's opportunity was left off that opportunity's
attachmentslist and out of itsmetacounts. About two dozen attachments on 11 opportunities are restored.
[5.9.0-1] - 2026-10-06¶
Added¶
- Files and links are counted separately —
meta.files_count/meta.links_counton opportunities andfile_count/link_counton notices count the files attached to a notice and the links listed on it. Any other attachment type counts only in the total, so the two need not sum to it.
meta.resolved_filtersreports how each agency value matched. Every resolved entry carriesmatched_by:key,code,name,aliasorfuzzy. Afuzzymatch is the one to double-check against the organization name echoed beside it.
Changed¶
- Agency filters no longer guess at a name they cannot place. A value in
agency,awarding_agencyorfunding_agencythat is not a code, an abbreviation or a recognizable form of an organization's name used to resolve to the closest-looking organization and return its records. It now resolves to nothing: a400on most endpoints, and a text match on the endpoints that fall back to one. Checkmeta.resolved_filtersif a saved query built on a loose name stops matching, and use a code or the organization's key for a stable reference.
Fixed¶
- An agency's official name returns its records. 25 agencies, including the Agricultural Marketing Service and the Food and Nutrition Service, returned nothing when filtered by full name while their abbreviation worked.
- An agency name copied from a response resolves to that agency.
US CUSTOMS AND BORDER PROTECTION, asagency_nameprints it, resolved to an unrelated office. - An agency's abbreviation resolves to the agency.
NCD,PCLOB,ATFand several others resolved to an unrelated office.
[5.8.1-3] - 2026-10-06¶
Fixed¶
- Agency filters find more agencies by name and code. A department's everyday name (
Department of Veterans Affairs, and likewise Defense, State and Homeland Security) now works on everyagencyfilter, and/api/federal_register/?agency=now finds the FAA and the Army.
[5.8.1-1] - 2026-10-05¶
Changed¶
- Searching for an ID that matches nothing now returns no results —
?search=on/api/opportunities/and/api/notices/with a single award number or ID that appears nowhere used to return ten unrelated opportunities.
- Search results that contain your search terms now come before similar-but-not-matching results —
?search=on/api/opportunities/and/api/notices/lists matches first, including those found only in attachments.
Fixed¶
/api/entities/stateandzip_codefilters return all matching entities — they previously missed most entities because addresses are stored in more than one key format;stateinput is now case-insensitive
[5.8.0-4] - 2026-10-05¶
Added¶
- Budget accounts add
request_onlyrows — current-law accounts that appear in the President's Budget but have no Treasury data, such as NIH's request; obligations are null. Filter withaccount_category=request_only. - Budget accounts add
proposedrows — recurring President's Budget requests for programs Congress never created, such as the EV Fund; obligations are null. For a current-law request total, filteraccount_category__in=budgetary,request_only.
Fixed¶
- Budget account sorts put empty values last — every
/api/budget/accounts/?ordering=lists accounts with no value last, and pages no longer repeat or skip rows; the/recipients/and entity/budget-flows/drill-downs list rows with no contract spend last. - Invalid
?ordering=on/api/budget/accounts/now returns 400 — an unsupported field used to be silently ignored; the error lists the fields you can sort by. - Malformed
funding_organization_idon budget/recipients/now returns 400 — a value that isn't a UUID used to return a 500.
[5.8.0-2] - 2026-10-02¶
Fixed¶
X-Results-CountTypeno longer reportsapproximatefor exact counts on repeated requests. A list response served from cache always saidapproximate, so an exact count (entities, vehicles, recipient-filtered awards) read as an estimate the second time it was requested. A cached response now carries the count type computed when it was first served.
[5.8.0-1] - 2026-10-02¶
Added¶
- Vehicles report how many companies hold them —
/api/vehicles/list and detail addholder_count(distinct companies holding one of the vehicle's contracts) andorder_winner_count(distinct companies that have won a task order under it) to the default shape. - Budget accounts say which period their figures run through:
data_through_period— the latest fiscal year is partial until it reaches 12. It is in the default shape of/api/budget/accounts/and filterable (data_through_period,__gte,__lte,__isnull).
Deprecated¶
- Vehicle
awardee_countis deprecated — it is the same value asorder_winner_countand will be removed in the next major API version.
Fixed¶
- Alerts created during scheduled maintenance start paused and resume on their own — while a maintenance window is in effect, a new alert comes back
paused(is_active: false) instead of going live and delivering matches from the maintenance work; it resumes when the window closes. - Award alerts report only awards the government changed after you created the alert — contract, IDV, OTA and OT IDV alerts match an award only when its FPDS last-modified date is on or after the alert's creation, so reprocessing on our side never alerts you about older awards.
- A new alert's first delivery never reaches back into a maintenance window — the first check still covers up to the previous 24 hours, but starts no earlier than the end of the latest maintenance window affecting that alert type.
[5.7.0-1] - 2026-10-02¶
Added¶
- Budget accounts report source-data anomalies —
/api/budget/accounts/addssource_anomaliesto the default shape, naming each problem found in the source data (such as capped File C contracts). - Budget accounts carry
account_category—budgetaryorcredit_financing, filterable withaccount_category; credit financing accounts are served but left out of organization budget totals.
Changed¶
- Budget account lists sort accounts without enacted budget authority last — the default ordering puts null
enacted_baafter every other account in an FY and breaks ties by id, so pages are stable.
[5.6.0-1] - 2026-10-01¶
Added¶
- Budget responses carry data-as-of headers.
/api/budget/accounts/responses includeX-Tango-Data-As-Of(the File A fiscal year and period, e.g.2026-P10) andX-Tango-Data-Published-At; both are omitted until a validated publication is live.
[5.5.0-8] - 2026-10-01¶
Fixed¶
- Sorting Federal Register documents newest-first by
comments_close_onoreffective_onputs undated documents last, soordering=-comments_close_onreturns the latest comment deadlines.
[5.5.0-4] - 2026-09-30¶
Changed¶
- Sign-in codes now stay valid for 10 minutes, up from 3, so a code that a mail filter holds up still works when it arrives; the email says how long it lasts.
Fixed¶
- The "Account Already Exists" email now links to the sign-in page instead of a password reset, since Tango accounts have no passwords.
[5.5.0-3] - 2026-09-30¶
Added¶
- The OpenAPI schema marks tier gates. A gated operation or filter parameter carries
x-tango-access-level, the lowest access level that can use it, and an operation with tier-gatedshapefields carriesx-tango-shape-access-level.
[5.5.0-1] - 2026-09-29¶
Added¶
- Award details on opportunities — where SAM has posted an award notice,
/api/opportunities/can returnawarded,award_date,award_amount,awardeeandawardee_uei, filterable with?awarded=and?awardee_uei=. Older records fill in gradually as they're reprocessed.
- Awards linked to solicitations — when SAM's award notice references its solicitation, the award carries
solicitation_opportunity_idand the solicitation getsaward_countplus anawardsexpand of up to ten recent awards. Awards posted without that reference stay unlinked.
- Award details on notices —
/api/notices/acceptsaward_date,award_amount,awardeeandawardee_ueiinshape, and opportunity alert summaries includeawardeewhen present.
[5.4.0-2] - 2026-09-29¶
Fixed¶
- Resolving "Department of the Army", "Department of the Navy" or "Department of the Air Force" returns the military department — the organization resolver used to return an unrelated USDA office for the Army and only DoD or a Navy sub-office for the other two.
[5.4.0-1] - 2026-09-29¶
Changed¶
- Creating an alert whose agency matches nothing now returns a 400 — an agency filter where no
|-separated term matches an organization used to be saved and never fire. Partial matches are still accepted, as are forecast, grant, exclusion and eBuy alerts, which fall back to the agency text.
- Alert agency values that can never match now return a 400 — a value over 150 characters, or one with no agency in it (
|||, an empty string), used to be saved and never fire. Existing alerts are re-checked only when their filters change.
- Alert filters with a key the alert type doesn't support now return a 400 that names the key — an unrecognized key next to a valid one used to be dropped silently, so the alert matched more than you asked for.
- A resumed alert starts matching from the moment you resume it — records created or changed while it was paused are no longer delivered on resume. Query the matching API endpoint directly if you need that period.
- Tango names itself consistently in search results — every page declares the site name
Tango by MakeGov; the home page title now leads with it, Terms drops a redundant "for Tango", and the API reference at/api/is titledAPI reference | Tango by MakeGov.
Fixed¶
- An agency abbreviation shared with a retired duplicate now covers both —
agency=FNSresolved to a retired FNS record, so Federal Register returned nothing and contract filters missed the live agency; it now resolves to the live agency and includes the retired one's records.
- Alerts keep working through very large data updates — a batch of millions of changed records is now matched in chunks over the following evaluation cycles instead of failing until the alert was paused. Matches arrive late rather than never, and one large batch can arrive as several deliveries.
- Filtering awards by recipient name and the entity
past_performanceexpansion pick up new award data again, within about half a day. They had stopped updating on 2026-09-19. Entity candidates fromPOST /api/resolve/now track registration changes within about a day.
[5.3.1-1] - 2026-09-28¶
Added¶
- SBIR topics carry their DoD DSIP cycle —
/api/sbir/topics/returnscycle_name(e.g.DOD_SBIR_2026_P1_CBZ) and filters on it exactly with?cycle_name=; DoD topics link to their DSIP solicitation by cycle, not only by solicitation number.
Changed¶
- Sign-in, verification and usage-alert emails show the sender as Tango by MakeGov and address you by name — usage alerts now come from
[email protected](previously[email protected]), so update allowlists keyed on the old address; other emails keep theirs.
[5.3.0-4] - 2026-09-28¶
Changed¶
- Ordering
/api/vehicles/bylatest_award_dateis much faster — the value is precomputed per vehicle instead of worked out from every vehicle's IDVs on each request.
Fixed¶
- A vehicle's
latest_award_dateis no longer null when one of its IDVs has no award date — it is the newest award date among the IDVs that have one. - Paging through
/api/vehicles/no longer repeats or skips vehicles — vehicles that tie on theorderingfield come back in a fixed order, so each one appears on exactly one page.
[5.3.0-2] - 2026-09-28¶
Fixed¶
agency=on SBIR topics, exclusions and budget accounts now reports what it resolved to — list responses carrymeta.resolved_filters(the organization each token matched) andmeta.warnings, like every other endpoint with anagency=filter.agency=Corps of Engineersnow resolves to the Army Corps of Engineers instead of all of DoD — an agency name no longer loses to a department that only mentions it in its description, on every endpoint that takesagency/awarding_agency/funding_agency.
[5.3.0-1] - 2026-09-27¶
Added¶
- Filter eBuy requests and IT Dashboard investments by agency —
/api/ebuy/requests/?agency=and/api/itdashboard/?agency=match a department or agency and everything beneath it, likeagencyon awards, grants and forecasts;|combines several, and an unrecognized value falls back to the agency text. - Filter budget accounts by agency —
/api/budget/accounts/?agency=accepts a name, abbreviation, or code and returns every account across that department's bureaus;?shape=organization(*)returns the resolved organization. - Filter SBIR topics by agency, including its sub-agencies —
/api/sbir/topics/?agency=now rolls up the agency hierarchy (HHSincludes NIH topics); topics and solicitations also expose anorganization(...)expand. A value is matched as text only when it names no agency (or against topics not linked to one), so a short fragment likeDOorNSthat happens to resolve to some organization no longer substring-matches: pass a full abbreviation such asDODorNSF. - Filter exclusions by agency, including every component under it —
/api/exclusions/?agency=DODnow returns exclusions issued by DoD and its components (Navy, Army, DLA, …), and exclusion alerts accept the sameagencyfilter. Each exclusion can also return its issuing agency with?shape=...,organization(*). - Filter Federal Register documents by agency —
/api/federal_register/?agency=EPAaccepts the same agency names, abbreviations, codes and organization keys as other endpoints; a department such asDOTincludes documents from its sub-agencies.
Changed¶
agencyalso matches records not yet linked to an organization — on grants, forecasts, protests, eBuy, IT Dashboard, SBIR topics, exclusions and budget accounts,agency=now also returns records whose agency couldn't be linked to an organization when their agency text matches the value, so a value likeJudiciaryorCorps of Engineersfinds those records instead of returning nothing.- Attachment text comes from the search index —
attachments(extracted_text)is read from the index behind attachment search; attachments with under 20 characters of text, or not linked to an opportunity, no longer return it.
Fixed¶
- Protests filed under the Government Printing Office's old name are linked to the Government Publishing Office, so
/api/protests/?agency=GPOincludes them.
[5.2.1-2] - 2026-09-26¶
Fixed¶
- Contract searches for uncommon terms no longer time out — searches sorted oldest-first, or narrowed by award date or fiscal year, now return in well under two seconds instead of failing with a 504.
[5.2.1-1] - 2026-09-25¶
Fixed¶
- Keyword search now finds closed opportunities —
/api/opportunities/?search=…&active=false, andsearchsent alongsidesolicitation_number,notice_typeoraward_number, used to return an empty page; it now searches closed opportunities as documented. - Notice search honors
active—/api/notices/?search=returns every notice of a matching opportunity, filtered byactivewhen you send it, including an open notice on an otherwise closed opportunity; withoutactiveit now includes closed notices. - Narrowly filtered searches no longer miss matches —
?search=combined with a selective filter such assolicitation_numberor a small agency, and opportunity search alerts, now find every match rather than only those in the top 1,000 overall. - Search reports an outage instead of an empty result — if the search service can't answer,
?search=on/api/opportunities/and/api/notices/returns503with aRetry-Afterheader rather than zero results.
[5.2.0-1] - 2026-09-24¶
Added¶
- Federal Register documents are now an API resource —
/api/federal_register/serves rules, proposed rules, notices and presidential documents since 1994, filterable by CFR citation, agency, RIN, type, dates and open comment period, with full-text search. - Federal Register alerts — create an alert with
query_type=federal_registerto be notified (alerts.federal_register.match) when a matching document is published or updated. - Check your eBuy access with
/api/ebuy/access/— returns whether/api/ebuy/requests/is enabled for your account, why not if it isn't, and which of your contracts it covers. It counts against your normal rate limits, not the eBuy daily budget.
Changed¶
- eBuy request search also matches attachment text now —
/api/ebuy/requests/?search=finds a request through text extracted from its attachments (SOWs, pricing schedules, etc.); a match on the title or description still ranks first.
Fixed¶
- eBuy alerts can be created now —
/api/webhooks/alerts/and/api/webhooks/subscriptions/rejectedquery_type: "ebuy_request"as "not a valid choice"; both accept it and deliveralerts.ebuy_request.match.
[5.1.0-6] - 2026-09-24¶
Added¶
- More multiple-award BPAs show up as vehicles — BPAs that FPDS reports without a solicitation number, such as NOAA's NMITS MAS BPAs, are now grouped into vehicles; they carry
is_synthetic_solicitation: trueand a nullsolicitation_identifier.
[5.1.0-2] - 2026-09-23¶
Fixed¶
- Contract lists filtered by a smaller or retired agency load reliably —
/api/contracts/?awarding_agency=…or?funding_agency=…sorted by award date could time out for agencies with relatively few contracts, or whose most recent contract is years old; those requests, including later pages, now return quickly.
[5.1.0-1] - 2026-09-23¶
Added¶
- eBuy requests are now a first-class endpoint —
/api/ebuy/requests/— search, filter and page the GSA eBuy requests your linked Schedule contract entitles you to, and download their attachments. Requires a paid plan and separate MakeGov authorization; reach out if interested.
[5.0.1-2] - 2026-09-22¶
Fixed¶
- New accounts created with an emailed sign-in code get their plan right away — accounts made this way since 4 September were left without a plan assigned and never received a welcome email; the plan is now granted as soon as the code is confirmed, and the affected accounts have been corrected.
- Plan upgrades apply immediately — an API request sent while an upgrade was being applied could leave your account throttled at your previous plan's limits for up to five minutes; the new limits now take effect as soon as the change completes.
- Alert limits apply to your whole account — your plan's alert allowance now counts alerts across all of your webhook endpoints, paused alerts included, instead of per endpoint; existing alerts are kept, but new ones are refused once the account is at its limit.
[5.0.0-1] - 2026-09-21¶
Changed¶
- Awards are built on a rebuilt pipeline — all four award types, their transactions and their search index now come from one chain that tracks amended and retracted actions, records where each value came from, and returns lists, filters and searches faster.
Breaking¶
- 🚨
employment_eligibility_verificationandservice_contract_inventoryare booleans on contract and IDV responses, not the strings"Y"and"N". Code that compares either field against"Y"has to switch to a boolean test.
[4.27.0-1] - 2026-09-20¶
Added¶
- Attachment document roles —
attachments(doc_role)on/api/opportunities/and/api/notices/labels each documentrequirement,instructions,pricing,terms,referenceorunknown, withdoc_role_altfor a close second. Pro+, named explicitly (not inattachments(*)); see the API reference.
Breaking¶
/api/vehicles/?shape=opportunity(attachments(extracted_text))now returns 400. Attachment text is a Pro+ field everywhere it is served, but the vehicles endpoint applies no tier check, so this path returned document bodies to any caller. Request it through/api/opportunities/or/api/notices/, where the tier gate applies.attachments(*)no longer returnsextracted_text— name it (attachments(extracted_text)) to keep receiving document bodies on/api/opportunities/,/api/notices/and/api/sled/opportunities/. Every other attachment field is unchanged.
Fixed¶
- Attachment bodies are no longer reachable on a Free plan —
attachments(*)on/api/notices/returnedextracted_textthat the named spelling correctly withheld. /api/notices/list responses withattachments(...)are faster — each notice on a page was fetching its own attachments instead of riding the page's single query.
[4.26.0-1] - 2026-09-18¶
Added¶
- Contract appeal decisions are now an API resource —
/api/contract_appeals/serves Civilian Board of Contract Appeals (CBCA) and Armed Services Board of Contract Appeals (ASBCA) decisions, filterable by board, docket number, appellant, judge, decision type and date, with full-text search. - The full text of each appeal decision is available on Enterprise plans — request
decision_textinshape; other plans get the rest of the record plus an upgrade hint, and can still search the text. - Contract appeal alerts — create an alert with
query_type=contract_appealto be notified (alerts.contract_appeal.match) when a matching decision is published or updated.
[4.25.3-6] - 2026-09-17¶
Breaking¶
- Court of Federal Claims protests now come from the court's own ECF/PACER system. CoFC protests loaded from the earlier CourtListener feed (mostly cases filed before 2026) are removed with their documents, and CoFC protest
ids change.
[4.25.3-3] - 2026-09-13¶
Changed¶
first_seen_atandlast_seen_aton state and local opportunities now record when the portal was observed, not when we processed it. Some values move earlier, by up to about 11 days; if you poll withfirst_seen_after, overlap your window by a few days.
Fixed¶
- Daily and premium quotas reset reliably at midnight UTC. Some accounts could previously be rate limited before reaching their full daily allowance.
- Looking up a notice or opportunity by an undashed ID no longer uses extra rate-limit quota. The redirect to the dashed ID is returned even when you are at your limit.
[4.25.3-2] - 2026-09-13¶
Added¶
- Securities and Exchange Commission forecasts.
GET /api/forecasts/now covers the SEC (?agency=SEC), whose forecast lists incumbent contracts nearing their end; the award date is that contract's end date, and the incumbent, dollar band, PSC, and business lead are inraw_data.
[4.25.3-1] - 2026-09-13¶
Added¶
status_reason=delistedanddelisted_aton state and local opportunities. A solicitation the portal stops listing before its deadline now readsstatus=closedwithstatus_reason=delisted, anddelisted_atsays when we first saw it gone; if it is listed again, it reopens.
Fixed¶
- Duplicate state and local opportunities are gone. Duplicate records left behind when a portal changed its identifier are removed; old ids resolve to the current record with a
301.
[4.25.2-1] - 2026-09-10¶
Added¶
meta.jurisdiction_declaredon state and local opportunities. It tells you whether the source itself stated the opportunity'sjurisdiction— a portal field naming the issuer type, or a portal that serves exactly one government — or whether we determined it from the name of the issuing body.falsemeans we determined it;nullmeans nothing established a level at all.
Changed¶
jurisdictionis far more accurate, and thestatebucket is smaller. Opportunities whose jurisdiction had never actually been determined were being reported as state-level. They now report the level of the body that is really buying, so a great many university, school-district, city and county solicitations move fromstatetoeducationorlocal, and a small number move tounknown. If you filter?jurisdiction=stateyou will see fewer results and they will be state agencies;?jurisdiction=localand?jurisdiction=educationreturn several times what they did.
Breaking¶
Fixed¶
- Georgia school-district and university solicitations report the right jurisdiction. They were reaching the API as state-level.
- Hawaii opportunities report the county, Department of Education or University of Hawaii that issued them rather than all reporting as state-level.
[4.25.1-1] - 2026-09-10¶
Added¶
-
Document text on state and local solicitations, on the Small plan and above —
?shape=attachments(extracted_text)returns the extracted body of a solicitation's documents. Three things to know. Ask for the field by name:attachments(*)does not include it, so addextracted_textto the expand when you want it. The key is absent, not null, whenever the text is not being served to you — on a Free plan it is withheld and listed inmeta.upgrade_hints, and where we have no text for a document it simply carries no key, so read it with a default rather than assuming it is there. And a document flaggedis_contestednever returns text, on any plan: the file we retrieved for it does not match what the solicitation listed, so we cannot vouch that its text belongs to this record.Every fact about a document stays free on every plan — name, size, page count, character count and extraction status — so you can tell whether a body is worth fetching before you fetch it. Searching document text is also free and unchanged:
?search=matches inside attachments on every plan and returns matching solicitations without any of their text.
[4.25.0-1] - 2026-09-10¶
Added¶
- State, local and education solicitations are now an API resource —
/api/sled/opportunities/and/api/sled/forecasts/, with filters for state, level of government, status, agency, category codes, deadlines and documents.
- 🚨 Beta, and complete only for what is currently open. Every state and territory jurisdiction we read is covered for open solicitations; historical ones are still being backfilled. Field names, filters and response shapes may change while this is in beta.
Changed¶
- Solicitation-number filters match every spelling — contracts, IDVs, protests, opportunities and notices now find a number written the FPDS way (
N6600120R0002) or the SAM.gov and GAO way (N66001-20-R-0002), in any case, and accept|-separated values.
Fixed¶
- CALC labor-category rates honour your plan. The
idvandrecipientexpansions on/idvs/{key}/lcats/and/entities/{uei}/lcats/require a paid plan and were being returned to Free callers.
- 🚨 A daily rate limit could outlast the day it counted. A daily counter could stop resetting at midnight UTC, leaving an account rate-limited indefinitely; it now re-arms its own reset on every request.
[4.23.1-2] - 2026-09-04¶
Fixed¶
- 🚨 Signing in from
tango.devworks again. The redesign moved sign-in to/, which meant the mirror served the page itself rather than handing you totango.makegov.com— so the bot check failed to load and the sign-in code was never sent. Both addresses now behave the same.
[4.23.1] - 2026-09-04¶
Added¶
- A comprehensive redesign of everything you reach while signed in. The account page is now five sections — Overview, API, Usage, Billing and Settings — each with its own sub-navigation and its own address, replacing the single page everything used to stack onto.
- Overview opens on what you have actually used: requests today against your limit, thirty days of history, your plan, and your keys.
- Usage shows requests per day, a breakdown by resource, and how close you are to each of your limits, over the period you choose.
- Billing gives your subscription, your invoices and the card you are charged on their own pages. If MakeGov invoices you directly, every page says how your account actually bills rather than hiding the tab.
- Settings holds your profile — your name and organization — and your notification preferences.
- Light and dark mode is a control in the account page's top bar. Leave it on System to follow your operating system; the choice is remembered in your browser.
- The OAuth application pages match the new account page too — registering, editing and deleting an application, and reviewing the tokens you have authorized.
- Sign in, sign up and the pages around them are redesigned, along with the Terms of Service.
- You can withdraw marketing consent — Settings → Notifications shows your current answer, when it was recorded, and lets you change it. It is Tango's own record; if you are already on a Mailchimp list, coming off that list is a separate step.
Changed¶
- Signing in now lands you on your account Overview. The welcome page is gone —
/welcome/redirects there, and a brand-new account starts on Overview too.
- 🚨 Sign in moved to
/, and creating an account moved to/start/. The old/accounts/login/and/accounts/signup/addresses redirect and keep any query string, so existing links and bookmarks still work.
[4.23.0-1] - 2026-09-03¶
Added¶
-
Entities can now return employee count and hiring locations — request
workforcein yourshapeon any entity endpoint:/api/entities/{uei}/?shape=uei,legal_business_name,workforce(*)"workforce": { "number_of_employees": "100 to 499", "locations": ["CA", "TX", "NY"], "location_count": 58, "last_verified": "2026-08-01" }number_of_employeesis a published size band, not an exact figure.locationslists the states where the company hires, which is often wider than the single address on its registration — about 17,000 companies span more than one state, and some span forty.
-
last_verifiedtells you how old the employee count is — it comes back insideworkforcefor a reason: it is the date the company itself last attested to these figures, and it ranges from days to over a decade. Read the count with the date, never on its own.workforceisnullfor a company we could not confidently match, which is most of them — roughly 138,000 entities carry one today.
[4.22.0-28] - 2026-09-01¶
Added¶
- Budget flows now return the 10 largest contracts per row — on
/api/entities/{uei}/budget-flows/and/api/budget/accounts/{id}/recipients/, so a big vendor's response stays a usable size.
- A row whose contracts were cut sets
contracts_truncatedand lists its fullpiids— fetch any one you need with/api/contracts/?uei={uei}&piid={piid}.
Fixed¶
- 🚨 Budget flows listed contracts that weren't the vendor's — on both budget drill-downs,
contractsmatched on PIID alone, pulling in other companies' awards that shared one. Each row now carries only that recipient's contracts.
- Budget-flow responses are dramatically smaller — for a large vendor a single page dropped from thousands of contracts to the handful actually belonging to it.
[4.22.0-15] - 2026-08-17¶
Added¶
- Premium endpoints now report their own budget — in
X-RateLimit-Premium-*headers, including on a 429 (see Rate limits).
- Your account page now shows your premium query budgets — usage, reset, and how to ask for more (see Rate limits).
Breaking¶
- 🚨
GET /badges/version.svghas been removed. The endpoint returned an SVG version badge; if you embedded it, it now returns 404. The same versions are still available as JSON from/api/version(the API contract version) and/healthz(the deployed build).
[4.22.0-12] - 2026-08-14¶
Changed¶
- The Micro plan is no longer offered. Small is now the entry paid plan. If you're on Micro, your price, limits, and features are unchanged. Where the API named Micro as the tier to upgrade to, it now names Small:
required_tierin a 403 and inmeta.upgrade_hints, the tier named when a custom cron alert schedule is refused, and the plan suggested by usage recommendations. One knock-on for Micro subscribers: a 403'scurrent_tiernow readsSmallrather thanMicro, because Micro and Small share one access level and that field names the level, not your plan.
- Upgrading your plan is now charged when you upgrade. The prorated difference for the rest of the billing period is billed at the moment you switch, rather than appearing on your next invoice. If that payment is declined, your plan stays as it was and nothing changes. Downgrades are unchanged — the credit still applies to your next invoice.
- Your daily request allowance resets once per day when you move to a higher plan. Previously every upgrade cleared the day's usage; a second upgrade on the same day now keeps it. The higher plan's limit still applies immediately either way.
[4.22.0-6] - 2026-08-11¶
Fixed¶
- Filtering by a department's 4-digit agency code now covers the whole department. Codes like
7500(HHS) and9700(DOD) matched only the department's own top-level office, so an agency filter that should have returned the department's full contract activity came back empty or errored. Sub-agency codes such as7530(CMS) were unaffected. Department abbreviations (HHS) and 3-digit codes (075) always worked and still do.
[4.22.0-5] - 2026-08-10¶
Fixed¶
- Search results now show the matching passage from an attachment far more often. When
?search=matched text inside a solicitation's documents, most results still came back without asnippet— the excerpt was drawn from a much smaller set of documents than the search itself covered, so the passage that caused the match usually could not be quoted. Snippets now come from the same document set the search runs against, so a result that matched on attachment text should carry the excerpt showing why. Request and response formats are unchanged:snippetis the same optional field, just populated far more often.
[4.22.0-3] - 2026-08-08¶
Changed¶
- A Claude or ChatGPT connector left unused for 90 days now asks you to reconnect. Access is unchanged while you keep using it — connectors refresh in the background every day — but one that sits idle for a full quarter has its authorization retired and prompts you to approve it again the next time you use it.
[4.22.0] - 2026-08-07¶
Added¶
-
The joint venture behind an SBA Mentor-Protégé agreement is now identified where we can find it. SBA publishes only the two firms in an agreement — it names no joint venture at all — so the JV is recovered by matching SAM registrations, and it appears as a
mentor_protege_jvrelationship from the JV to each of its two members. Viewed from the JV, each partner'srelationismember; viewed from a partner, the JV'srelationisjoint_venture. About 690 of roughly 1,900 current agreements resolve to a JV; the rest are not identifiable from published data, and some pairs never formed one.These links are inferred, and they say so. Every one carries
source: "inferred"— never thesbaused for the agreement itself — plusverification_methodnaming the evidence (document_verificationwhere the JV's own website names both partners,exact_matchwhere SAM records a partner as the JV's immediate owner,name_matchwhere the JV's legal name is built from both partners' names) and aconfidencetier. Treat them as a strong lead, not as a fact SBA published. Thementor_protegerelationship is unchanged and remains exactly what SBA asserts.
- Find an IT investment by an identifier that no longer exists. Agencies retire an IT investment by folding it into a successor, which leaves any UII you have on file — in a citation, a saved query, a bookmarked URL — pointing at nothing.
GET /api/itdashboard/?previous_uii=015-000000100now returns the investment(s) that absorbed it, on every tier, and full-text search covers retired identifiers too, so?search=015-000000100finds the successor.
Changed¶
- A retired UII on
GET /api/itdashboard/{uii}/now resolves instead of returning 404. If exactly one current investment absorbed it, the request redirects (301) to that investment, preserving your query string — so a client that follows redirects simply gets the right record. If the source split it across several successors, you get a300listing each candidate with its title and URL rather than an arbitrary pick. An identifier that was never a real investment still returns404.
[4.21.0] - 2026-08-03¶
Added¶
- Agency filters now tell you what your search term matched. List responses that use an agency filter (
awarding_agency,funding_agency,agency) include ameta.resolved_filtersblock naming, for each value you passed, the organization it resolved to — its name, identifier, hierarchy level, and CGAC code — ornullif it matched nothing. Agency matching is fuzzy, so a term likeHUDcould previously land on an unrelated organization and quietly scope your query to it; you would get a confident, plausible, wrong answer with no way to tell. Now you can see the match and catch it. Anything ignored or matched loosely is also listed inmeta.warnings. The block appears on successful responses only.
Changed¶
- An agency filter that matches nothing now returns a 400 instead of an empty list. If every value you pass to
awarding_agency,funding_agency, oragencyfails to match an organization, the request is rejected with a message naming the value, rather than returning zero results — which was indistinguishable from "no such records exist".GET /api/contracts/,/api/idvs/,/api/otas/, and/api/otidvs/already behaved this way; this brings/api/subawards/,/api/opportunities/,/api/notices/, and/api/vehicles/into line. If some of your values match and others don't, nothing changes — the matching ones still apply and the rest are reported inmeta.warnings, so multi-value searches like?agency=7530|7523keep working exactly as before./api/forecasts/,/api/grants/, and/api/protests/are unaffected: an unmatched value there still searches their agency text field, so it is a valid search rather than a dropped one. - Multi-value agency filters work correctly on subawards and contracts.
?awarding_agency=A|Bwas not being split into separate values onGET /api/subawards/, or onGET /api/contracts/in its default mode — the wholeA|Bstring was matched as one term. On contracts that returned an empty list; on subawards it matched a single unintended organization and returned results scoped to it. Both now return the union of both organizations, as documented.
Fixed¶
- Performance improvements for contract searches. Some vendor-scoped searches that could previously time out now return normally. Results, ordering, and pagination are unchanged.
[4.20.0] - 2026-08-02¶
Added¶
- Department of Education forecasts.
GET /api/forecasts/now covers the Department of Education — several hundred upcoming requirements, refreshed daily, filterable with?agency=ED(or combined, e.g.?agency=ED|HHS). Education publishes its forecast as a spreadsheet rather than a feed, so the available fields differ a little from other agencies: you get the contract name as the title, NAICS code, anticipated award date, fiscal year, set-aside, contract type, and the contracting point of contact, plus the funding office, incumbent, and dollar ranges in the record's raw data. There is no free-text requirement description, sodescriptionisnullon these records and keyword search matches on the title. Anticipated award dates are the first day of Education's target federal fiscal quarter — a target of FY2026 Q1 comes back as2025-10-01. - Entity relationships now report the relationship type and its source. Each entry in the
relationshipsexpand carriestype(prime_sub,parent_subsidiary,ultimate_parent,predecessor) andsource(sam,subawards), both selectable viashape. Match ontyperather thanrelation— it is the stable key. - SBA Mentor-Protégé agreements are now available as entity relationships. Firms in an active SBA Mentor-Protégé agreement carry a
mentor_protegerelationship linking mentor to protégé, with the approval date inrelationship_date. Viewed from the mentor the partner'srelationisprotege; viewed from the protégé it ismentor. Covers roughly 1,900 current agreements, refreshed as SBA republishes. Note that SBA publishes only currently-active agreements, so an agreement that expires or is terminated stops appearing rather than being marked inactive — and SBA's data does not identify the joint venture formed under an agreement, only the two firms.
Changed¶
relationno longer returnsaffiliatefor subcontracting or predecessor ties. The value collapsed two unrelated relationship types and both directions of a one-way tie, so it could not distinguish "hired this company as a subcontractor" from "was hired by them." It now names the partner's role:subcontractor/primefor subcontracting,predecessor/successorfor corporate succession. This affects the large majority of relationship entries — if you match on the stringaffiliate, switch to thetypefield, which is stable and won't churn again.- An entity linked by its ultimate parent now reads
descendantinstead ofchild. We only record an ultimate-parent tie when the ultimate owner differs from the immediate owner, so the other end of that tie is never a direct subsidiary — it sits at least one company further down the ownership chain.childremains correct for immediate parent/subsidiary ties. relationshipsentry counts can differ by requested shape. Entries are de-duplicated after yourshapeselection is applied, so asking for a narrower shape (e.g.shape=relationships.uei) can return fewer entries thanshape=relationshipsfor a company with several distinct ties to the same partner.
Fixed¶
- Duplicate entries removed from the
relationshipsexpand. Two companies that had each subcontracted to the other produced two identical entries; they now appear as the two distinct ties they are. - Attachment-text search results are current again. Between 2026-07-21 and this release,
GET /api/opportunities/?search=…matched attachment text against a stale index: text extracted from attachments in that window was not searchable, and some attachments that had since dropped out (extraction failed, unlinked from their opportunity) still produced matches. Roughly 36,000 attachments were affected. Newly changed attachments are indexed on the normal refresh cadence again; no client change is needed.
[4.19.0-4] - 2026-07-28¶
Added¶
X-Tango-API-Versionresponse header. Every API response now returns a stable API version (starting4.19.0) so you can pin against the contract you built for, independent of our deploy version.
Changed¶
/api/versionand the Swagger/OpenAPIversionnow report the API version (4.19.0) rather than the build number.
[4.19.0] - 2026-07-22¶
Added¶
- Period-of-performance dates on budget drill-down contracts. The contracts hydrated inline by
GET /api/budget/accounts/{id}/recipients/andGET /api/entities/{uei}/budget-flows/now include a nestedperiod_of_performanceobject (start_date,current_end_date,ultimate_completion_date) alongside the existingkey,piid,naics_code,psc_code,obligated, andaward_date. You can now tell which of an account's contracts are ending soon — the natural "about to recompete / about to end" question — directly from the drill-down, without a follow-up lookup on/api/contracts/. Dates arenullwhere FPDS doesn't carry them.
[4.18.7] - 2026-07-21¶
Fixed¶
- Bug fixes associated with an enterprise bulk data endpoint.
[4.18.0] - 2026-07-17¶
Added¶
- New endpoints:
/api/sbir/topics/and/api/sbir/solicitations/. Search and filter SBIR/STTR topics across every participating agency (DoD, NIH, NSF, NASA, DOE, DOT and more), plus the DoD DSIP solicitation cycles they belong to. Filter topics byactivity(open/closed),agency,year,program, solicitation or topic number, and open/close/release date ranges; order by any date or byactivity. Each topic links to the parent solicitation it belongs to — a DSIP cycle, a SAM.gov notice, or a Grants.gov announcement — so you can follow a topic through to the opportunity or grant that funds it. BAA PDFs are available as adocuments(*)expand on a solicitation. Response shaping (shape) is supported on both endpoints.
[4.17.0] - 2026-07-14¶
Added¶
- New endpoints:
/api/dibbs/rfqs/,/api/dibbs/rfps/, and/api/dibbs/awards/. Search the DLA Internet Bid Board System (DIBBS) — small-purchase requests for quote, large solicitations (RFP/IFB), and awards from the Defense Logistics Agency. Available on every plan, including Free.?open=truereturns the RFQs and solicitations still accepting responses today, derived from the closing date on each record. Each result also carriesis_open.- DIBBS is an NSN and part-number world, so
nsnandpart_numberfilter every resource, alongsidesolicitation,organization, and full-textsearchacross nomenclature and identifiers. RFQs addset_aside,status_code,quantityranges, andreturn_by_date/issue_dateranges; solicitations addbuyer_codeandissued_date/closes_dateranges; awards addaward_number,delivery_order_number,awardee_cage,entity(awardee UEI),award_date/posted_dateranges, and atotal_contract_pricerange. - Awards carry the resolved
awardeeentity when the awardee's CAGE code matches a registered entity;awardee_cageis always present either way. - Read this before you total anything. An award record is a line item, not a whole award: a delivery order covering many items appears as one record per item, and
total_contract_priceis the order's total repeated on every one of them. Summing it across records will badly overstate spend — one real order spans 483 line items that all read $9,000,000.00, so a naive total reports $4.35 billion for it — and inflates DIBBS spend overall from $6.0 billion to $33.0 billion. To total spend, count each order once. - Detail lookup is by
uuid. Response shaping (?shape=) is supported on both list and detail.
- New alert types:
alerts.dibbs_rfq.matchandalerts.dibbs_rfp.match. Subscribe to a saved DIBBS filter — say, an NSN or part number you supply — and get notified when a matching quote request or solicitation is newly posted or updated. Note that an RFQ or solicitation simply reaching its closing date is not itself a change to the record, so it does not trigger an alert. Alerts cover quote requests and solicitations; awards are not alertable. - New endpoint:
/api/exclusions/. Search SAM.gov exclusions — debarments, suspensions, and other exclusions from federal awards. Available on every plan, including Free.?active=truereturns the exclusions in force today, derived from the dates on each record. Each result also carriesis_currently_excluded.- Filter by
classification_type,exclusion_type,exclusion_program,excluding_agency_code,excluding_agency_name,uei,cage_code,npi,entity_uei,delisted, andactivate_date/termination_date/update_dateranges; full-textsearchacross names, excluding agency, and comments. - Detail lookup is by
exclusion_key. Response shaping (?shape=) is supported on both list and detail.
- New alert type:
alerts.exclusion.match. Subscribe to a saved exclusions filter and get notified when a matching exclusion is newly issued, revised, or lifted. Note that an exclusion simply reaching its termination date is not itself a change to the record, so it does not trigger an alert.
[4.16.0] - 2026-07-13¶
Added¶
- New endpoint:
/api/exclusions/. Search SAM.gov exclusions — debarments, suspensions, and other exclusions from federal awards. Available on every plan, including Free.?active=truereturns the exclusions in force today, derived from the dates on each record. Each result also carriesis_currently_excluded.- Filter by
classification_type,exclusion_type,exclusion_program,excluding_agency_code,excluding_agency_name,uei,cage_code,npi,entity_uei,delisted, andactivate_date/termination_date/update_dateranges; full-textsearchacross names, excluding agency, and comments. - Detail lookup is by
exclusion_key. Response shaping (?shape=) is supported on both list and detail.
- New alert type:
alerts.exclusion.match. Subscribe to a saved exclusions filter and get notified when a matching exclusion is newly issued, revised, or lifted. Note that an exclusion simply reaching its termination date is not itself a change to the record, so it does not trigger an alert.
Fixed¶
- Agency filters now accept an organization UUID.
agencyon opportunities,awarding_agencyandfunding_agencyon contracts and subawards, and the vehicles agency filter previously accepted only a name, abbreviation, or agency code — passing an organization UUID matched nothing and returned an empty result set rather than an error. Organization UUIDs are exactly what the organizations endpoints return, so the natural "look up an organization, then filter by it" workflow silently produced zero results. All of these filters now accept a name, abbreviation, 3-digit CGAC code, 4-digit FPDS code, or an organization UUID, and every form rolls up to descendants — filtering by a department returns records from all of its sub-agencies and offices. - Documented that
/api/opportunities/returns only active opportunities by default. The endpoint restricts results to active opportunities unless the request includesactive,solicitation_number,notice_type, oraward_number, any of which searches the full dataset. Sendingactive=falsereturns closed opportunities. Theactiveparameter was missing from the API reference entirely, so a search scoped to an organization whose opportunities have all closed appeared to return nothing at all.
[4.15.0] - 2026-07-12¶
Added¶
- SBA OHA decisions are now available through the protests endpoint. Decisions from the Small Business Administration's Office of Hearings and Appeals join GAO and the Court of Federal Claims as a third venue. Filter to them with
?source_system=sba_oha, or exclude them the same way. As with every other venue, the endpoint serves decisions from 2015 onward — roughly 1,350 OHA cases, most of them size and NAICS appeals, with new decisions arriving daily. - Five new protest fields, available on request via
shape.challenged_party(the firm whose size or status was challenged),naics_code(the code at issue in the appeal),size_standard(as written, for example$33.5 millionor1,000 employees),outcome_reason(why a case was dismissed — untimely, moot, withdrawn, lack of jurisdiction, lack of standing, insufficient specificity), andjudge(the authoring administrative judge). These are populated for SBA OHA decisions and are null for GAO and Court of Federal Claims records, so they are not part of the default response — request them explicitly with?shape=. They are available on every plan. - A
naics_codefilter on the protests endpoint, for finding the size and NAICS appeals that turned on a particular code:?naics_code=541512.
Changed¶
- The protests
outcomefield can now returnGranted,Remanded,Reversed, orVacated. These come from SBA OHA decisions. GAO and Court of Federal Claims records continue to use onlyDenied,Dismissed,Withdrawn, andSustained, so existing filters on those four values are unaffected — but code that treats the set of four as exhaustive should be widened.
Fixed¶
- Fixed how rate-limited requests are counted against your daily quota and how retry times are reported.
[4.14.8] - 2026-07-09¶
Changed¶
- Stripe Checkout now asks for your name and billing address, so invoices and receipts carry the right details.
Fixed¶
- Agency filters now accept an organization UUID.
agencyon opportunities,awarding_agencyandfunding_agencyon contracts and subawards, and the vehicles agency filter previously accepted only a name, abbreviation, or agency code — passing an organization UUID matched nothing and returned an empty result set rather than an error. Organization UUIDs are exactly what the organizations endpoints return, so the natural "look up an organization, then filter by it" workflow silently produced zero results. All of these filters now accept a name, abbreviation, 3-digit CGAC code, 4-digit FPDS code, or an organization UUID, and every form rolls up to descendants — filtering by a department returns records from all of its sub-agencies and offices. - Documented that
/api/opportunities/returns only active opportunities by default. The endpoint restricts results to active opportunities unless the request includesactive,solicitation_number,notice_type, oraward_number, any of which searches the full dataset. Sendingactive=falsereturns closed opportunities. Theactiveparameter was missing from the API reference entirely, so a search scoped to an organization whose opportunities have all closed appeared to return nothing at all. - If you cancelled a plan and later wanted to come back, the pricing page could show your old plan as still current and refuse to let you subscribe again. You can now resubscribe normally.
- Accounts billed by invoice rather than through Stripe no longer show as "Free" on the pricing page.
[4.14.6] - 2026-07-08¶
Changed¶
- Agency filters (
?awarding_agency=,?funding_agency=,?agency=) resolve substantially faster, especially the first request for an agency code after a deploy. - When a recipient name search times out, the request now fails fast with a
504instead of retrying against a slower table. Previously such a request could take up to roughly 50 seconds before returning results.
Fixed¶
- Agency and recipient filters now return a
504when the database times out, matching every other endpoint. Previously these filters reported a timeout as an empty result set, which was indistinguishable from a search that genuinely had no matching records. Requests with an unrecognized agency or recipient value still return an empty result set, as before. - Webhook alerts filtered by agency could stop delivering shortly after being created, and would not recover on their own. Newly created agency alerts now begin delivering as expected.
[4.14.4] - 2026-07-07¶
Fixed¶
- Budget account narratives without a subaccount granule now appear in a budget account's
narratives— around 3,500 of these were previously dropped on every data refresh and never surfaced
[4.14.2] - 2026-07-06¶
Fixed¶
- Planned-maintenance windows now reliably appear on status.makegov.com — some windows announced in 4.14.0 were silently failing to publish
- Alerts stay reliable during very large data refreshes — an alert evaluating against an unusually large batch of changes could time out and stop delivering; evaluation now processes those batches incrementally
- If an alert genuinely can't be evaluated for a sustained period, we now pause it and email you, instead of letting it fail silently — resuming the alert clears the error state
[4.14.0] - 2026-07-05¶
Added¶
- Planned API maintenance now appears on status.makegov.com as a scheduled-maintenance window (with the expected restore time) instead of unexplained downtime
Changed¶
- Raised webhook alert limits on larger plans. Alert evaluation runs in parallel batches behind the scenes, so match checks stay on schedule at high alert counts.
Fixed¶
- Webhooks: pausing and resuming an alert via
PATCH /api/webhooks/alerts/{id}/withis_active: false/truenow works. Previously the request returned 200 but the alert kept evaluating (or stayed paused); the alert'sstatusfield now correctly reflects"paused"/"active"after the update. Deactivating an alert also takes effect immediately for matches already being processed when it was paused.
[4.13.0] - 2026-07-04¶
Changed¶
- Award list endpoints now cap
?limit=per plan — 100 results per page on standard plans (higher on Enterprise). Requests above the cap return at most that many rows and set anX-Results-Limit-Clampedheader; use thenextcursor to page through the rest.
[4.12.7] - 2026-06-26¶
Changed¶
- Each matched webhook event is now delivered as its own HTTP request with one top-level
delivery_id. Previously events for the same endpoint could be combined into a single request, so a receiver expecting more than one event in theeventsarray now gets one per request.
Fixed¶
- Webhook delivery payloads now include the top-level
delivery_iddescribed in the payload format and Slack how-to — a UUID that stays the same across retries, so you can use it as an idempotency key to dedupe deliveries. - The "Send test" delivery and sample payload endpoint now return events in the same shape as real alert deliveries (
event_type,query_type,filters,matches), so you can validate your receiver against a representative payload. Test deliveries stay marked withtest: true.
[4.12.4] - 2026-06-23¶
Fixed¶
- Made LinkedIn (and other social) sign-in more resilient. When the identity provider was temporarily rate-limiting us, some login attempts failed with an error; sign-in now stays working through those brief provider hiccups.
[4.12.1] - 2026-06-18¶
Fixed¶
- Fixed alerts that trigger a Claude Code routine: these deliveries were failing to start the routine, and now send the correct request format so the routine runs reliably with the matched results as context. These deliveries are also sent once and not retried, so a transient hiccup can no longer start the same routine twice.
[4.12.0] - 2026-06-18¶
Added¶
- Manage your webhook endpoints right from your account page — create, edit, and enable or disable them without touching the API. Endpoints that fail to deliver for a sustained period are now disabled automatically, and we email you a heads-up with a link to re-enable.
Changed¶
- Webhooks are now available on all plans.
[4.11.0] - 2026-06-18¶
Added¶
- Webhook endpoints can now send custom request headers (such as an
Authorizationheader), so alerts can deliver to authenticated destinations — including triggering a Claude Code routine when an alert matches. Header values are stored encrypted and are never returned in API responses.
Changed¶
- Contract data ingestion now runs as one ordered pipeline with a freshness alert, so a stalled refresh is caught within a day instead of silently serving stale data.
[4.10.0] - 2026-06-17¶
Changed¶
- Webhook alerts are now evaluated against our data lake — faster, and no longer paused or throttled for matching too broadly.
Breaking¶
- The alert filter preflight endpoint (
POST /api/webhooks/subscriptions/check-filter/) has been removed, along with the automatic pausing of broad alerts. - The
weeklyalert frequency has been removed; use acustomcron expression for a weekly cadence.
[4.9.0] - 2026-06-16¶
Added¶
- Webhook alerts that match too broadly are now paused automatically and you'll get an email explaining how to narrow the filter (use a sub-agency instead of a whole department, add NAICS/PSC/keyword constraints, or split it into several alerts) and a link to re-enable it. A "firehose" filter that matches tens of thousands of records can't be evaluated efficiently, so it's safer to flag it than to let it flood your endpoint. Creating or updating an alert with a too-broad filter is now rejected up front, and a new
POST /api/webhooks/subscriptions/check-filter/endpoint lets you preview how many records a filter would match before you save it.
Fixed¶
- Webhook alert evaluation is faster and no longer times out on broad filters; entity, grant, and forecast alerts evaluate against an index instead of a full table scan.
[4.8.0] - 2026-06-10¶
Added¶
/api/forecasts/now includes USDA procurement forecasts (source_system=USDA, ~2,400 records).
Changed¶
- Labor-category rates (
/api/idvs/{key}/lcats/,/api/entities/{uei}/lcats/) now refresh daily. One-time effect:modifiedtimestamps reset table-wide at cutover. opportunity_historyon/api/grants/detail responses is now populated; it was previously alwaysnull.- Forecast
modifiedtimestamps now update only when a forecast actually changes, somodified_afterfilters and webhook alerts reflect true changes. /api/protests/: ~9,900 recovered historical GAO protests are restored, and existing cases carry fresher outcome and decision data.
Internal / Infra¶
- GSA eLibrary contract metadata refreshes daily through the new ingest pipeline. The
sinsarray may list codes in a different order (it is semantically a set), andlast_updatednow tracks GSA's newest published value, including downward corrections. - On
/api/contracts/, object-valued code/description fields such astradeoff_process,fair_opportunity_limited_sources, andsubcontracting_plancan now be named bare in ashape(e.g.?shape=piid,tradeoff_process) and behave exactly liketradeoff_process(*), matching howcompetitionalready works. Previously a bare reference returned a 400 with reasonunknown_fieldeven though the field was listed inavailable_fields. Shape errors for fields that genuinely require sub-selection now report the reasonrequires_subselectioninstead ofunknown_field.
[4.7.9] - 2026-06-09¶
Changed¶
- More trustworthy company-match confidence. Name resolution no longer reports high confidence for near-miss matches (similar-looking but different companies); confidence tiers now require the returned company's name to actually account for what you searched.
- Substantially more accurate company name resolution. Searching by short names ("Booz Allen"), common abbreviations (GDIT, HII, SAIC), or ticker symbols (LMT, MSFT) now reliably finds the right company, and vendors with several registrations under one parent company return a single, correct match instead of duplicates.
[4.7.6] - 2026-06-08¶
Changed¶
- Upgraded
dj-stripe2.9.1 → 2.10.3 (#2520). Under-the-hood plumbing change to the library that mirrors Stripe data into Tango's database: each Stripe object is now stored as a single JSON blob with the same Python attribute names exposed via property accessors. No user-visible change to checkout, billing, or self-serve plan management. Unlocks the queryable churn-feedback work introduced in #2519 — we can now ask the database for "subscriptions cancelled because of price" instead of reading each one from the Stripe dashboard. Deploy includes a one-timedjstripemigration +manage.py sync_stripe_datare-sync from the Stripe API (sub-minute given current data volume).
Internal / Infra¶
- IT Dashboard ingest moved to the lakehouse pipeline. Behind-the-scenes plumbing change — federal IT investments now load through the Parquet-based sync pipeline. No change to
/api/itdashboard/responses.
[4.7.4] - 2026-06-05¶
Added¶
- Your name on sign-up. The sign-up form now asks for your first and last name, so Tango can greet you by name and personalize your welcome email.
Changed¶
- Refreshed sign-in & sign-up pages. The login, sign-up, and email-verification screens got a visual and copy polish for a clearer, more consistent passwordless experience.
Fixed¶
- Clearer "create your account" page. When you enter an email that doesn't have an account yet, the account-creation page no longer shows confusing "already have an account? sign in instead" wording.
[4.7.3] - 2026-06-04¶
Added¶
- DoD contract-attribution signals on budget accounts.
/api/budget/accounts/now returns three new fields by default:attribution_status,attribution_confidence, andcontract_obligated_estimated. The underlying USAspending File C data captures only a small share of the Department of Defense's contract obligations, so DoD federal accounts previously looked identical to accounts with no contract activity at all (contract_obligatedcame backnull), dropping them out of any contract-share filter or ranking. Useattribution_statusto tell "no contracts" apart from "no source data," and fall back tocontract_obligated_estimated(an FPDS-based estimate) where the primary figure is missing. All three arenullfor account-years that haven't been backfilled yet.
[4.7.2] - 2026-06-03¶
Added¶
- Bot protection on sign-up and email sign-in code requests. The sign-up form and the "Email me a sign-in code" form on the login page now include a quick Cloudflare Turnstile challenge to keep automated traffic out. You may see a brief verification check before submitting.
- Microsoft sign-in + passwordless email sign-in. You can now sign in (and sign up) with a Microsoft work or school account, or with just your email and a one-time sign-in code — no password and no social provider required. The new "Email me a sign-in code" entry point appears on the login page alongside the social buttons.
Breaking¶
- Email sign-up is now open. Previously Tango required a social provider (Google, GitHub, LinkedIn) to create an account. Sign-up with email + one-time code is now supported, so the
/accounts/signup/page is reachable. If your integration assumed every Tango user is backed by a social identity, expect to see passwordless email accounts as well.
[4.7.0] - 2026-06-02¶
Changed¶
- Entity descriptions are now derived at render time.
GET /api/entities/{uei}/?shape=…(*)keeps returning{code, description}forentity_structure,entity_type,profit_structure,organization_structure,purpose_of_registration, andcountry_of_incorporation— same payload shape, same values for known codes. Behind the scenes, the description is now derived from a built-in code-to-label map (or, for country, django-genc's country table) rather than read from a stored*_desccolumn. For codes outside the known set,descriptioncomes back asnull.state_of_incorporation(*)has no map and now consistently returns{code, description: null}for every state.
Breaking¶
- Entity flag fields now serialize as bool. Four fields on
/api/entities/{uei}/change wire type:registered,registration_status,uei_status,public_display_flag. JSONtrue/false/nullreplace the previous string tokens ("Active"/"Inactive"/"Y"/"NPDY"/"Not Active"). Webhook alerts (alerts.entity.match) shipregistration_statusin the match summary and are subject to the same change. Mapping:registration_status"Active"→true,"Inactive"→false;uei_status"Active"→true,"Not Active"→false;public_display_flag"Y"→true,"NPDY"→false(a non-obvious source token — interpret as "Not Publicly DisplaYed");registered"Y"→true, any other value →null(no"N"token was observed in production data for this field). Any unmapped source value on any of the four →null. None of the four publicly-surfaced fields ever emitted a literal"N"token, so no"N"→falsemapping exists on the public surface. Action for integrators: webhook filters and client code that compared these fields to string tokens (e.g.registration_status === "Active") need to switch to bool comparison (registration_status === true). The 7*_desctop-level fields (purpose_of_registration_desc,entity_structure_desc,entity_type_desc,profit_structure_desc,organization_structure_desc,state_of_incorporation_desc,country_of_incorporation_desc) are removed from the default entity payload — use the?shape=…(*)expansion for any of these to keep getting{code, description}derived at render time. source_typeremoved from/api/entities/output. The field was auto-set to"sam"for every row and duplicated information already exposed viasource. Any client readingentity.source_typeshould switch toentity.source.- Stricter value sets on two entity enums.
evs_sourceis now constrained to{"D&B", "E&Y"}(or null), andexclusion_status_flagto{"Y", "N", "D"}(or null). Out-of-set legacy values were coerced tonullduring the upgrade. Field shape is unchanged — values still serialize as strings.exclusion_status_flag = "D"means "delinquent" and is kept intentionally alongside"Y"/"N"; a matching data-dictionary update at docs.makegov.com is tracked as a follow-up.
Removed¶
- Internal
field_provenanceJSONB column on entities (and the related CDC changelog tables) is gone. The column was never exposed via the API; field-level arbitration is handled upstream now. (#2376) - The internal
load_entities/load_dsbsSAM ingest loaders are gone. Entity data now flows entirely through the parquet-drivensync_entitiesfamily — no API surface change. (#2296)
[4.6.12] - 2026-06-01¶
Fixed¶
- Upgrading your plan now gives you the full daily request allowance right away. Previously, requests already made on your old tier counted against your new tier's daily budget immediately after an upgrade, so a paid plan could start the day with less headroom than expected. Upgrading to a higher tier now starts you on a clean daily budget. Additionally, requests rejected for exceeding your daily limit (HTTP 429) no longer count toward your daily usage.
[4.6.11] - 2026-05-29¶
Fixed¶
- Webhook alerts now deliver reliably again. Filter-based webhook alerts — opportunity, contract, entity, grant, and forecast matches — had stopped being evaluated, so matching records were not delivered to your endpoints. Alert evaluation has been corrected and delivery has resumed.
[4.6.10] - 2026-05-28¶
Changed¶
- Webhook deliveries now identify themselves. Outbound webhook requests from Tango — including test deliveries — now send a
User-Agentheader of the formMakeGov-Tango-Webhooks/<version> (+https://docs.makegov.com/)instead of a generic HTTP-client default. Receivers can use this to recognize, allowlist, and log MakeGov webhook traffic.
[4.6.8] - 2026-05-26¶
Added¶
- Federal budget data is now in the API. A new family of
/api/budget/endpoints exposes the full federal-account budget lifecycle — request → enacted → apportioned → obligated → outlayed — for ~2,000 federal accounts per fiscal year across FY17–FY26. Each account includes pre-computed execution ratios, year-over-year and 5-year trends, the contract/assistance/unlinked breakdown, and links to the underlying OMB Appendix.- List & search:
GET /api/budget/accounts/?search=acquisition+services+fund&fiscal_year=2024matches account title, agency, or bureau. Rich filters support pipeline detection (?apportioned_to_enacted_pct__gte=0.9&obligated_to_apportioned_pct__lte=0.3), forward-look (?ba_growth_next_year_pct__gte=0.5&contract_share_of_obligated_capped__gte=0.5), and request-vs-actual (?actual_vs_requested_contract__lte=0.5). - Drill-down: each account exposes
/recipients/(top contract recipients with hydrated contract details inline) and/quarters/(TAS-level quarterly obligation/outlay flow, FY21+) — both paginated with the standardcount / next / previous / resultsenvelope.?shape=*,appendix(*)adds the President's Budget Appendix object-class breakdown and?shape=*,narratives(*)adds the narrative + appropriations text. Both shape leaves returnnullwhen no data is available for the account-year, so consumers branch on truthiness once.
- List & search:
- Budget as context, not just a destination. Budget data is embedded where the question is usually being asked:
- Organizations get two opt-in budget shape leaves.
GET /api/organizations/{key}/?shape=name,budget_appropriation(*)returns the "what Congress appropriated" rollup for departments.GET /api/organizations/{key}/?shape=name,budget_spending(*)returns the "what this office (and its subtree) is spending on contracts" rollup, walking down the hierarchy so middle-tier orgs aggregate their descendants automatically. Ask for one, the other, or both — each has a fixed shape. Both support?shape=...,budget_appropriation[fiscal_year=2024](https://github.com/makegov/tango/blob/main/*)for historic views. - Entities get
GET /api/entities/{uei}/budget-flows/— the federal accounts that paid this vendor, paginated, with the surrounding account context (agency, enacted BA, contract share), the funding office hierarchy, and the contracts hydrated inline (key, NAICS, PSC, obligation, award date).
- Organizations get two opt-in budget shape leaves.
Fixed¶
- Procurement forecast refreshes no longer stall when one agency publishes a non-JSON document. A binary publication from one agency was causing the entire combined-format forecast refresh to fail, leaving other agencies' forecasts (HHS, GSA, DOI, DOL, DOT, VA, NRC, DOE, DHS, COMMERCE, ENERGY, TREASURY) out of date. Unsupported document formats are now skipped per-agency with a warning; remaining agencies refresh normally.
[4.6.7] - 2026-05-24¶
Added¶
- Entity metrics now include award counts per period.
GET /api/entities/{uei}/metrics/{months}/{period_grouping}/results now carryawards_count(distinct prime awards) andsubawards_count(distinct subawards) alongside the existingawards_obligatedandsubawards_obligateddollar sums. Counts participate in?group_by=agency/?group_by=department, so a single call returns per-agency award counts for a UEI — no more N+1 calls to the contracts / subawards endpoints. Counts are aggregated per month at the source; if you request a coarserperiod_grouping(quarter,year), the counts in each bucket sum the underlying monthly counts — read them as "award-months of activity" in that window rather than a global distinct count.
Fixed¶
- Attachment-text search is reliably fast on common terms.
GET /api/opportunities/?search=…with attachment-text search enabled previously could take several minutes on cold cache for broad terms (e.g.data governance) — long enough to hit the API timeout. Cold-path wall time on those searches now drops by roughly 30× (e.g. ~5 min → ~10 s fordata governance), comfortably under the timeout. The set of matching opportunities and per-page results are unchanged.
GET /api/opportunities/?search=is reliably fast on common terms. Searches matching thousands of opportunities (e.g.data governance,department of state) — including paired withordering=-last_notice_date/response_deadline/first_notice_date, deep pagination (?page=10), and field-selection viashape=— previously could hit the API timeout when the database cache was cold. Cold-path wall time on these searches now drops by roughly 6-7× and stays well under the timeout. The set of matching opportunities and the per-page results are unchanged. One caveat for clients that exhaustively crawl results via?page=Nwhile attachment-text search is enabled: the approximatecountfield can come back lower than before, which lowers the maximum?page=you can navigate to. If you want the precise total — or need to crawl all matches without missing pages — pass?exact=true(this was already the documented way to opt out of approximate counts; nothing about its behavior has changed).
[4.6.6] - 2026-05-23¶
Added¶
- Follow one or many resources with a single webhook alert. Webhook alerts for opportunities, contracts (including IDVs / OTAs / OTIDVs), grants, and forecasts now accept the same identifier you get from each resource's detail endpoint — and you can pass several at once. Use
opportunity_id,key(for contracts and IDV / OTA / OTIDV),grant_id, orid(for forecasts) in your alert'sfilters, either as a single value or as a pipe-OR list (e.g."key": "key1|key2|key3"). One alert that follows a set of resources is more efficient than one alert per resource, and counts as a single subscription against your tier's alert limit. Up to 500 IDs per filter; the same identifiers are also accepted on the corresponding list endpoints (e.g.GET /api/contracts/?key=k1|k2|k3).
[4.6.5] - 2026-05-22¶
Breaking¶
- Entity flag fields now boolean.
registered,registration_status,uei_status,public_display_flag,bonding_flag,disaster_registry_flag,debt_subject_to_offset,credit_card_usage,edi_information_flagnow serialize as JSONtrue/false/nullinstead of string tokens ("Active"/"Inactive"/"Y"/"N"/"NPDY"/"NA"). Webhook subscribers filtering onregistration_status === "Active"need to switch to=== true. Seven*_desccompanion fields (purpose_of_registration_desc,entity_structure_desc,entity_type_desc,profit_structure_desc,organization_structure_desc,state_of_incorporation_desc,country_of_incorporation_desc) are dropped from the top-level entity payload — use the?shape=…(*)expansions to get{code, description}derived at render time.
Fixed¶
GET /api/opportunities/?search=no longer times out on common terms. Searches likedata governanceordepartment of statepreviously could return a gateway timeout; they now return results reliably.
[4.6.2] - 2026-05-18¶
Added¶
GET /api/opportunities/?search=matches attachment text.?search=now searches text from opportunity attachments in addition to notice titles and descriptions. Matched results include asnippetcentered on the hit.
[4.6.1] - 2026-05-13¶
Fixed¶
GET /api/vehicles/?search=matches text from member-IDV descriptions. Vehicles whosesolicitation_identifieris a single run-on token (e.g.RFPCMS2016SPARC) now match substring queries like?search=SPARCvia the underlying IDV descriptions. The endpoint's default ordering (solicitation_identifier, agency_id, uuid) is unchanged. (issue #2224)
[4.6.0] - 2026-05-12¶
Added¶
- Shape aliases:
naics_code(...)andpsc_code(...)now work as expand forms.?shape=naics_code(code,description)is accepted as a synonym fornaics(...)on/api/contracts/and/api/idvs/(the only award endpoints with a NAICS expand).?shape=psc_code(code,description)is accepted as a synonym forpsc(...)across all award endpoints (/api/contracts/,/api/idvs/,/api/otas/,/api/otidvs/). The expanded object comes back under the canonical key (naics/psc). Bare scalarnaics_code/psc_codeis unchanged — it still returns the raw code value. (issue #2257) POST /api/webhooks/endpoints/test-delivery/now acceptsendpoint. The canonical field name matchesPOST /api/webhooks/subscriptions/, so SDKs and docs can use one identifier across both calls. The legacyendpoint_idfield is still accepted as a deprecated alias; if both are provided,endpointwins.endpoint_idwill be removed in the next major release. (issue #2252)POST /api/webhooks/alerts/accepts an optionalendpoint(UUID) field. Multi-endpoint accounts can disambiguate without dropping to/api/webhooks/subscriptions/; single-endpoint accounts can still omit it. (issue #2256)
Breaking¶
- Subject-based webhook subscriptions removed (#2267). Subscriptions now match by saved filters (
/api/webhooks/alerts/) only —subject_type,subject_ids, and the legacyresource_ids/entity_type/entity_ids/change_typesfields are no longer accepted byPOST /api/webhooks/subscriptions/(orPATCH). Existing integrations that still send these fields will see them silently dropped. See the alerts guide on docs.makegov.com for migration.
Fixed¶
- OpenAPI now matches runtime for
ordering./api/notices/no longer advertisesordering(the endpoint never accepted it — every value 400s), and/api/subawards/advertises itsorderingenum aslast_modified_date/-last_modified_dateonly. SDKs generated from the schema no longer surface anorderingkwarg on notices, and the subawards kwarg is typed to the actual allowlist. (issue #2254) - OpenAPI
orderingnow has a realenumon the remaining endpoints. Completes the schema/runtime parity work started in #2258:/api/contracts/,/api/idvs/,/api/opportunities/,/api/forecasts/,/api/vehicles/,/api/vehicles/{uuid}/orders/, and/api/gsa_elibrary_contracts/now declare an explicitenummatching each viewset's runtime allowlist (ordering_fields∪ordering_fields_map.keys()+ their-variants). SDK generators emit a typed kwarg instead of a free-formstr, so invalid values fail at type-check time rather than as a runtime 400. (issue #2262) POST /api/webhooks/alerts/no longer returns a sibling endpoint's subscription row when the same user has the same filter(query_type, filters)on two different endpoints. The post-create lookup is now scoped by the resolved endpoint instead of user — a latent dedup bug that became reachable once multi-endpoint disambiguation was added. (issue #2256)GET /api/webhooks/endpoints/sample-payload/no longer 500s. The endpoint previously referenced a subject-type attribute that was removed when subject-based webhooks were deprecated. Returns the alert-only sample set as expected.
[4.5.2] - 2026-05-11¶
Changed¶
- Public docs moved to docs.makegov.com. The in-app MkDocs site at
tango.makegov.com/docs/*is gone; legacy URLs 301-redirect to the new host path-preserving (/docs/getting-started/pricing/→docs.makegov.com/getting-started/pricing/, etc.). Bookmarks keep working. Pricing-tier upgrade responses (upgrade_url) and the welcome-email "Docs" CTA now point at the new host directly.
Fixed¶
/api/otas/and/api/otidvs/pagination no longer 400s on page 2. Cursor-based pagination on these endpoints used to return HTTP 400 whenever the page-1 anchor row had a NULLaward_date— common on OTAs/OTIDVs because many leading rows have no resolvable award date. Page 2 now succeeds and returns the next slice as expected. No client changes required. (issue #2139)
[4.5.1] - 2026-05-09¶
Changed¶
/api/notices/:?shape=office(organization_id)is now valid. The office payload on notices gainsorganization_idas a 7th key, matching the surface already exposed on/api/opportunities/and the awards endpoints. The other six keys (office_code,office_name,agency_code,agency_name,department_code,department_name) are unchanged.
Performance¶
/api/notices/is materially faster. Theofficeexpand no longer fetches fullOrganizationrows on every request. Uncached?limit=100drops from ~0.36 s to ~0.07–0.1 s. No query parameter changes needed; same shapes work faster. (issue #2236)
[4.5.0] - 2026-05-09¶
Fixed¶
?shape=case_id,docketsno longer 500. Namingdocketsas a leaf in the protests shape — without an inner expand likedockets(case_number)— used to raise an internal serializer error. The leaf is now equivalent todockets(*)and returns the default docket projection. Detail endpoint mirrors list. (issue #2222)
Performance¶
- Five more endpoints are materially faster. The organization expand on
/api/forecasts/,/api/grants/,/api/itdashboard/,/api/opportunities/(theofficeexpand), and awards (/api/contracts/,/api/idvs/,/api/otas/,/api/otidvs/) no longer fetches fullOrganizationrows from the database. Uncached?limit=100timing across eight endpoints: ~8.7 s total → ~1.9 s total (−78%); opportunities alone 3.4 s → 0.4 s (−88%), forecasts 2.1 s → 0.15 s (−93%). No query parameter changes needed — same shapes work faster. (issue #2225, PR #2233)
/api/contracts/,/api/idvs/,/api/otas/,/api/otidvs/:?shape=awarding_office(organization_id)and?shape=funding_office(organization_id)are now valid. The office payload gainsorganization_idas a 7th key (the other six —office_code,office_name,agency_code,agency_name,department_code,department_name— are unchanged). Rows that hit the legacy JSON fallback return{"organization_id": null, ...}.
/api/protests/is materially faster. End-to-end on a default?limit=100request, latency drops from ~3.8 s to ~80 ms. No API changes; same response shape.
[4.4.0] - 2026-05-08¶
Added¶
- Agency filter accepts more identifier forms. The
agency/awarding_agency/funding_agencyfilters on every endpoint that exposes one now resolve the same way: hand them a name, abbreviation, or code (CGAC, FPDS, AAC, and others) and Tango figures out the matching agency subtree. See the new Agency search guide. (issue #2207)
Changed¶
- Trimmed
?ordering=whitelist on/api/vehicles/. Removedawardee_countandvehicle_contracts_valuefrom the v4.3.0 ordering surface — both wereSubqueryannotations onVehicleStatswith no backing index, and their matching*_min/maxfilters were intentionally deferred. The remaining surface isvehicle_obligations,latest_award_date,total_obligated,award_date,last_date_to_order,fiscal_year,idv_count,order_count. (issue #2202 follow-up)
Breaking¶
- Comma
,is no longer anANDseparator in filter values; multi-value uses|only. Across every endpoint that accepts smart-text filter values (naics,psc, set-asides, business types, entity / agency text searches, etc.), a comma is now a literal character in the token value rather than anANDseparator.?awarding_agency=HHS,DODwas previously treated asHHS AND DOD(which was always empty — a single record can't equal two different values) and is now treated as a single literal value"HHS,DOD"(also always empty, but for the literal reason). For union semantics use|:?awarding_agency=HHS|DODreturns rows in either subtree. For intersection semantics, repeat the query parameter or use multiple separate filter parameters —AND-joining values inside one parameter has no clear use case in this API. Mixed inputs like?field=A,B|Cno longer raise a400 "Cannot combine AND and OR in the same query"error; they're parsed as"A,B"OR"C". The change also fixes the long-standing footgun where federal entity / agency names containing literal commas ("ENERGY, DEPARTMENT OF","GENERAL DYNAMICS, INC.") silently produced empty results when pasted into a filter. Out of scope:/api/gsa_elibrary_contracts/?sin=,?uei=,?search=continue to honor the prior comma-as-AND grammar (separate code path). (issue #2214)
Removed¶
- AND-via-comma is fully removed from documentation, swagger, and help text. Comma-as-AND no longer works anywhere downstream of
BaseSmartFilter/BasePerformantFilterand the swagger / public docs no longer advertise it. Multi-value uses|(OR) only. Consumers using,for intersection semantics should migrate to multiple distinct query parameters (e.g.?naics=541511&fiscal_year=2024rather than trying to AND values inside a single param). The swagger description-builder (make_description) now raisesValueErrorif a future internal call site passes"conjunctive": True, so the option cannot be reintroduced silently. Public docs (docs/guides/patterns/agency-search.md,docs/webhooks-user-guide.md,docs/index.md, forecasts API user guide) rewritten to drop OR-vs-AND framing in favor of OR-only language. (issue #2214)
Fixed¶
- Multi-value
?agency=A|Breturned zero results. Pipe-separated values onagency/awarding_agency/funding_agencynow correctly return the union of both subtrees (?awarding_agency=HHS|DODreturns rows from either) on every endpoint. Single-value queries are unchanged. (issue #2210, also reported externally asmakegov/tango-public#51)
- Numeric Federal Hierarchy keys (
fh_key) now resolve to the canonical agency. Previously, passing an L2fh_key(e.g. NIH's100019747) could resolve to a deep descendant office (an L3 within NIH whose name happened to contain "AGENCY") instead of NIH itself. The agency now picks the canonical match and the subtree expansion gives the full agency's records. Same behavior change applies to numeric CGAC-style codes when the leading zero is stripped (?agency=89now resolves to DOE rather than a sub-office under DOE).
- Long pipe-separated agency queries no longer return HTTP 400 on award endpoints.
?awarding_agency=DOD|HHS|VA|DHS|DOE|TREAS|USDA|DOJ|DOT|GSA(and similar 10-token OR queries) now return HTTP 200 with the union of every named department's awards on/api/contracts/,/api/idvs/, and other award endpoints. Empty / pipe-only inputs (|||) and all-unresolvable inputs (FAKE1|FAKE2|FAKE3) likewise return HTTP 200 with an empty result set rather than 400. A single typo'd agency name still returns 400 (e.g.?awarding_agency=DEPRTMENTOFENERGY).
- Single-word agency abbreviations no longer accidentally match an unrelated office. Some L3+ offices in the Federal Hierarchy data have a
nameliterally equal to a department abbreviation (e.g. an L3 office named"HHS"); previously, querying?agency=HHScould match that office instead of the canonical L1 HHS department, returning a narrow result set. Abbreviations now consistently resolve to the canonical agency. Multi-word canonical names like?agency=ENERGY, DEPARTMENT OFare unchanged.
?agency=A|Bon/api/forecasts/,/api/grants/,/api/protests/no longer over-matches via partial-text fallback. Where a token resolves cleanly to an agency, only that agency's subtree is matched. The previous behavior fell back to a partial-text match on the legacy text column when paired with multi-value input, which over-matched compound codes (e.g.?agency=HHS|...on grants matched every grant with"HHS-..."in itsagency_code— 1500+ instead of 40). Unresolvable tokens still fall back to the legacy text column as before.
[4.3.0] - 2026-05-07¶
Added¶
- Vehicle list filtering (
/api/vehicles/). New query parameters cover enum / code fields (vehicle_type,type_of_idc,contract_type,set_aside,who_can_use), reference codes (naics_code,psc_code,program_acronym), org hierarchy (agency,organization_id), numeric ranges (total_obligated_min/max,idv_count_min/max,order_count_min/max), and dates (fiscal_year,award_date_after/before,last_date_to_order_after/before).vehicle_type,type_of_idc, andcontract_typeaccept pipe-separated multi-value selections via theORsmart-filter grammar (e.g.?vehicle_type=A|B|C). The?ordering=whitelist is expanded withtotal_obligated,award_date,last_date_to_order,fiscal_year,idv_count,order_count,awardee_count, andvehicle_contracts_valuealongside the existingvehicle_obligationsandlatest_award_date. Examples:GET /api/vehicles/?vehicle_type=A,GET /api/vehicles/?vehicle_type=A|B,GET /api/vehicles/?naics_code=541512&total_obligated_min=1000000,GET /api/vehicles/?ordering=-total_obligated. (issue #2202) - Awardee search (
/api/vehicles/{uuid}/awardees/?search=). Filter the awardees response by entity-aware full-text search across IDV fields (PIID, key, solicitation identifier, NAICS, PSC) and the recipient entity (legal name, address, etc.). Examples:GET /api/vehicles/{uuid}/awardees/?search=ACCENTURE,GET /api/vehicles/{uuid}/awardees/?search=GS-35F-0119Y. The response count reflects filtered results. (issue #2202) /api/forecasts/: neworganizationshape expand. Each forecast row is now linked to a canonicalagencies.Organizationresolved deterministically from the existingagencytext field (12 distinct production acronyms —HHS,DHS,DOI,GSA,DOE,DOT,VA,DOL,NRC,NSF,COMMERCE,TREASURY). Use?shape=organization(*)for the canonical 7-key office payload (organization_id,office_code,office_name,agency_code,agency_name,department_code,department_name). Theorganizationexpand is also in the default response shape (no?shape=needed). The legacyagencytext field and filter are unchanged./api/grants/: neworganizationshape expand. Each grant opportunity is now linked to a canonicalagencies.Organizationresolved from the free-textagency_codefield — the resolver walks compound codes likeHHS-NIH11/HHS-FDA/HHS-CDC-GHC/DOS-ECA/USDOJ-OJP-BJAsegment-wise to the most-specific aliased sub-agency (NIH, FDA, CDC, the State Dept bureau, BJA, etc.) rather than collapsing to the parent department. Codes whose only recognizable token is the dept prefix (DOS-MEXcountry codes, etc.) resolve to the L1 department.?shape=organization(*)returns the canonical 7-key office payload;organizationis in the default response shape. The legacyagencytext field and filter are unchanged./api/itdashboard/: neworganizationshape expand (free tier — same gating asagency_name). Each IT Dashboard investment is now linked to a canonicalagencies.Organization, resolved deterministically from(agency_code, bureau_code)(preferred) and(agency_name, bureau_name)(fallback).?shape=organization(*)returns the canonical 7-key office payload. A small minority of investments (e.g., U.S. Army Corps of Engineers) currently resolve tonulldue to gaps in the canonical org data — tracked in #2176./api/protests/: neworganizationshape expand (free tier — same gating as the existingagencytext field). Each protest case is now linked to a canonicalagencies.Organizationresolved deterministically from the free-textagencyfield — handles hierarchical names (Department of the Army : NAVSEA : Surface Warfare), DoD branches (Army / Navy / Air Force resolve to their AGENCY-typed orgs under DoD, not to DoD itself), and synthetic GAO buckets (Independent Government Entities/Legislative Agenciesskip the dept lookup).?shape=organization(*)returns the canonical 7-key office payload. The deterministic FK is the canonical "what agency is this protest against?" signal — distinct from (and complementary to) the existing Bayesian?shape=resolved_agency(*)expand which returns a best-guess match with confidence and rationale. Both signals can be requested in the same call.
Changed¶
organizationis now in the default response shape for/api/forecasts/,/api/grants/,/api/itdashboard/, and/api/protests/. Previously opt-in via?shape=organization(*); now always present. Renders as the canonical 7-key office payload — same shape asawards,opportunities, andvehicles. Droporganizationfrom your?shape=parameter if you don't want it./api/opportunities/officepayload now includesorganization_id(was 6 keys, now 7). Additive change — same UUID is resolvable as before, just no longer requires a separate query to round-trip from the office to the underlying Organization. Other six keys (office_code,office_name,agency_code,agency_name,department_code,department_name) unchanged.- Bare
?shape=<expand-name>syntax now works on every dataset that has the expand registered withrelation="select"or"prefetch". Previously rejected with a 400unknown_fielderror on most endpoints —?shape=organizationreturns 200 on forecasts / grants / itdashboard / protests / vehicles / opportunities. The parens form (organization(*)) was always working and is unchanged.
[4.2.2] - 2026-05-05¶
Fixed¶
?shape=opportunity(...)on/api/vehicles/now returns the full Opportunity shape instead of a 400. The v4.2.1 implementation shipped a 4-field stub that only allowedopportunity_id,solicitation_title,solicitation_description, andsolicitation_date. Any consumer requesting a field the pre-cutovervehicle.opportunityFK exposed —title,sam_url,first_notice_date,office(...), or any other Opportunity field — received a 400 from shape validation. The fix resolves the fullOpportunitybysolicitation.opportunity_idand routes through the complete Opportunity shape so every field and nested expand (includingoffice(office_code,office_name,agency_code,agency_name,department_code,department_name)andset_aside(code,description)) works as it did pre-cutover. Available on both list and retrieve; opt-in via?shape=opportunity(...)(not in the default response shape). On list pages, all Opportunity rows for the page are fetched in a single batched query, soopportunity(...)is safe to request alongside other shapes. Closes #2193.solicitation_identifierno longer leaks the internalACRO:storage prefix on synthetic GWAC vehicles. Previously, certain GWAC vehicles returned values like"solicitation_identifier": "ACRO:SEWP". TheACRO:prefix is an internal storage detail (collision avoidance with real solicitation numbers in the unique index); the API response now strips it, so SEWP-family vehicles surface as"SEWP","SEWP IV","SEWP V", etc. Useis_synthetic_solicitation(boolean) andprogram_acronym(string) to identify GWAC-recovery vehicles. Refs #2193.
Changed¶
Deprecation: trueHTTP header removed from/api/vehicles/. v4.2.1 added an unconditionalDeprecation: trueheader on every list and retrieve response, advertising deprecation ofagency_details,competition_details, and?shape=opportunity(...). We're walking it back: there are no committed first-class replacements foragency_details.funding_officeor for uniquecompetition_detailskeys (extent_competed,set_aside,solicitation_procedures,number_of_offers_received), no sunset date, and?shape=opportunity(...)is now restored to full pre-cutover parity. The fields continue to work; tooling that was watching for the header should expect it to be absent until we publish a real migration plan.
Breaking¶
?shape=opportunity(solicitation_title)/opportunity(solicitation_description)/opportunity(solicitation_date)no longer validate. These three fields are top-level Vehicle fields, not Opportunity model fields — the v4.2.1 stub erroneously accepted them insideopportunity(...). Use?shape=solicitation_title,solicitation_description,solicitation_date(top-level, no nesting) instead — these come back in the default list shape and are cheaper than the fullopportunity(...)expand.opportunity(opportunity_id)continues to work —opportunity_idis also an Opportunity model field, AND it's available as a top-level Vehicle field. SDK consumers (tango-python,tango-node) verified clean. If you adopted the v4.2.1 stub shape between #2177 and #2193 landing, move the three companion fields to top-level.
[4.2.1] - 2026-05-03¶
Added¶
/api/vehicles/responses now includeis_synthetic_solicitation(boolean). True for a small set of GWAC vehicles where the underlying IDVs lacked a real solicitation identifier but had a recognizableprogram_acronym(8ASTARS,SEWP IV,COMMITS NEXGEN, etc.) — the API keys the vehicle off the program acronym so these GWACs surface in results instead of being dropped. SAM enrichment (solicitation_title/description/date/opportunity_id) is always null on synthetic rows. Use this flag to distinguish them in your own filtering or display logic./api/vehicles/responses now include three new top-level fields on both list and retrieve:program_acronym(the vehicle's program acronym, e.g."OASIS+", when present),idv_count(number of IDVs in the vehicle),total_obligated(sum of obligations across the vehicle's IDVs). Retrieve responses also include a nestedmetricsobject with 12 new metrics:competed_rate,award_concentration_hhi,order_concentration_hhi,top_recipient_share,avg_offers_received,avg_order_value,max_order_value,using_agency_count,recent_obligations_24mo,recent_orders_24mo,days_since_last_order,obligation_to_ceiling_ratio. Request the bag via?shape=metrics(*)or specific keys via?shape=metrics(competed_rate,top_recipient_share).organizationfield on/api/vehicles/list and retrieve responses — the awarding Organization expanded to the canonical 7-key office payload (organization_id,office_code,office_name,agency_code,agency_name,department_code,department_name). The same payload now also populatesagency_details.awarding_officeon both list and detail responses (was previously null on list and sometimes null on detail when per-IDV office signals were missing). Cached for 1 year per Organization.description(string) anddescriptions(array) on/api/vehicles/are populated again. Both had been returning null since v4.2.0; that's now fixed. The legacy compose-a-single-string behavior (descriptionreturns the longest common substring acrossdescriptions) is unchanged.
Changed¶
- Response keys are now alphabetized at every nesting level on
/api/vehicles/list and detail. Leaves and nested expand objects interleave by key name. The order is stable and independent of the order keys appear in your?shape=parameter —?shape=uuid,organizationand?shape=organization,uuidproduce the same JSON. Affects every shape-using endpoint (vehicles, contracts, opportunities, entities, etc.) — if you've been relying on response-key order, switch to explicit key access. The OpenAPI schema (Swagger UI) and SDK type definitions are unchanged.
Changed (internal data path; API surface preserved)¶
/api/vehicles/{uuid}/agency_detailsandcompetition_detailsJSON blobs and the?shape=opportunity(...)expand are now recomputed at request time (from the vehicle's IDVs and theawards_vehicle_solicitationcompanion respectively) instead of being stored columns. No deprecation announced: see the #2193 / 4.2.2 entry for the walk-back. The fields keep working. List responses includeagency_details.awarding_officepopulated from the same source as the top-levelorganizationfield —agency_details.funding_officeis null at the vehicle grain (see below).- Top-level
organization_idon/api/vehicles/retrieve responses is dropped from the default shape — it's redundant withorganization.organization_id(the same UUID, nested inside the office payload). Still requestable explicitly via?shape=organization_id, but the default response no longer surfaces it. - Funding-side organization signal removed from vehicles.
agency_details.funding_officeis null at the vehicle grain. Vehicles are not the right grain for funding-org analysis: a single vehicle is funded by many agencies via task orders, and task-order data already carries funding-side detail. Consumers needing funding-org rollups should query/api/vehicles/<uuid>/orders/and aggregate from there.
[4.2.0] - 2026-04-30¶
Added¶
- G2X integration (#1881): new
/api/news/and/api/events/endpoints (G2X User group required), a/api/company/rag/endpoint for unified company/people/news lookups, andg2x_about/g2x_ai_summary/g2x_employee_countfields available via?shape=on/api/entities/when a linked G2X profile exists - eBuy opportunities now available on
/api/opportunities/via?domain=ebuy(eBuy only) or?domain=sam,ebuy(union with SAM). eBuy results are served live from the G2X GraphQL API — no stored snapshot; filter byrequest_type,contract_type,set_aside_type,buyer_department,closes_after, orsearch. On upstream outage,?domain=sam,ebuydegrades to SAM-only with anupstream_warningsfield so SAM queries stay up vehicleis now available as a shape expand on/api/contracts/. Use?shape=vehicle(uuid,solicitation_title)to attach the contract's parent vehicle to each result, or?shape=vehicle(*)for the curated field set (uuid,solicitation_identifier,solicitation_title,solicitation_description,agency_id,vehicle_type,type_of_idc,contract_type,who_can_use,solicitation_date,award_date,last_date_to_order,fiscal_year,naics_code,psc_code,set_aside,description). Contracts without a parent IDV — or whose parent IDV is not part of any vehicle — returnnull. For richer vehicle detail (awardees, opportunity, totals), follow the returneduuidto/api/vehicles/{uuid}/. Resolution adds one query per page, not per row.
[4.1.1] - 2026-04-27¶
Added¶
cageis now an alias forcage_codeacross the API. Query entities with?cage=or?cage_code=(sending both is rejected — pick one). Awardrecipientshapes accept either key:recipient(cage)returnscage,recipient(cage_code)returnscage_code, andrecipient(*)returnscage_codeonly. If you want the shortcagekey, ask for it explicitly (refs #1350).
Fixed¶
- Filtering subawards by
?awarding_agency=<name|code>or?funding_agency=<name|code>now uses the same hierarchical resolution as the contracts and opportunities endpoints. Department queries (e.g.?awarding_agency=DOE,?awarding_agency=089) return all subawards under that department's subtree; agency codes (e.g.?awarding_agency=8900for DOE,?awarding_agency=7530for CMS) return that agency's subtree; abbreviations (e.g.?awarding_agency=CMS) resolve via the canonical alias table. Result counts will shift compared to the previous literal-code-match behavior — most notably, name-based queries that previously returned 0 (e.g.?awarding_agency=CMS) now return the full agency subtree, semantically aligned with/api/contracts/?awarding_agency=.... Closes #2076.
[4.1.0] - 2026-04-26¶
Internal / Infra¶
- Internal ETL pipeline rewrite for awards data freshness. No API surface or query-behavior changes (refs #2082).
[4.0.14] - 2026-04-26¶
Changed¶
- Latency spikes on
/metrics/endpoints during scheduled refreshes should be noticeably smaller after an internal pipeline rewrite. API surface is unchanged — the same/metrics/endpoints with?group_by=agency/?group_by=departmentparameters continue to work. - Filtering opportunities or notices by
?agency=<name|code>now uses the same hierarchical resolution as the contracts agency filter. Department queries (e.g.?agency=DOE,?agency=089) return the whole department subtree; agency codes (e.g.?agency=8900for DOE,?agency=8960for FERC) return just that agency's subtree. Result counts will shift compared to the previous full-text-search behavior — the new counts are semantically correct subtree matches rather than token co-occurrence. Closes #2058.
Fixed¶
- Forecasts loader: reduced log noise and false Sentry alerts.
sanitize_yeardata-quality events are now DEBUG-level; empty records is a no-op rather than an error; partial validation failures log as warnings (visible in admin, no Sentry alert). Closes #1974, closes #1935. - Filtering contracts by a department's full legal name (e.g.
?awarding_agency=ENERGY, DEPARTMENT OF) now correctly returns the whole department's contracts instead of 0 results. The embedded comma in federal inverted names was being misinterpreted as a target/context separator. Queries for?awarding_agency=DOEand the numeric CGAC/FPDS codes are unchanged. Closes #2079. - Filtering contracts by
awarding_agencyorfunding_agencyusing a real (but currently inactive) agency code such as5706,8933, or8944now returns an HTTP 200 response with the matching contracts, instead of an HTTP 400 "No agency found matching" error. Queries for agency codes that don't exist at all continue to return 400 as before. Closes #2057. - Filtering contracts by a numeric agency code (e.g.
awarding_agency=8900,funding_agency=8900) now returns the correct subtree of contracts instead of narrowing to a single sub-office. A 4-digit FPDS code resolves to that agency and its descendants; a 3-digit CGAC code resolves to the whole department (matching whatawarding_agency=DOEreturns). Name queries are unchanged. Closes #2056. - Filtering opportunities or notices by a numeric agency code (e.g.
?agency=8900) now resolves to the correct subtree instead of matching agencies whose search index happens to contain that token. Fixes over-broad matches for short numeric queries and makes sibling-agency queries (like DOE8900vs FERC8960under the same department) correctly distinct. Closes #2058.
[4.0.12] - 2026-04-22¶
Added¶
- The
/welcome/page now displays your active API key(s) with copy-to-clipboard and visibility toggle — no longer need to navigate to your profile to find your key after signing up
Changed¶
- The welcome page's API keys card now matches the richer layout used on your profile: full-width card at the top of the page with your daily requests remaining, per-key name + "Active" badge, click-to-copy, show/hide, and created date + rate limits. "Your plan" and "Quick links" now sit side-by-side below it so nothing important is hidden below the fold
[4.0.11] - 2026-04-21¶
- No user-facing changes
[4.0.10] - 2026-04-21¶
- No user-facing changes
[4.0.9] - 2026-04-20¶
- No user-facing changes
[4.0.8] - 2026-04-20¶
- No user-facing changes
[4.0.7] - 2026-04-18¶
- No user-facing changes
[4.0.6] - 2026-04-17¶
- No user-facing changes