SAM.gov API Complete Technical Guide
Updated
Comprehensive technical documentation for implementing the SAM.gov API, including authentication, code examples, error handling, and production deployment strategies.
What this guide covers: Step-by-step API implementation including authentication, endpoints, code examples, and error handling. Estimated implementation time varies based on your requirements.
Building a Complete Solution?
Once you have opportunities, get verified contacts for contracting officers & decision makers. Try GovCon Contacts →
Authentication & API Keys
API Key Required for All Access
SAM.gov API requires an api_key query parameter for all requests (even the 10/day limit):
curl "https://api.sam.gov/prod/opportunities/v2/search?api_key=your_sam_key&limit=1&postedFrom=01/01/2026&postedTo=01/28/2026"
Step 1: Create SAM.gov Account & Generate Key (Instant)
Basic access (10/day):
- Create free SAM.gov account
- Generate API key (instant)
Step 2: Entity Registration for 1,000/day (about 3 weeks)
For higher limits:
- Complete entity registration with UEI
- Wait for validation (up to 10 business days, per SAM.gov)
- Request "Data Entry" role
- Wait for role approval (your own Entity Administrator approves it, not GSA; escalate after 5 business days)
- Regenerate key with higher limits
- Wait a further 24 hours for the higher ceiling to take effect
The second wait is the one people misread. The API role is granted by your own entity's administrator, so if it stalls, the person to chase works at your company. The Federal Service Desk is explicit that it "cannot release administrator names, nor approve or reject role requests." Five business days is the point at which SAM tells you to stop waiting and escalate, by submitting an Entity Administrator Appointment Letter. Budget about three weeks end to end.
Sources: SAM.gov entity registration for the up-to-10-business-day activation window, and FSD article KB0035972, "User Roles: Who approves or rejects my role request in SAM.gov?" for who approves a role request and the five-day escalation. Checked 3 October 2026.
Rate Limits in One Line
The API documentation page publishes no numbers, only that limits vary "based on the federal or non-federal or general roles." The figures are 10 per 24 hours on a basic key, 1,000 after entity registration, and 10,000 for a federal system account (FSD KB0017604).
Who may ask for more, why 1,000 is a ceiling rather than a starting point, and the escalation path that exists only for federal system accounts are covered in SAM.gov Rate Limits Explained.
API Endpoints & Parameters
Primary Endpoints
| Endpoint | Purpose | Rate Limit Impact |
|---|---|---|
/opportunities/v2/search |
Search contract opportunities | 1 request per search |
/opportunities/v2/search?noticeid=... |
Get specific opportunity by ID | 1 request per lookup |
/entity-information/v3/entities |
Entity lookup and validation | 1 request per entity |
/exclusions/v3/search |
Check exclusions database | 1 request per search |
Search Parameters for Opportunities
Required on every call: api_key, postedFrom, postedTo. Everything else is optional.
| Parameter | What it filters |
|---|---|
postedFrom / postedTo |
Posted-date range, MM/dd/yyyy. The span cannot exceed one year. |
rdlfrom / rdlto |
Response-deadline range, MM/dd/yyyy, same one-year cap |
ptype |
Notice type, nine codes (below) |
solnum |
Solicitation number |
noticeid |
A single notice by id |
title |
Substring match against the title field only |
ncode |
NAICS code, up to 6 digits |
ccode |
Classification (PSC) code |
typeOfSetAside |
Set-aside code, one value only |
typeOfSetAsideDescription |
Set-aside description |
state |
Place-of-performance state |
zip |
Place-of-performance ZIP |
organizationCode |
Awarding organization code |
organizationName |
Department or sub-tier name |
status |
active, inactive, archived, cancelled, deleted |
limit |
Records per page, max 1000, defaults to 1 |
offset |
Record offset, starts at 0. GSA's own table calls it a page index; it is not. offset + limit cannot exceed ~1,000 (see restrictions) |
The nine ptype codes
| Code | Notice type |
|---|---|
o |
Solicitation |
p |
Presolicitation |
k |
Combined Synopsis/Solicitation |
r |
Sources Sought |
a |
Award Notice |
s |
Special Notice |
u |
Justification (J&A) |
g |
Sale of Surplus Property |
i |
Intent to Bundle Requirements (DoD-funded) |
s is Special Notice, not solicitation. The solicitation code is o. Passing s when you meant
solicitations returns a different notice class with a 200, so nothing in the response flags the mistake.
There is no keyword parameter. Full-text search does not exist on this endpoint. title matches
the title field only, so a term that appears in the description is unreachable.
Five Restrictions That Cost a Debugging Session
Four of these are stated in GSA's Get Opportunities API documentation, in passing, and each one looks like a bug the first time you hit it. The fifth is documented nowhere.
limit defaults to 1. Omit the parameter and a correct, authenticated, correctly-dated call returns
one record. It reads as an almost-empty result set rather than a pagination default, so the usual reaction
is to go looking for a filter problem that does not exist.
The posted-date window is capped at one year. postedFrom and postedTo more than a year apart are
rejected. The same cap applies to rdlfrom / rdlto. Backfilling several years means looping year-sized
windows, and every window spends a request against a daily limit of 1,000 on a registered
non-federal key.
Only one set-aside value is accepted. typeOfSetAside takes a single code, so "8(a) or WOSB" is two
calls rather than one filter. The accepted codes are SBA, SBP, 8A, 8AN, HZC, HZS, SDVOSBC, SDVOSBS, WOSB,
WOSBSS, EDWOSB, EDWOSBSS, LAS, IEE, ISBEE, BICiv, VSA and VSS.
The endpoint returns only the latest active version of a notice. Amendment history is not retrievable here, and a later call cannot recover it. If you need to know what a solicitation said before it was amended, this is not the surface that will tell you.
Pagination stops at roughly 1,000 records whatever totalRecords reports. The cap is on
offset + limit, not on offset alone, and past it the endpoint returns an empty 200 carrying the
original totalRecords, not an error. So the loop most tutorials recommend, page until
offset >= totalRecords, truncates silently at record 1,000. A smaller page size does not dodge it.
The measurement, the Elasticsearch signature behind it, and the partitioning you need to get past it are
in the pagination section of the SAM.gov API guide.
What we do: GET /api/v1/opportunities/search defaults to 20 records rather than 1, accepts a date
range of any width on paid plans, and paginates past the point where SAM stops: verified 2026-10-02,
offset=100000 still returns rows. It also carries the full description text in the response body
instead of a link that needs a second authenticated fetch, on 99.8% of 712,639 notices and on 100% of
everything posted more than two days ago. The endpoint reference lists
every filter it accepts.
Complete Code Examples
Python Implementation
import requests
import json
import time
from datetime import datetime, timedelta
import logging
class SAMAPIClient:
def __init__(self, api_key):
self.api_key = api_key
self.base_url = "https://api.sam.gov/prod"
self.session = requests.Session()
self.session.headers.update({
'Accept': 'application/json'
})
self.rate_limit_remaining = 10 # Track daily limit
def search_opportunities(self, **params):
"""Search for opportunities with rate limiting"""
if self.rate_limit_remaining <= 0:
raise Exception("Daily rate limit exceeded (10 requests)")
endpoint = "/opportunities/v2/search"
url = f"{self.base_url}{endpoint}"
# Default parameters - api_key goes in query params, not header
default_params = {
'api_key': self.api_key,
'limit': 10, # Small limit due to rate constraints
'offset': 0,
'postedFrom': (datetime.now() - timedelta(days=30)).strftime('%m/%d/%Y'),
'postedTo': datetime.now().strftime('%m/%d/%Y')
}
default_params.update(params)
try:
response = self.session.get(url, params=default_params, timeout=30)
self.rate_limit_remaining -= 1
if response.status_code == 200:
data = response.json()
return self._process_opportunities(data)
elif response.status_code == 429:
raise Exception("Rate limit exceeded")
else:
response.raise_for_status()
except requests.exceptions.RequestException as e:
logging.error(f"SAM API Error: {e}")
raise
def _process_opportunities(self, data):
"""Process the complex SAM.gov response structure"""
opportunities = []
# SAM.gov returns nested structure
opp_data = data.get('opportunitiesData', [])
for opp in opp_data:
# Extract data from nested structure
processed_opp = {
'id': opp.get('noticeId'),
'title': opp.get('title'),
'agency': opp.get('fullParentPathName'),
'posted_date': opp.get('postedDate'),
'response_deadline': opp.get('responseDeadLine'),
'type': opp.get('type'),
'base_type': opp.get('baseType'),
# Contact info (often incomplete)
'contact': self._extract_contact_info(opp.get('pointOfContact', [])),
# Set-aside info
'set_aside': opp.get('typeOfSetAside'),
# Location info
'place_of_performance': opp.get('placeOfPerformance', {}),
# NAICS code - API returns both naicsCode (string) AND naicsCodes (array)
'naics': opp.get('naicsCode'), # e.g., "541512" - also check naicsCodes array
# Solicitation number
'solicitation_number': opp.get('solicitationNumber'),
# Award info (if available)
'award': self._extract_award_info(opp.get('award', {})),
# Description is a URL link, not the text itself
# Append your API key to download: description_url + '?api_key=YOUR_KEY'
'description_url': opp.get('description'),
# Raw data for debugging
'raw_data': opp
}
opportunities.append(processed_opp)
return {
'opportunities': opportunities,
'total_records': data.get('totalRecords', 0),
'rate_limit_remaining': self.rate_limit_remaining
}
def _extract_contact_info(self, contacts):
"""Extract contact information (often incomplete)"""
if not contacts:
return None
contact = contacts[0] # Take first contact
return {
'name': contact.get('fullName'),
'email': contact.get('email'),
'phone': contact.get('phone'),
'title': contact.get('title')
}
def _extract_award_info(self, award_data):
"""Extract award information if available"""
if not award_data:
return None
return {
'amount': award_data.get('amount'),
'date': award_data.get('date'),
'awardee': award_data.get('awardee', {}).get('name')
}
def get_opportunity_details(self, notice_id):
"""Get detailed information for a specific opportunity.
Note: There is NO /opportunities/v2/{id} endpoint.
Use the search endpoint with the `noticeid` parameter instead.
The parameter is lowercase. `noticeId` is the spelling of the field in the
RESPONSE, and passing that casing as a query parameter does not filter."""
if self.rate_limit_remaining <= 0:
raise Exception("Daily rate limit exceeded")
# Use search with noticeid - the only way to get one specific opportunity
return self.search_opportunities(noticeid=notice_id)
# Usage example
def main():
# Initialize client
api_key = "your_sam_gov_api_key"
client = SAMAPIClient(api_key)
try:
# Search for recent opportunities by title
# Note: No full-text "keyword" parameter exists - use "title" instead
result = client.search_opportunities(
title="software", # Searches in title field only
ptype="o", # Solicitations only
limit=5
)
print(f"Found {len(result['opportunities'])} opportunities")
print(f"Rate limit remaining: {result['rate_limit_remaining']}")
for opp in result['opportunities']:
print(f"\nTitle: {opp['title']}")
print(f"Agency: {opp['agency']}")
print(f"Posted: {opp['posted_date']}")
print(f"Deadline: {opp['response_deadline']}")
print(f"NAICS: {opp['naics'] or 'None'}") # naicsCode (string) - also naicsCodes array available
print(f"Contact: {opp['contact']['email'] if opp['contact'] else 'None'}")
print(f"Description URL: {opp['description_url'] or 'None'}") # URL to download
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
main()
JavaScript/Node.js Implementation
const axios = require('axios');
class SAMAPIClient {
constructor(apiKey) {
this.apiKey = apiKey;
this.baseURL = 'https://api.sam.gov/prod';
this.rateLimitRemaining = 10; // Track daily limit
this.client = axios.create({
baseURL: this.baseURL,
headers: {
'Accept': 'application/json'
},
timeout: 30000
});
}
async searchOpportunities(params = {}) {
if (this.rateLimitRemaining <= 0) {
throw new Error('Daily rate limit exceeded (10 requests)');
}
// api_key goes in query params, not header
const defaultParams = {
api_key: this.apiKey,
limit: 10,
offset: 0,
postedFrom: this._getDateString(30), // Last 30 days
postedTo: this._getDateString(0) // Today
};
const searchParams = { ...defaultParams, ...params };
try {
const response = await this.client.get('/opportunities/v2/search', {
params: searchParams
});
this.rateLimitRemaining--;
return this._processOpportunities(response.data);
} catch (error) {
if (error.response?.status === 429) {
throw new Error('Rate limit exceeded');
}
throw new Error(`SAM API Error: ${error.message}`);
}
}
_processOpportunities(data) {
const opportunities = data.opportunitiesData?.map(opp => ({
id: opp.noticeId,
title: opp.title,
agency: opp.fullParentPathName,
postedDate: opp.postedDate,
responseDeadline: opp.responseDeadLine,
type: opp.type,
contact: this._extractContact(opp.pointOfContact),
setAside: opp.typeOfSetAside,
naics: opp.naicsCode, // String - also naicsCodes array available
solicitationNumber: opp.solicitationNumber,
// Description is a URL - append ?api_key=YOUR_KEY to download
descriptionUrl: opp.description,
rawData: opp
})) || [];
return {
opportunities,
totalRecords: data.totalRecords || 0,
rateLimitRemaining: this.rateLimitRemaining
};
}
_extractContact(contacts) {
if (!contacts || contacts.length === 0) return null;
const contact = contacts[0];
return {
name: contact.fullName,
email: contact.email,
phone: contact.phone,
title: contact.title
};
}
_getDateString(daysAgo) {
const date = new Date();
date.setDate(date.getDate() - daysAgo);
// SAM requires MM/dd/yyyy with both parts zero-padded.
// toLocaleDateString('en-US') yields 1/5/2026, which the endpoint rejects.
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
return `${mm}/${dd}/${date.getFullYear()}`;
}
}
// Usage example
async function main() {
const client = new SAMAPIClient('your_sam_gov_api_key');
try {
// Note: No "keyword" parameter - use "title" for title search
const result = await client.searchOpportunities({
title: 'software', // Searches title field only
ptype: 'o', // Solicitations
limit: 5
});
console.log(`Found ${result.opportunities.length} opportunities`);
console.log(`Rate limit remaining: ${result.rateLimitRemaining}`);
result.opportunities.forEach(opp => {
console.log(`\nTitle: ${opp.title}`);
console.log(`Agency: ${opp.agency}`);
console.log(`Posted: ${opp.postedDate}`);
console.log(`Contact: ${opp.contact?.email || 'None'}`);
console.log(`Description URL: ${opp.descriptionUrl || 'None'}`);
});
} catch (error) {
console.error(`Error: ${error.message}`);
}
}
main();
Error Handling & Rate Limits
Common Error Responses
| Status Code | Error | Cause | Solution |
|---|---|---|---|
| 401 | Unauthorized | Invalid API key | Check key format and permissions |
| 403 | Forbidden | Role not approved | Request Data Entry role in SAM.gov |
| 429 | Too Many Requests | Rate limit exceeded | Wait until next day (daily reset) |
| 500 | Internal Server Error | SAM.gov system issue | Retry later, contact SAM support |
# Python error handling example
import time
from requests.exceptions import RequestException, HTTPError, Timeout
def robust_sam_request(client, endpoint, params=None, max_retries=3):
"""Make SAM API request with comprehensive error handling"""
for attempt in range(max_retries):
try:
if client.rate_limit_remaining <= 0:
raise Exception("Rate limit exhausted - wait until tomorrow")
response = client.session.get(
f"{client.base_url}{endpoint}",
params=params,
timeout=30
)
if response.status_code == 200:
client.rate_limit_remaining -= 1
return response.json()
elif response.status_code == 401:
raise Exception("Invalid API key - check SAM.gov account")
elif response.status_code == 403:
raise Exception("Access denied - request Data Entry role")
elif response.status_code == 429:
raise Exception("Rate limit exceeded - daily limit is 10 requests")
elif response.status_code >= 500:
if attempt < max_retries - 1:
wait_time = 2 ** attempt # Exponential backoff
print(f"Server error, retrying in {wait_time} seconds...")
time.sleep(wait_time)
continue
else:
raise Exception("SAM.gov server error - try again later")
else:
response.raise_for_status()
except Timeout:
if attempt < max_retries - 1:
print("Request timeout, retrying...")
time.sleep(2)
continue
else:
raise Exception("Request timeout - SAM.gov may be slow")
except RequestException as e:
raise Exception(f"Network error: {e}")
raise Exception("Max retries exceeded")
Production Deployment Considerations
Rate Limit Management
- Daily Budget: 10 requests for public, 1,000 for entity users
- Request Prioritization: Cache aggressively, batch operations
- Monitoring: Track remaining requests per day
- Fallback Strategy: Manual CSV downloads when limits hit
# Production rate limiting strategy
import redis
from datetime import datetime, timedelta, timezone
class ProductionSAMClient:
def __init__(self, api_key, redis_host='localhost'):
self.api_key = api_key
self.redis = redis.Redis(host=redis_host, decode_responses=True)
def _quota_key(self):
"""Computed per call, never cached on the instance.
A worker constructed at startup and left running would otherwise keep
counting against the date it booted on, so its quota check silently
stops rolling over at midnight.
"""
return f"sam_api_calls_{datetime.now(timezone.utc).strftime('%Y-%m-%d')}"
def can_make_request(self):
"""Check if we can make another API call today"""
daily_calls = int(self.redis.get(self._quota_key()) or 0)
return daily_calls < 10 # Adjust for your plan
def record_api_call(self):
"""Record that we made an API call.
With more than one worker, check-then-increment is a race: both can pass
can_make_request() on the last remaining call. Increment FIRST and treat
the returned count as your reservation, backing off if it came back over
the ceiling.
"""
key = self._quota_key()
pipe = self.redis.pipeline()
pipe.incr(key)
pipe.expire(key, 86400) # 24 hour TTL
pipe.execute()
def get_cached_result(self, cache_key):
"""Get cached result to avoid unnecessary API calls"""
cached = self.redis.get(f"sam_cache_{cache_key}")
return json.loads(cached) if cached else None
def cache_result(self, cache_key, data, ttl=3600):
"""Cache result for 1 hour to reduce API usage"""
self.redis.setex(
f"sam_cache_{cache_key}",
ttl,
json.dumps(data)
)
Data Completeness Issues
A search response carries metadata, not the whole notice. Two of the fields you probably came for are links rather than text, and they are not the same link:
- Contract descriptions: the
descriptionfield is a URL (/opportunities/v1/noticedesc?noticeid=...), never inline text. Fetch it with your API key appended. - Attachments:
resourceLinksis a separate array of document URLs. It is not the description. The two get conflated constantly, including in guides that tell you to read attachments to recover the description. - Award information: linked when present, and often a separate Award Notice lookup.
- Contact details: emails and phones are frequently absent.
- Date range: one year per query, so older data means looping windows.
Descriptions do not require scraping. The notice's own description link returns them. Append your
API key to fetch it, and expect HTML rather than plain text, so plan on stripping tags and decoding
entities. The constraint is not difficulty, it is request budget: one extra call per notice, against a
quota of 10 or 1,000 a day.
Why Most Developers Switch to Alternatives
Development Time Reality Check
| Task | SAM.gov Implementation | Alternative API |
|---|---|---|
| Setup & Registration | ~3 weeks | 5 minutes |
| Basic Integration | 40+ hours (complex structure) | 2-4 hours (clean JSON) |
| Description Fetching | A second request per notice, plus quota accounting | Included in response |
| Award Data Linking | Manual correlation required | Included in response |
| Rate Limit Management | 10+ hours (Redis, monitoring) | 1,000 requests/hour |
| Error Handling | 15+ hours (complex edge cases) | Standard HTTP patterns |
| Testing & Debugging | 20+ hours (rate limited) | 5 hours (1,000 requests/hour) |
Estimates are illustrative and vary by project scope.
Alternative: GovCon API
Compare the same functionality with a developer-friendly API:
# GovCon API - Same functionality, 95% less code
import requests
def get_complete_opportunities():
"""Get complete opportunity data in one simple request"""
headers = {'Authorization': 'Bearer your_govcon_api_key'}
response = requests.get(
'https://govconapi.com/api/v1/opportunities/search',
headers=headers,
params={
'naics': '541330',
'state': 'CA',
'limit': 100 # 10x more data per request
}
)
if response.status_code == 200:
data = response.json()
for opp in data['data']:
print(f"Title: {opp['title']}")
print(f"Agency: {opp['agency']}")
print(f"Posted: {opp['posted_date']}")
print(f"Description: {opp['description_text'][:100]}...") # Included!
print(f"Contact: {opp['contact_email']}") # Complete contact info
print(f"Award Amount: ${opp['award_amount'] or 'TBD'}") # Integrated awards
print(f"Winner: {opp['awardee_name'] or 'TBD'}") # Winner information
print("---")
return data
else:
raise Exception(f"API Error: {response.status_code}")
# This is all you need - no registration, no extraction pipeline, no complex parsing
opportunities = get_complete_opportunities()
print(f"Found {len(opportunities['data'])} opportunities")
Skip the Development Time
Get the same data with far less code and no registration step.
Production Architecture Comparison
SAM.gov Production Architecture (Complex)
- API Client: Rate limiting, error handling, retries
- Description Fetching: A second request per notice, with its own retries and quota budget
- Data Pipeline: Data ingestion & normalization
- Cache Layer: Redis for rate limit management
- Database: Store incomplete API responses + extracted data
- Monitoring: Track API quotas and extraction pipeline health
- Maintenance: Handle SAM.gov website changes
Infrastructure Cost: $200-500/month
Maintenance Hours: 5-10 hours/month
GovCon API Production Architecture (Simple)
- API Client: Simple HTTP requests
- Data Processing: Direct JSON parsing
- Optional Cache: For performance optimization
Infrastructure Cost: $19/mo
Maintenance Hours: 0 hours/month
Summary
This guide covered the complete SAM.gov API implementation including authentication, endpoints, parsing, and production deployment. Key takeaways:
- Entity registration takes up to 10 business days, the role request about 5 more, and then provides 1,000 requests/day
- Public access (10/day) is available immediately for testing
- Response parsing requires handling nested JSON structures
- Caching is essential for staying within rate limits
For high-volume commercial applications, third-party APIs like GovCon API offer faster setup and higher limits as an alternative. Ours starts at $19/mo, and the plan comparison says what each plan includes.
Related Guides
- SAM.gov API Guide - Overview and getting started
- Response Structure Reference - JSON parsing patterns
- Rate Limits Explained - Access tiers and limits
- Capabilities vs Requirements - Choosing the right approach
- SAM.gov Exclusions Guide - Bulk sync, deduplication, compliance
- Federal Contracts API - Search FPDS prime contract transactions
- USASpending API Guide - Awarded-contract and federal spending data, endpoints and quirks
- Federal Contracting Officers Directory - Direct contact info for government buyers
Official Resources
- Get Opportunities Public API documentation - the endpoint reference: parameters, response fields, notice-type and set-aside codes
- SAM.gov API Portal - where the key is issued
- SAM.gov API Documentation
- FSD KB0017604: API rate limits and requesting a higher rate - the five tiers, the 24-hour delay, and who may request an increase
- FSD KB0035972: who approves a role request - your entity administrator, not the Federal Service Desk
- FSD KB0099391: efficient use of API key rate limits - federal accounts only, and confirms the increase does not apply to non-federal keys