RFPPlanet Data API

RFP data from government and private buyers, straight into your software

One REST API for RFPs, bids and tenders across the United States and Canada, from federal, state, provincial and local government agencies as well as companies, non-profits, universities and hospitals. Send clean, structured JSON to your CRM, bid pipeline, BI dashboards or product, updated every day.

# Open software RFPs in Texas
curl https://rfpplanet.com/api/v1/rfps?q=software&region=texas \
  -H "Authorization: Bearer $RFPPLANET_KEY"

{
  "data": [{
    "id": "SYS-6837",
    "title": "Records Management System",
    "status": "open",
    "deadline": "2026-10-19",
    "regions": [{ "name": "Texas" }],
    …
  }],
  "meta": { "has_more": true, "next_cursor": "eyJ…" }
}
1,913Open opportunities right now
4,351Added in the last 30 days
52k+Notices in our archive, including US federal
68States & provinces (US + Canada)
How it works

Live in an afternoon

No SDK needed. Any language that can make an HTTPS request can use it.

1

Get your key

Email [email protected] and we will set up your API key.

2

Search & filter

Filter by keyword, category, state or province, notice type, status and dates. Results are paged with a cursor.

3

Stay in sync

Run a job every hour with updated_since to pull only new and changed RFPs into your system.

Push new bids into Salesforce, HubSpot or Zoho as leads
Market and competitor dashboards in Power BI or Looker
Add an RFP feed to your bid-management or ERP product
Feed AI tools that match bids to your capabilities
Try it now

Query live data, no key needed

This demo returns up to 5 recent RFPs and a few fields. With a key you get all 25 fields, every filter, full pagination, documents and federal data.

GET https://rfpplanet.com/api/v1/rfps?q=software&status=open
Press Run to see live results.
Quick start

Copy, paste, run

Base URL https://rfpplanet.com/api/v1. Send your key in the Authorization: Bearer header from your server. Never put it in browser or mobile code.

curl "https://rfpplanet.com/api/v1/rfps?q=software&country=US&limit=25" \
  -H "Authorization: Bearer $RFPPLANET_KEY"

# Next page: add &cursor=<meta.next_cursor>
# One RFP:
curl https://rfpplanet.com/api/v1/rfps/SYS-6837 -H "Authorization: Bearer $RFPPLANET_KEY"
// Node 18+ (server side)
const BASE = 'https://rfpplanet.com/api/v1';
const headers = { Authorization: `Bearer ${process.env.RFPPLANET_KEY}` };

async function allOpenRfps(query) {
  const rfps = [];
  let cursor = null;
  do {
    const params = new URLSearchParams({ q: query, status: 'open', limit: '100' });
    if (cursor) params.set('cursor', cursor);
    const res = await fetch(`${BASE}/rfps?${params}`, { headers });
    if (!res.ok) throw new Error((await res.json()).error.message);
    const body = await res.json();
    rfps.push(...body.data);
    cursor = body.meta.next_cursor;
  } while (cursor);
  return rfps;
}

allOpenRfps('construction').then(r => console.log(r.length, 'RFPs'));
import os, requests

BASE = "https://rfpplanet.com/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['RFPPLANET_KEY']}"

def sync_changes(since_iso):
    """Everything created or changed since the last run."""
    params = {"status": "all", "sort": "updated_asc", "updated_since": since_iso, "limit": 100}
    while True:
        r = session.get(f"{BASE}/rfps", params=params, timeout=30)
        r.raise_for_status()
        body = r.json()
        for rfp in body["data"]:
            print(rfp["id"], rfp["status"], rfp["title"])  # upsert into your DB here
        if not body["meta"]["has_more"]:
            break
        params["cursor"] = body["meta"]["next_cursor"]

sync_changes("2026-10-01T00:00:00Z")
<?php
// composer require guzzlehttp/guzzle
$client = new GuzzleHttp\Client([
    'base_uri' => 'https://rfpplanet.com/api/v1/',
    'headers'  => ['Authorization' => 'Bearer ' . getenv('RFPPLANET_KEY')],
    'timeout'  => 30,
]);

$res  = $client->get('rfps', ['query' => ['q' => 'janitorial', 'region' => 'ontario', 'limit' => 50]]);
$body = json_decode((string) $res->getBody(), true);

foreach ($body['data'] as $rfp) {
    echo $rfp['id'], '  ', $rfp['deadline'], '  ', $rfp['title'], PHP_EOL;
}
echo 'Records left this month: ', $res->getHeaderLine('X-Records-Remaining'), PHP_EOL;
Reference

