Sell to State — Government Contracts Data API
Version 0.4.0
# Sell to State Data API
Programmatic access to **Singapore government contract awards (GeBIZ)** — 17,800+ awarded tenders across 113 buying agencies and 6,100+ suppliers. Built for analysts, vendors, and **AI agents**.
The same data behind the public [selltostate.com](https://selltostate.com) site and the [Explorer](https://app.selltostate.com/explorer), available as JSON, CSV, and an **MCP server** your AI assistant can call directly.
## Base URL
```
https://app.selltostate.com
```
> The API is served from `https://app.selltostate.com` at the documented paths — use this host today. A dedicated `https://api.selltostate.com` machine-API host is planned for GA but is **not yet deployed** (it currently returns 404); do not target it until it is announced.
## 60-second quickstart
1. Create a free account at [app.selltostate.com](https://app.selltostate.com) and start a subscription (or trial).
2. Generate an API key under **Account → API keys** (`app.selltostate.com/account/keys`). It is shown **once** — store it.
3. Call the API:
```bash
curl -s https://app.selltostate.com/api/v1/awards?q=cybersecurity&page_size=5 \
-H "x-api-key: sts_xxxxxxxx"
```
Without an active subscription every data call returns **`402 Payment Required`** with a `payment_link` (and, for agents, a `machine_payment` affordance) — see **Subscriptions & the paywall** below.
## Authentication
Two credential types, both resolved to one entitlement check:
| Type | Header | Who | Get one |
|---|---|---|---|
| **API key** | `x-api-key: sts_…` | Humans / your backend | Dashboard → Account → API keys |
| **Machine client** | `Authorization: Bearer client:<client_id>` | Autonomous agents | Self-register via DCR (`POST /api/oidc/register`) |
- API keys are `sts_` + 48 hex chars, shown once, stored only as a SHA-256 hash, and revocable from the dashboard.
- Machine clients self-register with no human in the loop (RFC 7591). A freshly registered client has **zero data scopes** until it subscribes — registration returns a `payment_link`, and the client can also pay autonomously via `POST /api/v1/pay`.
- A full OAuth 2.1 authorization-code + token flow (the `OAuth2` scheme below) is on the roadmap; today, present the issued `client_id` as `Authorization: Bearer client:<client_id>`.
## Subscriptions & the paywall
Data is gated by a single flat subscription (read live pricing at `GET /api/v1/price`). Until the caller's subject (`user:<id>` for a key, `client:<id>` for an agent) is active, **every data endpoint returns `402`**:
```json
{
"error": "payment_required",
"message": "An active subscription is required to access award data.",
"payment_link": "https://app.selltostate.com/checkout",
"plan": "data-monthly",
"machine_payment": {
"endpoint": "https://app.selltostate.com/api/v1/pay",
"method": "POST",
"accepts": ["payment_method_token", "customer"],
"scopes_granted": ["data:read", "data:export"]
},
"price": "$49/mo",
"currency": "usd"
}
```
The `402` also carries a header advertising both paths:
```
WWW-Authenticate: Bearer realm="data", charge="…/checkout", charge_automated="…/api/v1/pay"
```
- **Humans** open `payment_link` (hosted Stripe Checkout).
- **Agents** `POST /api/v1/pay` with a Stripe payment-method token and are charged off-session — no browser. On success the agent's very next data call returns `200`.
**Plans & caps:** a 2-day trial is capped at **20 rows** per export/page; a paid plan returns up to **5,000 rows**. v1 entitlement is all-or-nothing — an active subscription grants every data scope.
## Scopes
`data:read` (read awards), `data:export` (CSV export), `data:bulk` (bulk access). In v1 an active subscription grants all three.
## Pagination
List endpoints take `page` (default 1) and `page_size` (default 100; hard-capped by your plan — 20 trial / 5,000 paid). Responses echo `total`, `page`, and `pageSize`.
## Rate limits
- **Data calls:** 120 requests/minute per credential (per API key or machine client; anonymous/`402` traffic is limited per source IP).
- **Client registration:** 10 registrations/hour per IP.
Over the limit → `429` with a `Retry-After` (seconds) header.
## Using the API from an AI agent (MCP)
This API is a **Model Context Protocol (MCP) server**. Point any MCP-capable client (Claude Desktop, Claude Code, Cursor, your own agent) at the streamable-HTTP endpoint and it can search and export Singapore government contracts on its own — including self-registering and paying for access with no human in the loop.
**Endpoint**
```
https://app.selltostate.com/api/mcp
```
### Connect flow (DCR + OAuth)
The server advertises its auth the standard way, so spec-compliant clients wire themselves up automatically:
1. **Discover.** The client reads `GET /.well-known/oauth-protected-resource` (RFC 9728) to learn the resource and its authorization server, then `GET /.well-known/oauth-authorization-server` (RFC 8414) for the issuer, endpoints, PKCE support, and scopes (`data:read`, `data:export`, `data:bulk`).
2. **Register.** With no pre-shared credential, the client self-registers via RFC 7591 Dynamic Client Registration: `POST /api/oidc/register` returns a `client_id` (zero data scopes) plus a `payment_link`.
3. **Authenticate.** Present the issued client as `Authorization: Bearer client:<client_id>` on every MCP call. (`tools/list` is open and needs no credential; `tools/call` resolves the machine principal.) The full OAuth 2.1 authorization-code + token exchange is on the roadmap; the `Bearer client:<id>` form works today.
4. **Subscribe.** Until the client's subject is entitled, every `tools/call` returns the same **`402`** body as REST (see *Subscriptions & the paywall*) — carrying both a human `payment_link` and the agent-native `machine_payment` affordance. The agent calls the `pay` tool (`POST /api/v1/pay`) with a Stripe payment-method token, is charged off-session, and its very next `tools/call` returns data.
### Tool catalog
`tools/list` returns **26 tools** (as of 0.4.0), each mirroring a REST endpoint 1:1 — search, entity detail, faceted search, lists, and saved searches — plus the autonomous `pay` tool. The full catalog (name · scope · REST endpoint · `requires_user_key`) is the machine-readable **`x-mcp-tools`** extension on this document; treat that as the source of truth. Highlights: `whoami` (ungated self-introspection), `search` (faceted), `get_buyer`/`get_supplier_detail`/`get_sector`/`get_tender` (entity detail; `get_buyer_detail` is a back-compat alias of `get_buyer`), and the owner-scoped `*_list`/`*_saved_search` tools that require a user `x-api-key` (an OIDC client token gets a `requires_user_key` result, mirroring the REST `403`).
### Raw JSON-RPC (manual test)
List the tools (open, no auth):
```bash
curl -s https://app.selltostate.com/api/mcp \
-H "content-type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
Call a tool (needs an entitled principal, else `402`):
```bash
curl -s https://app.selltostate.com/api/mcp \
-H "content-type: application/json" \
-H "Authorization: Bearer client:<client_id>" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_awards","arguments":{"q":"cybersecurity","year":2024}}}'
```
### Example agent prompts
Once the connector is attached, a user can simply ask:
- *"Search Sell to State for cybersecurity awards in 2024 over S$1M and summarise the top 5 suppliers."*
- *"Use the Sell to State tools to pull every award won by the Ministry of Education this year and export them as CSV."*
- *"Which suppliers does the Singapore government buy construction services from? Use get_agency on the relevant buyer."*
If the agent hits a `402`, the affordance is self-explanatory: it can present the `payment_link` to the user, or — when authorised to spend — call `pay` and continue without interruption.
## Data notes
Award amounts are in their **local award currency** (Singapore awards in SGD); the subscription **price** is in USD. `description` is the tender description, truncated to 240 chars in list/JSON responses.
## Support
[[email protected]](mailto:[email protected]) · status & changelog below.
---
## What's new in 0.4.0 — REST · MCP · CLI parity
0.4.0 closes the gap between the three machine surfaces so **every capability is reachable the same way from REST, the MCP server, and the official CLI** (`@data-atlas/cli` → the `selltostate` binary). Same auth (`x-api-key` or `Bearer client:<id>`), same paywall, same entitlement caps.
### New REST endpoints (all under `/api/v1`, same machine paywall as `/awards`)
| Endpoint | Returns |
|---|---|
| `GET /api/v1/whoami` | Self-introspection: `{subject, type, active, plan, scopes, export_cap}`. **No 402** — a valid-but-unentitled key returns `200` with `active:false` so an agent can decide to pay *before* doing work. Missing credential → `401`. |
| `GET /api/v1/search` | Faceted full-text (Typesense) search over one `scope` (`tenders\|buyers\|suppliers\|sectors`): `{hits, found, page, per_page, facets}`. Search-budget rate limit (300/min). |
| `GET /api/v1/buyers` · `GET /api/v1/buyers/{slug}` | Top buyers by spend · buyer detail (summary, yearly trend, sector breakdown, top suppliers). |
| `GET /api/v1/suppliers` · `GET /api/v1/suppliers/{slug}` | Top suppliers by awarded total · supplier detail (summary, yearly trend, sectors, top buyers). |
| `GET /api/v1/sectors` · `GET /api/v1/sectors/{slug}` | Curated sector taxonomy · sector detail (summary, yearly trend, active partners via `?kind=buyers\|suppliers`). |
| `GET /api/v1/tenders/{id}` | Single award by numeric `award_id` **or** public tender page id, with related awards. |
| `GET/POST /api/v1/lists` · `GET/PATCH/DELETE /api/v1/lists/{id}` · `POST/DELETE /api/v1/lists/{id}/items` | Saved award **lists** CRUD. **Owner-scoped: requires an `x-api-key` (user) credential** — OIDC client tokens get `403` (a list belongs to a real user). |
| `GET/POST /api/v1/saved-searches` · `GET/PATCH/DELETE /api/v1/saved-searches/{id}` | Saved **searches** CRUD. Same owner-scoped rule as lists. |
Entity-detail and partner tables are capped to your plan's row cap on trial keys (`table_cap` echoes the applied cap).
### MCP tool catalog (24 tools — `x-mcp-tools` on this document is the machine-readable source of truth)
Every REST capability above has a matching MCP tool. `get_buyer` is canonical; `get_buyer_detail` is a kept back-compat alias. Lists/saved-searches tools require a user `x-api-key` (an OIDC client gets a `forbidden`/`requires_user_key` result, mirroring the REST `403`). `whoami` is ungated.
### Official CLI — `selltostate`
`npm i -g @data-atlas/cli` ships the `selltostate` binary — a thin wrapper over this exact REST surface, 1:1 per command, with `--format table|json|csv`. Authenticate once with `selltostate auth login` (stores an `x-api-key` at `~/.config/selltostate/config.json`, mode `0600`) or pass `STS_API_KEY`. See the `x-cli` extension on this document for the full command map, and the [CLI page](https://selltostate.com/cli/) on the marketing site.
```bash
selltostate auth login # verify + persist your x-api-key via /whoami
selltostate whoami # {subject, active, plan, scopes, export_cap}
selltostate search "cybersecurity" --scope tenders --year-min 2023
selltostate get buyer ministry-of-education
selltostate export --supplier ACME -o awards.csv
```
Data
Search and export awarded government tenders. Requires an active subscription (else 402).
GET /api/v1/awards
Search awarded government tenders
Paginated, filterable query over the full GeBIZ award set, ordered by awarded amount (desc). Requires an active subscription — otherwise returns `402` with a payment link. Row count per page is capped by your plan (20 trial / 5,000 paid).
**Note:** `amount_max` is honored by the MCP `search_awards`/`export` tools; the REST endpoint currently filters on `amount_min` only (`amount_max` parity fix pending GA).
Parameters
q in query — Full-text match on tender description (ILIKE).
agency in query — Buyer agency — exact slug or partial display-name match.
supplier in query — Winning supplier — partial display-name match.
category in query — Category slug (exact).
year in query — Award year (YYYY).
amount_min in query — Minimum awarded amount (in the award's local currency).
amount_max in query — Maximum awarded amount. MCP tools only today (see note).
page in query — 1-based page number.
page_size in query — Rows per page (capped by plan).
GET /api/v1/awards.csv
Export awarded tenders as CSV
Same filters as `searchAwards`, returned as `text/csv` (attachment `selltostate-awards.csv`). Columns: `tender_no,supplier,agency,category,awarded_amt,award_date,description`. Row count capped by plan (20 trial / 5,000 paid). Requires an active subscription; un-entitled callers are redirected to `/checkout`.
Parameters
q in query
agency in query
supplier in query
category in query
year in query
amount_min in query
GET /api/v1/whoami
Self-introspection — auth, entitlement & scopes
Resolve the *calling* credential's identity, entitlement and granted scopes. **Deliberately different from the data paywall:** a valid-but-unentitled credential returns `200` with `active:false` (so an agent/CLI can decide to pay *before* doing work); a missing/invalid credential returns `401` (not `402`). Granted scopes are all data scopes when active, else empty (v1 is all-or-nothing). Shares the standard 120/min per-caller rate limit.
GET /api/v1/search
Faceted full-text search (one collection)
Faceted Typesense search over a single `scope` collection. Returns hits, total `found`, pagination, and `facets` (counts per facet value). Uses the **search budget** rate limit (300/min) shared with the public `/api/search` route. Behind the same machine paywall as `/awards` — no human-session fallback.
Parameters
scope in query — Collection: tenders | buyers | suppliers | sectors (default tenders).
q in query — Full-text query.
country in query — ISO country filter (multi-country instances).
sector in query — Sector slug (repeatable).
type in query — Record type filter (scope-dependent).
status in query — Status filter (scope-dependent).
year_min in query — Earliest award year.
year_max in query — Latest award year.
amount_min in query — Minimum amount.
amount_max in query — Maximum amount.
page in query — 1-based page (default 1).
per_page in query — Hits per page (≤ 50).
GET /api/v1/buyers
Top buyers by spend
Top buying agencies ranked by total awarded spend. `?limit` (default 50, max 500). Trial keys are row-capped (`table_cap`).
Parameters
limit in query — Rows to return (default 50, max 500).
GET /api/v1/buyers/{slug}
Buyer (agency) detail
Full buyer profile: header summary, yearly spend trend, sector breakdown, and a paginated top-suppliers table (`?sup_page`). `404` when the slug is unknown.
Parameters
slug in path (required) — Buyer slug, e.g. ministry-of-education.
sup_page in query — Top-suppliers page (1-based).
GET /api/v1/suppliers
Top suppliers by awarded total
Top suppliers ranked by total awarded value. `?limit` (default 50, max 500). Trial keys are row-capped.
Parameters
limit in query — Rows to return (default 50, max 500).
GET /api/v1/suppliers/{slug}
Supplier detail
Full supplier profile: header summary, yearly awarded trend, sector breakdown, and a paginated top-buyers table (`?buyer_page`). `404` when the slug is unknown.
Parameters
slug in path (required) — Supplier slug.
buyer_page in query — Top-buyers page (1-based).
GET /api/v1/sectors
Sector taxonomy
The curated sector taxonomy (slug + name). Small reference list — no row cap.
GET /api/v1/sectors/{slug}
Sector detail
Sector profile: header summary, yearly trend, and active partners. `?kind=buyers|suppliers` (default buyers) selects the partner table; `?page` paginates it. `404` when the slug is unknown.
Parameters
slug in path (required) — Sector slug.
kind in query — Partner table: buyers | suppliers (default buyers).
page in query — Partner table page (1-based).
GET /api/v1/tenders/{id}
Single award (tender) detail
One award resolved by **numeric `award_id` OR public tender page id**, plus related awards (same tender / same buyer). Related lists are pre-capped; no trial row cap applies. `404` when unknown.
Parameters
id in path (required) — Numeric award_id or public tender page id.