SAM.gov API Response Structure Reference
Technical reference for SAM.gov's JSON response format. This guide covers nested fields, data types, parsing patterns, and helper code for working with federal contract data.
About this guide: SAM.gov returns detailed nested JSON. This reference helps you understand the structure and write effective parsing code.
Contacts are scoped to the notice, not to the office
pointOfContact carries whatever the posting office typed on that one notice. Verified contracting officer email and phone are a separate dataset. Try GovCon Contacts →
The SAM.gov JSON Response Structure
Example: Single Opportunity Response (Heavily Nested)
Here's a simplified version of what SAM.gov returns for a single contract opportunity:
{
"opportunitiesData": [
{
"noticeId": "abc123def456ghi789",
"title": "Software Development Services",
"sol": "W52P1J-25-R-0001",
"fullParentPathName": "Department of Defense.Department of the Army.Army Contracting Command.Army Contracting Command - Detroit Arsenal (ACC-DTA)",
"fullParentPathCode": "DOD.DA.ACC.ACC-DTA",
"postedDate": "2025-11-01",
"type": "Solicitation",
"baseType": "Solicitation",
"archiveType": "auto15",
"archiveDate": "2025-12-16",
"typeOfSetAsideDescription": "Total Small Business Set-Aside (FAR 19.5)",
"typeOfSetAside": "SBA",
"responseDeadLine": "2025-11-30T17:00:00-05:00",
"pointOfContact": [
{
"type": "primary",
"title": "",
"fullName": "John Smith",
"email": "
[email protected]",
"phone": "586-555-1234",
"fax": null
},
{
"type": "secondary",
"title": "Contracting Officer",
"fullName": "Jane Doe",
"email": "",
"phone": "586-555-5678",
"fax": null
}
],
"placeOfPerformance": {
"streetAddress": "123 Main Street",
"streetAddress2": "Suite 100",
"city": {
"code": "48397",
"name": "Warren"
},
"state": {
"code": "MI",
"name": "Michigan"
},
"zip": "48397",
"country": {
"code": "USA",
"name": "UNITED STATES"
}
},
"organizationType": "OFFICE", // String, not object in current API
"naicsCode": "541511", // String format
"naicsCodes": ["541511"], // Also available as array
"additionalInfoLink": "https://sam.gov/opp/abc123def456ghi789/view",
"uiLink": "https://sam.gov/opp/abc123def456ghi789/view",
"links": [
{
"rel": "self",
"href": "https://api.sam.gov/prod/opportunities/v2/search?noticeid=abc123def456ghi789&limit=1"
}
],
"resourceLinks": [
{
"type": "document",
"name": "Amendment 001",
"link": "https://sam.gov/api/prod/opps/v3/opportunities/resources/files/abc123/download?&token=..."
},
{
"type": "document",
"name": "Original Solicitation",
"link": "https://sam.gov/api/prod/opps/v3/opportunities/resources/files/def456/download?&token=..."
}
],
"officeAddress": {
"zipcode": "48397",
"city": "Warren",
"countryCode": "USA",
"state": "MI"
},
// Award information (if available) - completely separate structure
"award": {
"date": "2025-12-15",
"number": "W52P1J-25-C-0001",
"amount": 150000,
"lineItemNumber": "0001",
"awardee": {
"name": "ACME Software Solutions",
"location": {
"streetAddress": "456 Tech Drive",
"city": "Detroit",
"state": "MI",
"zipCode": "48201",
"countryCode": "USA"
},
"ueiSAM": "ABC123DEF456",
"cageCode": "1A2B3"
}
}
}
],
"totalRecords": 1247,
"offset": 0,
"limit": 10
}
What the data actually looks like at scale
The nested structure above is only half the story. Once you have a few hundred thousand notices in a database, you start finding problems the schema doesn't warn you about. These are real defects we have observed across SAM contract data, not theoretical edge cases.
Our index holds every SAM opportunity notice since October 2024 (FY2025), the current exclusion list, and 880,000+ SAM entity registrations, read from the same source the API serves. The defect measurements in this section were taken on a May 13, 2026 snapshot and carry that date wherever they appear. The corpus grows as SAM publishes, so the totals move; the structural ratios (the UEI coverage gap, the amendment churn) hold across months.
Implausible award dates
Among the 56,590 award notices in the May 13, 2026 snapshot, 41 records have award_date values past 2027, including dates in years 3025, 6202, 7202, and 9202. These are typos that survived every upstream check; one record is dated nearly seven thousand years in the future. Less than 0.1% of records, but enough to break a "top N by date" analytics query in production.
What we do: validate every date param at the API edge before it reaches the database. Customers got a 500 when they accidentally sent 2026-06-31 (June has 30 days) until we patched our own validator; we now reject month-day combinations that do not exist on the calendar, with a clear 400 error.
Missing buyer-identification on award notices
14.4% of award notices have no award_uei_sam populated. In that same snapshot, 8,171 of 56,590 records cannot be linked back to a vendor by UEI. Some are pre-UEI legacy records; some are GSA Schedule and IDV vehicles where the awardee is captured only as a freetext name string. If you build a "company profile" feature and join on award_uei_sam, you will silently undercount by roughly one in seven.
Published-already-archived notices
SAM occasionally publishes contract opportunities whose archiveDate is on or before the postedDate. In the 52 days before that snapshot we ingested 399 such records. They are technically in the dataset but never visible in any live open opportunities feed, because the archive cutoff has already passed at the moment of publication. If you depend on SAM's "active" semantics rather than computing your own from archive_date_detailed, you will miss these notices entirely.
Notice IDs are not stable across amendments
noticeId looks like a primary key but does not behave like one. SAM mints a new noticeId for every amendment to a solicitation. Our internal audits show roughly 74% of records that appear "missing" between two snapshots of the dataset are actually older revisions of solicitations already present under a newer noticeId. If you treat the field as a stable identifier and dedupe on it, you will accumulate every revision as a separate record.
The more stable identifier is solicitation_number (the human-readable string like W52P1J-25-R-0001), but it is not unique across agencies. Real deduplication needs both fields plus a posted-date window.
solicitation_number and notice_id get confused constantly
The two fields use entirely different formats. noticeId is a 32-character hex UUID (abc123def456ghi789...). solicitation_number follows agency-specific patterns like N0003925R4014 or 36C25026B0043. Customers regularly paste the wrong one into URL paths and get back HTTP 400 or empty results. We see this pattern routinely in our access logs. Both fields belong in any UI you build on this data, clearly labeled.
The literal string "null" instead of a SQL NULL
SAM's exclusion records ship with the four-character string "null" in fields that should be empty. At an earlier point we found that 119,343 of 163,450 exclusion rows in our staging database had uei_sam stored as the literal text "null" rather than SQL NULL. A literal lookup at /exclusions/null matched a real record; a search filter ?uei_sam=null matched 73% of the table. We have since coerced "null", "none", "n/a", and the empty string to actual SQL NULL during ingest, and our API no longer exhibits this behavior. Anyone loading the same source data into their own database will need to do the same normalization, or their WHERE column IS NULL queries will silently miss everything.
Inconsistent date formats across fields
Within a single opportunity record, SAM mixes formats:
postedDate: date-only string "2025-11-01"
responseDeadLine: ISO 8601 with offset "2025-11-30T17:00:00-05:00"
archiveDate: date-only string
award.date: date-only string sometimes, ISO sometimes
The CSV exports use yet another format. A parser has to recognize at least four patterns before sorting and filtering work correctly.
Mixed types in the same field
naicsCode can be a string (single primary code) or an array, depending on the record. placeOfPerformance.city can be an object with {code, name} or a plain string. set_aside_type may be the code ("SBA") or the description ("Total Small Business Set-Aside (FAR 19.5)") depending on how the source system reported it. Your parser needs isinstance() checks at every level.
Free-text contamination in structured fields
awardee_name sometimes contains the full mailing address concatenated into the name itself ("ACME CORP 123 MAIN ST CHESAPEAKE VA 23320-5999"). Splitting this back into name + address with a regex butchers 21% of records on the messy variants. The safest move is to keep the raw string and accept that "company name" is sometimes "company name plus a comma-separated address." Searching by name needs full-text or trigram indexing rather than equality.
Cancellation status is not exposed
SAM has three lifecycle states for a notice: active, archived, and cancelled. The JSON exposes only the first two. A cancelled notice still has a future archiveDate and looks active in the data; the cancellation lives in a separate event stream or shows up only when SAM updates the human-readable detail page. If you need cancellation awareness, you have to scrape it.
Summary of findings (May 13, 2026 snapshot)
| Issue |
Scale we observe |
Customer impact |
Implausible award_date values |
41 of 56,590 award notices |
Breaks "sort by date" analytics |
Missing award_uei_sam |
14.4% (8,171 of 56,590) |
Vendor-profile join misses roughly 1 in 7 |
| Published-already-archived notices |
399 in a 52-day window |
Invisible in any "open opportunities" feed |
| Notice ID churn on amendments |
~74% of apparent "missing" records |
Duplicate records if deduped on the wrong field |
Literal "null" strings (historical, since cleaned in our index) |
Was 119,343 of 163,450 exclusion uei_sam rows |
WHERE uei_sam IS NULL silently misses everything |
| Mixed date formats in one record |
Every opportunity |
Sorting and filtering require multi-format parsing |
| Mixed types per field |
Common |
Runtime type errors |
Address concatenated into awardee_name |
Roughly 1 in 5 of the messier variants |
Regex splitting corrupts vendor names |
| No cancellation status in the JSON |
All notices |
Cancelled notices look active until archived |
None of these are documented anywhere in SAM's official references. We surface them here because they cost real engineering time to discover on your own dataset.
The public bulk is a filtered subset of SAM's full registry
The monthly Public V2 entity extract that most tools (including ours) build on contains 876,399 entities as of the May 2026 release. SAM's internal search index returns 2,332,884 records for the same query. The 1.46M-record gap isn't a bug or a stale snapshot. The bulk is a deliberately filtered subset.
| Filter on SAM's internal index | Records | Notes |
| (no filter, full registry) | 2,332,884 | What SAM indexes internally |
publicDisplayFlag = "Y" | 1,333,263 | Opt-in public disclosure only |
recordStatus = "A" | 848,808 | Close to the 782K active rows in the public bulk |
| Public V2 monthly bulk (all filters applied) | 876,399 | What ends up in our table |
Where the extra 1.46M comes from
Most of the non-bulk-eligible records cluster into three buckets.
Privacy-redacted entities (roughly 1.0M). SAM lets registrants opt out of public disclosure at registration time, setting publicDisplayFlag="N". They stay in SAM's index so the website can show "this UEI is registered, just not browsable", but no public API or bulk extract surfaces them. They are FOIA-protected by design.
Non-contract-vendor registration purposes. The bulk is filtered to entities registered for All Awards (full federal contracting). SAM also indexes registrations purposed only for Federal Assistance Awards (grants), IRS 1099 reps and certs, or sub-vendor representations. These exist in ent but are not included in the contract-vendor bulk because they are not bidding on contracts.
Pre-UEI / CCR-era migration debris. SAM replaced CCR (Central Contractor Registration) in 2018, and DUNS+4 was replaced by UEI in 2022. Old CCR records were migrated into SAM's index for audit-trail continuity. About 77% of records in SAM's full index still carry a 2012-07-28 migration-baseline timestamp, meaning they haven't been touched since the CCR to SAM migration. Mostly defunct entities retained for reference.
What this means in practice
If you are querying federal contractors for vendor research, due diligence, or contract pipeline intelligence, the 876K public-bulk universe is the right scope. The extra 1.46M in SAM's full index is noise for that use case: roughly 1.0M privacy-redacted (legally inaccessible to any API), about 400K migration debris (defunct companies, decade-old inactive records), and 50K to 100K non-contract-vendor registrations.
The only meaningful gap is the privacy-redacted cohort: roughly 1M actively-contracting entities who chose not to be publicly listed. No public API surfaces them. Awareness of this gap matters when reconciling totals against counts from other sources (USASpending's vendor count derives from a different pipeline and can differ in turn).
Key Parsing Considerations
Common Patterns to Handle
- Nested Objects: Data can be 3-4 levels deep (e.g., placeOfPerformance.city.name)
- Optional Fields: Always use null-safe access patterns
- Mixed Types: Some fields may be string, array, or object depending on data
- Contact Arrays: pointOfContact contains multiple contacts with different types
- NAICS Codes: Returns BOTH naicsCode (string) AND naicsCodes (array)
- Address Formats: placeOfPerformance vs officeAddress use different structures
- Date Formats: Mix of date-only (YYYY-MM-DD) and ISO timestamps
- Set-Aside Codes: Use lookup table to translate codes to readable names
- Agency Hierarchy: fullParentPathName contains dot-separated department chain
What it takes to parse this reliably
A parser for the response above
The class below handles the nesting, the mixed types, and the date formats documented above. It runs 337 lines and it is not finished.
import re
from datetime import datetime, timezone
from typing import Dict, List, Optional, Any
class SAMOpportunityParser:
"""Complex parser for SAM.gov opportunity data"""
def __init__(self):
# Set-aside code translations
self.set_aside_codes = {
'SBA': 'Small Business Set-Aside',
'A6': '8(a) Set-Aside',
'HZC': 'HUBZone Set-Aside',
'SDVOSBC': 'Service-Disabled Veteran-Owned Small Business',
'WOSB': 'Women-Owned Small Business',
'EDWOSB': 'Economically Disadvantaged Women-Owned Small Business',
'': 'Full and Open Competition'
}
# Organization type mappings
self.org_type_codes = {
'O': 'Office',
'D': 'Department',
'A': 'Agency',
'S': 'Sub-Agency'
}
def parse_opportunity(self, raw_data: Dict) -> Dict:
"""Parse a single opportunity from SAM.gov response"""
try:
# Extract basic fields with null checking
opportunity = {
'notice_id': self._safe_get(raw_data, 'noticeId'),
'title': self._safe_get(raw_data, 'title', '').strip(),
'solicitation_number': self._safe_get(raw_data, 'sol'),
'posted_date': self._parse_date(raw_data.get('postedDate')),
'response_deadline': self._parse_datetime(raw_data.get('responseDeadLine')),
'notice_type': self._safe_get(raw_data, 'type'),
'base_type': self._safe_get(raw_data, 'baseType'),
'archive_date': self._parse_date(raw_data.get('archiveDate')),
}
# Parse complex agency hierarchy
agency_data = self._parse_agency_hierarchy(raw_data)
opportunity.update(agency_data)
# Parse set-aside information
opportunity['set_aside'] = self._parse_set_aside(raw_data)
# Parse contact information (complex nested array)
opportunity['contacts'] = self._parse_contacts(raw_data.get('pointOfContact', []))
# Parse performance location (deeply nested)
opportunity['performance_location'] = self._parse_location(
raw_data.get('placeOfPerformance', {})
)
# Parse office address (different structure than performance location)
opportunity['office_address'] = self._parse_office_address(
raw_data.get('officeAddress', {})
)
# Parse NAICS codes (array of objects)
opportunity['naics_codes'] = self._parse_naics_codes(
raw_data.get('naicsCode', [])
)
# Parse organization type
opportunity['organization_type'] = self._parse_organization_type(raw_data)
# Parse resource links (documents, amendments)
opportunity['resource_links'] = self._parse_resource_links(
raw_data.get('resourceLinks', [])
)
# Parse award information (if available)
opportunity['award_info'] = self._parse_award_info(
raw_data.get('award', {})
)
# Generate clean URLs
opportunity['sam_url'] = self._generate_sam_url(opportunity['notice_id'])
# Extract additional metadata
opportunity['total_records'] = raw_data.get('totalRecords')
return opportunity
except Exception as e:
# Robust error handling for malformed data
print(f"Error parsing opportunity {raw_data.get('noticeId', 'unknown')}: {e}")
return self._create_error_record(raw_data, str(e))
def _safe_get(self, data: Dict, key: str, default: Any = None) -> Any:
"""Safely extract value with null checking"""
value = data.get(key, default)
if isinstance(value, str):
return value.strip() if value else default
return value if value is not None else default
def _parse_agency_hierarchy(self, data: Dict) -> Dict:
"""Parse complex agency hierarchy string"""
full_path = data.get('fullParentPathName', '')
path_code = data.get('fullParentPathCode', '')
# Split hierarchy: "Dept.Agency.Sub-Agency.Office"
path_parts = full_path.split('.')
code_parts = path_code.split('.')
return {
'department': path_parts[0] if len(path_parts) > 0 else '',
'agency': path_parts[1] if len(path_parts) > 1 else '',
'sub_agency': path_parts[2] if len(path_parts) > 2 else '',
'office': path_parts[3] if len(path_parts) > 3 else '',
'department_code': code_parts[0] if len(code_parts) > 0 else '',
'agency_code': code_parts[1] if len(code_parts) > 1 else '',
'full_agency_name': full_path,
'full_agency_code': path_code
}
def _parse_set_aside(self, data: Dict) -> Dict:
"""Parse set-aside information with code translation"""
code = data.get('typeOfSetAside', '')
description = data.get('typeOfSetAsideDescription', '')
return {
'code': code,
'description': description,
'standardized_name': self.set_aside_codes.get(code, code),
'is_small_business': code in ['SBA', 'A6', 'HZC', 'SDVOSBC', 'WOSB', 'EDWOSB']
}
def _parse_contacts(self, contacts_data: List[Dict]) -> List[Dict]:
"""Parse contact array with inconsistent structure"""
contacts = []
for contact in contacts_data:
if not isinstance(contact, dict):
continue
parsed_contact = {
'type': contact.get('type', '').lower(),
'title': self._safe_get(contact, 'title', ''),
'name': self._safe_get(contact, 'fullName', ''),
'email': self._clean_email(contact.get('email', '')),
'phone': self._clean_phone(contact.get('phone', '')),
'fax': self._clean_phone(contact.get('fax', ''))
}
# Skip contacts with no useful information
if parsed_contact['name'] or parsed_contact['email']:
contacts.append(parsed_contact)
return contacts
def _parse_location(self, location_data: Dict) -> Dict:
"""Parse complex nested location structure"""
if not location_data:
return {}
# Handle nested city/state/country objects
city_obj = location_data.get('city', {})
state_obj = location_data.get('state', {})
country_obj = location_data.get('country', {})
return {
'street_address': self._safe_get(location_data, 'streetAddress', ''),
'street_address_2': self._safe_get(location_data, 'streetAddress2', ''),
'city': city_obj.get('name', '') if isinstance(city_obj, dict) else str(city_obj),
'city_code': city_obj.get('code', '') if isinstance(city_obj, dict) else '',
'state': state_obj.get('code', '') if isinstance(state_obj, dict) else str(state_obj),
'state_name': state_obj.get('name', '') if isinstance(state_obj, dict) else '',
'zip_code': self._safe_get(location_data, 'zip', ''),
'country': country_obj.get('code', '') if isinstance(country_obj, dict) else str(country_obj),
'country_name': country_obj.get('name', '') if isinstance(country_obj, dict) else ''
}
def _parse_office_address(self, office_data: Dict) -> Dict:
"""Parse office address (different structure than performance location)"""
if not office_data:
return {}
return {
'city': self._safe_get(office_data, 'city', ''),
'state': self._safe_get(office_data, 'state', ''),
'zip_code': self._safe_get(office_data, 'zipcode', ''),
'country': self._safe_get(office_data, 'countryCode', '')
}
def _parse_naics_codes(self, naics_data) -> List[Dict]:
"""Parse NAICS - SAM.gov returns BOTH naicsCode (string) AND naicsCodes (array)"""
naics_codes = []
# SAM.gov returns naicsCode as a string (e.g., "541511")
# AND naicsCodes as an array (e.g., ["541511"])
if isinstance(naics_data, str) and naics_data:
naics_codes.append({
'code': naics_data,
'title': '', # Title not provided by SAM.gov API
'is_primary': True
})
elif isinstance(naics_data, list):
# Handle legacy array format if encountered
for naics in naics_data:
if isinstance(naics, dict):
parsed_naics = {
'code': self._safe_get(naics, 'code', ''),
'title': self._safe_get(naics, 'title', ''),
'is_primary': len(naics_codes) == 0
}
if parsed_naics['code']:
naics_codes.append(parsed_naics)
elif isinstance(naics, str):
naics_codes.append({
'code': naics,
'title': '',
'is_primary': len(naics_codes) == 0
})
return naics_codes
def _parse_award_info(self, award_data: Dict) -> Optional[Dict]:
"""Parse award information (completely different structure)"""
if not award_data:
return None
# Parse awardee information (nested in award object)
awardee_data = award_data.get('awardee', {})
awardee_location = awardee_data.get('location', {})
return {
'award_date': self._parse_date(award_data.get('date')),
'award_number': self._safe_get(award_data, 'number', ''),
'award_amount': self._parse_amount(award_data.get('amount')),
'line_item_number': self._safe_get(award_data, 'lineItemNumber', ''),
'awardee_name': self._safe_get(awardee_data, 'name', ''),
'awardee_uei': self._safe_get(awardee_data, 'ueiSAM', ''),
'awardee_cage_code': self._safe_get(awardee_data, 'cageCode', ''),
'awardee_address': {
'street': self._safe_get(awardee_location, 'streetAddress', ''),
'city': self._safe_get(awardee_location, 'city', ''),
'state': self._safe_get(awardee_location, 'state', ''),
'zip_code': self._safe_get(awardee_location, 'zipCode', ''),
'country': self._safe_get(awardee_location, 'countryCode', '')
}
}
def _parse_date(self, date_str: Optional[str]) -> Optional[str]:
"""Parse various date formats from SAM.gov"""
if not date_str:
return None
try:
# Handle multiple date formats
for fmt in ['%Y-%m-%d', '%m/%d/%Y', '%Y-%m-%dT%H:%M:%S']:
try:
dt = datetime.strptime(date_str.split('T')[0], fmt)
return dt.strftime('%Y-%m-%d')
except ValueError:
continue
return date_str # Return original if parsing fails
except Exception:
return None
def _parse_datetime(self, datetime_str: Optional[str]) -> Optional[str]:
"""Parse datetime with timezone handling"""
if not datetime_str:
return None
try:
# Remove timezone suffix for parsing
clean_dt = re.sub(r'[-+]\d{2}:\d{2}$', '', datetime_str)
dt = datetime.fromisoformat(clean_dt)
return dt.isoformat()
except Exception:
return datetime_str
def _clean_email(self, email: str) -> str:
"""Clean and validate email addresses"""
if not email:
return ''
email = email.strip().lower()
# Basic email validation
if '@' in email and '.' in email.split('@')[-1]:
return email
else:
return ''
def _clean_phone(self, phone: str) -> str:
"""Clean phone number format"""
if not phone:
return ''
# Remove non-numeric characters except +
clean_phone = re.sub(r'[^\d+\-\(\)\s]', '', phone.strip())
return clean_phone if len(re.sub(r'[^\d]', '', clean_phone)) >= 10 else ''
def _parse_amount(self, amount: Any) -> Optional[float]:
"""Parse monetary amounts"""
if amount is None:
return None
try:
if isinstance(amount, (int, float)):
return float(amount)
elif isinstance(amount, str):
# Remove currency symbols and commas
clean_amount = re.sub(r'[^\d.]', '', amount)
return float(clean_amount) if clean_amount else None
except ValueError:
return None
return None
def _generate_sam_url(self, notice_id: str) -> str:
"""Generate SAM.gov URL for opportunity"""
return f"https://sam.gov/opp/{notice_id}/view" if notice_id else ""
# _parse_resource_links, _parse_organization_type and _create_error_record
# are called above and still have to be written.
# Usage: the caller loops and absorbs per-record failures
def process_sam_response(sam_response: Dict) -> List[Dict]:
"""Process SAM.gov API response"""
parser = SAMOpportunityParser()
opportunities = []
for opp_data in sam_response.get('opportunitiesData', []):
parsed_opp = parser.parse_opportunity(opp_data)
opportunities.append(parsed_opp)
return opportunities
That class is 337 lines and still incomplete. It calls three methods it never defines:
_parse_resource_links,
_parse_organization_type, and
_create_error_record. Past those, a parser that survives the defects documented above also has to carry:
- Resource link and document attachment handling
- Amendment tracking, because
noticeId changes on every revision
- Cancellation detection, which the JSON does not expose at all
- Error recovery and logging for records that fail mid-parse
- Coercing the literal string
"null" to SQL NULL before load
The same notice, flattened
GovCon API response for the same opportunity
The same source record, one level deep, with the set-aside code translated, the NAICS titles resolved, and the two contacts split into named fields.
{
"data": [
{
"notice_id": "abc123def456ghi789",
"title": "Software Development Services",
"solicitation_number": "W52P1J-25-R-0001",
"agency": "Department of Defense",
"department": "Department of Defense",
"sub_agency": "Army Contracting Command",
"office": "ACC - Detroit Arsenal",
"posted_date": "2025-11-01",
"response_deadline": "2025-11-30T17:00:00-05:00",
"notice_type": "Solicitation",
"set_aside_type": "Small Business Set-Aside",
"set_aside_code": "SBA",
"naics": ["541511", "541512"],
"naics_titles": ["Custom Computer Programming Services", "Computer Systems Design Services"],
"primary_naics": "541511",
"contact_name": "John Smith",
"contact_email": "
[email protected]",
"contact_phone": "586-555-1234",
"secondary_contact": "Jane Doe",
"secondary_email": "",
"secondary_phone": "586-555-5678",
"performance_city": "Warren",
"performance_state": "MI",
"performance_state_name": "Michigan",
"performance_zip": "48397",
"performance_country": "USA",
"performance_address": "123 Main Street, Suite 100",
"sam_url": "https://sam.gov/opp/abc123def456ghi789/view",
"description_text": "The Army requires software development services for...",
"award_date": "2025-12-15",
"award_number": "W52P1J-25-C-0001",
"award_amount": 150000.00,
"awardee_name": "ACME Software Solutions",
"awardee_location": "Detroit, MI",
"awardee_uei": "ABC123DEF456",
"archive_date": "2025-12-16",
"last_updated": "2025-11-01T10:30:00Z",
"active": true
}
],
"pagination": {
"total": 1247,
"limit": 100,
"offset": 0,
"has_next": true
}
}
Reading the flat response
import requests
response = requests.get(
'https://govconapi.com/api/v1/opportunities/search',
headers={'Authorization': 'Bearer your_api_key'},
params={'naics': '541511', 'limit': 100}
)
opportunities = response.json()['data']
for opp in opportunities:
print(f"Title: {opp['title']}")
print(f"Agency: {opp['agency']}") # flat string, not a dotted path
print(f"Contact: {opp['contact_email']}") # primary contact already selected
print(f"Description: {opp['description_text']}") # carried on the record
print(f"Award Amount: ${opp['award_amount'] or 'TBD'}")
print("---")
What the two code paths actually show
Both blocks on this page read the same opportunity. The parser is 337 lines, still calls three methods it does not define, and has not yet touched a single defect documented earlier: the amendment churn, the literal "null", the four date formats, the fields that change type between records. The client is 17 lines and parses nothing, because the flattening, the code translation, and the null coercion happen before the response is served.
We are not going to attach an hours figure or a dollar figure to that gap. It depends on which fields you need, how much of the defect surface you actually hit, and how long you keep the integration alive. What the two blocks do show is where the cost sits: not in calling the endpoint, but in everything between the response and a row you can query.
If you would rather consume the flat shape than maintain the parser, the API reference documents every field shown above.
Why SAM.gov's Structure is So Complex
Historical Technical Debt
- Legacy System Consolidation: Merged data from a set of separate federal systems, each keeping its own field structure
- Backward Compatibility: Must support old XML and SOAP formats
- Regulatory Requirements: Complex government data standards
- Multiple Data Sources: Different agencies submit data in different formats
Government vs. Commercial API Design
| Aspect |
Government APIs |
Commercial APIs |
| Design Priority |
Compliance & completeness |
Developer experience |
| Data Structure |
Preserves source formats |
Optimized for consumption |
| Field Naming |
Regulatory terminology |
Intuitive naming |
| Breaking Changes |
Rarely allowed |
Managed with versioning |
| Performance |
Secondary concern |
Primary design goal |
Summary
SAM.gov's nested JSON is a faithful record of how federal contracting data is collected, and that is exactly what makes it expensive to consume: the response preserves the shapes of the source systems instead of resolving them. The parser above covers the patterns you hit first. The defects documented earlier are the ones that surface later, on your own dataset, usually after the integration is already in production.
Related Guides
Official Resources
Community Feedback
Help improve this guide for fellow developers. Press Ctrl+Enter to share issues you encountered, missing information, or suggestions.