Endpoints

All endpoints are GET and return JSON. The full interactive reference, with schemas, is on the API reference page. Download the OpenAPI spec or the Postman collection.

EndpointWhat it returnsRecords used
GET /rfps Search and list RFPs one per RFP returned
GET /rfps/{id} One RFP with all fields 1
GET /rfps/{id}/documents RFP files with signed download links (15 min)Pro+ one per file
GET /federal Search and list US federal opportunities (SAM.gov)Basic+ one per row returned
GET /federal/{id} One federal opportunityBasic+ 1
GET /categories Category list free
GET /regions States and provinces (filter with ?country=US) free
GET /countries Countries free
GET /notice-types Notice types free
GET /usage Your plan, records used and left this month free
GET /exports Daily bulk files (JSON Lines + CSV, gzipped) with signed linksEnterprise not metered (licence)

Filters for /rfps

ParameterTypeDescription
qstringKeyword in title, organization, RFP number or scope of work.
statusstringopen (default), closed, cancelled, awarded, all.
categorystringCategory ids or slugs, comma separated (see /categories).
countrystringCountry ISO code, id or name, comma separated (US, CA).
regionstringState / province ids or slugs, comma separated (see /regions).
notice_typestringText match on notice type (e.g. RFQ).
posted_fromdatePosted on or after (YYYY-MM-DD).
posted_todatePosted on or before (YYYY-MM-DD).
deadline_fromdateDeadline on or after (YYYY-MM-DD).
deadline_todateDeadline on or before (YYYY-MM-DD).
updated_sincestringOnly rows changed since this time: ISO 8601 or Unix seconds/milliseconds. Use for incremental sync.
sortstringposted_desc (default), posted_asc, updated_asc, deadline_asc.
limitintegerResults per page, 1–100 (default 25).
cursorstringValue of meta.next_cursor from the previous page.

RFP fields (25)

