Scoutee API: developer documentation
Scoutee API documentation: X-API-Key authentication, the GET /tenders search, every query parameter, response shapes, per-plan quotas, error codes and the MCP server.
Actualizado el 7 de septiembre de 2026
The Scoutee API gives read access to the tender database from your own tools: a monitoring script, a CRM, an internal dashboard. It exposes two endpoints, the search and a single notice, and the same two operations as an MCP server for assistants.
Base URL: https://scoutee.org/api
Every response is JSON (application/json), UTF-8. Dates are ISO 8601 (2026-09-05T14:30:00Z).
API keys
A key belongs to a workspace, not to a person: it outlives whoever created it, and the workspace administrators can revoke it at any time.
A key can only be created, and only works, while the workspace is on a paid plan — Standard, or Beta. If the workspace goes back to the free plan, the existing keys stay listed but calls answer 403.
To create a key: your workspace page, section "API", button "Create a key". The full value is shown once, at creation. Scoutee only stores its SHA-256 digest and cannot show it to you again; if you lose it, revoke the key and create another one.
Key format: sct_ followed by 40 hexadecimal characters. The first 12 characters (the prefix, for example sct_1a2b3c4d) are shown in the key list so that you can recognise a key.
A workspace can hold at most 10 active keys. Beyond that, creation answers 409: revoke a key before creating another one.
Authentication
Every request carries the key in the X-API-Key header:
curl -s "https://scoutee.org/api/tenders?page_size=5" \
-H "X-API-Key: sct_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"
A key is a read-only credential: it opens GET /tenders and GET /tenders/{id}, nothing else. Any other path answers 403, including the key management routes themselves, which require a signed-in session.
The key is never optional. The API has no anonymous access and no free-plan access: it exists only for workspaces on a paid plan. A request without X-API-Key answers 401 {"detail": "Login required"}, and a key that is unknown or revoked answers 401 {"detail": "Clé API invalide"}.
Never put a key in code that runs in a browser, or in a public repository: it opens the complete search results your workspace pays for.
Quotas
Each search consumes one quota unit. The counter is kept per key: two keys of the same workspace do not share one bucket. Fetching a single notice (GET /tenders/{id}) consumes nothing.
| Plan | Searches per hour | Burst per minute | Results per page | Deepest page |
|---|---|---|---|---|
| Standard | 10,000 | 240 | 200 | 500 |
| Beta | 10,000 | 240 | 200 | 500 |
These are the only two rows there can be: a key only exists on a paid plan, and both paid plans carry exactly the same limits. Whichever of Standard or Beta your workspace is on, a key gets 10,000 searches per hour, 240 per minute, 200 results per page and 500 pages at most. (The tighter limits the website applies to visitors and to the free plan are a matter for the site's own search; nothing reaches the API without a key.)
Asking for more than these caps is not an error: the value is clamped. page_size=500 returns 200 results, and the page_size field of the response reports the value actually applied.
Every search response carries three headers:
| Header | Content |
|---|---|
X-Quota-Limit |
Searches allowed per hour |
X-Quota-Remaining |
Searches left in the current hour |
X-Quota-Plan |
Plan applied — the workspace's own plan, standard or premium (the internal name of Beta) |
GET /tenders
Paginated search. By default: open tenders only, newest first. Notices published on several portals are deduplicated: one row per notice, the other portals appearing in also_on.
Parameters
All optional, passed in the query string.
| Parameter | Type | Default | Description |
|---|---|---|---|
workspace_id |
integer | none | Ignored with an API key: the key is already bound to a workspace. |
page |
integer, minimum 1 | 1 |
Requested page. Clamped to 500. |
page_size |
integer, 1 to 500 | 50 |
Results per page. Clamped to 200. |
source_id |
integer | none | Keep only the notices of one portal. |
q |
string, 200 characters at most | none | Free text over the title, the buyer and the description. Every word must match the start of a word in the notice (nettoy finds nettoyage); a substring inside a word does not match. Case and accent insensitive. |
keyword |
string, repeatable | none | Keywords. Whole-word match (plural tolerated), case and accent insensitive, in the title, the buyer or the description, plus the cached translations of the keyword on the sources of that language. Several keyword widen the search (any of them). |
exclude_term |
integer, repeatable | none | Identifiers of terms (translations or variants of a keyword) to drop from the search. |
country |
string, repeatable | none | Countries of the portal, by their English name (France, Belgium, Germany; Europe for TED). Several country add up. The by_country object of any response lists the exact values in use. |
sector |
string of two digits, repeatable | none | Sectors, that is CPV 2008 divisions: the first two digits of a CPV code (45 construction work, 72 IT services, 85 health and social work). A notice matches when it belongs to any of the given sectors. Never applied unless asked: with no sector, every sector is searched. A value outside the 45 divisions answers 422 with the code invalid_sector. The by_sector object of a response lists the divisions in use. |
min_value |
number | none | Minimum estimated value, in the currency of the notice. |
max_value |
number | none | Maximum estimated value. |
sort |
newest, oldest or deadline |
newest |
Publication descending, publication ascending, or deadline ascending. |
include_closed |
boolean | false |
Include closed notices. |
seen_after |
ISO 8601 date-time | none | Keep only the notices first collected after that instant. Useful for incremental monitoring. |
Response
200 OK, a TenderPage object:
| Field | Type | Description |
|---|---|---|
items |
array of Tender |
The notices of this page. |
total |
integer | Total number of notices matching the criteria. |
page |
integer | Page actually returned. |
page_size |
integer | Page size actually applied. |
pages |
integer | Number of reachable pages, capped by the plan. |
by_country |
object, country code to integer | Notices per portal country, with every other filter applied but without the country filter. Meant to build a facet. |
by_sector |
object, two-digit division to integer | Notices per sector, with every other filter applied — country included — but without the sector filter. Meant to build a facet. A notice carrying several sectors is counted once per sector, so the values do not add up to total. |
A Tender object:
| Field | Type | Description |
|---|---|---|
id |
integer | Scoutee identifier of the notice. |
source_id |
integer | Identifier of the portal it came from. |
source_name |
string, nullable | Name of the portal. |
source_country |
string, nullable | Country of the portal, two letters. |
external_id |
string | Identifier of the notice on the portal. |
title |
string | Title of the consultation. |
buyer |
string, nullable | Public buyer. |
description |
string, nullable | Subject of the contract, as published. |
url |
string | Page of the notice on the source portal. |
location |
string, nullable | Place of performance. |
procedure |
string, nullable | Procedure type, as published. |
cpv_codes |
array of strings | CPV codes attached to the notice. |
sectors |
array of two-digit strings | Sectors (CPV divisions) of the notice: the divisions of its CPV codes when it has any, otherwise the single division our classifier gave it. Empty when neither applies. |
estimated_value |
number, nullable | Estimated value. |
currency |
string, nullable | Currency of that value. |
published_at |
date-time, nullable | Publication date. |
deadline_at |
date-time, nullable | Deadline for submissions. |
first_seen_at |
date-time | First collection by Scoutee. |
last_seen_at |
date-time | Last collection. |
closed_at |
date-time, nullable | Closure observed. null while the notice is open. |
favorite |
boolean | Always false with an API key: favorites belong to a user. |
also_on |
array of AlsoOn |
Other portals that published the same notice. |
An AlsoOn object: source_id (integer), source_name (string), source_country (string, nullable), url (string).
Example
curl -s -G "https://scoutee.org/api/tenders" \
-H "X-API-Key: $SCOUTEE_API_KEY" \
--data-urlencode "keyword=roadworks" \
--data-urlencode "keyword=signage" \
--data-urlencode "country=France" \
--data-urlencode "sort=deadline" \
--data-urlencode "page_size=50"
{
"items": [
{
"id": 918233,
"source_id": 12,
"source_name": "PLACE",
"source_country": "FR",
"external_id": "25-114287",
"title": "Travaux de voirie et de signalisation horizontale",
"buyer": "Communauté de communes du Val de Loire",
"description": "Marché à bons de commande pour la réfection de voirie...",
"url": "https://www.marches-publics.gouv.fr/?page=Entreprise.EntrepriseAdvancedSearch&id=25-114287",
"location": "Loiret",
"procedure": "Procédure adaptée",
"cpv_codes": ["45233220", "45233221"],
"sectors": ["45"],
"estimated_value": 420000.0,
"currency": "EUR",
"published_at": "2026-09-01T08:00:00Z",
"deadline_at": "2026-10-03T12:00:00Z",
"first_seen_at": "2026-09-01T09:12:44Z",
"last_seen_at": "2026-09-05T06:03:11Z",
"closed_at": null,
"favorite": false,
"also_on": []
}
],
"total": 137,
"page": 1,
"page_size": 50,
"pages": 3,
"by_country": { "France": 137, "Belgium": 12 },
"by_sector": { "45": 96, "71": 28, "50": 13 }
}
Notices are returned in the language they were published in: the API does not translate them.
In Python, with requests:
import os
import requests
BASE = "https://scoutee.org/api"
HEADERS = {"X-API-Key": os.environ["SCOUTEE_API_KEY"]}
response = requests.get(
f"{BASE}/tenders",
headers=HEADERS,
params={
"keyword": ["roadworks", "signage"],
"country": ["France"],
"sort": "deadline",
"page_size": 50,
},
timeout=30,
)
response.raise_for_status()
page = response.json()
print(page["total"], "notices", "|", response.headers["X-Quota-Remaining"], "searches left")
for tender in page["items"]:
print(tender["id"], tender["deadline_at"], tender["title"])
Walking every page:
def iter_tenders(**params):
"""Every page of a search, in the requested order."""
page = 1
while True:
response = requests.get(
f"{BASE}/tenders",
headers=HEADERS,
params={**params, "page": page, "page_size": 200},
timeout=30,
)
response.raise_for_status()
body = response.json()
yield from body["items"]
if page >= body["pages"]:
return
page += 1
For incremental monitoring, keep the timestamp of your last run and pass it back as seen_after: only the notices discovered since then come back.
GET /tenders/{id}
One notice, enriched like a search result. Consumes no quota.
curl -s "https://scoutee.org/api/tenders/918233" \
-H "X-API-Key: $SCOUTEE_API_KEY"
The response is a Tender object, identical to those of items. If the requested identifier points at the copy of a notice published on several portals, the canonical copy is returned. An unknown identifier answers 404.
MCP server
The same two operations are exposed as an MCP server, so an assistant — Claude Code, Cursor, VS Code, Windsurf, or anything else that speaks the protocol — can search tenders on your behalf without you writing a line of HTTP.
- URL:
https://scoutee.org/api/mcp - Transport: Streamable HTTP, stateless, JSON responses (no stream to hold open, so it works through any proxy)
- Authentication: the same workspace key, in
X-API-Keyor inAuthorization: Bearer, and just as mandatory — a call without one comes back as a tool error asking for a key - Quotas: identical to REST — 10,000 searches an hour on either paid plan, one unit per
search_tenderscall, nothing forget_tender, counted in the same per-key bucket
Tools
search_tenders takes the query parameters of GET /tenders, with the same semantics: q, keyword (array), country (array), source_id, min_value, max_value, sort (newest, oldest, deadline), include_closed, seen_after, page and page_size. All optional. page_size defaults to 20 rather than 50, since a notice is a large object to hand to a model, and is still capped at 200. The result is a TenderPage plus three fields that carry what the quota headers carry over HTTP: quota_plan, quota_limit and quota_remaining.
get_tender takes a single tender_id and returns the same Tender object as GET /tenders/{id}. It consumes no quota.
Claude Code
claude mcp add --transport http scoutee https://scoutee.org/api/mcp \
--header "X-API-Key: sct_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"
Cursor, VS Code, Windsurf and other clients
Most of them read a config file holding an mcpServers object (.cursor/mcp.json, .vscode/mcp.json, ~/.codeium/windsurf/mcp_config.json...):
{
"mcpServers": {
"scoutee": {
"url": "https://scoutee.org/api/mcp",
"headers": {
"X-API-Key": "sct_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"
}
}
}
}
Python
With the mcp package (pip install mcp):
import asyncio
import os
import httpx2
from mcp.client import Client
from mcp.client.streamable_http import streamable_http_client
URL = "https://scoutee.org/api/mcp"
async def main():
headers = {"X-API-Key": os.environ["SCOUTEE_API_KEY"]}
async with httpx2.AsyncClient(headers=headers) as http:
async with Client(streamable_http_client(URL, http_client=http)) as client:
print([tool.name for tool in (await client.list_tools()).tools])
result = await client.call_tool(
"search_tenders",
{"keyword": ["roadworks"], "country": ["France"], "page_size": 10},
)
page = result.structured_content
print(page["total"], "notices |", page["quota_remaining"], "searches left")
for tender in page["items"]:
print(tender["id"], tender["deadline_at"], tender["title"])
asyncio.run(main())
A call that fails comes back with is_error set and the English message the REST API would have answered with, followed by its stable code in brackets (Invalid API key [invalid_api_key]): an unknown key, an exhausted quota, an unknown identifier. The codes are those of the Errors section below.
Errors
An error body is always {"detail": "..."}, and the message is always in English, whatever the language of your integration: English is the working language of the API. Every error a user can meet also carries a stable code in the X-Error-Code response header, so your integration can branch on the code and write its own message instead of matching on the text.
| Code | X-Error-Code |
Case | detail |
|---|---|---|---|
| 401 | — | No key at all (there is no anonymous access) | Login required |
| 401 | invalid_api_key |
Key unknown or revoked | Invalid API key |
| 402 | plan_required |
Creating or revoking a key on a workspace without a paid plan | This feature requires a paid plan |
| 403 | api_key_disabled |
The workspace of the key is not on a paid plan (left it, or never had one) | API key disabled: this workspace is not on a paid plan |
| 403 | api_key_scope |
Any path other than the tender search | This key only grants access to the tender search |
| 404 | — | Unknown notice identifier | Tender not found |
| 429 | quota_exceeded |
Hourly quota exhausted | You have reached the limit of 10000 searches per hour of your plan. Try again in 12 min. |
| 429 | search_burst |
Too many requests in one minute | Too many searches at once, try again in a minute |
A quota_exceeded answer repeats its numbers in X-Error-Limit (searches an hour) and X-Error-Minutes (the wait), so a message can be rebuilt in any language.
A 429 carries a Retry-After header, in seconds. Honour it rather than retrying at once:
import time
def search(**params):
"""One search, waiting out the per-minute quota when it is hit."""
for _ in range(3):
response = requests.get(f"{BASE}/tenders", headers=HEADERS, params=params, timeout=30)
if response.status_code != 429:
response.raise_for_status()
return response.json()
time.sleep(int(response.headers.get("Retry-After", "60")))
raise RuntimeError("quota still exhausted after three attempts")
402 and 403 are not fixed by retrying: check the plan of the workspace, or create a new key if yours was revoked.
Over MCP the same failures come back as tool errors carrying the same messages, rather than as HTTP status codes.