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)¶
Use Tango from Claude Desktop, Cursor, or other MCP-compatible clients. The Tango MCP server exposes 4 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
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.makegov.com/api/contracts/?limit=5&ordering=-award_date"
import requests
headers = {"X-API-KEY": "your-api-key-here"}
response = requests.get(
"https://tango.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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
- Copy your API key (keep it secure!)
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.makegov.com/api/contracts/"
import httpx
headers = {'X-API-KEY': 'your-api-key-here'}
response = httpx.get(
'https://tango.makegov.com/api/contracts/',
headers=headers
)
const response = await fetch('https://tango.makegov.com/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.makegov.com/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.makegov.com/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
For the full list of per-window headers (daily/burst) 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¶
{
"error": "InsufficientPermissions",
"message": "You don't have permission to access this resource",
"code": 403
}
Causes:
- Insufficient permissions for the requested resource
- Account type doesn't support the requested feature
Solutions:
- Check your account type and permissions
- Contact support to upgrade your account if needed
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.
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 COFC bid protest cases with dockets and decisions
- 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 (Micro 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 (Micro 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 hierarchy resolved from SAM.gov entity graph |
| 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) |
CALC labor rates (Micro and above)¶
The /api/lcats/ endpoint provides GSA CALC labor category rate data. This entire endpoint requires a paid plan.
Identifier validation (Micro and above)¶
The POST /api/validate/ endpoint validates the format of a PIID, solicitation number, or UEI. Available on Pro+ plans (Micro 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": "Micro"}
],
"upgrade_url": "https://tango.makegov.com/docs/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 |
| Micro | 250 / 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.
Webhook subscriptions by plan¶
| Plan | Webhook subscriptions | Custom cron |
|---|---|---|
| Free | 1 | -- |
| Micro | 3 | Yes |
| 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/ - Forecasts:
/api/forecasts/ - Grants:
/api/grants/ - IT Dashboard:
/api/itdashboard/ - Opportunities (canonical routes):
- Opportunities:
/api/opportunities/ - Opportunity Notices:
/api/notices/
- Opportunities:
- Protests:
/api/protests/
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.makegov.com/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,solicitation_number,snippet,sam_url, plus relation id leaves:agency_id,department_id,office_id - Expansions:
attachments(attachment_id,extracted_text,mime_type,name,posted_date,resource_id,type,url)(shorthand:attachments→attachments(*))office(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,notice_type(code,type))set_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,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): adds attachments(*), description, notice_history(*), primary_contact(*)
# 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(*)
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,award_number,solicitation_number - Expansions:
attachments(attachment_id,extracted_text,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,last_updated,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(*)
# 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,uei,display_name)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, Micro+ 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 (Micro+):
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
# Micro+ 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)
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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/api/contracts/?limit=1"
Example response headers:
X-RateLimit-Daily-Limit: 2400
X-RateLimit-Daily-Remaining: 2350
X-RateLimit-Daily-Reset: 86400
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
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. - Then retry with exponential backoff + jitter to avoid a thundering herd.
Python example¶
import random
import time
import httpx
url = "https://tango.makegov.com/api/contracts/?limit=1"
headers = {"X-API-KEY": "your-api-key-here"}
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 not None:
time.sleep(float(wait))
continue
reset = r.headers.get("X-RateLimit-Reset")
if reset is not None:
time.sleep(float(reset))
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.makegov.com/api/contracts/?limit=1";
const headers = { "X-API-KEY": "your-api-key-here" };
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();
if (body.wait_in_seconds != null) {
await sleep(body.wait_in_seconds * 1000);
continue;
}
const reset = r.headers.get("X-RateLimit-Reset");
if (reset != null) {
await sleep(Number(reset) * 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.makegov.com/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.makegov.com/api/vehicles/?ordering=-vehicle_obligations&page=1"
Sort by most recent award activity:
curl -H "X-API-KEY: <key>" \
"https://tango.makegov.com/api/vehicles/?ordering=-latest_award_date&page=1"
Sort by obligations, then by latest award date:
curl -H "X-API-KEY: <key>" \
"https://tango.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/api/vehicles/<vehicle-uuid>/orders/?page=1"
# Order by obligated amount (highest first)
curl -H "X-API-KEY: <key>" \
"https://tango.makegov.com/api/vehicles/<vehicle-uuid>/orders/?ordering=-obligated&page=1"
# Order by total contract value
curl -H "X-API-KEY: <key>" \
"https://tango.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/api/naics/?search=5415&limit=25"
# Free-text on description (returns 9 hits today)
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/api/organizations/?cgac=089"
# FPDS code — multi-stage search picks it up via aliases
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.makegov.com/api/organizations/?search=2100"
# fh_key — direct path lookup (DOE fh_key = 100011980)
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.makegov.com/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 (title, solicitation number, NAICS, response deadline, opportunity ID), - 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 — opportunity_id, 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 —opportunity_id,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("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['opportunity_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.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.opportunity_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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/api/webhooks/alerts/$ALERT_ID/" \
-d '{"is_active": true}'
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.makegov.com/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.makegov.com/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 |
weekly |
Once per week | All tiers |
custom |
5-field cron in cron_expression |
Micro 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", "obligated": 5364614.65, "recipient_uei": "ABC123DEF456"},
{"id": "CONT_AWD_FA8773_24_C_0099_5700_GS35F0119Y_4732", "piid": "FA877324C0099", "obligated": 1450000.00, "recipient_uei": "DEF456GHI789"}
],
"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, obligated, recipient_uei); 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, obligated, recipient_uei
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 webhook lab¶
The Tango repo ships a standalone webhook lab that receives, verifies, and displays signed deliveries. Use it during integration before you have a production endpoint.
# In your tango checkout:
just webhook-lab # starts on localhost:8011
cloudflared tunnel --url http://localhost:8011 # public URL
just webhook-lab-subscribe --url "https://<tunnel>/tango/webhooks" --clear
Then trigger a test delivery:
curl -X POST -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.makegov.com/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.makegov.com/api/webhooks/alerts/"
# Pause without deleting
curl -X PATCH -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.makegov.com/api/webhooks/alerts/<alert_id>/" \
-d '{"is_active": false}'
# Delete
curl -X DELETE -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.makegov.com/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; Micro 3; 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.makegov.com/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.makegov.com/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
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.makegov.com/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 →
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 →
IT Dashboard¶
- IT Dashboard — Federal IT investment data from itdashboard.gov, keyed by Unique Investment Identifier (UII). Filters and shape expansions are tier-gated. Response shaping →
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 (exact). |
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 (exact). |
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 have separate premium query rate limits. See your account dashboard for current limits.
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.
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);
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. |
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). |
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,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): adds attachments(...), description, notice_history(*), primary_contact(*). The default attachments(...) expansion lists attachment_id, file_size, mime_type, name, posted_date, resource_id, type, url — note that the extracted_text sub-field is Pro+ tier and is not in the default. Free-tier callers who request it receive the response without it plus a meta.upgrade_hints entry.
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). - 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.
Search temporarily unavailable
The ?search= parameter is currently returning HTTP 400 due to a missing database migration (search_vector field). Use solicitation_number, naics, or psc filters instead until this is resolved.
| Param | What it does |
|---|---|
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 (exact). |
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,last_updated,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(*)
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). - 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
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);
}
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, TREASURY, DOI, DOL, DOT, VA, NRC), 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 using a 12-acronym alias map covering: HHS, DHS, DOI, GSA, DOE, DOT, VA, DOL, NRC, NSF, COMMERCE, TREASURY. Forecasts whose agency value doesn't map to one of these 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);
}
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.
Authentication: Protests endpoints require authentication (API key or OAuth2). Unauthenticated requests receive HTTP 401.
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 (for example gao). Multi-value: use | for OR. |
outcome |
Filter by protest outcome (for example Denied, Dismissed, Withdrawn, Sustained). Multi-value: use | for OR. |
case_type |
Filter by case type (for example Bid Protest, Bid Protest: Cost). Multi-value: use | for OR. |
agency |
Filter by protested agency text. 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 exact solicitation number. |
protester |
Filter by protester name text. Multi-value: use | for OR. |
filed_date_after, filed_date_before |
Filed date range filters. |
decision_date_after, decision_date_before |
Decision date range filters. |
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)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_urldigest
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.
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). |
# 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.makegov.com/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.makegov.com/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.makegov.com/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 (Micro+). 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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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 →
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 →
IT Dashboard¶
- IT Dashboard — Federal IT investment records from itdashboard.gov, keyed by Unique Investment Identifier (UII). Response shaping →
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.makegov.com/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
- recipient_name, recipient_uei - Vendor/recipient filters
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 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
- Grants
- GSA eLibrary Contracts
- Protests
- 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, awardee_count, order_count |
int | None | Denormalized rollups. |
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
- 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,
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
- 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
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
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 (GAO, COFC, etc.).
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,
search=None,
)
Filter Parameters:
- source_system - Filter by source system (e.g., "gao")
- 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 case number (e.g., "b-423274")
- 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
- 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.
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")
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, 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 |
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 |
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
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.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, 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 18+ with native
fetchand ESM-first design. - Tested Against the Real API – Integration tests (mirroring the Python SDK) keep behavior aligned.
Installation¶
Requirements: Node.js 18 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)/listIdvs(options)/getIdv(key, options)listIdvAwards(key, options)/listIdvChildIdvs({key, ...options})/listIdvTransactions(key, options)getIdvSummary(identifier)/listIdvSummaryAwards(identifier, options)listOtas(options)/getOta(key)/listOtidvs(options)/getOtidv(key)/listOtidvAwards(key, options)listSubawards(options)
Vehicles
listVehicles(options)/getVehicle(uuid, options)/listVehicleAwardees(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)
Forecasts / Opportunities / Notices / Grants
listForecasts(options)/listOpportunities(options)/listNotices(options)/listGrants(options)searchOpportunityAttachments(options)
GSA eLibrary / Protests / IT Dashboard / Subawards / LCATs
listGsaElibraryContracts(options)/listProtests(options)/getProtest(caseNumber)listItDashboard(options)/getItDashboard(uii)listLcats(options)/listIdvLcats(key, 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/iterateVehicles
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;
results: T[];
}
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).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
│ │ ├── 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
├── docs/ # Documentation
│ ├── API_REFERENCE.md
│ ├── DYNAMIC_MODELS.md
│ └── SHAPES.md
├── tests/ # Test suite (Vitest)
│ └── unit/
│ ├── client.test.ts
│ ├── errors.test.ts
│ ├── shapes.factory.test.ts
│ ├── shapes.generator.test.ts
│ ├── shapes.parser.test.ts
│ ├── shapes.schema.test.ts
│ ├── utils.dates.test.ts
│ ├── utils.http.test.ts
│ ├── utils.number.test.ts
│ └── utils.unflatten.test.ts
├── 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.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.
Requirements¶
- Node.js 18 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 |
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 });
getIdvSummary(identifier) / listIdvSummaryAwards(identifier, options?)¶
Deprecated. These methods wrap the
/api/idvs/{identifier}/summary/and/api/idvs/{identifier}/summary/awards/routes, which were removed server-side and now return 404. The methods will be removed from the SDK in a future release. For solicitation-grouped views, query/api/vehicles/instead (see Vehicles).
const summary = await client.getIdvSummary("SOLICITATION_IDENTIFIER");
const awards = await client.listIdvSummaryAwards("SOLICITATION_IDENTIFIER", { limit: 25 });
Entities¶
listEntities(options)¶
const resp = await client.listEntities({
search: "Acme",
shape: ShapeConfig.ENTITIES_MINIMAL,
});
Filters:
search- 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.
Opportunities¶
listOpportunities(options)¶
Search SAM.gov opportunities with shaping.
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 });
Protests¶
listProtests(options?)¶
const protests = await client.listProtests({ source_system: "gao", limit: 25 });
getProtest(caseNumber)¶
const protest = await client.getProtest("CASE_UUID");
IT Dashboard¶
listItDashboard(options?)¶
const investments = await client.listItDashboard({ search: "cloud", limit: 25 });
getItDashboard(uii)¶
const investment = await client.getItDashboard("023-000001234");
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();
const code = await client.getPsc("D302");
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.
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:
TangoAPIErrorTangoAuthErrorTangoNotFoundErrorTangoRateLimitErrorTangoValidationErrorShapeErrorShapeParseErrorShapeValidationErrorTypeGenerationErrorModelInstantiationError
Pagination¶
All list endpoints return:
interface PaginatedResponse<T> {
count: number;
next: string | null;
previous: string | null;
pageMetadata: Record<string, unknown> | null;
results: T[];
}
You can follow next / previous manually or use your own wrapper.
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.GRANTS_MINIMAL |
listGrants() |
ShapeConfig.IDVS_MINIMAL |
listIdvs() |
ShapeConfig.IDVS_COMPREHENSIVE |
getIdv() |
ShapeConfig.VEHICLES_MINIMAL |
listVehicles() |
ShapeConfig.VEHICLES_COMPREHENSIVE |
getVehicle() |
ShapeConfig.VEHICLE_AWARDEES_MINIMAL |
listVehicleAwardees() |
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".
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]¶
[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://govcon.dev/mcp and uses HTTP transport — you connect your MCP client to the remote endpoint; no local install required. Use it from Claude Desktop, Cursor, the OpenAI Responses API, or any MCP-compatible client.
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, and IT Dashboard investments. Filter by vendor, agency, NAICS, PSC, dates, and many other criteria; optionally get aggregate statistics (obligated amounts, set-aside breakdown, etc.). - 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¶
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 server is hosted at https://govcon.dev. The MCP endpoint is https://govcon.dev/mcp. Configure your client to connect to this URL over HTTP and send your API key (see Using with MCP clients below).
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://govcon.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://govcon.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 Desktop¶
If you have the Tango MCP Desktop Extension (.mcpb), install it in Claude Desktop (Settings → Extensions), set the Server URL to https://govcon.dev/mcp, and enter your Tango API key when prompted. The extension connects to the hosted server over HTTP — no local server required.
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 (e.g. ChatGPT MCP) or when you obtain access tokens from Tango's OAuth server.
-
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://govcon.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, and all. Filter by vendor, agency, NAICS, PSC, dates, keyword, and many other criteria. Set include_summary=true for aggregate statistics across matching records. |
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: 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.
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 / COFC). |
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 hosted server is at https://govcon.dev; the MCP endpoint is
https://govcon.dev/mcp(HTTP transport). - 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://govcon.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://govcon.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://govcon.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.makegov.com — sign in to manage your account and API keys.
Privacy¶
Data handling and privacy are described in our Privacy policy.
References¶
- govcon.dev — Hosted Tango MCP server (HTTP transport)
- 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.
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 |
Alert event types¶
When a filter subscription matches, the delivered event uses the alerts.* namespace. There are five:
alerts.opportunity.matchalerts.contract.match— also emitted foridv,ota, andotidvquery typesalerts.entity.matchalerts.grant.matchalerts.forecast.match
If you subscribe to idv, ota, or otidv, matches arrive as alerts.contract.match events; inspect query_type on the event to distinguish.
Frequency options¶
| Frequency | Description | Tier requirement |
|---|---|---|
realtime |
Evaluated after each ingestion cycle | All tiers |
daily |
Evaluated once per day | All tiers |
weekly |
Evaluated once per week | All tiers |
custom |
Custom cron expression (5-field) | Pro+ (Micro and above) |
Tier limits¶
Filter subscriptions are available to all tiers, including Free:
| Tier | Max filter subscriptions |
|---|---|
| Free | 1 |
| Micro | 3 |
| 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).
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.)."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.
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).
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.
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, weekly, 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.makegov.com/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
}
]
}
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.
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", "obligated": 1450000, "recipient_uei": "ABC123..."},
{"id": "CONT_AWD_...", "piid": "FA8773-24-C-...", "obligated": 2300000, "recipient_uei": "DEF456..."}
],
"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 alerts.{contract,opportunity,entity,grant,forecast}.match |
alert_id |
UUID | Stable id of the alert that produced this match |
query_type |
string | contract / opportunity / entity / grant / forecast |
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 include the resource's primary id (id for contracts, opportunity_id for opportunities, uei for entities, grant_id for grants, forecast_id for forecasts) plus a small set of high-signal fields. 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"
}
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": "weekly"
}
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 |
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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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",
"obligated": 2450000,
"recipient_uei": "ABC123XYZ4567"
}
],
"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.makegov.com/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.makegov.com/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.makegov.com/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; Micro 3; 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. - 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.makegov.com/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.makegov.com/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.makegov.com/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", "obligated": 1450000, "recipient_uei": "ABC123..."},
{"id": "CONT_AWD_...", "piid": "FA8773-24-C-...", "obligated": 230000, "recipient_uei": "DEF456..."},
{"id": "CONT_AWD_...", "piid": "N00033-24-D-...", "obligated": 5400000, "recipient_uei": "GHI789..."}
],
"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 / Micro 3 / 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.makegov.com/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.makegov.com/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.makegov.com/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 burns alert budget 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.makegov.com/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.makegov.com/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.makegov.com/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.makegov.com/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 alert-budget burn. |
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 / Micro 3 / 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.makegov.com/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.makegov.com/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.makegov.com/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": [
{"grant_id": "...", "opportunity_number": "DHS-26-CISA-067-001", "title": "Cybersecurity Grant Program FY26", "agency_code": "DHS-CISA"},
{"grant_id": "...", "opportunity_number": "DHS-26-CISA-067-002", "title": "State and Local Cybersecurity Pilot", "agency_code": "DHS-CISA"}
],
"modified": [
{"grant_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 —
dailyorweeklyis usually plenty.realtimeworks but most agencies refresh on a daily cycle anyway. - Tier caps apply. Free 1 / Micro 3 / 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.makegov.com/api/webhooks/alerts/" \
-d '{
"name": "VA IT services forecast — FY26",
"query_type": "forecast",
"filters": {
"agency": "VA",
"naics_starts_with": "5415",
"fiscal_year": "2026"
},
"frequency": "weekly"
}'
That's it. Tango re-evaluates weekly 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.makegov.com/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.makegov.com/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": "weekly"
}'
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="weekly",
)
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: "weekly",
});
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": "...", "external_id": "VA-2026-PRE-08712", "title": "VistA Modernization Phase IV", "agency": "VA", "naics_code": "541512", "anticipated_award_date": "2026-09-30"},
{"id": "...", "external_id": "VA-2026-PRE-08813", "title": "Health Data Lake Refresh", "agency": "VA", "naics_code": "541511", "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.
weeklyis typically the right cadence;realtimeworks but won't actually fire more often than the underlying refresh. - 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 / Micro 3 / 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¶
Breaking¶
Fixed¶
Internal / Infra¶
[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.
Breaking¶
Fixed¶
[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.
Changed¶
Breaking¶
Fixed¶
[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