# 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 | | `journalMetricExpansions` | array \| null | How each journal-metric filter condition resolved to ISSNs; omitted when no metric filter was used | ### 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 journal-metric filters. --- # 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) 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-09-07 **API** * `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** * 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. Reset to example loads a working request. * 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. * 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; Python installs from GitHub until it reaches PyPI. * 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 | | `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 | | `429` | QUOTA\_EXCEEDED | Per-minute, daily, or monthly quota exceeded | | `429` | PREVIEW\_RATE\_LIMIT\_EXCEEDED | Preview rate limit exceeded | | `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. * **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. * **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. ### 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. ### 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`. **This 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. A journal the metric provider has no value for is **absent** from the result, not scored zero, so a `lt` threshold does not sweep up unrated journals. ```json { "filters": { "journal_metric.2yr_mean_citedness": { "gte": 5.0 } } } ``` ```json { "filters": { "journal_metric.2yr_mean_citedness": { "lt": 2.0 } } } ``` 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: a threshold matching more ISSNs than the cap returns an error 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": 212, "issnCount": 383, "sampleIssns": ["0028-0836", "1476-4687", "0027-8424"] } ] } ``` ## Filtering on other fields Indexed on every collection: `doc_id`, `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. Filtering on any other payload field still works and is not rejected, but it is matched by scanning rather than by index, which can be slow or time out on large collections. When that happens the response carries a warning: ```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, doc_id, doi, isbn, issn, journal, keywords, last_updated_date, medical_board_approved, open_access, publication_date, publisher, section, topic, url." } ] } ``` ## 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. ### 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 Install a client, run your first search, read the response. 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 the [REST API quickstart](/docs/quickstart). 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) 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) 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. Parameters and response shapes match their REST counterparts exactly. See [Search](/docs/search) and [Preview and unlock](/docs/preview-unlock) for the full field reference. `/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. ## 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) 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). 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. | | 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 } ``` ## 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 | 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"]}' ``` --- # Getting started Redpine ships official clients for Python, TypeScript and Go, and every example on this site shows them alongside the raw HTTP call. Pick your language once from the tabs; the choice follows you across pages. Skip this page and go to [MCP](/docs/mcp): connect Claude, Cursor, ChatGPT, or any MCP client with one line and no code. Come back here only if you're calling the API yourself. ## 1. Get an API key Create an API key from your [API Keys](https://app.redpine.ai/api-keys) page in the dashboard. Keys are scoped to your organization and optionally to specific collections. There are two kinds. 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 SDKs read it from there; cURL examples pass it in the header. ```bash export REDPINE_API_KEY=sk_test_YOUR_SANDBOX_KEY ``` ## 2. Install a client Skip this step if you're calling the REST API directly. cURL Python TypeScript Go ```bash # Nothing to install. Every request is a POST or GET with a Bearer header. ``` ```bash pip install "redpine-sdk @ git+https://github.com/redpine-ai/redpine-sdk-python@v0.1.4" ``` ```bash npm install @redpine-ai/sdk ``` ```bash go get github.com/redpine-ai/redpine-sdk-go@latest ``` ## 3. Make your first request Search a collection with a query. That's all you need. 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) } } ``` ## 4. Explore the response Results are ranked by relevance. Each result includes the matched text, the collection it came from, and any metadata from the original document. The SDKs return the same fields as typed objects: `results`, `queryId` and `latencyMs` in TypeScript; `Results`, `QueryId` and `LatencyMs` in Go; `results`, `query_id` and `latency_ms` in Python. Because this used the 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 } ``` ## What's next? Everything the Python, TypeScript and Go clients expose: filters, assisted search, errors, retries. Four ways to search, and how to pick one. See what a search would return, and what it would cost, before you pay for it. Agentic retrieval that plans its own searches and verifies every result. List every collection name your key can search, at no cost. What the sandbox key covers, and how to go live. API key management and scoping. Full endpoint reference and examples. Every status code, what causes it, and how to fix it. --- # 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. ## Getting a sandbox key Create one from your API Keys page in the dashboard: give it a name and turn on **Sandbox key**. Organizations created from now on also get one automatically, labelled **Sandbox**, sitting alongside the live key. 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. Python is not on PyPI yet and installs from GitHub. Report problems as issues on the language repository. ## Install Python TypeScript Go ```bash pip install "redpine-sdk @ git+https://github.com/redpine-ai/redpine-sdk-python@v0.1.4" ``` ```bash npm install @redpine-ai/sdk ``` ```bash go get github.com/redpine-ai/redpine-sdk-go@latest ``` ## 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_high_impact = ( F("publication_year").gte(2022) & F("journal_metric.2yr_mean_citedness").gte(5) & ~F("issn").in_(["1234-5679"]) ) r = client.search("crispr delivery", collections=["corpus"], filters=recent_high_impact) ``` ```typescript import { F } from "@redpine-ai/sdk"; const recentHighImpact = F("publication_year").gte(2022) .and(F("journal_metric.2yr_mean_citedness").gte(5)) .and(F("issn").in(["1234-5679"]).not()); const r = await client.search("crispr delivery", { collections: ["corpus"], filters: recentHighImpact }); ``` ```go recentHighImpact := redpine.F("publication_year").Gte(2022). And(redpine.F("journal_metric.2yr_mean_citedness").Gte(5)). And(redpine.F("issn").In("1234-5679").Not()) r, err := client.Search(ctx, "crispr delivery", redpine.SearchOptions{ Collections: []string{"corpus"}, Filters: recentHighImpact, }) ``` ## 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 [Getting started](/docs/quickstart) 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 on indexed fields | | `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 | ### 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 } ``` ## 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: `doc_id`, `journal`, `publisher`, `keywords`, `publication_date`, `doi`, `issn`, `article_type`, `section`, `isbn`, `open_access`, `chapter_number`, `chapter_title`, and `chapter_authors`. Other fields still work but are matched by scanning. `license` is returned in result metadata but is not a filter. 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 [Getting started](/docs/quickstart) 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. | ## 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 field with no payload index, which is matched by scanning. 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 field with no payload index, which is matched by scanning. 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`. Exclusion uses `ne` / `not_in` / `not` — there is no separate syntax: `{"and": [{"field": "issn", "not_in": ["1234-5679"]}]}`. Indexed on every collection (any other field is matched by scanning and returns a `filterWarnings` entry): `article_type`, `chapter_authors`, `chapter_number`, `chapter_title`, `doc_id`, `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` and `journal_metric.i10_index` accept range operators only and are resolved server-side into the matching ISSNs; see `journalMetricExpansions` in the response. | | `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 field with no payload index, which is matched by scanning. 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. 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`. Exclusion uses `ne` / `not_in` / `not` — there is no separate syntax: `{"and": [{"field": "issn", "not_in": ["1234-5679"]}]}`. Indexed on every collection (any other field is matched by scanning and returns a `filterWarnings` entry): `article_type`, `chapter_authors`, `chapter_number`, `chapter_title`, `doc_id`, `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` and `journal_metric.i10_index` accept range operators only and are resolved server-side into the matching ISSNs; see `journalMetricExpansions` in the response. | | `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 field with no payload index, which is matched by scanning. The search still runs. Omitted when there are none. | | `journalMetricExpansions` | array | no | What each journal-metric threshold (e.g. impactFactor >= 5) expanded to. 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`. Exclusion uses `ne` / `not_in` / `not` — there is no separate syntax: `{"and": [{"field": "issn", "not_in": ["1234-5679"]}]}`. Indexed on every collection (any other field is matched by scanning and returns a `filterWarnings` entry): `article_type`, `chapter_authors`, `chapter_number`, `chapter_title`, `doc_id`, `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` and `journal_metric.i10_index` accept range operators only and are resolved server-side into the matching ISSNs; see `journalMetricExpansions` in the response. | | `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 field with no payload index, which is matched by scanning. 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. 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 | |---|---|---|---| | `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 field with no payload index, which is matched by scanning. The search still runs. Omitted when there are none. | | `journalMetricExpansions` | array | no | What each journal-metric threshold (e.g. impactFactor >= 5) expanded to. 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. |