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®ion=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…" } }
No SDK needed. Any language that can make an HTTPS request can use it.
Filter by keyword, category, state or province, notice type, status and dates. Results are paged with a cursor.
Run a job every hour with updated_since to pull only new and changed RFPs into your system.
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.
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;
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.
| Endpoint | What it returns | Records 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) |
/rfps| Parameter | Type | Description |
|---|---|---|
q | string | Keyword in title, organization, RFP number or scope of work. |
status | string | open (default), closed, cancelled, awarded, all. |
category | string | Category ids or slugs, comma separated (see /categories). |
country | string | Country ISO code, id or name, comma separated (US, CA). |
region | string | State / province ids or slugs, comma separated (see /regions). |
notice_type | string | Text match on notice type (e.g. RFQ). |
posted_from | date | Posted on or after (YYYY-MM-DD). |
posted_to | date | Posted on or before (YYYY-MM-DD). |
deadline_from | date | Deadline on or after (YYYY-MM-DD). |
deadline_to | date | Deadline on or before (YYYY-MM-DD). |
updated_since | string | Only rows changed since this time: ISO 8601 or Unix seconds/milliseconds. Use for incremental sync. |
sort | string | posted_desc (default), posted_asc, updated_asc, deadline_asc. |
limit | integer | Results per page, 1–100 (default 25). |
cursor | string | Value of meta.next_cursor from the previous page. |
| Field | Type | Description | Example |
|---|---|---|---|
id | string | RFPPlanet RFP number. Use it with /rfps/{id}. | SYS-6837 |
title | string | Title of the opportunity. | Electronic Document and Records Management System |
organization | string|null | Issuing organization or agency. | City of Fredericton |
notice_type | string|null | RFP, RFQ, RFI, IFB, Sources Sought, etc. | RFP (Request for Proposal) |
status | string | open, closed, cancelled or awarded. | open |
countries | array | Countries: [{id, name, code}] where code is ISO 3166 (US, CA). | [{"id":1,"name":"Canada","code":"CA"}] |
regions | array | States / provinces: [{id, name, slug}]. | [{"id":77,"name":"New Brunswick","slug":"new-brunswick"}] |
category | object|null | Main category {id, name, slug}. | {"id":57,"name":"Software Development","slug":"software-development"} |
categories | array | All categories, main category first. | [{"id":57,…}] |
posted_at | datetime | When the RFP was published on RFPPlanet (ISO 8601). | 2026-10-01T16:06:10-04:00 |
updated_at | datetime | Last change. Use with updated_since for sync. | 2026-10-01T16:10:14-04:00 |
deadline | date|null | Proposal due date (YYYY-MM-DD). | 2026-10-19 |
questions_deadline | date|null | Last day to submit questions. | 2026-10-05 |
pre_bid_meeting | date|null | Pre-bid / site meeting date. | null |
contract_term | string|null | Contract length as published. | 7 Years |
work_location | string|null | Onsite, Offsite or Hybrid. | Offsite |
submission_method | string|null | How bids are submitted. | |
estimated_budget | number|null | Estimated contract value in USD, when published. | 250000 |
eligibility | string|null | Who can bid (plain text). | Canada Organization |
description | string|null | Scope of work as plain text. | Electronic Document and Records Management System will deliver… |
description_html | string|null | Scope of work as HTML. Sanitize before displaying. | <p>Electronic Document…</p> |
flags | object | date_extended, addendum_issued, cancelled, awarded (booleans). | {"date_extended":false,"addendum_issued":false,"cancelled":false,"awar... |
documents_available | integer | Number of files you can fetch from /rfps/{id}/documents. | 1 |
source_url | string|null | Original posting. Plans with source links only; otherwise null. | https://nbon-rpanb.gnb.ca/… |
url | string | RFP page on RFPPlanet. | https://rfpplanet.com/SYS-6837/electronic-document-and-records-managem... |
Every error has the same shape: {"error": {"code", "message", "request_id"}}.
| HTTP | Code | Meaning |
|---|---|---|
| 401 | missing_api_key | No key sent. Use the Authorization: Bearer header. |
| 401 | invalid_api_key | Key is wrong, revoked or expired. |
| 403 | feature_not_in_plan | Your plan does not include this endpoint. |
| 403 | plan_expired | Access period ended. Renew to continue. |
| 403 | account_suspended | Account suspended. Contact support. |
| 403 | ip_not_allowed | Key is locked to other IP addresses. |
| 404 | not_found | Unknown id or endpoint. |
| 422 | invalid_parameters | A parameter is not valid; see error.fields. |
| 429 | rate_limited | Too many requests this minute; wait Retry-After seconds. |
| 429 | quota_exceeded | Monthly records used up; resets on the 1st. |
| 500 | server_error | Error on our side. It has been logged; please retry later. |
/usage are free.X-Records-Limit | Records in your monthly plan |
X-Records-Remaining | Records left this month |
X-RateLimit-Limit | Requests allowed per minute |
X-RateLimit-Remaining | Requests left this minute |
Retry-After | Seconds to wait after a 429 |
X-Request-Id | Quote it when contacting support |
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.
rfp.created | A new RFP matching your filters was published. |
rfp.updated | An RFP you could already see changed: deadline extended, addendum issued, cancelled, awarded or details corrected. |
federal.created | A new US federal notice (plans with federal access). |
federal.updated | A US federal notice changed. |
ping | Sent only when you press "Test" on the Developer page. |
X-RFPPlanet-Event | Event name, e.g. rfp.created |
X-RFPPlanet-Delivery | Unique delivery id (dlv_…); the same id is reused on retries, so use it to ignore duplicates |
X-RFPPlanet-Timestamp | Unix time the request was signed |
X-RFPPlanet-Signature | v1=<hex HMAC-SHA256 of "{timestamp}.{raw body}" with your signing secret> |
{
"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})
// 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);
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.
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.
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.
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.