Skip to content

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):

  1. Create free SAM.gov account
  2. Generate API key (instant)

Step 2: Entity Registration for 1,000/day (about 3 weeks)

For higher limits:

  1. Complete entity registration with UEI
  2. Wait for validation (up to 10 business days, per SAM.gov)
  3. Request "Data Entry" role
  4. Wait for role approval (your own Entity Administrator approves it, not GSA; escalate after 5 business days)
  5. Regenerate key with higher limits
  6. 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 description field is a URL (/opportunities/v1/noticedesc?noticeid=...), never inline text. Fetch it with your API key appended.
  • Attachments: resourceLinks is 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.

Official Resources

A REST API and MCP server for U.S. federal procurement data.

Get a free key, emailed instantly, or follow the quickstart.

Found something wrong, missing or confusing on this page?

Press Ctrl + Enter to send a note. Highlight text first and it travels with your note.