# Redpine Connect: full documentation
Generated from https://docs.redpine.ai. Every documentation page, in order.
---
# Assisted search
## How this differs from Search
`/search/query` runs one hybrid retrieval pass and returns whatever ranks highest. `/search/assisted` runs a multi-round agent loop: it reads the query for entities, intent, and aspects, plans internal searches to cover them, and keeps searching and replanning until it has gathered enough candidates or hits the iteration cap. Every candidate gathered is then verified against the query before delivery: a full pool of candidates is not the same as a full pool of verified results, which is why a request can still come back as `no_relevant_results` after several rounds.
**Billing differs too:** only delivered, verified results are charged. A clarification request or an honest "no relevant results" answer costs nothing: internal search fan-out and LLM tokens spent along the way are absorbed by Redpine, not billed to you. See [Rate limits](/docs/rate-limits) for the shared billing and rate-limit headers.
## Assisted Search
`POST /api/v1/search/assisted`
Runs the agentic search loop and returns verified results, a clarifying question, or an explicit no-relevant-results outcome. Accepts the same `filters` as `/search/query`, applied to every internal search.
### Request body
| Parameter | Type | Required | Description |
| ------------------ | -------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `collection` | string | One of `collection`/`collections` | Collection name to search |
| `collections` | string\[] | One of `collection`/`collections` | Collections to search together (max 5) |
| `query` | string | Yes | Natural-language question (max 1000 characters) |
| `limit` | integer | No | Maximum verified results to return (default 10, max 30) |
| `filters` | object \| null | No | Same filter forms as `/search/query`, applied to every internal search |
| `include_metadata` | boolean | No | Include chunk metadata in results (default true) |
| `include_figures` | boolean | No | Fetch and attach figure images as base64 in metadata.figures\[].image\_data, for delivered results only (default false, requires include\_metadata) |
### Response fields
| Field | Type | Description |
| -------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| `status` | `"results"` \| `"clarification_needed"` \| `"no_relevant_results"` | Outcome of the assisted search |
| `queryUnderstanding` | object | How the query was read: entities, intent, aspects, and any structured filters named in the query text |
| `results` | array | Verified results. Empty unless status is `results` |
| `clarification` | object \| null | The clarifying question and reason. Set only when status is `clarification_needed` |
| `billing` | object | What was actually charged: `chargedResults` and `tokensCharged` |
| `queryId` | string | Query identifier for audit reference |
| `latencyMs` | integer | End-to-end latency in milliseconds |
| `iterationsRun` | integer | Number of search + replan rounds executed |
| `filterWarnings` | array \| null | Advisory warnings for filter fields with no payload index; omitted when there are none |
### Result object
| Field | Type | Description |
| ------------ | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | string | Chunk/point ID |
| `text` | string | Chunk text content |
| `metadata` | object \| null | Chunk metadata (if `include_metadata=true`) |
| `collection` | string \| null | Origin collection of this result. Populated only for requests made with the `collections` (multi-collection) form |
| `doiUrl` | string \| null | Resolvable DOI link (`https://doi.org/{doi}`) |
| `section` | string \| null | Comma-joined source section(s) of the article (abstract, introduction, background, methods, results, discussion, conclusion, case, supplementary, other) |
| `relevance` | object | Relevance verdict: `matchedTerms`, `judgeScore` (0..1), and a one-sentence `rationale` |
### Example request
cURL
Python
TypeScript
Go
```bash
curl -X POST "https://api.redpine.ai/api/v1/search/assisted" \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"collection": "my-collection",
"query": "Does topical retinoid use increase photosensitivity?",
"limit": 10,
"include_metadata": true
}'
```
```python
from redpine import Redpine
client = Redpine() # reads REDPINE_API_KEY
a = client.assisted_search(
"Does topical retinoid use increase photosensitivity?",
collection="my-collection",
limit=10,
)
if a.status == "results":
for hit in a.results:
print(hit.relevance.judge_score, hit.section, hit.text[:200])
elif a.status == "clarification_needed":
print(a.clarification.question)
else:
print("No relevant results")
print(a.billing.charged_results, a.billing.tokens_charged, a.iterations_run)
```
```typescript
import { Redpine } from "@redpine-ai/sdk";
const client = new Redpine(); // reads REDPINE_API_KEY
const a = await client.assistedSearch("Does topical retinoid use increase photosensitivity?", {
collection: "my-collection",
limit: 10,
});
if (a.status === "results") {
for (const hit of a.results ?? []) {
console.log(hit.relevance.judgeScore, hit.section, hit.text.slice(0, 200));
}
} else if (a.status === "clarification_needed") {
console.log(a.clarification?.question);
} else {
console.log("No relevant results");
}
console.log(a.billing.chargedResults, a.billing.tokensCharged, a.iterationsRun);
```
```go
client, err := redpine.New() // reads REDPINE_API_KEY
if err != nil {
return err
}
a, err := client.AssistedSearch(ctx, "Does topical retinoid use increase photosensitivity?",
redpine.AssistedSearchOptions{Collection: "my-collection", Limit: 10})
if err != nil {
return err
}
switch a.Status {
case "results":
for _, hit := range *a.Results {
fmt.Println(hit.Relevance.JudgeScore, hit.Text)
}
case "clarification_needed":
fmt.Println(a.Clarification.Question)
default:
fmt.Println("No relevant results")
}
fmt.Println(a.Billing.ChargedResults, a.Billing.TokensCharged, a.IterationsRun)
```
Pass `allow_clarification=False` (`allowClarification: false`, `AllowClarification: &f`) to make the endpoint search on its best interpretation instead of asking. A `503` while the assisted service is unavailable raises `AssistedUnavailable`; the SDKs retry it twice with backoff before surfacing it.
### Example response (verified results)
```json
{
"status": "results",
"queryUnderstanding": {
"entities": ["retinoid", "photosensitivity"],
"intent": "Determine whether an adverse effect is associated with a treatment",
"aspects": ["mechanism of action", "clinical outcomes"],
"filters": {}
},
"results": [
{
"id": "abc123",
"text": "Topical retinoids increase skin sensitivity to UV light by thinning the stratum corneum...",
"metadata": {
"title": "Retinoid Dermatology Review"
},
"collection": null,
"doiUrl": "https://doi.org/10.1234/example",
"section": "discussion",
"relevance": {
"matchedTerms": ["retinoid", "photosensitivity"],
"judgeScore": 0.91,
"rationale": "Directly states the photosensitizing mechanism of topical retinoids."
}
}
],
"clarification": null,
"billing": {
"chargedResults": 1,
"tokensCharged": 612
},
"queryId": "qry_a1b2c3d4e5f6",
"latencyMs": 3840,
"iterationsRun": 2
}
```
## Clarification flow
When the query is too underspecified to search confidently, the endpoint returns `status: "clarification_needed"` with a question instead of running the full search. Re-issue the request with the query augmented by the user's answer. Clarification requests are never billed.
```json
{
"status": "clarification_needed",
"queryUnderstanding": {
"entities": ["treatment"],
"intent": "Unclear -- no specific condition or drug named",
"aspects": [],
"filters": {}
},
"results": [],
"clarification": {
"question": "Which treatment and condition are you asking about?",
"reason": "The query is too underspecified to search usefully."
},
"billing": {
"chargedResults": 0,
"tokensCharged": 0
},
"queryId": "qry_f6e5d4c3b2a1",
"latencyMs": 1120,
"iterationsRun": 0
}
```
## Filtering
The `filters` parameter accepts the same simple and structured-DSL forms as `/search/query`, and is applied to every internal search the agent runs. See [Filtering](/docs/filtering) for operators, indexed fields, and the filters that are rejected.
---
# Authentication
This page covers the REST API. Connecting an agent over MCP instead? Most MCP clients skip keys entirely and use a browser login. See [MCP](/docs/mcp).
## API keys
Create and manage API keys from your [API Keys](https://app.redpine.ai/api-keys?utm_source=docs\&utm_medium=referral) page in the dashboard. Keys are tied to your organization.
There are two kinds:
| Prefix | Kind | Behaviour |
| ---------- | ------- | ----------------------------------------------------------------- |
| `sk_live_` | Live | Searches real content and spends credits |
| `sk_test_` | Sandbox | Returns canned results at no cost, never reaches licensed content |
Build against a sandbox key, then swap it for a live one. Nothing else about the request changes. See [Sandbox](/docs/sandbox) for what it covers.
## Header format
Include your API key in every request using the `Authorization` header:
```bash
Authorization: Bearer sk_live_YOUR_API_KEY
```
An `X-API-Key` header is also accepted, if that fits your HTTP client better:
```bash
X-API-Key: sk_live_YOUR_API_KEY
```
## Example request
The SDKs read `REDPINE_API_KEY` from the environment (falling back to `CONNECT_API_KEY`) and send the Bearer header for you. Pass the key explicitly only when the environment is not the right place for it, such as a request-scoped key in a multi-tenant service.
cURL
Python
TypeScript
Go
```bash
curl -X POST "https://api.redpine.ai/api/v1/search/query" \
-H "Authorization: Bearer sk_live_abc123def456" \
-H "Content-Type: application/json" \
-d '{"collection": "my-collection", "query": "search text"}'
```
```python
from redpine import Redpine
client = Redpine() # REDPINE_API_KEY
client = Redpine(api_key="sk_live_abc123def456") # explicit
r = client.search("search text", collection="my-collection")
```
```typescript
import { Redpine } from "@redpine-ai/sdk";
const client = new Redpine(); // REDPINE_API_KEY
const explicit = new Redpine({ apiKey: "sk_live_abc123def456" });
const r = await client.search("search text", { collection: "my-collection" });
```
```go
client, err := redpine.New() // REDPINE_API_KEY
client, err = redpine.New(redpine.WithAPIKey("sk_live_abc123def456"))
r, err := client.Search(ctx, "search text", redpine.SearchOptions{Collection: "my-collection"})
```
Constructing a client with no key available fails immediately with `AuthError` (Go: an error from `New`) rather than on the first request.
## Key scoping
API keys can be scoped to control access:
* **Organization-wide**: access all collections in your organization.
* **Collection-specific**: restrict access to one or more named collections. Requests to other collections return `403`.
## Authentication errors
If the API key is missing, the API returns `401 Unauthorized` with code `UNAUTHORIZED`. If it's present but doesn't match a live key, the code is `INVALID_API_KEY`; if it's been revoked, `API_KEY_REVOKED`:
```json
{
"error": {
"code": "UNAUTHORIZED",
"message": "Authentication required",
"requestId": "req_a1b2c3d4"
}
}
```
If the key is valid but lacks access to the requested collection, the API returns `403 Forbidden` with code `NO_COLLECTION_ACCESS`. See [Errors](/docs/errors) for the full list of codes.
If a sandbox key reaches an endpoint that has no sandbox behaviour, the API returns `403 SANDBOX_UNSUPPORTED_ENDPOINT` rather than serving real data.
## Security best practices
* Never expose API keys in client-side code or public repositories.
* Use collection-scoped keys when possible to limit blast radius.
* Rotate keys periodically and revoke unused keys.
* Store keys in environment variables or a secrets manager.
---
# Changelog
## 2026-10-05
**Dashboard**
* Home's setup card leads with the copy prompt, under **For your agents**. The chat app buttons sit on the right and read **Add to Claude** and **Add to ChatGPT**.
* Home no longer has the Playground, Data Sources, API Keys and Buy credits quick actions. Data Sources leaves the sidebar and the command menu.
**Documentation**
* [Get started](/) is now the docs home page at docs.redpine.ai. Links to `/get-started` keep working and land there.
* Every setup prompt in these docs, including the per-client "let a coding agent handle setup" one, is now the same prompt as Home's Copy prompt.
## 2026-10-04
**API**
* New organizations no longer get a sandbox key automatically. Create one from the API Keys page when you want it. Sandbox keys that were created automatically have been revoked; sandbox keys you created yourself are unaffected. See [Sandbox](/docs/sandbox).
* An MCP OAuth access token stops working as soon as the API key behind it is revoked, including keys revoked from the dashboard. Before, a `client_credentials` token could keep working for the rest of its hour.
**Dashboard**
* While a sandbox key is selected, the Playground shows an amber banner above the results, tags every result **Sample**, and names the response "Sandbox · sample data, no charge". Live keys are listed first.
* The API Keys page explains what a sandbox key does under its name.
* Switching keys in the Playground cancels the request in flight, so one key's results never appear under another.
**Documentation**
* [Sandbox](/docs/sandbox) says sandbox keys are opt-in and how the Playground marks sandbox output.
## 2026-10-03
**Dashboard**
* Home's setup card splits by client. Chat apps (Claude and ChatGPT) link straight to their connector and plugin listings. Coding agents copy a setup prompt that connects over browser sign-in, checks the tools, offers three first questions and asks before any paid unlock.
* Customer-facing wording says "collection" throughout, never "meta-collection".
## 2026-10-02
**Dashboard**
* Home's Copy prompt can open straight in Claude Code or Codex with the setup prompt filled in. If the app is not installed, the prompt is copied instead.
* On Data Sources, a collection that searches several others opens a panel listing what it covers: each collection with its licence type, description and your price per 1,000 tokens, plus any tool integrations it carries.
## 2026-10-01
**API**
* Journal-metric filters are back: `journal_metric.2yr_mean_citedness`, `journal_metric.h_index` and `journal_metric.i10_index` take range operators on search, preview, unlock, assisted search and MCP. Each threshold is resolved to the matching ISSNs from OpenAlex data (snapshot 2026-09-23), and REST responses report the result in `journalMetricExpansions`. See [Filtering](/docs/filtering#journal-metrics).
* New `journal_metric.impact_factor`: an impact factor Redpine computes from OpenAlex citation data with the standard two-year formula. It is not Clarivate's Journal Impact Factor, and a journal without enough articles has no value.
* Each metric has a lowest `gte` and a highest `lte` it accepts on its own. A threshold outside them returns `422 INVALID_FILTER` naming the bound and how to fix it.
* A metric condition can expand to at most 35,000 ISSNs, a request can send at most 70,000 `in`/`not_in` values counting your own lists, and a filter can hold at most 8 `journal_metric` conditions. A filter over any limit returns `422 INVALID_FILTER` naming the limit.
* A condition that matches no journal adds a `journal_metric_matched_nothing` filter warning.
* A filter number that parses as NaN or Infinity returns `422 INVALID_FILTER` naming the field. Before, it returned a `500`.
* `POST /search/preview` accepts an `Idempotency-Key` header, so a retried request returns the original `queryId` instead of running a second search. Reusing a key with a different body returns `422 IDEMPOTENCY_KEY_REUSED`. See [Preview and unlock](/docs/preview-unlock#retrying-a-preview-safely).
**SDKs**
* The Python, TypeScript and Go SDKs return `filters_applied` (`filtersApplied`) on search and preview responses and `figure_count` (`figureCount`) on preview and result rows. Their filter documentation covers the up-front `422` rules, journal metrics including impact factor, and the limits. See [SDKs](/docs/sdks).
**Dashboard**
* Home replaces Get started. It has one setup prompt, quick actions for the Playground, Data Sources, API Keys and, for owners, buying credits, and a heading that changes after your first query. Usage moves to Analytics, which also holds the audit log for owners and admins.
* The organisation switcher moves into the first breadcrumb on every page.
* The playground's filter builder offers impact factor.
**Documentation**
* [Filtering](/docs/filtering) documents journal metrics again: the four fields, what a `0` means, exclusion with `not`, each metric's bounds and the expansion limits.
## 2026-09-30
**Dashboard**
* New members land on a setup page with one prompt their agent reads to connect itself over MCP sign-in, with no API key needed. Pending invitations are shown there too.
* The sidebar is grouped into Build (Data Sources, Playground, API Keys) and Manage (Members, Billing, Audit), with a Docs link. API keys and AI assistants share one page with tabs, and the three-step onboarding dialog is gone.
## 2026-09-29
**API**
* First searches on large collections are faster, with no change in what they return.
## 2026-09-24
**Dashboard**
* Closing the cookie dialog keeps analytics off, and moving a switch no longer saves your choice on its own. Only Save choices, Accept all and Reject all do.
* The sign-in consent line links the API Access Terms and the Privacy Policy.
## 2026-09-23
**API**
* MCP server instructions describe the catalog and list every tool, including `get_balance` and `request_journal`, instead of telling the agent which tool to call first.
**Dashboard**
* A cookie banner asks before any analytics run. Reject all, Customize and Accept all carry equal weight, and Cookie preferences can be reopened from the sign-in page, the user menu and Settings.
## 2026-09-18
**API**
* On a search across several collections, MCP `relevance` is the reranker's probability that the passage answers the query. Single-collection searches keep the similarity score.
* Searches made through a collection's own MCP search tool are counted once in usage. Before, they were counted twice.
* An empty MCP `preview` returns the same message as an empty search, pointing to `request_journal`.
**Dashboard**
* Audit log search rows show only the filters you sent and mark searches that returned nothing with a No results badge. The status filter has a No results option.
## 2026-09-17
**API**
* Redpine's science collection no longer returns preprints.
* Searches across several collections are reranked by a much faster model.
* Queries written in non-Latin scripts, such as Cyrillic, Arabic or Chinese, are matched by meaning alone when keyword matching would drop most of their words. Before, such queries could return unrelated documents.
## 2026-09-16
**API**
* The same search returns the same results, and so the same price, every time it runs.
**Documentation**
* The quickstart now redirects to [Get started](/), and [SDKs](/docs/sdks) has a "Your first request" section: start with a sandbox key, then make the same call in cURL, Python, TypeScript and Go.
## 2026-09-15
**API**
* MCP `confirm` opens with a `receipt` naming what that call unlocked (each result's `id`, `title` and `doi`) with `cost_charged` and `balance_remaining`. `unlocked_count` stays the running total for the `queryId`; confirming results you already own returns an empty receipt and a charge of `0`. See [MCP](/docs/mcp).
* Every MCP result row from `search`, `preview` and `confirm` carries `relevance`, the passage's similarity to the query, comparable across collections. See [MCP](/docs/mcp#search-results-over-mcp).
* A result's source label within a meta-collection is the same on every surface: search results, collection listings and MCP tools.
* API keys created with `hide_balance` no longer see the organisation's balance in `get_balance`, the `find-tools` status line, the insufficient-credits error on `confirm`, or the `402` body on `/api/v1/search` and `/api/v1/search/unlock`. Costs are still reported.
* `get_balance` and the `find-tools` status line say when a trial ended because credits were added, instead of showing a bare count; `get_balance` returns `trial_ended_reason`.
* An error for a tool that cannot be previewed points to `inspect-tool` to check the price, instead of suggesting a direct call.
* `journalMetricExpansions` is removed from search, preview, unlock and assisted-search responses; `journal_metric` filters have been rejected since 2026-09-14, so it could never carry data.
* `GET /api/v1/search/results/{queryId}` returns the `filterWarnings` an assisted search originally carried.
* Error details returned to clients no longer include raw text from the retrieval service.
**Dashboard**
* The organisation audit log has a **Connections** filter for MCP reconnect events, and the default view and the Recent Activity panel no longer show them, so searches and purchases stay visible. Connection grants and revocations remain in MCP Activity.
* New organisations receive occasional emails to help them get started and to announce new collections. They come from [support@redpine.ai](mailto:support@redpine.ai), and replies reach a person.
**Documentation**
* [Filtering](/docs/filtering) documents which filters are rejected with `422 INVALID_FILTER`, the one-operator-per-condition rule, date formats and the `filtersApplied` echo. [MCP](/docs/mcp) documents the shared result shape of `search`, `preview` and `confirm`, and figure accounting. [Errors](/docs/errors) lists `INVALID_FILTER`.
## 2026-09-14
**API**
* Search filters that cannot be honoured are rejected up front with `422 INVALID_FILTER` naming the field: unknown fields, mixed filter formats, unknown operators, range and equality operators on one condition, invalid dates, unrecognised `article_type` values, `journal_metric.*`, `doc_id`, and fields a collection has no index for. `limit` outside 1–30 is rejected instead of clamped. These errors no longer say "Retry once". See [Filtering](/docs/filtering).
* A query the search service rejects returns `422 INVALID_QUERY`, and a burst of rejected queries returns `429 SEARCH_RATE_LIMITED` with `Retry-After`, instead of a `503`. This applies to REST search, MCP `search` and `preview`, and assisted search.
* Fewer real queries are refused as noise. A repeated character is rejected only when it is the whole query, such as `aaaaaaa`, so a query that merely contains a long run of one letter now goes through. See [Errors](/docs/errors).
* Search, preview and unlock responses echo the filters that were applied as `filtersApplied`.
* Over MCP, `search`, `preview` and `confirm` return the same result rows, including authors, ISSN, keywords, section, article type and open-access status. Direct `search` reports `cost_charged` and `balance_remaining`. Figure delivery reports `images_attached`, `unavailable` and `omitted_over_limit`, which add up to `figure_count`, and each image caption names its result. See [MCP](/docs/mcp).
* Direct MCP `search` returns its rows as structured content instead of Markdown. Clients read `structuredContent`; the "Found N results" header, `score` and `chunk_index` are gone.
* An expired MCP session returns HTTP `404`, so clients reconnect instead of looping on an error. A session in use no longer expires 24 hours after it began.
* MCP `search` is marked as not read-only, because it can charge; `preview` stays read-only.
* Agent-facing text and tool discovery reflect the caller's own catalog.
## 2026-09-11
**API**
* Fixed intermittent `500` errors on search, and slow account and balance responses while searches were running.
* Daily spend alert emails are sent again.
**Dashboard**
* Onboarding, joining an organisation, creating an API key and connecting an MCP client record your acceptance of the version of Redpine's terms you were shown. The policies live at [legal.redpine.ai](https://legal.redpine.ai), and the old in-app policy pages redirect there. Connecting an MCP client no longer accepts the API terms on your organisation's behalf; an admin accepts them in the browser first.
## 2026-09-10
**API**
* Preview results carry `figureCount`. `POST /search/unlock` takes `includeFigures` to return the figure images with the unlocked text, at no extra cost and up to 50 images per call, and `GET /search/results/{queryId}` can fetch them again. Over MCP, `confirm` takes `include_figures`. See [Search](/docs/search).
* MCP tools have readable titles and plainer descriptions.
* Fewer intermittent `503` errors on search.
**SDKs**
* The Python SDK is on PyPI: `pip install redpine-sdk`. See [SDKs](/docs/sdks).
**Documentation**
* [Get started](/) gives the exact setup steps for your client (Claude Code, Codex, Cursor, Claude Desktop, ChatGPT and others) or SDK, and the docs home page is redesigned.
## 2026-09-09
**API**
* `data_provider` is no longer returned in result metadata, and a filter on it returns `422 INVALID_FILTER`.
**Dashboard**
* Playground results show the publisher alongside the journal and publication details, making sources easier to identify.
* You can clear the playground's recent calls for your current organisation.
* Legal documents open in a new tab during sign-in and setup, so checking a policy no longer takes you away from the form you are completing.
## 2026-09-08
**API**
* Search filters out very short fragments, such as isolated headings, so results contain more useful text. See [Search](/docs/search).
**Dashboard**
* The playground's Code tab shows the request with the Python, TypeScript and Go SDKs, the same calls the guides use; cURL stays the raw request. Results is the first tab, and the results panel scrolls on its own. See [SDKs](/docs/sdks).
## 2026-09-07
**API**
* Agents can request a missing journal or publisher with the free `request_journal` MCP tool. Available to organisations with access to a collection.
* `open_access` filters across a meta-collection: a collection that holds only open-access content answers the filter as a whole, and `open_access: false` excludes it with a `filterWarnings` entry. `license` is returned in result metadata but is not a filter. See [Filtering](/docs/filtering).
* Collection listings show exact prices only. A meta-collection whose members all cost the same shows one price rather than a range, and a price that cannot be resolved is reported as unavailable, never as `$0.00`. There are no more estimated per-query costs. See [Collections](/docs/collections).
* Balance and charge amounts over MCP are reported at ledger precision, so a sub-cent charge no longer rounds to `$0.00`.
* People who join through an organisation invite no longer get a separate personal workspace created alongside it.
* Reliability fixes for assisted search and for retrieval connection errors.
* `X-Query-Cost` is what the request was charged. For a billing-exempt organisation it carried an estimate, which read as a charge; it is now `0.000000` whenever nothing was charged (`exempt`, `trial` and `sandbox` modes), on the REST headers and in the MCP billing footer. See [Rate limits and billing headers](/docs/rate-limits).
* A search on a collection that no longer exists answers `404 COLLECTION_NOT_FOUND` instead of `503 QDRANT_UNAVAILABLE`.
**Dashboard**
* Request a journal or publisher from Data Sources and see your organisation's submitted requests.
* The playground is rebuilt around the request: pick a key, choose collections as chips, ask, and send. Results render as reading rows with title, journal, year, DOI and source link and the query terms marked, with the raw response, headers and the request as cURL, Python, TypeScript and Go a tab away. A ledger line shows status, latency, results, tokens, what was charged and the balance, read from the response itself.
* Results render as markdown by default (headings, lists and tables in the chunk text), with a Plain text view that marks the query terms. The ledger line names the billing mode and says "no charge" for exempt, trial and sandbox requests.
* With Include figures on, figures render as thumbnails that open full size with their caption. Figures that were not fetched keep their label, caption and link.
* The documentation pages inside the dashboard are retired. Every old `/docs` link, in the dashboard and on the public site, redirects to the matching page on docs.redpine.ai. The playground moved to `/org/{orgId}/playground` and stays in the sidebar next to API Docs.
* The billing transaction table shows a sub-cent charge as `<$0.01` instead of `$0.00`.
**Documentation**
* This changelog.
* [Filtering](/docs/filtering) documents `open_access` across open-access collections and that `license` is not a filter; [Collections](/docs/collections) documents when `priceRange` is null.
* Each tool on an [integration page](/docs/integrations) has its own anchor, so links from the playground land on the tool.
## 2026-09-04
**CLI**
* New `redpine preview`, `redpine confirm` (alias `unlock`) and `redpine balance`, so the free-preview-then-pay flow works from the terminal. See [Preview and unlock](/docs/preview-unlock).
* Reads `REDPINE_API_KEY` and `REDPINE_BASE_URL`, the same names the SDKs use. `CONNECT_API_KEY` and `CONNECT_SERVER_URL` still work.
* Output is the JSON envelope whenever stdout is not a terminal; `--json` and `--pretty` force either. This is what the README always said and it now holds.
* A tool result flagged as an error renders as the error envelope with a non-zero exit, instead of success.
* `redpine update` verifies the download against the release's `checksums.txt`. A newer release is a notice on stderr, no longer a block on every command.
* `whoami` labels sandbox keys.
## 2026-09-03
**SDKs are public**
* Official clients for [Python, TypeScript and Go](/docs/sdks). TypeScript is on npm as `@redpine-ai/sdk`; Go via the module proxy.
* The SDKs add `preview()` and `unlock()`, and `get_results` returns the preview shape with `locked`, `tokens` and `cost` per row. New typed errors for `402` (insufficient credits) and `410` (expired).
* Every guide on this site now shows the SDK call next to the cURL request.
**API**
* [Preview and unlock](/docs/preview-unlock): `POST /api/v1/search/preview` runs a search for free and returns teaser rows with a price per row; `POST /api/v1/search/unlock` pays for the rows you choose. `GET /api/v1/search/results/{queryId}` returns the same shape. Previews last 7 days, and over MCP a confirm can be retried.
* [Sandbox mode](/docs/sandbox): `sk_test_` keys return synthetic fixtures at no cost, on the same endpoints. Every response carries `X-Billing-Mode`.
* Assisted search falls back to a second model provider when the first is unavailable.
* MCP search results no longer expose the internal retrieval score. It was a per-collection number that could not be compared across collections.
* `last_updated_date` is filterable on the editorial collections. It is the freshness signal there, since editorial articles are revised in place rather than republished. See [Filtering](/docs/filtering).
* Integrations such as media and aviation tools are enabled per organisation, off by default. Turn them on from the Data Sources page in the dashboard.
* Organisations on a billing exemption no longer open every MCP session with a "No balance remaining" notice.
* Every email the platform sends, including the sign-in code and receipts, is readable on a phone without zooming.
**Dashboard**
* API Keys: create a sandbox key, badged so it cannot be mistaken for a live one.
* Data Sources: switch integrations on or off for your organisation, and see whether each data source is granted directly, included with a bundle, or open access.
* Publisher organisations get an editorial theme across the dashboard, with the publisher's logo in the masthead.
**Documentation**
* SDK examples on every guide, and the quickstart installs a client instead of showing raw HTTP.
* The language picker remembers your choice across pages, and always has a tab selected.
* [MCP](/docs/mcp): setup guides per client (Claude Code, Codex, Cursor, Claude Desktop, ChatGPT and others) and the `search`, `preview` and `confirm` tool surface.
* [Which search endpoint](/docs/search-endpoints): a short decision guide between direct, preview and assisted search.
* [Integrations](/docs/integrations) lists the tools available to your organisations when you are signed in.
* The `X-API-Key` header is documented as an alternative to `Authorization: Bearer`. See [Authentication](/docs/authentication).
## 2026-09-02
**API**
* Searches across several collections are merged by a cross-encoder reranker rather than by rank alone. Before this, equal-length result lists tied rank for rank and the wrong collection took the top slot in most measured searches. Single-collection searches are unchanged.
## 2026-09-01
**Documentation**
* This site, docs.redpine.ai, is live.
* The [API reference](/docs/api-reference) is generated from the public OpenAPI spec, the same spec the SDKs are built from.
* `/llms.txt` and `/llms-full.txt` serve the docs as Markdown for agents, and any page is available as Markdown by appending `.md` to its URL.
**API**
* Search results are cached reliably, including assisted search, so re-fetching by `queryId` within 7 days works every time.
## 2026-08-31
**API**
* The organisation audit log lists only events that belong to your organisation. Actions taken by Redpine staff from outside the organisation are attributed to "Redpine Staff".
* A meta-collection can bundle integrations alongside collections. Tools included this way appear on the Data Sources page and over MCP.
* A signup through an invite link gets a welcome email that names the invite and what it grants, and lands back on the invite.
## 2026-08-28
**API, Dashboard**
* An invite link can require accepting a terms document before it is claimed. The acceptance is recorded with the claim.
* The post-signup setup commands for Claude Code and Codex are corrected. Both failed on first use before.
## 2026-08-27
**API, Dashboard**
* `isbn`, `open_access`, `chapter_number`, `chapter_title` and `chapter_authors` are indexed filter fields on every collection, and `article_type` accepts `book` and `book-chapter`. See [Filtering](/docs/filtering).
* Invite links: an invite from Redpine is claimed at `app.redpine.ai/redeem/{code}`. It can grant credits, collections and integrations to the workspace you choose, and the page shows what it grants before you claim.
* New accounts get a three-step onboarding on first visit: connect a client, see what you can reach, add a card for premium content. It can be closed and reopened.
## 2026-08-25
**API**
* Search no longer expands query terms from a synonym dictionary by default. Measured on real queries, expansion lowered ranking quality. Where it still applies, an acronym expands only when written the way the dictionary spells it (`PCR`, not `pcr`), and a phrase matches only at word boundaries.
* Over MCP, a search across a meta-collection names the source collection on each result, and the `search-{collection}` tool description lists the sources it covers.
**Dashboard**
* A new visual design across the dashboard: surface hierarchy, rounded controls, one type family.
* An integration's own error message is shown as a tool error in the playground, not as a malformed request.
## 2026-08-23
**API**
* A tool that ran and returned a domain error (no results for that keyword, a rule that said no) now returns `422 TOOL_ERROR` with the tool's message. It was reported as `502`, which behind the CDN lost the message entirely.
* Fixed intermittent `503` responses under bursts of MCP connection attempts.
## 2026-08-20
**API**
* A paid MCP confirm returns `doi`, `pmid`, `pmcid` and `url` on every result that has them, so results can be cited. Editorial content, which has no DOI, is located by `url`.
## 2026-08-19
**API, Dashboard**
* Search runs on a new multilingual embedding model with a normalised metadata schema: `publication_date` is a real date field, and `article_type` and `section` use controlled vocabularies. See [Filtering](/docs/filtering).
* When an MCP search fails, the error says why (a timeout while a large collection warms up, a connection failure) with an `error_code`, instead of a generic "please try again".
* The public OpenAPI spec documents the billing response headers and the assisted-search semantics, and matches the live schemas.
* Billing: one page for balance, adding funds, payment settings and a single activity table with payments and disputes. The old billing routes redirect.
**CLI**
* `redpine search` takes `--filter key=value` (also `!=`, `>=`, comma-separated lists) and `--filter-json` for a full filter document.
* Updated Go toolchain and dependencies.
## 2026-08-18
**API**
* Fixed: `POST /api/v1/search/assisted` returned a validation error for a day, because the per-collection search route captured it.
* Meta-collections support MCP preview and confirm, and a meta-collection can be open to every organisation.
## 2026-08-17
**API**
* Meta-collections: a named collection that spans several publisher, journal or curated collections, searched and billed as one unit. `GET /api/v1/search/collections` lists a meta-collection with its member names, member count and price range; pass its name as `collection` to search every member together. See [Collections](/docs/collections).
## 2026-08-11
**API, Dashboard**
* Articles listed as retracted are excluded from search results on the scholarly collections.
* Signing out revokes the session on the server, not only in the browser.
* MCP connections no longer count toward the organisation's API key limit, which had blocked key creation for organisations with many connected clients.
* Invite or add several people in one submission: the members dialog takes a pasted list, and a bad address is reported beside the successes instead of failing the whole batch.
* A missing display name is filled from Google on sign-in; otherwise the dashboard asks for one.
* Error messages in the dashboard show the API's own message instead of a generic one.
## 2026-08-10
**API, Dashboard**
* Collection names are matched case-insensitively, so any capitalisation of a name resolves to the same collection. Display spelling is unchanged.
* Publisher dashboard for content partners: earnings, tokens served, citations, top content, a geography view, and a live feed of the last hundred citations of your content.
* The organisation audit log records how each sign-in happened (Google, one-time code, passkey) and sign-ins refused by the identity provider.
## 2026-08-07
**API**
* A revoked API key returns `401 API_KEY_REVOKED` rather than `INVALID_API_KEY`. See [Errors](/docs/errors).
* Every search result carries the name of the collection it came from.
---
# Collections
## List collections
```
GET https://api.redpine.ai/api/v1/search/collections
```
Returns all collections your API key has access to. The response includes each collection's name and description. Use the `name` field as the `collection` parameter when calling the Search API.
Some entries are meta-collections (a named container spanning several physical collections, searched as one unit) and carry three extra fields (see below).
### Authentication
Requires a valid API key via `Authorization: Bearer` header. The collections returned depend on what you have access to.
### Response fields
| Field | Type | Description |
| ------------- | ------- | ------------------------------------ |
| `collections` | array | Array of collection objects |
| `count` | integer | Total number of collections returned |
### Collection object
| Field | Type | Description |
| ------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | string | Collection identifier; use this as the collection parameter in search requests |
| `description` | string \| null | Human-readable description of the collection's contents |
| `members` | string\[] | Meta-collections only. Display names of the physical collections this one spans. Absent on a plain collection |
| `memberCount` | integer | Meta-collections only. Number of entries in `members`. Absent on a plain collection |
| `priceRange` | object \| null | Meta-collections only. `{min, max, per}`: lowest/highest per-1000-token price across members, since a meta has no single price. `min` equals `max` when every member costs the same. Null when any member's price cannot be resolved, never a range built from a subset. Absent on a plain collection |
### Example request
cURL
Python
TypeScript
Go
```bash
curl "https://api.redpine.ai/api/v1/search/collections" \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```python
from redpine import Redpine
client = Redpine() # reads REDPINE_API_KEY
for c in client.collections().collections:
print(c.name, c.document_count)
```
```typescript
import { Redpine } from "@redpine-ai/sdk";
const client = new Redpine(); // reads REDPINE_API_KEY
for (const c of (await client.collections()).collections) {
console.log(c.name, c.documentCount);
}
```
```go
client, err := redpine.New() // reads REDPINE_API_KEY
if err != nil {
return err
}
cs, err := client.Collections(ctx)
if err != nil {
return err
}
for _, c := range cs.Collections {
fmt.Println(c.Name, c.DocumentCount)
}
```
This call costs nothing, so it is a cheap startup check that the key works and can reach the collections you expect.
### Example response
```json
{
"collections": [
{
"name": "clinical-research-2024",
"description": "Clinical research articles from 2024 covering biomedical literature"
},
{
"name": "research-cs",
"description": "Computer science research papers"
},
{
"name": "clinical-trials",
"description": null
},
{
"name": "biomedical-all",
"description": "All biomedical collections, searched together",
"members": ["clinical-research-2024", "clinical-trials"],
"memberCount": 2,
"priceRange": { "min": "0.800000", "max": "1.200000", "per": "1000_tokens" }
}
],
"count": 4
}
```
## Usage notes
Call this endpoint to discover which collections are available before making search requests. The `name` field is what you pass as `collection` in the `POST /api/v1/search/query` endpoint, including a meta-collection's name, which searches all of its members together.
---
# Errors
## Error response format
All error responses follow this structure:
```json
{
"error": {
"code": "ERROR_CODE",
"message": "Human-readable description of what went wrong",
"requestId": "req_a1b2c3d4"
}
}
```
* `code`: Machine-readable error code. Use this for programmatic error handling.
* `message`: Human-readable description. Safe to display to end users.
* `requestId`: Unique request identifier. Include this when contacting support.
This is the shape for every endpoint documented in this API reference. A separate, per-IP request limiter guards login, invites, and most dashboard/admin endpoints; its 429 responses are a bare `{"error": "Rate limit exceeded: ..."}` string, not this object. See [Rate limits](/docs/rate-limits).
## Error codes
| Status | Code | Meaning |
| ------ | ------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| `200` | None | Success |
| `401` | UNAUTHORIZED | Missing API key |
| `401` | INVALID\_API\_KEY | API key is malformed or does not match a live key |
| `401` | API\_KEY\_REVOKED | API key has been revoked |
| `403` | NO\_COLLECTION\_ACCESS | No access to requested collection |
| `403` | SANDBOX\_UNSUPPORTED\_ENDPOINT | Sandbox key used on an endpoint with no sandbox behaviour |
| `404` | COLLECTION\_NOT\_FOUND | Collection not found |
| `409` | IDEMPOTENCY\_KEY\_IN\_PROGRESS | A preview with this `Idempotency-Key` is still running |
| `410` | CACHED\_RESULT\_EXPIRED | Cached result or preview has expired (7-day window), or the preview was written by an older payload version |
| `422` | VALIDATION\_ERROR | Invalid request body |
| `422` | INVALID\_FILTER | A search filter cannot be applied: unknown field, unsupported operator combination, or invalid value |
| `422` | INVALID\_QUERY | The query carries too little to search on, such as only symbols, only stop words, or one repeated character |
| `422` | IDEMPOTENCY\_KEY\_REUSED | This `Idempotency-Key` was already used with a different request body |
| `429` | QUOTA\_EXCEEDED | Per-minute, daily, or monthly quota exceeded |
| `429` | PREVIEW\_RATE\_LIMIT\_EXCEEDED | Preview rate limit exceeded |
| `429` | SEARCH\_RATE\_LIMITED | Too many rejected queries in a short time |
| `500` | INTERNAL\_ERROR | Internal server error |
| `503` | SERVICE\_UNAVAILABLE | A dependency the request needs (e.g. the quota service) is unreachable |
## Handling errors in the SDKs
Every non-2xx response becomes a typed error carrying `status`, `code`, `message` and `requestId` from the envelope above, so the `code` column is what you branch on and `requestId` is what you send to support. `429` and `503` are retried automatically before they surface (see [Rate limits](/docs/rate-limits)).
| Status | Python | TypeScript | Go |
| ------------- | --------------------- | --------------------- | --------------------------- |
| `401` | `AuthError` | `AuthError` | `*AuthError` |
| `402` | `InsufficientCredits` | `InsufficientCredits` | `*InsufficientCreditsError` |
| `403` | `AccessDenied` | `AccessDenied` | `*AccessDeniedError` |
| `404` | `NotFound` | `NotFound` | `*NotFoundError` |
| `410` | `Expired` | `Expired` | `*ExpiredError` |
| `422` | `ValidationError` | `ValidationError` | `*ValidationError` |
| `429` | `QuotaExceeded` | `QuotaExceeded` | `*QuotaExceededError` |
| `503` | `AssistedUnavailable` | `AssistedUnavailable` | `*AssistedUnavailableError` |
| anything else | `RedpineError` | `RedpineError` | `*APIError` |
Python
TypeScript
Go
```python
from redpine import AccessDenied, QuotaExceeded, RedpineError
try:
r = client.search("your search query", collection="my-collection")
except AccessDenied as e:
print("key cannot reach this collection:", e.code) # NO_COLLECTION_ACCESS
except QuotaExceeded as e:
print("back off for", e.retry_after, "seconds")
except RedpineError as e:
print(e.status, e.code, e.message, e.request_id)
```
```typescript
import { AccessDenied, QuotaExceeded, RedpineError } from "@redpine-ai/sdk";
try {
await client.search("your search query", { collection: "my-collection" });
} catch (e) {
if (e instanceof AccessDenied) console.log("key cannot reach this collection:", e.code);
else if (e instanceof QuotaExceeded) console.log("back off for", e.retryAfter, "seconds");
else if (e instanceof RedpineError) console.log(e.status, e.code, e.message, e.requestId);
else throw e;
}
```
```go
_, err := client.Search(ctx, "your search query", redpine.SearchOptions{Collection: "my-collection"})
var denied *redpine.AccessDeniedError
var quota *redpine.QuotaExceededError
var apiErr *redpine.APIError
switch {
case errors.As(err, &denied):
fmt.Println("key cannot reach this collection:", denied.Code)
case errors.As(err, "a):
if quota.RetryAfter != nil {
fmt.Println("back off for", *quota.RetryAfter, "seconds")
}
case errors.As(err, &apiErr):
fmt.Println(apiErr.Status, apiErr.Code, apiErr.Message, apiErr.RequestID)
case err != nil:
return err
}
```
A missing key never reaches the network: the Python and TypeScript constructors raise `AuthError` and Go's `New` returns an error when no explicit key is given and neither `REDPINE_API_KEY` nor the legacy `CONNECT_API_KEY` is set.
## Troubleshooting
* **401 UNAUTHORIZED**: The Authorization header is missing entirely. Include `Authorization: Bearer sk_live_...` (or `sk_test_...` for sandbox).
* **401 INVALID\_API\_KEY**: The header is present but the key doesn't match a live key. Check for typos or a stale/rotated key.
* **401 API\_KEY\_REVOKED**: The key was revoked. Generate a new one. Retrying with a revoked key will keep failing.
* **403 NO\_COLLECTION\_ACCESS**: The API key doesn't have access to this collection. Use an org-wide key or add the collection to the key's scope.
* **403 SANDBOX\_UNSUPPORTED\_ENDPOINT**: The key is a sandbox key (`sk_test_`) and this endpoint has no sandbox behaviour. Use a live key, or one of the endpoints listed in [Sandbox](/docs/sandbox). This is deliberate: an endpoint that quietly behaved normally would leave you unable to tell a fixture from a real result.
* **404 COLLECTION\_NOT\_FOUND**: Verify the collection name exists. Names are case-sensitive.
* **410 CACHED\_RESULT\_EXPIRED**: The `queryId` you passed to `GET /api/v1/search/results/{queryId}` or `POST /api/v1/search/unlock` is older than the 7-day window, or the preview it points to was written by an older payload version. Run the search or preview again to get a fresh `queryId`.
* **422 VALIDATION\_ERROR**: Check required fields (query is required; exactly one of collection/collections). Ensure query is under 1000 characters and limit is between 1-30. On `POST /api/v1/search/unlock`, this also covers a `resultId` that isn't part of the preview and any unrecognized field in the request body. Both preview and unlock reject unknown keys rather than ignoring them.
* **422 INVALID\_FILTER**: The `message` says what to change and names the field where there is one (also in `details.field`). Common causes: a field name typo (the message lists the indexed fields), `doc_id`, a `journal_metric.*` threshold outside the [metric bounds](/docs/filtering#metric-bounds) or matching more than 35,000 ISSNs, two operator families in one condition, a field the collection has no index for, or an invalid date or `article_type`. Retrying the same filter fails the same way. See [Filtering](/docs/filtering#rejected-filters).
* **422 INVALID\_QUERY**: The query has too little to search on: only whitespace or symbols, too few letters or digits, one character repeated (`aaaaaaa`), the same word over and over, or only stop words (`what is the`). Short identifiers such as `p53`, `BRCA1` or `COVID-19` are fine. Rephrase the query; retrying it unchanged fails the same way.
* **409 IDEMPOTENCY\_KEY\_IN\_PROGRESS** and **422 IDEMPOTENCY\_KEY\_REUSED**: See [Retrying a preview safely](/docs/preview-unlock#retrying-a-preview-safely). Retry the first after a moment; for the second, send a new key.
* **429 QUOTA\_EXCEEDED**: You've hit the per-minute, daily, or monthly quota. Back off and retry. See [Rate limits](/docs/rate-limits) for how long each window takes to reset.
* **429 PREVIEW\_RATE\_LIMIT\_EXCEEDED**: This organization's preview rate limit was exceeded. Wait for the Retry-After period. See [Rate limits](/docs/rate-limits) for the current per-hour default.
* **429 SEARCH\_RATE\_LIMITED**: More than 30 queries were rejected as `INVALID_QUERY` within a minute. Wait for the `Retry-After` period and check what is generating the queries.
* **500 INTERNAL\_ERROR**: An unexpected error occurred. Retry the request. If it persists, contact support with the requestId.
* **503 SERVICE\_UNAVAILABLE**: A backing service the request depends on (e.g. the quota check `POST /api/v1/search/unlock` performs) is temporarily unreachable. On unlock, this happens before the charge commits: nothing is charged and no result is unlocked, so it's safe to retry with backoff.
***
Usage is tracked per API key. View statistics on your Usage page in the dashboard.
---
# Filtering
## Operators
| Operator | Description | Example value |
| --------- | ----------------------- | --------------------- |
| `eq` | Equals | `"research"` |
| `ne` | Not equals | `"deleted"` |
| `in` | Matches any in list | `["tech", "science"]` |
| `not_in` | Excludes values in list | `["spam", "junk"]` |
| `gt` | Greater than | `2020` |
| `gte` | Greater than or equal | `2020` |
| `lt` | Less than | `2025` |
| `lte` | Less than or equal | `2025` |
| `between` | Range (inclusive) | `[2020, 2025]` |
## Building filters in the SDKs
The SDKs ship a builder, `F`, that produces the structured DSL below without hand-writing JSON. Pick a field, apply one operator, combine with and, or, not. The result goes straight into the `filters` argument of `search` and `assisted_search`. A raw object in either format is accepted in the same place if you already have one.
Python
TypeScript
Go
```python
from redpine import F
recent = F("publication_date").between("2020-01-01", "2025-12-31")
by_journal = F("issn").in_(["1234-5679", "2345-6787"]) | F("publisher").eq("Example Publishing")
not_one = ~F("issn").eq("2345-6787")
high_impact = F("journal_metric.2yr_mean_citedness").gte(5)
r = client.search("crispr delivery", collection="my-collection", filters=recent & by_journal & not_one)
```
```typescript
import { F } from "@redpine-ai/sdk";
const recent = F("publication_date").between("2020-01-01", "2025-12-31");
const byJournal = F("issn").in(["1234-5679", "2345-6787"]).or(F("publisher").eq("Example Publishing"));
const notOne = F("issn").eq("2345-6787").not();
const highImpact = F("journal_metric.2yr_mean_citedness").gte(5);
const r = await client.search("crispr delivery", {
collection: "my-collection",
filters: recent.and(byJournal).and(notOne),
});
```
```go
recent := redpine.F("publication_date").Between("2020-01-01", "2025-12-31")
byJournal := redpine.F("issn").In("1234-5679", "2345-6787").Or(redpine.F("publisher").Eq("Example Publishing"))
notOne := redpine.F("issn").Eq("2345-6787").Not()
highImpact := redpine.F("journal_metric.2yr_mean_citedness").Gte(5)
r, err := client.Search(ctx, "crispr delivery", redpine.SearchOptions{
Collection: "my-collection",
Filters: recent.And(byJournal).And(notOne),
})
```
Operators map one to one onto the table above: `eq`, `ne`, `in`, `not_in`, `gt`, `gte`, `lt`, `lte`, `between`. Python spells them `in_` and `not_in`, TypeScript `in` and `notIn`, Go `In` and `NotIn`. Combining two `and` filters flattens into one `and` list rather than nesting, so the JSON sent is the compact form shown in the sections below.
## Simple format
Key-value pairs where each key is a metadata field name. Supports exact match, list membership, range operators, and negation. An object value takes only range keys (`gt`, `gte`, `lt`, `lte`) or `not`.
Don't mix the two formats: a filter with `and`, `or` or `not` beside plain field keys is rejected, because the field keys would be ignored. Put them inside the list as `{"field": ..., "eq": ...}` conditions.
### Exact match
```json
{
"filters": {
"journal": "Nature"
}
}
```
### Range
```json
{
"filters": {
"publication_date": { "gte": "2020-01-01", "lte": "2025-12-31" }
}
}
```
### Any-of list
Pass an array to match any value in the list (equivalent to `in`).
```json
{
"filters": {
"issn": ["1234-5679", "2345-6787"]
}
}
```
### Negation
```json
{
"filters": {
"publisher": { "not": "Example Publishing" }
}
}
```
## Structured DSL
Use boolean combinators (`and`, `or`, `not`) with explicit field conditions for complex filtering logic. Supports arbitrary nesting.
Each condition takes one operator. The range bounds `gt`, `gte`, `lt` and `lte` are the exception and combine in one condition (`{"field": "publication_date", "gte": "2020-01-01", "lte": "2025-12-31"}`); `between` does not combine with `gte` or `lte`. Any other pair, such as `gte` with `ne`, is rejected: put each in its own condition under `and`.
### Basic AND condition
```json
{
"filters": {
"and": [
{ "field": "publication_date", "gte": "2020-01-01" },
{ "field": "publisher", "eq": "Example Publishing" }
]
}
}
```
### OR condition
```json
{
"filters": {
"or": [
{ "field": "publisher", "eq": "tech" },
{ "field": "publisher", "eq": "science" }
]
}
}
```
### Nested combinators
Combinators can be nested to build complex expressions. This example matches documents published between 2020 and 2025 that are either from one of two specific ISSNs or from Example Publishing.
```json
{
"filters": {
"and": [
{ "field": "publication_date", "gte": "2020-01-01" },
{ "field": "publication_date", "lte": "2025-12-31" },
{
"or": [
{ "field": "issn", "in": ["1234-5679", "2345-6787"] },
{ "field": "publisher", "eq": "Example Publishing" }
]
}
]
}
}
```
### NOT combinator
Use `not` to exclude results matching a condition. Like `and` and `or`, it takes an **array** of conditions: passing a bare object is a parse error.
```json
{
"filters": {
"and": [
{ "field": "publisher", "eq": "Example Publishing" },
{
"not": [
{ "field": "issn", "eq": "2345-6787" }
]
}
]
}
}
```
## Filtering by journal and article identity
`issn` and `doi` are both indexed. Prefer `issn` over `journal` when you mean a specific journal: the same journal appears in the corpus under several title spellings ("Example Medical Journal", "Example Med J", "EXAMPLE MEDICAL JOURNAL"), while its ISSN does not change.
ISSN accepts hyphenated or bare, upper- or lower-case check character. All four forms select the same journal:
```json
{ "filters": { "issn": "1234-561X" } }
```
`"1234-561X"`, `"1234-561x"`, `"1234561X"` and `"1234561x"` are equivalent.
DOIs are matched case-insensitively, and a resolver prefix is optional:
```json
{ "filters": { "doi": "10.1234/example.2020.001" } }
```
`"10.1234/EXAMPLE.2020.001"`, `"https://doi.org/10.1234/example.2020.001"` and `"doi:10.1234/example.2020.001"` are equivalent.
Exclusion uses the operators you already have: `ne`, `not_in` and `not`. There is no separate exclusion syntax. Documents carrying no ISSN at all are not removed by an `issn` exclusion.
```json
{
"filters": {
"and": [
{ "field": "issn", "not_in": ["1234-5679", "2345-6787"] }
]
}
}
```
## Journal metrics
Filter by a journal-level citation metric. These fields accept **range operators only** (`gt`, `gte`, `lt`, `lte`, `between`) and are resolved server-side into the matching ISSNs, so they cost nothing extra at search time.
Available: `journal_metric.2yr_mean_citedness`, `journal_metric.h_index`, `journal_metric.i10_index`, `journal_metric.impact_factor`.
**`2yr_mean_citedness` is not an impact factor.** The value is OpenAlex's 2-year mean citedness, published under CC0. The Journal Impact Factor is Clarivate's proprietary metric, computed over the Web of Science corpus with a different citation window; the two are not interchangeable and the numbers will not agree.
OpenAlex scores a journal with no citations in the window `0`, so `lt` and `lte` thresholds include such journals. A `0` can also mean OpenAlex indexes few or no works for that journal, so it is not evidence of coverage. To leave out high-impact journals, use `not` with `gte` (below). A `not` condition also keeps documents with no ISSN or no metric.
`impact_factor` is an impact factor computed by Redpine from OpenAlex citation data with the JIF formula. It is not Clarivate's Journal Impact Factor. The 2025 value is the citations received in 2025 by everything a journal published in 2023 and 2024, divided by the articles and reviews it published in those two years. Journals that publish many news items, letters and editorials (for example BMJ, JAMA, The Lancet) score far below their JCR figure, because OpenAlex classifies those items as articles.
A journal with fewer than 20 articles and reviews in those two years, or where they are under half of what it published, has no `impact_factor` rather than `0`: no threshold matches it, and a `not` keeps it.
When a condition matches no journal, the response carries a `filterWarnings` entry with code `journal_metric_matched_nothing`.
```json
{ "filters": { "journal_metric.2yr_mean_citedness": { "gte": 5.0 } } }
```
```json
{ "filters": { "journal_metric.impact_factor": { "gte": 3 } } }
```
Exclude by metric:
```json
{
"filters": {
"not": [
{ "field": "journal_metric.2yr_mean_citedness", "gte": 20 }
]
}
}
```
The response reports what each threshold expanded to, so the selection is inspectable. The expansion is capped at 35,000 ISSNs per condition and 70,000 values per request, counting every metric expansion plus your own `in` and `not_in` lists. A filter over either limit returns `422 INVALID_FILTER` naming the limit, rather than a truncated list, because a partial list would return confidently wrong results.
```json
{
"results": [ ... ],
"journalMetricExpansions": [
{
"field": "journal_metric.2yr_mean_citedness",
"condition": "gte 5.0",
"matchedJournals": 2883,
"issnCount": 4949,
"sampleIssns": ["0028-0836", "1476-4687", "0027-8424"]
}
]
}
```
### Metric bounds
A threshold with only one end has to stay inside these bounds. They are checked before anything is searched, and they leave room for the data to grow, so a threshold that works today keeps working when the metrics are refreshed.
| Field | Lower bound alone: lowest `gte` | Upper bound alone: highest `lte` |
| ----------------------------------- | ------------------------------- | -------------------------------- |
| `journal_metric.2yr_mean_citedness` | 1.1 | 0 |
| `journal_metric.h_index` | 35 | 3 |
| `journal_metric.i10_index` | 160 | none: add a lower end |
| `journal_metric.impact_factor` | 0.9 | 0.2 |
`gt` and `lt` are held to the same bounds by what they select. `h_index` and `i10_index` are whole numbers, so `"gt": 34` on `h_index` selects what `"gte": 35` does and is accepted, and `"lt": 4` selects what `"lte": 3` does. On citedness, `"gt": 1.1` is accepted and `"gt": 1.0` is not; on impact factor, `"gt": 0.9` is accepted and `"gt": 0.8` is not.
A range with both ends (`between`, or `gte` with `lte`) is accepted whenever it matches at most 35,000 ISSNs, so a narrow band such as `"between": [0.5, 0.9]` works. The bounds come from the current OpenAlex snapshot (2026-09-23) and can move when it is refreshed; this table moves with them.
Two metric conditions near their bounds use most of a request's 70,000 values, so a third, or a long `in` or `not_in` list beside them, can be refused. That 422 names the counts; raise a threshold to make room. A filter can also hold at most 8 `journal_metric` conditions; put both bounds for one metric in a single condition.
Outside the bounds, the `422 INVALID_FILTER` names the field, the value you sent and the bound, and carries the bound in `details.min_gte` and `details.max_lte`. To fix it:
* A `gte` or `gt` below the minimum: raise it to the minimum or above, or add an upper end to make a band.
* An `lt` or `lte` on its own above the maximum: to filter below a value X, wrap the opposite range in `not`. X must be at or above the metric's lowest `gte` in the table. `"lt": 2` on citedness would match about 126,000 ISSNs; `{"not": [{"field": "journal_metric.2yr_mean_citedness", "gte": 2}]}` sends about 19,000 and keeps the same journals. A `not` also keeps documents with no ISSN or no metric. Below the lowest `gte` (citedness `"lt": 0.5`, say) no one-sided filter is that narrow: the error suggests the narrowest `not` allowed and says it is broader than what you sent.
* A band over 35,000 ISSNs: narrow it.
## Filtering on other fields
Indexed on every collection: `journal`, `publisher`, `keywords`, `publication_date`, `doi`, `issn`, `article_type`, `section`, `isbn`, `open_access`, `chapter_number`, `chapter_title` and `chapter_authors`.
Additionally indexed on editorial collections only: `topic`, `url`, `medical_board_approved` and `last_updated_date`.
### Open access
`open_access` is a boolean on each result. Where a collection holds only open-access content, its articles do not carry the field; the collection answers the filter instead:
* `open_access: true` matches everything in that collection, so its results are returned alongside per-article matches from other collections
* `open_access: false`, or `open_access` under `or` / `not`, excludes that collection and the response says so with a `filterWarnings` entry, code `open_access_collection_excluded`
Results from such a collection carry `open_access: true` in their metadata.
`license` (the Creative Commons licence id, where the publisher declares one) is returned in result metadata but is not a filter: sending it is rejected with `INVALID_FILTER`. Filter on `open_access` instead.
Other fields returned in result metadata, such as `pmid`, `title` or `authors`, have no index. Collections that support scan matching still apply them, but slowly, and large collections can time out; the response carries a warning. Collections that do not support scan matching reject them with `422 INVALID_FILTER`. The warning looks like this:
```json
{
"results": [ ... ],
"filterWarnings": [
{
"code": "unindexed_filter_field",
"field": "authors",
"message": "Filter field 'authors' has no payload index, so it is matched by scanning and may be slow or time out on large collections. Indexed fields: article_type, chapter_authors, chapter_number, chapter_title, doi, isbn, issn, journal, keywords, last_updated_date, medical_board_approved, open_access, publication_date, publisher, section, topic, url."
}
]
}
```
## Rejected filters
A filter that cannot be applied exactly is rejected before the search runs, with `422 INVALID_FILTER` naming the field. Nothing is charged, and retrying the same filter fails the same way. Rejected:
* A field that is not a result metadata field at all, such as a typo
* `doc_id`, which is never returned on results: filter on `doi` to select one document
* A `journal_metric.*` condition naming an unknown metric, using a non-range operator, outside the [metric bounds](#metric-bounds) or matching more than 35,000 ISSNs, a request whose filter values exceed 70,000 in total, or a filter with more than 8 `journal_metric` conditions (see [Journal metrics](#journal-metrics))
* A field the collection has no index for, where that collection cannot match it by scanning. The message names the field and the collection; on a multi-collection search each collection is checked
* An unknown operator, a condition mixing operator families, or the two formats mixed
* An `article_type` outside `research-article`, `review`, `case-report`, `editorial`, `letter`, `correction`, `retraction`, `brief-report`, `protocol`, `commentary`, `book`, `book-chapter`, `preprint`, `monograph`, `dataset` and `other`. Common synonyms such as `review-article` are accepted
* A date that is not a real calendar date, or not in an accepted form (see [Date filtering](#date-filtering))
```json
{
"error": {
"code": "INVALID_FILTER",
"message": "'journall' is not a filterable field. Indexed fields: article_type, chapter_authors, chapter_number, chapter_title, doi, isbn, issn, journal, keywords, last_updated_date, medical_board_approved, open_access, publication_date, publisher, section, topic, url.",
"details": {
"filter": { "journall": "Nature" },
"field": "journall"
},
"requestId": "req_a1b2c3d4"
}
}
```
A search that runs echoes the filters it applied as `filtersApplied`, on `POST /api/v1/search/query` and `POST /api/v1/search/preview`, and on the MCP `search` and `preview` tools. It is omitted when no filters were sent.
## Date filtering
ISO date strings (`YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SS`) are automatically detected and used for datetime range queries. No special syntax is needed: just pass the date string as a value.
`publication_date` also accepts a year (`YYYY`) or a year and month (`YYYY-MM`). `last_updated_date` takes a full date.
### Date range with gte/lte
```json
{
"filters": {
"and": [
{ "field": "publication_date", "gte": "2024-01-01" },
{ "field": "publication_date", "lte": "2024-12-31" }
]
}
}
```
### Date range with between
```json
{
"filters": {
"field": "publication_date",
"between": ["2024-01-01", "2024-12-31"]
}
}
```
***
The `filters` parameter is part of the Search API request body. See [Search](/docs/search) for the full endpoint reference.
---
# Introduction
Licensed, non-public research and data: search it, or reach live tools.
## Start here
Connect your client, or run your first search from code.
Official Python, TypeScript and Go clients.
API keys, scoping, and the Bearer token header.
Build and test your integration without spending credits.
The core endpoint: parameters, response shape, examples.
Every public endpoint, with full parameter and response detail.
## Going further
Four ways to search, and how to pick one.
Tools beyond search (news mentions, aviation data, and more) through the same account.
Agentic retrieval that plans its own searches and verifies every result.
See what a search would return, and what it would cost, before you pay for it.
Narrow results by metadata, dates, and identifiers.
List what you can search, and what each collection supports.
Every status code, what causes it, and how to fix it.
What changed in the API, SDKs, CLI and docs, newest first.
---
# MCP
Skip to [Connect your client](#connect-your-client), pick yours, paste the one line it gives you. Most clients need no key. Your browser opens once to approve access, and you're done.
## What this is
[MCP](https://modelcontextprotocol.io) (Model Context Protocol) is the standard AI agents use to connect directly to external tools. If your agent or IDE speaks MCP, it can talk to Redpine Connect the moment you add one server: no API integration to write, no request shapes to learn.
Once connected, your agent can search Redpine's collections and reach every integration (news mentions, aviation data, and more) on its own: it discovers what's available and figures out how to call it. You don't write any code, and neither does it.
If you're building your own integration instead of using an existing client, see [Get started](/). Everything below is for connecting an agent.
## The short version
```
https://api.redpine.ai/mcp
```
That's the whole server. Every client below just needs this URL. Most clients open your browser once to approve access. No key to copy.
## Connect your client
Pick yours: each links straight to its instructions below.
### Claude Code
Run this in your terminal, then approve Redpine in the browser it opens:
```bash
claude mcp add --transport http redpine https://api.redpine.ai/mcp
```
### Codex
Register the server, then authorize it: the second command opens your browser:
```bash
codex mcp add redpine --url https://api.redpine.ai/mcp
codex mcp login redpine
```
### Cursor
Open `~/.cursor/mcp.json` (or `.cursor/mcp.json` in your project), add this, and save. Then click **Login** on the Redpine server in Cursor's MCP settings:
```json
{
"mcpServers": {
"redpine": {
"url": "https://api.redpine.ai/mcp"
}
}
}
```
Prefer a static key over the browser login? Create one from your [API Keys](https://app.redpine.ai/api-keys?utm_source=docs\&utm_medium=referral) page and use this instead:
```json
{
"mcpServers": {
"redpine": {
"url": "https://api.redpine.ai/mcp",
"headers": {
"X-API-Key": "YOUR_API_KEY"
}
}
}
}
```
### Redpine CLI
```bash
brew install redpine-ai/tap/connect-cli
redpine auth login
```
### Claude Desktop
1. Open Claude Desktop → **Settings** → **Connectors**
2. Click **Add custom connector** at the bottom-left
3. Set the name to "Redpine" and paste this URL:
```
https://api.redpine.ai/mcp
```
4. Click **Add**, then click **Connect**
### ChatGPT
1. Open ChatGPT → **Settings** → **Apps/Connectors**
2. Enable Developer Mode in advanced settings
3. Click **Create App**
4. Set the name to "Redpine" and paste this URL:
```
https://api.redpine.ai/mcp
```
5. Select **OAuth** as the authentication method
6. Click **Connect**
### Windsurf
Open Windsurf → **Settings** → **MCP**, and add a new server with this URL:
```
https://api.redpine.ai/mcp
```
### Lovable
1. Go to **Connectors** → **All**, then scroll to **Custom MCP**
2. Name it "Redpine" and add this URL:
```
https://api.redpine.ai/mcp
```
3. Click **OAuth**, press **Add & Continue**, then choose your organization
### Antigravity
Antigravity doesn't support the browser login, so this one needs a key. Create one from your [API Keys](https://app.redpine.ai/api-keys?utm_source=docs\&utm_medium=referral) page first.
1. Open Antigravity
2. In the Agent panel, click the "more options" menu → **MCP Servers** → **Manage MCP Servers**
3. Click **View raw config**
4. Paste this and save:
```json
{
"mcpServers": {
"redpine": {
"serverUrl": "https://api.redpine.ai/mcp",
"headers": {
"X-API-Key": "YOUR_API_KEY"
}
}
}
}
```
### Something else
Any MCP client that supports a remote HTTP server works, even if it's not listed above: this is a standard [Streamable HTTP](https://modelcontextprotocol.io/specification) endpoint. Give it the URL:
```
https://api.redpine.ai/mcp
```
If your client asks how to authenticate, it almost certainly supports OAuth discovery and will figure it out on its own from that URL alone. If it needs manual configuration, see [Authenticating without OAuth](#authenticating-without-oauth) below.
## What your agent can do once connected
You don't need to tell your agent which tool to use. Just ask a question, like "find me recent news mentions of Nvidia" or "search my documents for adverse event reports," and it works out which integration and tool applies, then calls it.
Under the hood, this happens through three tools every client sees: `find-tools` looks up what's available by keyword, `inspect-tool` gets the exact parameters for one, and `call-tool` runs it. Your agent chains these on its own; you never call them directly unless you're scripting against MCP yourself.
Browse everything your organization has access to (full tool list, parameters, and pricing) on the [Integrations](/docs/integrations) page.
## Search over MCP
Search maps onto the same three REST behaviours as everywhere else, but `preview` and `confirm` are their own top-level tools while `search` itself isn't: it's reached the same way any integration tool is, through `find-tools` then `call-tool`, not as a separate named tool most clients see directly.
| MCP tool | How your agent reaches it | REST equivalent | What it does |
| --------- | ------------------------------------------------ | ----------------------------- | ------------------------------------------------------------------------ |
| `search` | Discovered via `find-tools`, run via `call-tool` | `POST /api/v1/search/query` | Runs a search and returns full results, billed |
| `preview` | Directly, top-level | `POST /api/v1/search/preview` | Runs the same search for free, returns teaser rows and an estimated cost |
| `confirm` | Directly, top-level | `POST /api/v1/search/unlock` | Pays for and returns some or all of a `preview` call's results in full |
Your agent picks between these the same way it picks any tool: given a task, not a command. Ask it to search, and it finds and calls `search`; ask it to check what something would cost first, and it reaches for `preview` then `confirm` directly. Search parameters and filters match their REST counterparts. See [Search](/docs/search), [Filtering](/docs/filtering) and [Preview and unlock](/docs/preview-unlock). Responses use the MCP shape below. `/search/assisted`'s agentic loop isn't exposed as a fourth MCP tool: an MCP-connected agent already plans its own multi-step searches using `search`, so there's no separate tool for it. To search a specific collection by name, your agent finds it the same way it finds any other detail: by calling `list_collections`, discovered the same way `search` is.
### Search results over MCP
`search`, `preview` and `confirm` return one JSON shape. Read `structuredContent`: the text content carries the same JSON, but may have a balance or trial status line around it. A direct `search` bills immediately and reports what it cost:
```json
{
"tool": "search",
"collection": "my-collection",
"workflow": "complete",
"billing_status": "paid",
"cost_charged": "0.214000",
"tokens_charged": 214,
"balance_remaining": "9.786000",
"result_count": 1,
"results": [
{
"id": "abc123",
"title": "Diabetes Overview",
"publisher": "Example Publishing",
"year": "2021",
"journal": "Example Medical Journal",
"locked": false,
"publication_date": "2021-03-15",
"doi": "10.1234/example",
"chunks": [{ "text": "Type 2 diabetes symptoms include increased thirst, frequent urination..." }]
}
],
"filtersApplied": { "publication_date": { "gte": "2020-01-01" } }
}
```
| Field | Returned by | Description |
| ----------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `queryId` | `preview`, `confirm` | Pass to `confirm`. A direct `search` has none |
| `workflow` | `search`, `preview` | `complete` when the full results are included; `requires_confirm` when a preview is locked until `confirm` |
| `billing_status` | `search`, `confirm`, locked `preview` | How the call is billed, for example `paid`, `trial`, `exempt` or `sandbox` |
| `cost_charged`, `balance_remaining` | `search`, `confirm` | What this call charged, and the balance after it. `balance_remaining` is `null` when the API key hides the balance |
| `tokens_charged` | `search` | Tokens billed. `0` when nothing was charged |
| `cost_to_unlock` | locked `preview` (paid) | What unlocking every result would cost. Each row also carries its own `cost_to_unlock` |
| `cost_to_unlock_remaining` | `confirm` (paid) | What the still-locked results would cost |
| `results` | all three | Rows with `id`, `title`, `publisher`, `year`, `journal`, `locked` and `chunks`, plus identifiers (`doi`, `pmid`, `pmcid`, `url`) and other metadata when present. A chunk holds `text`, or `snippet` while the row is locked. `figure_count` appears on rows with figures |
| `filterWarnings` | all three | Advisory filter warnings; omitted when there are none |
| `filtersApplied` | `search`, `preview` | The filters that were applied; omitted when none were sent |
| `figures` | `search`, `confirm`, and a `complete` (free) `preview`, with `include_figures` | `images_attached`, `unavailable` and `omitted_over_limit`, which add up to the `figure_count` of the delivered rows |
| `message` | `search` | What to try next when there are no results |
Every row also carries `relevance`, a score from 0 to 1. When a search across several collections was reranked, it is the reranker's probability that the passage answers the query; otherwise it is the passage's similarity to the query. A top row below 0.75 usually means the whole set is off topic, on-topic sets usually top above 0.78, and rows under 0.70 are weak even in a good set. It scores the passage, not the paper, so a strong paper can show a middling chunk. Read it before deciding what to unlock.
Images arrive as image blocks after the JSON, at most 10 per response, each captioned with `[result ]` so it can be matched to its row.
A filter that cannot be applied is an error naming the field, for example `'journall' is not a filterable field. Indexed fields: ...`. Fix the filter rather than retrying. See [Filtering](/docs/filtering#rejected-filters).
## Sandbox keys and MCP
A [sandbox key](/docs/sandbox) works over MCP for search (`search`, `preview`, and `confirm` all return fixture results, same as the REST API). It does **not** work for integration tools reached through `call-tool`: those reach real third-party services, so a sandbox key gets `403 SANDBOX_UNSUPPORTED_ENDPOINT` instead. Use a live key to try aviation, media, or any other integration tool.
## Authenticating without OAuth
Most clients above use OAuth: your browser opens once, you approve Redpine for your organization, and the client is done. No key ever touches its config file. This is what "Connect" or "Login" buttons trigger.
If your client doesn't support that flow, or you're scripting against MCP directly, send an API key instead:
```
X-API-Key: sk_live_YOUR_API_KEY
```
Everything else about the request is identical either way. Create a key from your [API Keys](https://app.redpine.ai/api-keys?utm_source=docs\&utm_medium=referral) page. See [Authentication](/docs/authentication) for the two kinds and what each does.
## Managing connections
See and revoke connected clients from your organization's **Connections** page in the [dashboard](https://app.redpine.ai/?utm_source=docs\&utm_medium=referral). Revoking there ends that client's access immediately. No restart needed on your end.
## Troubleshooting
| Symptom | What's happening |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Nothing happens after clicking Connect/Login | Check for a blocked pop-up: the OAuth approval opens in a new browser window. |
| `403 SANDBOX_UNSUPPORTED_ENDPOINT` on a tool call | You're on a sandbox key calling an integration tool. Switch to a live key. See [Sandbox keys and MCP](#sandbox-keys-and-mcp) above. |
| `401` immediately after connecting | The connection was revoked, or the OAuth approval didn't complete. Reconnect from your client. |
| Error ending `[error_code: upstream_rejected]` | The search was refused and will be refused the same way again. Don't retry; contact support with the query and collection name. `upstream_timeout`, `upstream_unavailable` and `upstream_error` are worth one retry |
| Agent can't find the right tool | Ask it to call `find-tools` with a more specific query, or browse [Integrations](/docs/integrations) directly to confirm the tool exists and your organization has access. |
Anything else, see [Errors](/docs/errors) for the full status code reference.
---
# Preview and unlock
## Why preview
`POST /api/v1/search/query` charges for a search whether or not it turned out to be worth paying for. Preview removes that risk: it runs the same search pipeline, but returns teaser rows and an estimated price instead of billing you. You decide whether to pay only after seeing what's there.
* `POST /api/v1/search/preview`: free. Runs the search, returns teaser rows plus a `queryId` and an estimated cost to unlock.
* `POST /api/v1/search/unlock`: pays for and returns some or all of those results in full.
`POST /api/v1/search/query` is unchanged. Preview is additive: use it when you want to inspect relevance before spending, and keep calling `/api/v1/search/query` directly wherever that fits your flow better.
One identifier runs through both calls: the same `queryId` a search already returns. There is no second id to learn, and it works the same way `GET /api/v1/search/results/{queryId}` already does: a preview is available for 7 days.
Preview works even at a zero credit balance, since it costs nothing. It's the right call to make when deciding whether to top up. Unlock is the paywall: it's the only one of the two that spends a quota slot, and it counts as the query.
## Preview
`POST /api/v1/search/preview`
Takes the same request body as `POST /api/v1/search/query` (`collection`/`collections`, `query`, `limit`, `filters`, `include_metadata`). Every result comes back with `locked: true`, `text` holding a short teaser rather than the full chunk, and `tokens`/`cost` showing what unlocking that one row would cost. Locators (`doi`, `pmid`, `pmcid`, `url`) and full metadata are included in the free response, so you can judge relevance before paying for anything.
Preview consumes no quota and no trial query: it is unmetered on the billing side. It is rate limited per organization per hour; see [Rate limits](/docs/rate-limits) for the current default. Extra API keys on the same organization share one limit, they don't multiply it.
### Example request
cURL
Python
TypeScript
Go
```bash
curl -X POST "https://api.redpine.ai/api/v1/search/preview" \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"collection": "my-collection",
"query": "What are the symptoms of diabetes?",
"limit": 10
}'
```
```python
from redpine import Redpine
client = Redpine() # reads REDPINE_API_KEY
p = client.preview("What are the symptoms of diabetes?", collection="my-collection", limit=10)
for row in p.results:
print(row.id, row.locked, row.tokens, row.cost, row.text) # teaser text while locked
print("unlock all for", p.cost_to_unlock_remaining)
```
```typescript
import { Redpine } from "@redpine-ai/sdk";
const client = new Redpine(); // reads REDPINE_API_KEY
const p = await client.preview("What are the symptoms of diabetes?", { collection: "my-collection", limit: 10 });
for (const row of p.results) {
console.log(row.id, row.locked, row.tokens, row.cost, row.text); // teaser text while locked
}
console.log("unlock all for", p.costToUnlockRemaining);
```
```go
client, err := redpine.New() // reads REDPINE_API_KEY
if err != nil {
return err
}
p, err := client.Preview(ctx, "What are the symptoms of diabetes?",
redpine.PreviewOptions{Collection: "my-collection", Limit: 10})
if err != nil {
return err
}
for _, row := range p.Results {
fmt.Println(row.Id, row.Locked, row.Text) // teaser text while locked
}
fmt.Println("unlock all for", p.CostToUnlockRemaining)
```
`preview` takes the same `filters` as `search`, including the `F` builder from [Filtering](/docs/filtering).
### Example response
```json
{
"queryId": "qry_a1b2c3d4e5f6",
"results": [
{
"id": "abc123",
"text": "Type 2 diabetes symptoms include increased...",
"metadata": {
"title": "Diabetes Overview",
"doi": "10.1234/example"
},
"collection": "my-collection",
"locked": true,
"tokens": 214,
"cost": "0.214000"
}
],
"costToUnlockRemaining": "0.214000",
"costCharged": null
}
```
### Retrying a preview safely
Send an `Idempotency-Key` header (1–255 characters, any value you choose) when your client might retry a preview, for example after a timeout. A repeat with the same key and the same body runs no new search: it returns the original `queryId`, and any rows you already unlocked come back in full with `locked: false`. Without a key, a retry creates a new `queryId`, and unlocking it would buy the same results again.
```bash
curl -X POST "https://api.redpine.ai/api/v1/search/preview" \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Idempotency-Key: preview-7f3a9c" \
-H "Content-Type: application/json" \
-d '{"collection": "my-collection", "query": "What are the symptoms of diabetes?"}'
```
* Keys are scoped to your organization and kept for 24 hours.
* The `Idempotency-Status` response header says `created` when a preview ran and `replayed` when the stored `queryId` was returned.
* The same key with a different body returns `422 IDEMPOTENCY_KEY_REUSED`. Use a new key for a new request.
* A repeat while the first request is still running returns `409 IDEMPOTENCY_KEY_IN_PROGRESS`. Retry it shortly.
* If the original preview has expired, the request runs a new preview and the key moves to it.
## Unlock
`POST /api/v1/search/unlock`
Pays for previewed results and returns them in full.
### Request body
| Parameter | Type | Required | Description |
| ----------- | ----------------- | -------- | ------------------------------------------------------------------------------------- |
| `queryId` | string | Yes | `queryId` from a previous `POST /api/v1/search/preview` |
| `resultIds` | string\[] \| null | No | Results to unlock, by id. Omit (or pass null) to unlock every result from the preview |
### Response fields
| Field | Type | Description |
| ------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `queryId` | string | Pass this to a later `POST /api/v1/search/unlock` call for the same preview |
| `results` | array | The full result set, filtered through the unlock ledger: rows this call (or an earlier one) paid for come back in full, the rest still teased |
| `costToUnlockRemaining` | string | Cost to unlock every result not yet unlocked |
| `costCharged` | string \| null | What this call charged, not a running total. Null on a preview (always free) and on an unlock whose entire delta was already unlocked |
| `filterWarnings` | array \| null | Advisory warnings for filter fields with no payload index; omitted when there are none. Same diagnostics `POST /api/v1/search/query` returns. See [Search](/docs/search) |
| `journalMetricExpansions` | array \| null | How each journal-metric filter condition resolved to ISSNs; omitted when no metric filter was used |
| `filtersApplied` | object \| null | The filters the preview was sent, after validation. Returned by preview only; omitted when no filters were sent |
An unlock that buys something is the query: it spends one quota slot. A retried unlock (one whose `resultIds` were already paid for) charges nothing and burns no quota slot, so it's safe to retry.
### Example request
cURL
Python
TypeScript
Go
```bash
curl -X POST "https://api.redpine.ai/api/v1/search/unlock" \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"queryId": "qry_a1b2c3d4e5f6"
}'
```
```python
u = client.unlock(p.query_id) # every row from the preview
for row in u.results:
print(row.locked, row.text) # locked is now False, text is the full chunk
print("charged", u.cost_charged)
```
```typescript
const u = await client.unlock(p.queryId); // every row from the preview
for (const row of u.results) {
console.log(row.locked, row.text); // locked is now false, text is the full chunk
}
console.log("charged", u.costCharged);
```
```go
u, err := client.Unlock(ctx, p.QueryId, nil) // nil: every row from the preview
if err != nil {
return err
}
for _, row := range u.Results {
fmt.Println(row.Locked, row.Text) // Locked is now false, Text is the full chunk
}
if u.CostCharged != nil {
fmt.Println("charged", *u.CostCharged) // nil when nothing new was unlocked
}
```
Two errors are specific to unlock and have their own SDK classes: `402` when the organisation cannot pay for the delta (`InsufficientCredits`, Go `*InsufficientCreditsError`) and `410` when the preview is past its 7-day window (`Expired`, Go `*ExpiredError`). Neither is retried.
### Example response
```json
{
"queryId": "qry_a1b2c3d4e5f6",
"results": [
{
"id": "abc123",
"text": "Type 2 diabetes symptoms include increased thirst, frequent urination, unexplained weight loss...",
"metadata": {
"title": "Diabetes Overview",
"doi": "10.1234/example"
},
"collection": "my-collection",
"locked": false,
"tokens": 214,
"cost": "0.214000"
}
],
"costToUnlockRemaining": "0.000000",
"costCharged": "0.214000"
}
```
## Partial unlock
Pass `resultIds` to buy some rows from a preview and leave the rest locked:
cURL
Python
TypeScript
Go
```bash
curl -X POST "https://api.redpine.ai/api/v1/search/unlock" \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"queryId": "qry_a1b2c3d4e5f6",
"resultIds": ["abc123", "def456"]
}'
```
```python
# pick rows from the preview, e.g. the cheap ones
cheap = [row.id for row in p.results if isinstance(row.tokens, int) and row.tokens < 300]
u = client.unlock(p.query_id, result_ids=cheap)
print("charged", u.cost_charged, "still locked for", u.cost_to_unlock_remaining)
```
```typescript
// pick rows from the preview, e.g. the cheap ones
const cheap = p.results.filter((row) => (row.tokens ?? Infinity) < 300).map((row) => row.id);
const u = await client.unlock(p.queryId, cheap);
console.log("charged", u.costCharged, "still locked for", u.costToUnlockRemaining);
```
```go
// pick rows from the preview, e.g. the cheap ones
var cheap []string
for _, row := range p.Results {
if row.Tokens != nil && *row.Tokens < 300 {
cheap = append(cheap, row.Id)
}
}
u, err := client.Unlock(ctx, p.QueryId, cheap)
if err != nil {
return err
}
fmt.Println("still locked for", u.CostToUnlockRemaining)
```
Only the ids you pass are charged. Re-sending an id you already unlocked (in this call or an earlier one) costs nothing, since unlock charges the delta only: call it again later with more ids to unlock the rest of the same preview, or the same ids again with no risk of a duplicate charge.
`costToUnlockRemaining` is an estimate, summed per row. The amount actually charged by an unlock is computed per billing group, so the two can differ by a rounding fraction.
## Two-call example
```bash
# 1. Preview for free -- see teasers and the price
curl -X POST "https://api.redpine.ai/api/v1/search/preview" \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"collection": "my-collection", "query": "What are the symptoms of diabetes?", "limit": 10}'
# 2. Unlock the results worth paying for
curl -X POST "https://api.redpine.ai/api/v1/search/unlock" \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"queryId": "qry_a1b2c3d4e5f6", "resultIds": ["abc123"]}'
```
---
# Rate limits
## Current limits
| Endpoint | Limit |
| ------------------------------------ | ------------------------------ |
| `POST /api/v1/search/query` | 60 requests / minute (default) |
| `POST /api/v1/tools/{prefix}/{tool}` | 60 requests / minute (default) |
| `POST /api/v1/search/preview` | 50 previews / hour (default) |
| Other endpoints | Varies by endpoint |
Search and tool calls share one per-organization requests/minute budget. The default is 60; your organization may have a custom limit. Contact support to adjust it.
`POST /api/v1/search/preview` is throttled separately, on a per-organization requests/hour budget rather than the requests/minute one above: it's a free-teaser abuse guard, not a query quota, so it's kept apart from the metered limit search and tool calls share. This budget is shared with the MCP `preview` tool, not a separate allowance. It's keyed on organization, not API key: minting a second key on the same org does not multiply the allowance. The default is 50/hour; your organization may have a custom limit. Contact support to adjust it. `POST /api/v1/search/unlock` is not separately rate limited, but only counts against the requests/minute budget above when it actually unlocks new results. A repeat unlock of results you already paid for costs nothing and doesn't count as a query.
Endpoints outside search and tools (login, invites, and most dashboard/admin routes) are protected by a separate, IP-address-keyed limiter. Its `429` response is a bare `{"error": "Rate limit exceeded: ..."}` string, not the `{code, message, requestId}` shape used everywhere else on this page, and it never sends `X-RateLimit-*` headers.
## Daily and monthly quotas
Beyond the per-minute limit above, your organization also has a daily and a monthly query quota (defaults: 1,000/day and 25,000/month). Hitting either returns the same `429` shape as the per-minute limit, but `Retry-After` reflects the real reset time: up to \~24 hours for the daily quota, up to \~31 days for the monthly one. Call `GET /api/v1/search/quota` to check your current usage and limits for both windows before you hit them.
## Response headers
`POST /api/v1/search/query` and `POST /api/v1/search/{collection}` include rate limit headers reporting your **daily** quota, so you can track usage against it:
```
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 943
X-RateLimit-Reset: 1709000000
```
* `X-RateLimit-Limit`: Your organization's daily query limit (not the per-minute limit above).
* `X-RateLimit-Remaining`: Daily queries remaining.
* `X-RateLimit-Reset`: Unix timestamp for the next UTC midnight, when the daily count resets.
These headers are only sent on the two endpoints above, and only when quota enforcement is available (Redis reachable). Other endpoints, including `GET /api/v1/search/collections`, `GET /api/v1/search/results/{queryId}`, and `GET /api/v1/search/quota` itself, never send them.
## Billing headers
Search responses also carry billing headers so you can track cost per request without a separate call:
```
HTTP/1.1 200 OK
X-Tokens-Used: 842
X-Query-Cost: 0.842000
X-Billing-Mode: live
X-Credits-Remaining: 48.316000
```
* `X-Tokens-Used`: Tokens billed for this request.
* `X-Query-Cost`: What this request was charged, to 6 decimal places. `0.000000` when nothing was charged, which is every request in `exempt`, `trial` or `sandbox` mode.
* `X-Billing-Mode`: How this request was billed: `live` (charged normally), `exempt` (billing-exempt org, no charge), `trial` (covered by trial credit), or `sandbox` (a sandbox key returned synthetic results at no cost).
* `X-Credits-Remaining`: Organization credit balance after this request. Omitted if your organization has balance visibility hidden.
None of these headers are present when billing is kill-switched for your organization.
## Exceeding the limit
When you exceed a limit, the API returns `429 Too Many Requests` with a `Retry-After` header. This example is the per-minute case; daily/monthly quota errors use the same shape with a longer `Retry-After` (see above):
```
HTTP/1.1 429 Too Many Requests
Retry-After: 30
{
"error": {
"code": "QUOTA_EXCEEDED",
"message": "Rate limit exceeded. Try again in 30 seconds.",
"requestId": "req_a1b2c3d4"
}
}
```
## Best practices
* **Respect Retry-After**: wait the indicated time before retrying.
* **Use exponential backoff**: on repeated 429s, double your wait time between retries.
* **Cache results**: avoid redundant requests for the same query.
* **Monitor your usage**: check `X-RateLimit-Remaining` to proactively throttle before hitting limits.
## In the SDKs
The clients do the first two for you. A `429` or `503` is retried up to `max_retries` times (default 2), sleeping for `Retry-After` when the server sent it and otherwise backing off exponentially with jitter from 0.5 s, capped at 30 s. Nothing else is retried. When retries run out, `QuotaExceeded` surfaces with `retry_after` set, so a daily or monthly quota hit (hours or days) is yours to schedule rather than something the client sleeps through.
The SDKs return parsed bodies, not raw responses, so the `X-RateLimit-*` and billing headers above are not exposed. Use the quota endpoint for the same numbers:
cURL
Python
TypeScript
Go
```bash
curl "https://api.redpine.ai/api/v1/search/quota" \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```python
client = Redpine(max_retries=3) # or 0 to handle 429s yourself
q = client.quota()
print(q.daily_remaining, "of", q.daily_limit, "today;", q.monthly_remaining, "of", q.monthly_limit, "this month")
```
```typescript
const client = new Redpine({ maxRetries: 3 }); // or 0 to handle 429s yourself
const q = await client.quota();
console.log(q.dailyRemaining, "of", q.dailyLimit, "today;", q.monthlyRemaining, "of", q.monthlyLimit, "this month");
```
```go
client, err := redpine.New(redpine.WithMaxRetries(3)) // or 0 to handle 429s yourself
if err != nil {
return err
}
q, err := client.Quota(ctx)
if err != nil {
return err
}
fmt.Println(q.DailyRemaining, "of", q.DailyLimit, "today;", q.MonthlyRemaining, "of", q.MonthlyLimit, "this month")
```
---
# Sandbox
## What sandbox is for
Wiring up an API takes a few passes. You get the auth header wrong, then the request body, then you discover your JSON parser chokes on a field you did not expect. None of that is worth spending credits on, and none of it needs real data.
A sandbox key returns canned results from the same endpoints, in the same shape, at no cost. When your integration works, you swap the key for a live one and nothing else changes.
In the dashboard Playground, a sandbox key shows an amber **Sandbox** banner above the results and tags every result **Sample**. If you see those, you are not looking at real search results.
## Getting a sandbox key
Sandbox keys are opt-in. Create one from your API Keys page in the dashboard: give it a name and turn on **Sandbox key**. A new organization starts with no sandbox key, so the first key you create searches real content unless you choose otherwise.
Sandbox keys are prefixed `sk_test_`, live keys `sk_live_`:
```bash
Authorization: Bearer sk_test_YOUR_SANDBOX_KEY
```
## Sandbox results are synthetic
The text is lorem ipsum, the figures are generated, and every DOI sits inside the reserved `10.0000/` test namespace. Nothing you get back is a real record, and none of it should be stored as though it were.
Every result carries two markers so your own code can tell:
* `metadata.synthetic` is `true`
* `id` is prefixed `sandbox-`
If your pipeline writes search results to a database, assert on one of those markers before the write. A fabricated DOI that reaches your production store is very hard to find again.
## Making a sandbox request
Identical to a live request. Only the key changes.
cURL
Python
TypeScript
Go
```bash
curl -X POST "https://api.redpine.ai/api/v1/search/query" \
-H "Authorization: Bearer sk_test_YOUR_SANDBOX_KEY" \
-H "Content-Type: application/json" \
-d '{"collection": "my-collection", "query": "your search query"}'
```
```python
from redpine import Redpine
client = Redpine(api_key="sk_test_YOUR_SANDBOX_KEY")
r = client.search("your search query", collection="my-collection")
for hit in r.results:
assert hit.id.startswith("sandbox-")
assert hit.metadata["synthetic"] is True
print(hit.id, hit.text[:60])
```
```typescript
import { Redpine } from "@redpine-ai/sdk";
const client = new Redpine({ apiKey: "sk_test_YOUR_SANDBOX_KEY" });
const r = await client.search("your search query", { collection: "my-collection" });
for (const hit of r.results) {
console.assert(hit.id.startsWith("sandbox-") && hit.metadata?.synthetic === true);
console.log(hit.id, hit.text.slice(0, 60));
}
```
```go
package main
import (
"context"
"fmt"
"strings"
redpine "github.com/redpine-ai/redpine-sdk-go"
)
func main() {
client, err := redpine.New(redpine.WithAPIKey("sk_test_YOUR_SANDBOX_KEY"))
if err != nil {
panic(err)
}
r, err := client.Search(context.Background(), "your search query",
redpine.SearchOptions{Collection: "my-collection"})
if err != nil {
panic(err)
}
for _, hit := range r.Results {
if !strings.HasPrefix(hit.Id, "sandbox-") || (*hit.Metadata)["synthetic"] != true {
panic("expected a sandbox result, got " + hit.Id)
}
fmt.Println(hit.Id, hit.Text)
}
}
```
In a test suite, point the client at the sandbox key through the environment instead: `REDPINE_API_KEY=sk_test_...` and the same code runs unchanged against fixtures.
## Telling sandbox and live apart
Two signals, and it is worth asserting on at least one in your test suite:
* The key prefix: `sk_test_` against `sk_live_`.
* The `X-Billing-Mode` response header: `sandbox` against `live`.
If you are ever unsure which mode a request ran in, read the header. It is set on every response. The SDKs return parsed bodies rather than raw responses, so from SDK code assert on the result markers above (`sandbox-` id prefix, `metadata.synthetic`) instead of the header.
## What sandbox covers
| Endpoint | Sandbox behaviour |
| ---------------------------------- | ---------------------------------------------- |
| `POST /api/v1/search/query` | Returns fixture results |
| `POST /api/v1/search/{collection}` | Returns fixture results |
| `POST /api/v1/search/assisted` | Returns fixture results |
| MCP `search`, `preview`, `confirm` | Return fixture results |
| MCP `call-tool` | Refused: tools reach live third-party services |
| Everything else | `403 SANDBOX_UNSUPPORTED_ENDPOINT` |
Anything without sandbox behaviour fails loudly rather than quietly doing the real thing. An endpoint that silently behaved normally under a sandbox key would leave you unable to tell a fixture from a real result, which defeats the point.
```json
{
"error": {
"code": "SANDBOX_UNSUPPORTED_ENDPOINT",
"message": "This endpoint has no sandbox behaviour. Use a live API key, or one of the supported sandbox endpoints.",
"requestId": "req_a1b2c3d4"
}
}
```
## What still applies
* **Rate limits.** Sandbox requests count against your quota, so you can exercise your `429` handling.
* **Collection access.** A sandbox key has a scope like any other. Requesting a collection it cannot reach returns `403`, exactly as a live key would.
* **Errors.** Malformed requests return the same `422` they would in production.
The point is that you test against the real error surface, not a happy path.
## Results you can rely on
The same query always returns the same results. That makes sandbox usable in a test suite rather than only in a demo: you can assert on specific ids and counts without your assertions flapping between runs.
Different queries return different fixtures, covering the shapes you will meet in production: results with figures, with tables, with math, multi-result pages, and an empty result set. Send a few different queries while building, so you exercise more than one.
## Going live
Replace the key. That is the whole migration: same base URL, same endpoints, same request and response shapes.
Keep the sandbox key afterwards. It is the cheapest way to run your integration tests in CI without spending credits every time your build runs.
## What sandbox does not tell you
Whether our results are any good for your use case. Fixtures are synthetic, so they say nothing about retrieval quality on your actual questions. Evaluating that needs a live key against a real collection. Talk to us if you want help sizing that.
---
# SDKs
Typed clients for the three languages most integrations are written in. All three are generated from the same OpenAPI spec as the [API reference](/docs/api-reference), so they expose the same endpoints, field names and errors as the REST API.
| Language | Package | Source |
| -------------------- | -------------------------------------- | --------------------------------------------------------------------------------- |
| Python 3.10+ | `redpine-sdk`, import `redpine` | [redpine-ai/redpine-sdk-python](https://github.com/redpine-ai/redpine-sdk-python) |
| TypeScript, Node 20+ | `@redpine-ai/sdk` | [redpine-ai/redpine-sdk-js](https://github.com/redpine-ai/redpine-sdk-js) |
| Go 1.24+ | `github.com/redpine-ai/redpine-sdk-go` | [redpine-ai/redpine-sdk-go](https://github.com/redpine-ai/redpine-sdk-go) |
The SDKs are at 0.x. The surface is stable for search, assisted search, collections and quota, but expect additive changes before 1.0. Report problems as issues on the language repository.
## Install
Python
TypeScript
Go
```bash
pip install redpine-sdk
```
```bash
npm install @redpine-ai/sdk
```
```bash
go get github.com/redpine-ai/redpine-sdk-go@latest
```
## Your first request
Create an API key from your [API Keys](https://app.redpine.ai/api-keys?utm_source=docs\&utm_medium=referral) page. Start with a **sandbox** key (`sk_test_`): it returns canned results at no cost, so you can get the request shape right without spending credits. Swapping it for a live key (`sk_live_`) later changes nothing else about your integration.
Put it in your environment. The clients read it from there; cURL passes it in the header.
```bash
export REDPINE_API_KEY=sk_test_YOUR_SANDBOX_KEY
```
Then search a collection with a query. That is the whole first request. Don't know a collection name yet? `GET /api/v1/search/collections` lists every collection your key can reach, at no cost. See [Collections](/docs/collections).
cURL
Python
TypeScript
Go
```bash
curl -X POST "https://api.redpine.ai/api/v1/search/query" \
-H "Authorization: Bearer $REDPINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"collection": "my-collection", "query": "your search query"}'
```
```python
from redpine import Redpine
client = Redpine() # reads REDPINE_API_KEY
r = client.search("your search query", collection="my-collection")
for hit in r.results:
print(hit.text[:200])
```
```typescript
import { Redpine } from "@redpine-ai/sdk";
const client = new Redpine(); // reads REDPINE_API_KEY
const r = await client.search("your search query", { collection: "my-collection" });
for (const hit of r.results) {
console.log(hit.text.slice(0, 200));
}
```
```go
package main
import (
"context"
"fmt"
redpine "github.com/redpine-ai/redpine-sdk-go"
)
func main() {
client, err := redpine.New() // reads REDPINE_API_KEY
if err != nil {
panic(err)
}
r, err := client.Search(context.Background(), "your search query",
redpine.SearchOptions{Collection: "my-collection"})
if err != nil {
panic(err)
}
for _, hit := range r.Results {
fmt.Println(hit.Text)
}
}
```
Results come back ranked by relevance. Each one carries the matched text, the collection it came from, and any metadata from the original document. Because this used a sandbox key the results are synthetic: note `synthetic` and the `sandbox-` id. A live key returns the same shape with real content.
```json
{
"results": [
{
"id": "sandbox-0001-chunk-0",
"text": "Lorem ipsum dolor sit amet...",
"collection": "my-collection",
"metadata": { "title": "Lorem ipsum dolor", "synthetic": true }
}
],
"queryId": "qry_a1b2c3d4e5f6",
"latencyMs": 42
}
```
## Configure
Every client reads `REDPINE_API_KEY` from the environment. Pass the key explicitly if you would rather not use the environment. A sandbox key (`sk_test_`) works everywhere a live key does and costs nothing. See [Authentication](/docs/authentication).
Python
TypeScript
Go
```python
from redpine import Redpine
client = Redpine() # reads REDPINE_API_KEY
client = Redpine(api_key="sk_test_...", timeout=30.0, max_retries=2)
```
```typescript
import { Redpine } from "@redpine-ai/sdk";
const client = new Redpine(); // reads REDPINE_API_KEY
const client2 = new Redpine({ apiKey: "sk_test_...", timeoutMs: 30_000, maxRetries: 2 });
```
```go
import redpine "github.com/redpine-ai/redpine-sdk-go"
client, err := redpine.New() // reads REDPINE_API_KEY
client, err = redpine.New(
redpine.WithAPIKey("sk_test_..."),
redpine.WithTimeout(30*time.Second),
redpine.WithMaxRetries(2),
)
```
## Search
Pass exactly one of `collection` (a single collection) or `collections` (several at once). The response shape is the one documented under [Search](/docs/search): `results`, `queryId`, `latencyMs`. Python exposes the same fields in snake case.
Python
TypeScript
Go
```python
r = client.search("your search query", collection="my-collection", limit=10)
for hit in r.results:
print(hit.id, hit.text[:200])
print(r.query_id)
```
```typescript
const r = await client.search("your search query", { collection: "my-collection", limit: 10 });
for (const hit of r.results) {
console.log(hit.id, hit.text.slice(0, 200));
}
console.log(r.queryId);
```
```go
r, err := client.Search(ctx, "your search query", redpine.SearchOptions{
Collection: "my-collection",
Limit: 10,
})
if err != nil {
return err
}
for _, hit := range r.Results {
fmt.Println(hit.Id, hit.Text)
}
fmt.Println(r.QueryId)
```
Don't know a collection name? `collections()` lists every collection the key can reach, at no cost.
Python
TypeScript
Go
```python
for c in client.collections().collections:
print(c.name)
```
```typescript
for (const c of (await client.collections()).collections) console.log(c.name);
```
```go
cs, err := client.Collections(ctx)
for _, c := range cs.Collections {
fmt.Println(c.Name)
}
```
## Filters
`F` builds the structured filter documented in [Filtering](/docs/filtering) without hand-writing JSON. Operators are `eq`, `ne`, `in`, `not_in`, `gt`, `gte`, `lt`, `lte` and `between`. Combine with and, or and not. A raw filter object is accepted anywhere a built filter is.
Python
TypeScript
Go
```python
from redpine import F
recent_reviews = (
F("publication_date").gte("2022-01-01")
& F("article_type").eq("review")
& ~F("issn").in_(["1234-5679"])
)
r = client.search("crispr delivery", collections=["corpus"], filters=recent_reviews)
```
```typescript
import { F } from "@redpine-ai/sdk";
const recentReviews = F("publication_date").gte("2022-01-01")
.and(F("article_type").eq("review"))
.and(F("issn").in(["1234-5679"]).not());
const r = await client.search("crispr delivery", { collections: ["corpus"], filters: recentReviews });
```
```go
recentReviews := redpine.F("publication_date").Gte("2022-01-01").
And(redpine.F("article_type").Eq("review")).
And(redpine.F("issn").In("1234-5679").Not())
r, err := client.Search(ctx, "crispr delivery", redpine.SearchOptions{
Collections: []string{"corpus"},
Filters: recentReviews,
})
```
## Assisted search
[Assisted search](/docs/assisted-search) plans its own searches and verifies every result. Check `status` before reading `results`: it is `results`, `clarification_needed` or `no_relevant_results`.
Python
TypeScript
Go
```python
a = client.assisted_search("what delivery vectors work for in vivo crispr?", collection="my-collection")
if a.status == "results":
for hit in a.results:
print(hit.relevance, hit.text[:200])
elif a.status == "clarification_needed":
print(a.clarification.question)
```
```typescript
const a = await client.assistedSearch("what delivery vectors work for in vivo crispr?", { collection: "my-collection" });
if (a.status === "results") {
for (const hit of a.results ?? []) console.log(hit.relevance, hit.text.slice(0, 200));
} else if (a.status === "clarification_needed") {
console.log(a.clarification?.question);
}
```
```go
a, err := client.AssistedSearch(ctx, "what delivery vectors work for in vivo crispr?", redpine.AssistedSearchOptions{
Collection: "my-collection",
})
if err != nil {
return err
}
switch a.Status {
case "results":
for _, hit := range *a.Results {
fmt.Println(hit.Relevance, hit.Text)
}
case "clarification_needed":
fmt.Println(a.Clarification.Question)
}
```
## Preview and unlock
[Preview](/docs/preview-unlock) runs the search for free and returns teaser rows with a price per row; `unlock` pays for the rows you choose. Both return the same shape: `results` with `locked`, `tokens` and `cost` per row, plus `costToUnlockRemaining` and `costCharged`.
Python
TypeScript
Go
```python
p = client.preview("your search query", collection="my-collection")
worth_it = [row.id for row in p.results if isinstance(row.tokens, int) and row.tokens < 300]
u = client.unlock(p.query_id, result_ids=worth_it) # result_ids=None unlocks every row
for row in u.results:
if not row.locked:
print(row.text)
print("charged", u.cost_charged)
```
```typescript
const p = await client.preview("your search query", { collection: "my-collection" });
const worthIt = p.results.filter((row) => (row.tokens ?? Infinity) < 300).map((row) => row.id);
const u = await client.unlock(p.queryId, worthIt); // omit resultIds to unlock every row
for (const row of u.results) {
if (!row.locked) console.log(row.text);
}
console.log("charged", u.costCharged);
```
```go
p, err := client.Preview(ctx, "your search query", redpine.PreviewOptions{Collection: "my-collection"})
if err != nil {
return err
}
var worthIt []string
for _, row := range p.Results {
if row.Tokens != nil && *row.Tokens < 300 {
worthIt = append(worthIt, row.Id)
}
}
u, err := client.Unlock(ctx, p.QueryId, worthIt) // nil unlocks every row
if err != nil {
return err
}
for _, row := range u.Results {
if !row.Locked {
fmt.Println(row.Text)
}
}
```
Re-sending an id you already paid for costs nothing, so retrying an unlock is safe.
## Re-fetch results and check quota
Every search returns a `queryId`. Fetching it again within 7 days returns the same results without a new charge. Quota returns the daily and monthly counters described in [Rate limits](/docs/rate-limits).
Python
TypeScript
Go
```python
again = client.get_results(r.query_id)
q = client.quota()
print(q.daily_remaining, q.monthly_remaining)
```
```typescript
const again = await client.getResults(r.queryId);
const q = await client.quota();
console.log(q.dailyRemaining, q.monthlyRemaining);
```
```go
again, err := client.GetResults(ctx, r.QueryId)
q, err := client.Quota(ctx)
fmt.Println(q.DailyRemaining, q.MonthlyRemaining)
```
## Errors and retries
Non-2xx responses raise one typed error per status, carrying `status`, `code`, `message` and `requestId` from the [error envelope](/docs/errors). The clients retry `429` and `503` automatically, honouring `Retry-After` and otherwise backing off exponentially with jitter, up to `maxRetries` (default 2). Nothing else is retried.
| HTTP | Python | TypeScript | Go |
| ----- | ------------------------------- | ------------------------------ | ------------------------------------ |
| 401 | `AuthError` | `AuthError` | `*AuthError` |
| 402 | `InsufficientCredits` | `InsufficientCredits` | `*InsufficientCreditsError` |
| 403 | `AccessDenied` | `AccessDenied` | `*AccessDeniedError` |
| 404 | `NotFound` | `NotFound` | `*NotFoundError` |
| 410 | `Expired` | `Expired` | `*ExpiredError` |
| 422 | `ValidationError` | `ValidationError` | `*ValidationError` |
| 429 | `QuotaExceeded` (`retry_after`) | `QuotaExceeded` (`retryAfter`) | `*QuotaExceededError` (`RetryAfter`) |
| 503 | `AssistedUnavailable` | `AssistedUnavailable` | `*AssistedUnavailableError` |
| other | `RedpineError` | `RedpineError` | `*APIError` |
Python
TypeScript
Go
```python
from redpine import QuotaExceeded, RedpineError
try:
r = client.search("...", collection="my-collection")
except QuotaExceeded as e:
print("retry after", e.retry_after)
except RedpineError as e:
print(e.status, e.code, e.message, e.request_id)
```
```typescript
import { QuotaExceeded, RedpineError } from "@redpine-ai/sdk";
try {
await client.search("...", { collection: "my-collection" });
} catch (e) {
if (e instanceof QuotaExceeded) console.log("retry after", e.retryAfter);
else if (e instanceof RedpineError) console.log(e.status, e.code, e.message, e.requestId);
else throw e;
}
```
```go
r, err := client.Search(ctx, "...", redpine.SearchOptions{Collection: "my-collection"})
var quota *redpine.QuotaExceededError
var apiErr *redpine.APIError
if errors.As(err, "a) {
fmt.Println("retry after", quota.RetryAfter)
} else if errors.As(err, &apiErr) {
fmt.Println(apiErr.Status, apiErr.Code, apiErr.Message, apiErr.RequestID)
}
```
## Async Python
`AsyncRedpine` mirrors `Redpine` method for method on top of `httpx`. Use it as an async context manager or call `aclose()` when done.
```python
from redpine import AsyncRedpine
async with AsyncRedpine() as client:
r = await client.search("your search query", collection="my-collection")
```
## Which one should I use?
Calling the API from a language not listed here, or from an environment where a dependency is unwelcome? The REST API in [Get started](/) needs nothing beyond an HTTP client. Connecting an AI agent rather than writing code? Use [MCP](/docs/mcp).
---
# Which search endpoint?
Redpine Connect has four ways to run a search. They share the same retrieval pipeline underneath; what differs is when you pay, how many rounds it runs, and whether it's code or an agent driving it.
| | Billed | Rounds | In the [SDKs](/docs/sdks) | Best when |
| ---------------------------------------------------------------------------------- | -------------------------------- | ---------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------- |
| [Search](/docs/search)
`POST /search/query` | Always, win or lose | One pass | `search()` | You already trust the query and just want results fast |
| [Preview and unlock](/docs/preview-unlock)
`POST /search/preview` + `/unlock` | Only what you unlock | One pass | `preview()` then `unlock()` | You want to see relevance and cost before committing to either |
| [Assisted search](/docs/assisted-search)
`POST /search/assisted` | Only delivered, verified results | Multiple, agent-planned | `assisted_search()` | The query is a real question, not a keyword string, and you want every result checked before it's billed |
| [MCP](/docs/mcp)
`search` / `preview` / `confirm` tools | Same as their REST equivalents | Depends which tool the agent picks | n/a | You're connecting an agent rather than writing HTTP calls yourself |
A few rules of thumb:
* **Default to `/search/query`** for anything you're confident is a well-formed query against a known collection. It's the fastest path and the one every other option builds on.
* **Reach for preview and unlock** while you're still exploring: testing a new query shape, unsure if a filter is too narrow, or want to see the price before spending on a large `limit`.
* **Reach for assisted search** when the input is closer to a question than a query, and getting a wrong result is worse than getting no result. It plans its own internal searches and verifies each candidate before charging for it.
* **MCP isn't a fifth option**: it's the same three REST behaviors (`search`, `preview`, `confirm`) reached through tool calls instead of HTTP requests, for agents that speak MCP. Nothing about billing or retrieval quality changes.
---
# Search
## Search documents
`POST /api/v1/search/query`
Search documents in a collection and return ranked results. The retrieval mode (dense, sparse, or hybrid: dense + sparse vectors) and reranking settings are determined by the collection's configuration and cannot be overridden by the caller.
### Request body
| Parameter | Type | Required | Description |
| ------------------ | -------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `collection` | string | One of `collection`/`collections` | Collection name to search. List valid names via [Collections](/docs/collections) |
| `collections` | string\[] | One of `collection`/`collections` | Collections to search together (max 5), merged into one ranked list |
| `query` | string | Yes | Search query text (max 1000 characters) |
| `limit` | integer | No | Max results to return (default 10, max 30) |
| `filters` | object \| null | No | Metadata filters. A filter that cannot be applied is rejected with `422 INVALID_FILTER`. See [Filtering](/docs/filtering) |
| `include_metadata` | boolean | No | Include metadata in results (default true) |
| `include_figures` | boolean | No | Fetch and include figure images as base64 in metadata.figures\[].image\_data (default false, adds latency) |
| `image_max_width` | integer | No | Maximum image width in pixels (default 800, 100-1920) |
| `image_max_height` | integer | No | Maximum image height in pixels (default 600, 100-1080) |
| `image_quality` | integer | No | JPEG quality for fetched images (default 75, 1-100) |
### Searching multiple collections
Pass `collections` instead of `collection` to search several collections in one request.
You get back one relevance-ranked list spanning every collection, not one list per collection. Each result's `collection` field reports which one it came from, so sort or split client-side if you need them apart.
cURL
Python
TypeScript
Go
```bash
curl -X POST "https://api.redpine.ai/api/v1/search/query" \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"collections": ["my-collection", "another-collection"], "query": "your search query", "limit": 10}'
```
```python
r = client.search(
"your search query",
collections=["my-collection", "another-collection"],
limit=10,
)
for hit in r.results:
print(hit.collection, hit.text[:80])
```
```typescript
const r = await client.search("your search query", {
collections: ["my-collection", "another-collection"],
limit: 10,
});
for (const hit of r.results) {
console.log(hit.collection, hit.text.slice(0, 80));
}
```
```go
r, err := client.Search(ctx, "your search query", redpine.SearchOptions{
Collections: []string{"my-collection", "another-collection"},
Limit: 10,
})
if err != nil {
return err
}
for _, hit := range r.Results {
fmt.Println(*hit.Collection, hit.Text)
}
```
### Response fields
| Field | Type | Description |
| ------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------ |
| `results` | array | Array of search results with id, text, metadata, and collection |
| `queryId` | string | Unique query identifier. Use to re-fetch results for free within 7 days via `GET /api/v1/search/results/{queryId}` |
| `latencyMs` | integer | Search latency in milliseconds |
| `filterWarnings` | array \| null | Advisory warnings for filter fields with no payload index; omitted when there are none |
| `journalMetricExpansions` | array \| null | How each journal-metric filter condition resolved to ISSNs; omitted when no metric filter was used |
| `filtersApplied` | object \| null | The filters you sent, after validation; omitted when no filters were sent |
### Result object
| Field | Type | Description |
| ------------ | -------------- | ---------------------------------------------------------------- |
| `id` | string | Chunk/point ID |
| `text` | string | Chunk text content |
| `metadata` | object \| null | Chunk metadata (if `include_metadata=true`) |
| `collection` | string \| null | Origin collection of this result, as requested. Always populated |
### Example request
cURL
Python
TypeScript
Go
```bash
curl -X POST "https://api.redpine.ai/api/v1/search/query" \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"collection": "my-collection",
"query": "What are the symptoms of diabetes?",
"limit": 10,
"include_metadata": true,
"include_figures": true,
"filters": {
"and": [
{"field": "publication_date", "gte": "2020-01-01"},
{"field": "publisher", "eq": "Example Publishing"}
]
}
}'
```
```python
from redpine import Redpine, F
client = Redpine() # reads REDPINE_API_KEY
r = client.search(
"What are the symptoms of diabetes?",
collection="my-collection",
limit=10,
include_figures=True,
filters=F("publication_date").gte("2020-01-01") & F("publisher").eq("Example Publishing"),
)
for hit in r.results:
print(hit.metadata["title"], hit.text[:200])
print(r.query_id, r.latency_ms)
```
```typescript
import { Redpine, F } from "@redpine-ai/sdk";
const client = new Redpine(); // reads REDPINE_API_KEY
const r = await client.search("What are the symptoms of diabetes?", {
collection: "my-collection",
limit: 10,
includeFigures: true,
filters: F("publication_date").gte("2020-01-01").and(F("publisher").eq("Example Publishing")),
});
for (const hit of r.results) {
console.log(hit.metadata?.title, hit.text.slice(0, 200));
}
console.log(r.queryId, r.latencyMs);
```
```go
client, err := redpine.New() // reads REDPINE_API_KEY
if err != nil {
return err
}
figures := true
r, err := client.Search(ctx, "What are the symptoms of diabetes?", redpine.SearchOptions{
Collection: "my-collection",
Limit: 10,
IncludeFigures: &figures,
Filters: redpine.F("publication_date").Gte("2020-01-01").
And(redpine.F("publisher").Eq("Example Publishing")),
})
if err != nil {
return err
}
for _, hit := range r.Results {
fmt.Println((*hit.Metadata)["title"], hit.Text)
}
fmt.Println(r.QueryId, r.LatencyMs)
```
The SDKs accept a raw filter object too, in either format from [Filtering](/docs/filtering), if you already have one built.
### Example response
```json
{
"results": [
{
"id": "abc123",
"text": "Type 2 diabetes symptoms include increased thirst, frequent urination...",
"metadata": {
"title": "Diabetes Overview",
"figures": [
{
"id": "fig1",
"label": "Figure 1",
"caption": "Glucose metabolism pathway",
"image_data": ""
}
]
},
"collection": "my-collection"
}
],
"queryId": "qry_a1b2c3d4e5f6",
"latencyMs": 42,
"filtersApplied": {
"and": [
{"field": "publication_date", "gte": "2020-01-01"},
{"field": "publisher", "eq": "Example Publishing"}
]
}
}
```
## Re-fetch results
Every search response includes a `queryId`. Use it to retrieve the same results again without being charged, for up to 7 days after the original search.
`GET /api/v1/search/results/{queryId}`
The request must use the same API key that performed the original search.
**Response:** Identical to the original search response, with `latencyMs: 0`. Includes `X-Cache: hit` and `X-Cache-Expires` headers.
**Errors:**
* `404`: Query ID not found or belongs to a different API key
* `410`: Cached result has expired (past 7-day window)
### Example
cURL
Python
TypeScript
Go
```bash
curl "https://api.redpine.ai/api/v1/search/results/qry_a1b2c3d4e5f6" \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
```python
again = client.get_results(r.query_id)
assert again.latency_ms == 0
```
```typescript
const again = await client.getResults(r.queryId);
console.log(again.latencyMs); // 0
```
```go
again, err := client.GetResults(ctx, r.QueryId)
if err != nil {
return err
}
fmt.Println(again.LatencyMs) // 0
```
The re-fetched rows carry the [preview and unlock](/docs/preview-unlock) shape: `locked` is false on rows you have paid for, and the SDKs type the result accordingly. An unknown id raises `NotFound` (Go: `*NotFoundError`); an expired one raises `Expired` (Go: `*ExpiredError`). Neither is retried.
## Filtering
Use the `filters` parameter to narrow search results by metadata fields. Indexed on every collection: `journal`, `publisher`, `keywords`, `publication_date`, `doi`, `issn`, `article_type`, `section`, `isbn`, `open_access`, `chapter_number`, `chapter_title`, and `chapter_authors`. Other result metadata fields have no index: they are matched by scanning where the collection supports it, and rejected with `422 INVALID_FILTER` where it does not. A field that is not a result metadata field, `doc_id` and `license` are rejected with `422 INVALID_FILTER`. `journal_metric.*` thresholds are resolved to the matching ISSNs; see [Filtering](/docs/filtering#journal-metrics).
Full operator list, both request formats, and worked examples: see [Filtering](/docs/filtering).
---
# API reference
Generated straight from the OpenAPI spec: every request parameter, response field, and status code for each endpoint. For a narrative walkthrough instead, start at [Get started](/) or [Which search endpoint?](/docs/search-endpoints).
The core endpoint: run a search and get billed, ranked results back.
Shorthand for search with the collection named in the path instead of the body.
Same pipeline as search, free, returns teaser rows and an estimated cost.
Pays for and returns some or all of a preview call's results.
Agentic retrieval: plans its own searches, verifies every candidate.
Every collection your key can search, at no cost.
Retrieve a prior search's results again for free within 7 days.
Daily and monthly query usage against your organization's limits.
---
# Re-fetch cached results
`GET` `https://api.redpine.ai/api/v1/search/results/{queryId}`
## Authentication
```
Authorization: Bearer sk_live_YOUR_API_KEY
```
## Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
| `queryId` | string | yes | The queryId from a previous search response. |
## Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| `includeFigures` | boolean | no | Include figure images for results already unlocked. Free, like the rest of this endpoint: it re-delivers content that is already paid for and unlocks nothing. Applies to results that came from POST /api/v1/search/preview; a cached POST /api/v1/search/query response does not retain figures. |
| `imageMaxWidth` | integer | no | Maximum image width in pixels. |
| `imageMaxHeight` | integer | no | Maximum image height in pixels. |
| `imageQuality` | integer | no | JPEG quality for fetched images. |
## Responses
| Status | Description |
|---|---|
| `200` | Cached search results, filtered through the unlock ledger (latencyMs: 0) |
| `401` | Missing or invalid API key |
| `404` | Query ID not found or belongs to a different API key |
| `410` | Cached result has expired (past 7-day window) |
### 200 response fields
| Field | Type | Required | Description |
|---|---|---|---|
| `filterWarnings` | array | no | Advisory warnings about the supplied filter — for example filtering on a known field with no payload index, which is matched by scanning, or a collection excluded because it holds only open-access content and the filter asked for open_access=false. The search still runs. Omitted when there are none. |
| `journalMetricExpansions` | array | no | How each journal-metric condition resolved to ISSNs; omitted when no metric filter was used |
| `latencyMs` | integer | yes | Search latency in milliseconds |
| `queryId` | string | yes | Unique identifier for this query, matching the original search response. |
| `results` | object[] | yes | Every result from the original search, filtered through the unlock ledger. |
---
# Current quota usage and limits
`GET` `https://api.redpine.ai/api/v1/search/quota`
## Authentication
```
Authorization: Bearer sk_live_YOUR_API_KEY
```
## Responses
| Status | Description |
|---|---|
| `200` | Current quota usage |
| `401` | Missing or invalid API key |
### 200 response fields
| Field | Type | Required | Description |
|---|---|---|---|
| `dailyLimit` | integer | yes | Max queries per day. |
| `dailyRemaining` | integer | yes | Queries remaining today. |
| `dailyUsed` | integer | yes | Queries used today. |
| `monthlyLimit` | integer | yes | Max queries per month. |
| `monthlyRemaining` | integer | yes | Queries remaining this month. |
| `monthlyUsed` | integer | yes | Queries used this month. |
---
# List collections
`GET` `https://api.redpine.ai/api/v1/search/collections`
## Authentication
```
Authorization: Bearer sk_live_YOUR_API_KEY
```
## Responses
| Status | Description |
|---|---|
| `200` | List of accessible collections |
| `401` | Missing or invalid API key |
| `403` | Forbidden |
| `429` | Rate limit exceeded |
### 200 response fields
| Field | Type | Required | Description |
|---|---|---|---|
| `collections` | object[] | yes | List of accessible collections. |
| `count` | integer | yes | Total number of accessible collections. |
---
# Assisted search (verified results only)
`POST` `https://api.redpine.ai/api/v1/search/assisted`
## Authentication
```
Authorization: Bearer sk_live_YOUR_API_KEY
```
## Request body
| Field | Type | Required | Description |
|---|---|---|---|
| `allowClarification` | boolean | no | If true, the endpoint may return a clarifying question instead of results when the query is too underspecified. Set false if your client cannot do a follow-up turn. |
| `collection` | string | no | Collection to search. Provide either this or `collections`, not both. |
| `collections` | array | no | Collections to search together (max 5). Results are merged into one relevance-ranked list and each result is labeled with its origin collection. Provide either this or 'collection', not both. |
| `filters` | object | no | Same filter forms as `/api/v1/search/query`; applied to every internal search. |
| `imageMaxHeight` | integer | no | Maximum image height in pixels |
| `imageMaxWidth` | integer | no | Maximum image width in pixels |
| `imageQuality` | integer | no | JPEG quality for fetched images |
| `includeFigures` | boolean | no | Fetch and attach figure images (base64, in metadata.figures[].image_data) for the delivered results only. Adds latency; requires `includeMetadata`. `includeImages` is accepted as a deprecated alias. |
| `includeMetadata` | boolean | no | Whether to include metadata in results |
| `limit` | integer | no | Maximum verified results to return. |
| `query` | string | yes | Search query text |
## Responses
| Status | Description |
|---|---|
| `200` | Verified results, a clarification request, or an explicit no-relevant-results outcome |
| `401` | Missing or invalid API key |
| `403` | API key does not have access to the requested collection |
| `422` | Validation error (e.g. query too long, invalid filter) |
| `429` | Rate or quota limit exceeded |
| `503` | Assisted search temporarily unavailable |
### 200 response fields
| Field | Type | Required | Description |
|---|---|---|---|
| `billing` | object | yes | What was actually charged — only delivered, verified results are billed. |
| `clarification` | object | no | Set only when status is `clarification_needed`. |
| `filterWarnings` | array | no | Advisory warnings about the supplied filter — for example filtering on a known field with no payload index, which is matched by scanning, or a collection excluded because it holds only open-access content and the filter asked for open_access=false. The search still runs. Omitted when there are none. |
| `iterationsRun` | integer | yes | Number of search+replan rounds executed |
| `journalMetricExpansions` | array | no | How each journal-metric condition resolved to ISSNs; omitted when no metric filter was used |
| `latencyMs` | integer | yes | End-to-end latency in milliseconds |
| `queryId` | string | yes | Query ID for audit reference |
| `queryUnderstanding` | object | yes | How the endpoint read the query. |
| `results` | object[] | no | Verified results (empty unless status='results') |
| `status` | `results` \| `clarification_needed` \| `no_relevant_results` | yes | Outcome of the assisted search |
---
# Search a single collection by name
`POST` `https://api.redpine.ai/api/v1/search/{collection}`
## Authentication
```
Authorization: Bearer sk_live_YOUR_API_KEY
```
## Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
| `collection` | string | yes | Name of the collection to search. |
## Request body
| Field | Type | Required | Description |
|---|---|---|---|
| `filters` | object | no | Optional metadata filter. Two accepted forms. Flat (top-level keys are ANDed): `{"journal": "Nature", "publication_date": {"gte": "2020-01-01"}}`. Structured DSL (for OR / nesting): `{"and": [{"field": "journal", "eq": "Nature"}]}`. Operators: `eq`, `ne`, `in`, `not_in`, `gt`, `gte`, `lt`, `lte`, `between`. Combinators: `and`, `or`, `not`. One operator per condition, except range bounds (`gt`, `gte`, `lt`, `lte`) together; put other combinations in separate conditions under `and`. Mixing the flat and structured forms in one filter is rejected. Exclusion uses `ne` / `not_in` / `not` — there is no separate syntax: `{"and": [{"field": "issn", "not_in": ["1234-5679"]}]}`. Indexed on every collection (a field that is not a known result metadata field is rejected; a known field without an index, such as `pmid`, is matched by scanning and returns a `filterWarnings` entry, and is rejected on a collection that has no index for it): `article_type`, `chapter_authors`, `chapter_number`, `chapter_title`, `doi`, `isbn`, `issn`, `journal`, `keywords`, `open_access`, `publication_date`, `publisher`, `section`. Indexed on the editorial collections only: `last_updated_date`, `medical_board_approved`, `topic`, `url`. `open_access` is also answered for a collection that holds only open-access content and carries no such field: `true` matches everything there, `false` (or the field under `or` / `not`) excludes that collection with a `filterWarnings` entry. `license` is returned in result metadata but is not filterable. `issn` accepts hyphenated or bare, upper- or lower-case X (`"1234-561X"`, `"1234561x"`). `doi` is matched case-insensitively and an optional `https://doi.org/` or `doi:` prefix is accepted. `journal_metric.2yr_mean_citedness`, `journal_metric.h_index`, `journal_metric.i10_index` and `journal_metric.impact_factor` accept range operators only and are resolved server-side into the matching ISSNs; see `journalMetricExpansions` in the response. `impact_factor` is an impact factor computed by Redpine from OpenAlex citation data with the JIF formula. It is not Clarivate's Journal Impact Factor. Journals that publish many news items, letters and editorials (for example BMJ, JAMA, The Lancet) score far below their JCR figure, because OpenAlex classifies those items as articles. A metric condition may match at most 35,000 journal ISSNs and a request may send at most 70,000 filter values. A threshold with one end must stay within its documented bound: journal_metric.2yr_mean_citedness: gte 1.1 or higher, or lte 0.0 or lower; journal_metric.h_index: gte 35 or higher, or lte 3 or lower; journal_metric.i10_index: gte 160 or higher (no upper bound alone); journal_metric.impact_factor: gte 0.9 or higher, or lte 0.2 or lower. On h_index and i10_index, gt N counts as gte N+1 and lt N as lte N-1. A range with both ends (gte with lte, or between) may sit anywhere if it matches no more than 35,000 ISSNs. For 'below X' use not with gte X, X at or above the metric's minimum; not also keeps documents with no journal metric, and on impact_factor journals with no impact_factor. Two conditions near their bounds use most of the 70,000 values, so a third, or a long in/not_in list beside them, can be refused. |
| `imageMaxHeight` | integer | no | Maximum image height in pixels |
| `imageMaxWidth` | integer | no | Maximum image width in pixels |
| `imageQuality` | integer | no | JPEG quality for fetched images |
| `includeFigures` | boolean | no | Fetch and include figure images as base64 in metadata (adds latency). includeImages is accepted as a deprecated alias. |
| `includeMetadata` | boolean | no | Whether to include metadata in results |
| `limit` | integer | no | Maximum results to return (default 10, max 30) |
| `query` | string | yes | Natural language or keyword search query. |
## Responses
| Status | Description |
|---|---|
| `200` | Search results |
| `401` | Missing or invalid API key |
| `403` | API key does not have access to the requested collection |
| `404` | Collection not found |
| `422` | Validation error (e.g. a `collection` key in the body) |
| `429` | Rate or quota limit exceeded |
| `503` | Search temporarily unavailable (Qdrant or the quota service is down) |
### 200 response fields
| Field | Type | Required | Description |
|---|---|---|---|
| `filterWarnings` | array | no | Advisory warnings about the supplied filter — for example filtering on a known field with no payload index, which is matched by scanning, or a collection excluded because it holds only open-access content and the filter asked for open_access=false. The search still runs. Omitted when there are none. |
| `filtersApplied` | object | no | The filters you sent, after validation. Omitted when no filters were sent. |
| `journalMetricExpansions` | array | no | How each journal-metric condition resolved to ISSNs; omitted when no metric filter was used |
| `latencyMs` | integer | yes | Search latency in milliseconds |
| `queryId` | string | yes | Unique identifier for this query. Use it to re-fetch the same results for free within 7 days via GET /api/v1/search/results/{queryId}. |
| `results` | object[] | yes | Ranked list of matching document chunks. |
---
# Preview search results without charging
`POST` `https://api.redpine.ai/api/v1/search/preview`
## Authentication
```
Authorization: Bearer sk_live_YOUR_API_KEY
```
## Request body
| Field | Type | Required | Description |
|---|---|---|---|
| `collection` | string | no | Name of the collection to search. Mutually exclusive with `collections`. |
| `collections` | array | no | Collections to search together (max 5, unique). Each collection is searched with its own access entitlements and the results are merged into one relevance-ranked list; each result carries its origin `collection`. Mutually exclusive with `collection`. |
| `filters` | object | no | Optional metadata filter. Two accepted forms. Flat (top-level keys are ANDed): `{"journal": "Nature", "publication_date": {"gte": "2020-01-01"}}`. Structured DSL (for OR / nesting): `{"and": [{"field": "journal", "eq": "Nature"}]}`. Operators: `eq`, `ne`, `in`, `not_in`, `gt`, `gte`, `lt`, `lte`, `between`. Combinators: `and`, `or`, `not`. One operator per condition, except range bounds (`gt`, `gte`, `lt`, `lte`) together; put other combinations in separate conditions under `and`. Mixing the flat and structured forms in one filter is rejected. Exclusion uses `ne` / `not_in` / `not` — there is no separate syntax: `{"and": [{"field": "issn", "not_in": ["1234-5679"]}]}`. Indexed on every collection (a field that is not a known result metadata field is rejected; a known field without an index, such as `pmid`, is matched by scanning and returns a `filterWarnings` entry, and is rejected on a collection that has no index for it): `article_type`, `chapter_authors`, `chapter_number`, `chapter_title`, `doi`, `isbn`, `issn`, `journal`, `keywords`, `open_access`, `publication_date`, `publisher`, `section`. Indexed on the editorial collections only: `last_updated_date`, `medical_board_approved`, `topic`, `url`. `open_access` is also answered for a collection that holds only open-access content and carries no such field: `true` matches everything there, `false` (or the field under `or` / `not`) excludes that collection with a `filterWarnings` entry. `license` is returned in result metadata but is not filterable. `issn` accepts hyphenated or bare, upper- or lower-case X (`"1234-561X"`, `"1234561x"`). `doi` is matched case-insensitively and an optional `https://doi.org/` or `doi:` prefix is accepted. `journal_metric.2yr_mean_citedness`, `journal_metric.h_index`, `journal_metric.i10_index` and `journal_metric.impact_factor` accept range operators only and are resolved server-side into the matching ISSNs; see `journalMetricExpansions` in the response. `impact_factor` is an impact factor computed by Redpine from OpenAlex citation data with the JIF formula. It is not Clarivate's Journal Impact Factor. Journals that publish many news items, letters and editorials (for example BMJ, JAMA, The Lancet) score far below their JCR figure, because OpenAlex classifies those items as articles. A metric condition may match at most 35,000 journal ISSNs and a request may send at most 70,000 filter values. A threshold with one end must stay within its documented bound: journal_metric.2yr_mean_citedness: gte 1.1 or higher, or lte 0.0 or lower; journal_metric.h_index: gte 35 or higher, or lte 3 or lower; journal_metric.i10_index: gte 160 or higher (no upper bound alone); journal_metric.impact_factor: gte 0.9 or higher, or lte 0.2 or lower. On h_index and i10_index, gt N counts as gte N+1 and lt N as lte N-1. A range with both ends (gte with lte, or between) may sit anywhere if it matches no more than 35,000 ISSNs. For 'below X' use not with gte X, X at or above the metric's minimum; not also keeps documents with no journal metric, and on impact_factor journals with no impact_factor. Two conditions near their bounds use most of the 70,000 values, so a third, or a long in/not_in list beside them, can be refused. |
| `limit` | integer | no | Maximum results to return (default 10, max 30) |
| `query` | string | yes | Natural language or keyword search query. |
## Responses
| Status | Description |
|---|---|
| `200` | Every result, locked, plus the total cost to unlock them all |
| `401` | Missing or invalid API key |
| `403` | API key does not have access to the requested collection |
| `404` | Collection not found |
| `422` | Validation error (e.g. query too long) |
| `429` | This org's preview rate limit was exceeded (shared with the MCP preview tool) -- not the org's query quota, which a preview never consumes. |
| `503` | Search temporarily unavailable (Qdrant is down) |
### 200 response fields
| Field | Type | Required | Description |
|---|---|---|---|
| `costCharged` | string | no | What THIS call charged — not a running total. Null on a preview (always free) and on an unlock whose entire delta was already unlocked. |
| `costToUnlockRemaining` | string | yes | Cost to unlock every result not yet unlocked. An estimate, not a binding quote: it is summed from each result's own individually-rounded cost, while the amount actually charged on the next unlock is computed per collection at that call's combined token total — the two can differ by a rounding fraction. |
| `filterWarnings` | array | no | Advisory warnings about the supplied filter — for example filtering on a known field with no payload index, which is matched by scanning, or a collection excluded because it holds only open-access content and the filter asked for open_access=false. The search still runs. Omitted when there are none. |
| `filtersApplied` | object | no | The filters you sent, after validation. Omitted when no filters were sent. |
| `journalMetricExpansions` | array | no | How each journal-metric condition resolved to ISSNs; omitted when no metric filter was used. |
| `queryId` | string | yes | Pass this to POST /search/unlock. |
| `results` | object[] | yes | Every result from the query, filtered through the unlock ledger. |
---
# Search documents
`POST` `https://api.redpine.ai/api/v1/search/query`
## Authentication
```
Authorization: Bearer sk_live_YOUR_API_KEY
```
## Request body
| Field | Type | Required | Description |
|---|---|---|---|
| `collection` | string | no | Name of the collection to search. Mutually exclusive with `collections`. |
| `collections` | array | no | Collections to search together (max 5, unique). Each collection is searched with its own access entitlements and the results are merged into one relevance-ranked list; each result carries its origin `collection`. Mutually exclusive with `collection`. |
| `filters` | object | no | Optional metadata filter. Two accepted forms. Flat (top-level keys are ANDed): `{"journal": "Nature", "publication_date": {"gte": "2020-01-01"}}`. Structured DSL (for OR / nesting): `{"and": [{"field": "journal", "eq": "Nature"}]}`. Operators: `eq`, `ne`, `in`, `not_in`, `gt`, `gte`, `lt`, `lte`, `between`. Combinators: `and`, `or`, `not`. One operator per condition, except range bounds (`gt`, `gte`, `lt`, `lte`) together; put other combinations in separate conditions under `and`. Mixing the flat and structured forms in one filter is rejected. Exclusion uses `ne` / `not_in` / `not` — there is no separate syntax: `{"and": [{"field": "issn", "not_in": ["1234-5679"]}]}`. Indexed on every collection (a field that is not a known result metadata field is rejected; a known field without an index, such as `pmid`, is matched by scanning and returns a `filterWarnings` entry, and is rejected on a collection that has no index for it): `article_type`, `chapter_authors`, `chapter_number`, `chapter_title`, `doi`, `isbn`, `issn`, `journal`, `keywords`, `open_access`, `publication_date`, `publisher`, `section`. Indexed on the editorial collections only: `last_updated_date`, `medical_board_approved`, `topic`, `url`. `open_access` is also answered for a collection that holds only open-access content and carries no such field: `true` matches everything there, `false` (or the field under `or` / `not`) excludes that collection with a `filterWarnings` entry. `license` is returned in result metadata but is not filterable. `issn` accepts hyphenated or bare, upper- or lower-case X (`"1234-561X"`, `"1234561x"`). `doi` is matched case-insensitively and an optional `https://doi.org/` or `doi:` prefix is accepted. `journal_metric.2yr_mean_citedness`, `journal_metric.h_index`, `journal_metric.i10_index` and `journal_metric.impact_factor` accept range operators only and are resolved server-side into the matching ISSNs; see `journalMetricExpansions` in the response. `impact_factor` is an impact factor computed by Redpine from OpenAlex citation data with the JIF formula. It is not Clarivate's Journal Impact Factor. Journals that publish many news items, letters and editorials (for example BMJ, JAMA, The Lancet) score far below their JCR figure, because OpenAlex classifies those items as articles. A metric condition may match at most 35,000 journal ISSNs and a request may send at most 70,000 filter values. A threshold with one end must stay within its documented bound: journal_metric.2yr_mean_citedness: gte 1.1 or higher, or lte 0.0 or lower; journal_metric.h_index: gte 35 or higher, or lte 3 or lower; journal_metric.i10_index: gte 160 or higher (no upper bound alone); journal_metric.impact_factor: gte 0.9 or higher, or lte 0.2 or lower. On h_index and i10_index, gt N counts as gte N+1 and lt N as lte N-1. A range with both ends (gte with lte, or between) may sit anywhere if it matches no more than 35,000 ISSNs. For 'below X' use not with gte X, X at or above the metric's minimum; not also keeps documents with no journal metric, and on impact_factor journals with no impact_factor. Two conditions near their bounds use most of the 70,000 values, so a third, or a long in/not_in list beside them, can be refused. |
| `imageMaxHeight` | integer | no | Maximum image height in pixels |
| `imageMaxWidth` | integer | no | Maximum image width in pixels |
| `imageQuality` | integer | no | JPEG quality for fetched images |
| `includeFigures` | boolean | no | Fetch and include figure images as base64 in metadata (adds latency). includeImages is accepted as a deprecated alias. |
| `includeMetadata` | boolean | no | Whether to include metadata in results |
| `limit` | integer | no | Maximum results to return (default 10, max 30) |
| `query` | string | yes | Natural language or keyword search query. |
## Responses
| Status | Description |
|---|---|
| `200` | Search results |
| `401` | Missing or invalid API key |
| `403` | API key does not have access to the requested collection |
| `404` | Collection not found |
| `422` | Validation error (e.g. query too long) |
| `429` | Rate or quota limit exceeded |
| `503` | Search temporarily unavailable (Qdrant or the quota service is down) |
### 200 response fields
| Field | Type | Required | Description |
|---|---|---|---|
| `filterWarnings` | array | no | Advisory warnings about the supplied filter — for example filtering on a known field with no payload index, which is matched by scanning, or a collection excluded because it holds only open-access content and the filter asked for open_access=false. The search still runs. Omitted when there are none. |
| `filtersApplied` | object | no | The filters you sent, after validation. Omitted when no filters were sent. |
| `journalMetricExpansions` | array | no | How each journal-metric condition resolved to ISSNs; omitted when no metric filter was used |
| `latencyMs` | integer | yes | Search latency in milliseconds |
| `queryId` | string | yes | Unique identifier for this query. Use it to re-fetch the same results for free within 7 days via GET /api/v1/search/results/{queryId}. |
| `results` | object[] | yes | Ranked list of matching document chunks. |
---
# Pay for previewed results and receive them in full
`POST` `https://api.redpine.ai/api/v1/search/unlock`
## Authentication
```
Authorization: Bearer sk_live_YOUR_API_KEY
```
## Request body
| Field | Type | Required | Description |
|---|---|---|---|
| `imageMaxHeight` | integer | no | Maximum image height in pixels |
| `imageMaxWidth` | integer | no | Maximum image width in pixels |
| `imageQuality` | integer | no | JPEG quality for fetched images |
| `includeFigures` | boolean | no | Fetch figure images, returned as base64 in each result's metadata. Candidates are every result unlocked under this queryId, this call's and earlier calls' alike, but at most 50 images are fetched per call: the ids in resultIds — or, when it is omitted, the results this call unlocked — get that budget first, and figures past it come back as captions with no image_data. Ask again for the ids you still want; re-sending ids already unlocked charges nothing. Free — figures are not priced into the token cost, so this changes latency and response size, never the charge. Off by default: use the preview's figureCount to decide. includeImages is a deprecated alias. |
| `queryId` | string | yes | The `queryId` from a previous POST /search/preview response. |
| `resultIds` | array | no | Result ids to unlock. Omit (or pass `null`) to unlock every result from the preview. Re-sending an id that is already unlocked costs nothing — only the delta is charged. |
## Responses
| Status | Description |
|---|---|
| `200` | The full result set filtered through the unlock ledger, plus what this call charged |
| `401` | Missing or invalid API key |
| `402` | Billing suspended for this organization, or insufficient credits for the delta |
| `404` | queryId not found or belongs to a different API key |
| `410` | The preview has expired, or was written by an older payload version |
| `422` | One or more resultIds are not in this preview |
| `429` | Rate or quota limit exceeded, or the API key's spending budget is exhausted. An unlock that buys new results counts as one query against the org's quota; a preview and a repeat unlock count for nothing. Nothing is charged when this fires. |
| `503` | The quota service is unavailable, so the quota slot this unlock would consume could not be counted and the unlock was refused. The refusal happens before the charge commits: nothing is charged and no result is unlocked. |
### 200 response fields
| Field | Type | Required | Description |
|---|---|---|---|
| `costCharged` | string | no | What THIS call charged — not a running total. Null on a preview (always free) and on an unlock whose entire delta was already unlocked. |
| `costToUnlockRemaining` | string | yes | Cost to unlock every result not yet unlocked. An estimate, not a binding quote: it is summed from each result's own individually-rounded cost, while the amount actually charged on the next unlock is computed per collection at that call's combined token total — the two can differ by a rounding fraction. |
| `filterWarnings` | array | no | Advisory warnings about the supplied filter — for example filtering on a known field with no payload index, which is matched by scanning, or a collection excluded because it holds only open-access content and the filter asked for open_access=false. The search still runs. Omitted when there are none. |
| `filtersApplied` | object | no | The filters you sent, after validation. Omitted when no filters were sent. |
| `journalMetricExpansions` | array | no | How each journal-metric condition resolved to ISSNs; omitted when no metric filter was used. |
| `queryId` | string | yes | Pass this to POST /search/unlock. |
| `results` | object[] | yes | Every result from the query, filtered through the unlock ledger. |