FieldTypeDescriptionExample
idstringRFPPlanet RFP number. Use it with /rfps/{id}.SYS-6837
titlestringTitle of the opportunity.Electronic Document and Records Management System
organizationstring|nullIssuing organization or agency.City of Fredericton
notice_typestring|nullRFP, RFQ, RFI, IFB, Sources Sought, etc.RFP (Request for Proposal)
statusstringopen, closed, cancelled or awarded.open
countriesarrayCountries: [{id, name, code}] where code is ISO 3166 (US, CA).[{"id":1,"name":"Canada","code":"CA"}]
regionsarrayStates / provinces: [{id, name, slug}].[{"id":77,"name":"New Brunswick","slug":"new-brunswick"}]
categoryobject|nullMain category {id, name, slug}.{"id":57,"name":"Software Development","slug":"software-development"}
categoriesarrayAll categories, main category first.[{"id":57,…}]
posted_atdatetimeWhen the RFP was published on RFPPlanet (ISO 8601).2026-10-01T16:06:10-04:00
updated_atdatetimeLast change. Use with updated_since for sync.2026-10-01T16:10:14-04:00
deadlinedate|nullProposal due date (YYYY-MM-DD).2026-10-19
questions_deadlinedate|nullLast day to submit questions.2026-10-05
pre_bid_meetingdate|nullPre-bid / site meeting date.null
contract_termstring|nullContract length as published.7 Years
work_locationstring|nullOnsite, Offsite or Hybrid.Offsite
submission_methodstring|nullHow bids are submitted.Email
estimated_budgetnumber|nullEstimated contract value in USD, when published.250000
eligibilitystring|nullWho can bid (plain text).Canada Organization
descriptionstring|nullScope of work as plain text.Electronic Document and Records Management System will deliver…
description_htmlstring|nullScope of work as HTML. Sanitize before displaying.<p>Electronic Document…</p>
flagsobjectdate_extended, addendum_issued, cancelled, awarded (booleans).{"date_extended":false,"addendum_issued":false,"cancelled":false,"awar...
documents_availableintegerNumber of files you can fetch from /rfps/{id}/documents.1
source_urlstring|nullOriginal posting. Plans with source links only; otherwise null.https://nbon-rpanb.gnb.ca/…
urlstringRFP page on RFPPlanet.https://rfpplanet.com/SYS-6837/electronic-document-and-records-managem...

Errors

Every error has the same shape: {"error": {"code", "message", "request_id"}}.

HTTPCodeMeaning
401missing_api_keyNo key sent. Use the Authorization: Bearer header.
401invalid_api_keyKey is wrong, revoked or expired.
403feature_not_in_planYour plan does not include this endpoint.
403plan_expiredAccess period ended. Renew to continue.
403account_suspendedAccount suspended. Contact support.
403ip_not_allowedKey is locked to other IP addresses.
404not_foundUnknown id or endpoint.
422invalid_parametersA parameter is not valid; see error.fields.
429rate_limitedToo many requests this minute; wait Retry-After seconds.
429quota_exceededMonthly records used up; resets on the 1st.
500server_errorError on our side. It has been logged; please retry later.

Limits & headers

Usage is metered by records returned, not by calls. A page of 25 RFPs uses 25 records. Lookups and /usage are free.
X-Records-LimitRecords in your monthly plan
X-Records-RemainingRecords left this month
X-RateLimit-LimitRequests allowed per minute
X-RateLimit-RemainingRequests left this minute
Retry-AfterSeconds to wait after a 429
X-Request-IdQuote it when contacting support
Webhooks Pro+

Get new RFPs pushed to you

Add an https endpoint on your Developer page, pick events and filters (keyword, country, state, category), and we POST each new or changed RFP within minutes. Every delivery is signed and failed ones are retried with back-off for about 20 hours.

Events

rfp.createdA new RFP matching your filters was published.
rfp.updatedAn RFP you could already see changed: deadline extended, addendum issued, cancelled, awarded or details corrected.
federal.createdA new US federal notice (plans with federal access).
federal.updatedA US federal notice changed.
pingSent only when you press "Test" on the Developer page.

Headers

X-RFPPlanet-EventEvent name, e.g. rfp.created
X-RFPPlanet-DeliveryUnique delivery id (dlv_…); the same id is reused on retries, so use it to ignore duplicates
X-RFPPlanet-TimestampUnix time the request was signed
X-RFPPlanet-Signaturev1=<hex HMAC-SHA256 of "{timestamp}.{raw body}" with your signing secret>
No code needed: paste a Zapier Webhooks by Zapier → Catch Hook or a Make Custom webhook URL as your endpoint, then send RFPs to Salesforce, HubSpot, Google Sheets, Slack or email.

Example delivery

{
    "id": "dlv_10452",
    "event": "rfp.updated",
    "created_at": "2026-10-05T09:15:02+00:00",
    "api_version": "v1",
    "data": {
        "id": "SYS-6837",
        "title": "Managed IT Services for County Offices",
        "organization": "Travis County",
        "status": "open",
        "deadline": "2026-11-14",
        "flags": {
            "date_extended": true,
            "addendum_issued": false,
            "cancelled": false,
            "awarded": false
        },
        "updated_at": "2026-10-05T09:14:40+00:00",
        "url": "https://rfpplanet.com/rfp/SYS-6837/managed-it-services"
    }
}
// data holds the full RFP object (same fields as GET /rfps/{id})

Verify the signature

// Express: keep the raw body, it is what we signed
app.post('/hooks/rfpplanet', express.raw({ type: 'application/json' }), (req, res) => {
  const crypto = require('crypto');
  const ts = req.get('X-RFPPlanet-Timestamp');
  const sig = (req.get('X-RFPPlanet-Signature') || '').replace('v1=', '');
  const expected = crypto.createHmac('sha256', process.env.RFPPLANET_WEBHOOK_SECRET)
    .update(`${ts}.${req.body}`).digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
  if (!fresh || sig.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body);
  // upsert event.data by event.data.id, then answer quickly:
  res.sendStatus(200);
});
# Flask
import hmac, hashlib, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["RFPPLANET_WEBHOOK_SECRET"].encode()

@app.post("/hooks/rfpplanet")
def rfpplanet_hook():
    ts = request.headers.get("X-RFPPlanet-Timestamp", "0")
    sig = request.headers.get("X-RFPPlanet-Signature", "").removeprefix("v1=")
    body = request.get_data()  # raw bytes
    expected = hmac.new(SECRET, ts.encode() + b"." + body, hashlib.sha256).hexdigest()
    if abs(time.time() - int(ts)) > 300 or not hmac.compare_digest(sig, expected):
        abort(401)
    event = request.get_json()
    # upsert event["data"] by event["data"]["id"]
    return "", 200
<?php
$body = file_get_contents('php://input');            // raw body
$ts   = $_SERVER['HTTP_X_RFPPLANET_TIMESTAMP'] ?? '0';
$sig  = substr($_SERVER['HTTP_X_RFPPLANET_SIGNATURE'] ?? '', 3); // drop "v1="
$expected = hash_hmac('sha256', $ts . '.' . $body, getenv('RFPPLANET_WEBHOOK_SECRET'));

if (abs(time() - (int) $ts) > 300 || !hash_equals($expected, $sig)) {
    http_response_code(401);
    exit;
}
$event = json_decode($body, true);
// upsert $event['data'] by $event['data']['id']
http_response_code(200);
Pricing

Simple plans, metered by records

Start a free trial and buy online with PayPal or Razorpay; your key is ready the moment payment succeeds. Pay monthly, or save two months with annual billing. Need an invoice or custom volume? Talk to us.

New API sign-ups are paused for now. Please contact [email protected] for an API key.

For scale: 4,351 new notices were added in the last 30 days and 1,913 are open right now. A record is one notice returned, so pick the plan that covers what you will actually pull.

Trial

Try the API with recent RFPs
Free
3 days, no card needed
  • 100 records / month
  • RFPs posted in the last 30 days
  • 14 days access
  • Email support
Email us for a key

Basic

For one integration or internal tool
$99/month
or $990/year
  • 500 records / month
  • Open, closed, cancelled & awarded RFPs
  • US federal opportunities
  • Incremental sync (updated_since)
  • Email support
Email us for a key
Most popular

Pro

For products and sales teams
$299/month
or $2,990/year
  • 1,000 records / month
  • Everything in Basic
  • RFP document downloads
  • Original source links
  • Webhooks (push new & changed RFPs)
  • 3 API keys
  • Priority support
Email us for a key

Enterprise

Data licence, bulk delivery and SLA
Custom
Tailored volume and licence
  • Custom volume
  • Bulk daily export (JSONL + CSV)
  • Webhooks
  • Full historical archive
  • Resale / white-label licence
  • SLA and dedicated support
Email us for a key
FAQ

Questions developers ask

Estimate how many notices you will pull in a month. A sync filtered by category, state or keyword usually needs a few hundred to a couple of thousand records, which fits Basic. If you copy every new and updated notice, including US federal, choose Pro. For the full archive, bulk files or resale, ask about Enterprise. You can check usage at any time with /usage and upgrade online.

By records returned, not by calls. A page of 25 RFPs uses 25 records; a single RFP uses 1; each document link uses 1. Lookups (/categories, /regions, /countries, /notice-types) and /usage are free. Quotas reset on the 1st of each month.

New RFPs are added every day by our research team, and US federal notices are pulled from SAM.gov daily. Use updated_since to fetch only what changed since your last sync.

Over the per-minute limit you get HTTP 429 with a Retry-After header. When the monthly records run out you get 429 quota_exceeded until the 1st, or until you upgrade. Pages are automatically shortened so you never go over.

Document download links from /rfps/{id}/documents are signed and expire after 15 minutes. Download the file right away, or request a new link later.

The United States and Canada. Listings come from state, provincial, municipal and education buyers, and from companies, non-profits, hospitals and utilities that run competitive bids. US federal (SAM.gov) opportunities are included on plans that list them.

Yes, in internal tools and to your own users, within your plan limits. Reselling or redistributing the data as a feed needs an Enterprise licence.

Call the API from your server, never from a browser or mobile app. Each key can be locked to your server IP addresses. If a key leaks, "Roll" it on your Developer page: the old key stops working at once and you get a new one.

Sign in and open Developer in your account. Start the free trial, or pay for a plan with PayPal or Razorpay; your key is ready as soon as the payment succeeds. Plans do not renew automatically: we email you 7 days and 1 day before the end, and renewing adds the new period after the current one. Switching to another plan starts it right away (no proration). For invoices or custom terms, use the form on this page.

Yes. On Pro and above, add a webhook on your Developer page with optional filters (keyword, country, state, category). We POST every new or changed RFP that matches, signed with HMAC-SHA256, and retry failed deliveries with back-off for about 20 hours. Each delivered event counts as one record.

Yes, without code: in Zapier use "Webhooks by Zapier → Catch Hook" (in Make, "Custom webhook"), paste that URL as your webhook, then map the RFP fields into Salesforce, HubSpot, Google Sheets, Slack and others. Polling with updated_since works too.
Get access

Request a key or a quote

Tell us what you're building. We reply within one business day with a quote for higher volume, bulk delivery, resale or partner integrations. For a trial key, start the free trial from your account; it is ready straight away.

Partners & resellers: building a bid-management, CRM or procurement product? Ask about white-label and revenue-share options.

Never Miss a Relevant RFP Again

Set up free email alerts and get notified when new government and private-sector bids and tenders match your industry and location. Choose daily or weekly delivery.