Performance
Operational Notes
- Concurrent Requests: 5-15 recommended for best performance
- Data Updates: daily (new postings and modifications). Check
current_as_ofon/api/v1/statusfor current freshness. - Compression: responses are gzip-encoded when your client sends
Accept-Encoding: gzip.
Best Practices for High-Volume Usage
Expect geographic variation in response times:
curl -w "Total: %{time_total}s (Network: ~%{time_connect}s + Server: ~%{time_starttransfer}s)\n" \
-H "Authorization: Bearer YOUR_API_KEY" \
"https://govconapi.com/api/v1/opportunities/search?naics=541330&limit=100"
Use pagination within a filtered query:
curl -H "Authorization: Bearer YOUR_API_KEY" \
"https://govconapi.com/api/v1/opportunities/search?naics=541330&limit=100&offset=0"
For keeping a local mirror in sync, use /api/v1/opportunities/delta instead. It filters by last_seen via a since cursor (use the returned sync.server_time as your next since), so you only pull what changed.
Monitor your usage to stay within rate limits:
- Free plan: 50 requests/day
- Developer: 1,000 requests/hour
A second, per-minute burst cap runs alongside your plan quota. It is 5% of your rate limit, floored at 15/minute: 50 requests/minute on Developer/Pro (5% of 1,000/hour) and 15/minute on Free, measured in a rolling 60-second window. A tight loop can trip the burst cap while you are still well under your plan total, a response can report quota left in X-RateLimit-Remaining and still return 429. The burst window clears within 60 seconds: a burst 429 carries Retry-After: 60, so a small delay between calls (~1.2s) or a brief backoff on the first 429 resolves it.
Every authenticated response reports your current rate-limit state in headers, so you can throttle before you hit the ceiling instead of reacting to a 429:
X-RateLimit-Limit, your plan's request ceiling for the window.X-RateLimit-Remaining, requests left in the current window. Slow down as this approaches0.
On a 429 response we also send Retry-After (in seconds), which tells you exactly when to resume. The pattern: back off on X-RateLimit-Remaining, recover on Retry-After.
Use specific filters to reduce server processing time:
curl -H "Authorization: Bearer YOUR_API_KEY" \
"https://govconapi.com/api/v1/opportunities/search?naics=541330&posted_after=2025-11-01"
Note: NAICS filtering is well-optimized and faster than generic searches
Error Handling Best Practices
import requests
import time
def make_api_request(url, headers):
response = requests.get(url, headers=headers)
# Proactive: slow down before you hit the ceiling.
remaining = int(response.headers.get('X-RateLimit-Remaining', 1))
if remaining < 10:
time.sleep(1) # ease off as the window runs low
if response.status_code == 429:
# Rate limit exceeded: Retry-After tells you exactly when to resume.
retry_after = int(response.headers.get('Retry-After', 3600))
print(f"Rate limited. Waiting {retry_after} seconds...")
time.sleep(retry_after)
return make_api_request(url, headers) # Retry
elif response.status_code == 402:
# Plan upgrade needed
error = response.json()
print(f"Upgrade required: {error['detail']}")
return None
return response.json()
Technical Architecture (Click to expand)
Rate Limiting
- Free: 50 requests/day
- Developer: 1,000 requests/hour
- Plan Restrictions: page size (100 vs 1,000) enforced by plan level. All search filters available on every plan.
Performance
- Indexed filters: agency, NAICS, PSC, dates, and full-text are indexed; specific filters return faster than broad keyword-only scans.
- NAICS filtering is among the fastest paths; prefer it when you can.
- Data size: Loading... opportunities, refreshed daily.
- Transport: HTTPS only; responses gzip-encoded on
Accept-Encoding: gzip.
Data source & freshness
- Source: SAM.gov (opportunities, awards, entity registry, exclusions) plus contracting-officer contacts.
- Refresh: daily for opportunities/awards; the SAM entity registry is a monthly snapshot; exclusions refresh daily. Check
/api/v1/statusfor current freshness.
Production Usage Guide
Python client with pagination & error handling (Click to expand)
# Production-ready integration pattern
import requests
import time
from datetime import datetime, timedelta
class GovConAPIClient:
def __init__(self, api_key, plan='developer'):
self.api_key = api_key
self.base_url = 'https://govconapi.com/api/v1'
self.headers = {'Authorization': f'Bearer {api_key}'}
# Configure based on your plan
if plan == 'developer':
self.rate_limit = 1000 # 1,000 / hour
self.burst_limit = 50 # 50 / minute (5% of the 1,000/hour rate limit)
else:
self.rate_limit = 50 # 50 / day (free)
self.burst_limit = 15 # 15 / minute (the burst floor)
def search_opportunities(self, filters=None, limit=100):
"""Search with automatic pagination and rate limiting.
Every filter is optional, but a filtered search pages far less."""
all_results = []
offset = 0
while True:
params = {'limit': limit, 'offset': offset}
if filters:
params.update(filters)
response = self._make_request('/opportunities/search', params)
if not response or 'data' not in response:
break
all_results.extend(response['data'])
# Use the explicit pagination.has_next signal returned by the API.
if not response.get('pagination', {}).get('has_next'):
break
offset += limit
time.sleep(0.1) # gentle on the rate limit
return all_results
def _make_request(self, endpoint, params=None):
"""Make request with proper error handling"""
url = f"{self.base_url}{endpoint}"
try:
response = requests.get(url, headers=self.headers, params=params)
if response.status_code == 429:
retry_after = int(response.headers.get('Retry-After', 3600))
print(f"Rate limited. Waiting {retry_after} seconds...")
time.sleep(retry_after)
return self._make_request(endpoint, params)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
print(f"Request failed: {e}")
return None
# Example usage
client = GovConAPIClient('your_api_key_here', 'developer')
# Search for DoD opportunities in the last 30 days
filters = {
'agency': 'Department of Defense',
'posted_after': (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')
}
opportunities = client.search_opportunities(filters)
print(f"Found {len(opportunities)} opportunities")
Load Testing Recommendations
- Burst Testing: Developer/Pro: up to 50 requests/minute (5% of the 1,000/hour quota) in any 60-second window
- Sustained Load: Stay under 80% of your hourly limit for consistent performance
- Concurrent Requests: 5-15 concurrent requests recommended for optimal reliability
- Response Caching: Cache responses for 10-15 minutes to reduce API calls
- Geographic Considerations: Network latency varies 50-400ms by location
- Performance Monitoring: Use curl timing options to distinguish network vs server performance
Enterprise Integration Patterns (Click to expand)
Pattern 1: Daily Sync with the delta endpoint
Use /api/v1/opportunities/delta, not /search, for keeping a local copy in sync. Delta returns every record changed since your since timestamp (including backfilled records that posted_after would miss) and pages cleanly with has_next.
def daily_sync(client, last_sync_time):
"""Pull every opportunity that's been added or updated since last_sync_time."""
offset = 0
new_server_time = None
while True:
resp = client._make_request('/opportunities/delta', {
'since': last_sync_time,
'limit': 1000,
'offset': offset,
})
if not resp:
break
for op in resp['data']:
store_opportunity(op) # upsert by notice_id
# Capture the server's clock from the FIRST page so we use a single
# consistent cutoff across all paginated calls in this run.
if new_server_time is None:
new_server_time = resp['sync']['server_time']
if not resp['pagination']['has_next']:
break
offset += 1000
return new_server_time # save this; pass as last_sync_time tomorrow
Pattern 2: Agency Monitoring
Watching N agencies? Make one delta call (or one search call), filter agencies client-side. Looping the API once per agency burns the rate limit and adds latency for no benefit.
def monitor_agencies(client, agencies_of_interest, last_sync_time):
"""Pull all changes since last sync, route by agency client-side."""
of_interest = {a.lower() for a in agencies_of_interest}
resp = client._make_request('/opportunities/delta', {
'since': last_sync_time,
'limit': 1000,
})
if not resp:
return
for op in resp['data']:
agency = (op.get('agency') or '').lower()
if any(target in agency for target in of_interest):
if is_new_opportunity(op):
send_notification(op)
return resp['sync']['server_time']
Pattern 3: Map a prime contractor's supply chain
For BD intel, M&A diligence, and set-aside compliance: combine prime award history with the subaward layer. Two calls give you "what they won as a prime" + "who they paid as subs" + a leaderboard of the top sub-vendors.
def supply_chain_map(client, prime_uei):
"""Profile a prime contractor's federal footprint, top to bottom."""
# 1. Prime-side awards: what contracts have they won?
profile = client._make_request(f'/companies/{prime_uei}', {})
awards = client._make_request(f'/companies/{prime_uei}/awards', {'limit': 1000, 'sort': 'amount'})
# 2. Sub-side flow: who have they paid, and how much?
# Page size is up to 1,000 on paid plans, the same on every list endpoint.
# For a top prime with thousands of FFATA reports, page through with offset.
subs = client._make_request(f'/companies/{prime_uei}/subawards', {'limit': 1000})
return {
'prime_name': profile['name'],
'total_won_as_prime': profile['total_value'], # all-time award notices
'total_paid_to_subs': subs['summary']['total_subaward_amount'],
'distinct_sub_vendors': subs['summary']['distinct_sub_vendors'],
'distinct_contracts': subs['summary']['distinct_prime_contracts'],
'subaward_date_range': (subs['summary']['first_subaward_date'],
subs['summary']['last_subaward_date']),
'top_5_subs_by_dollars': _top_n_subs(subs['data'], 5),
'top_5_awards': awards['data'][:5],
}
For the per-contract supply-chain forensics (every sub on one prime PIID), use /subawards/search?piid=<piid>. For the agency or NAICS slice across all primes, use the same endpoint with those filters instead of a UEI.
Pattern 4: True federal revenue for a small business
A small business in federal contracting often earns the larger half of its federal income as a sub, not as a prime. Looking up /companies/{uei}/awards alone undercounts. The honest revenue view is the union:
def true_federal_revenue(client, uei):
"""Combined prime + sub revenue picture. /companies/{uei} returns this in one call."""
profile = client._make_request(f'/companies/{uei}', {})
return {
'name': profile['name'],
'prime_revenue': profile['total_value'], # SAM Award Notice value (see fpds_obligated_total for the fuller FPDS prime total)
'sub_revenue': profile['sub_revenue_total'], # subaward dollars received, current FFATA coverage
'combined': profile['total_value'] + profile['sub_revenue_total'],
'prime_share': profile['prime_revenue_share'], # 0..1; null when both are zero
'top_paying_primes': profile['top_paying_primes'], # who their actual customers are
'top_naics_as_prime': profile['top_naics'],
}
# Example output for an SMB:
# prime_revenue = $1.2M, sub_revenue = $8.4M, combined = $9.6M, prime_share = 0.13
# → 87% of their federal revenue is sub work; the prime-only view missed it.
Two different prime measures appear here: this snippet's hand-combined combined uses total_value (the SAM Award Notice slice) for prime, while the response's prime_revenue_share field uses the broader FPDS prime obligation total (fpds_obligated_total). Both the FPDS prime total and the FFATA subaward total cover the FY2025-onward window (since 2024-10-01), so prime_revenue_share compares like-for-like. For the authoritative prime figure use fpds_obligated_total; for the underlying records, see /companies/{uei}/prime-relationships.
Pattern 5: Active-payment compliance screen on an excluded vendor
For compliance and FCA investigation: when an entity hits the SAM exclusion list, the question that matters is whether clean primes are still paying them. /exclusions/{uei_sam} answers both halves of the screen, the exclusion record itself, and the recent-payments enrichment, in one call.
def active_payment_screen(client, uei):
"""Returns the exclusion record + the smoking-gun recent-payments view,
or None if the UEI is clean by UEI lookup (still pair with name search for full coverage)."""
resp = client.get(f'/api/v1/exclusions/{uei}')
if resp.status_code == 404:
return None # no UEI-keyed exclusion; check by entity_name for full coverage
body = resp.json()
return {
'exclusion_type': body['exclusion_type'],
'excluding_agency': body['excluding_agency_code'],
'create_date': body['create_date'],
'active': body['record_status'] == 'Active',
# The compliance signal:
'still_being_paid': body['recent_sub_payments']['subaward_count'] > 0,
'total_received_last_12mo': body['recent_sub_payments']['total_received'],
'most_recent_payment': body['recent_sub_payments']['most_recent_subaward_date'],
'most_recent_paying_prime': body['recent_sub_payments']['most_recent_paying_prime'],
'top_paying_primes': body['recent_sub_payments']['top_paying_primes'],
}
Pair this with /exclusions/search?entity_name=... for full screening coverage: ~80% of SAM exclusions are individuals or entities with no UEI, so a UEI lookup alone is not a full screen. For the broader risk surface (address clusters, name variants, coordinated waves), the same enrichment is also present on /vendor-risk/{uei} under subaward_exposure.