API documentation
Four web tools for AI agents: search, fetch, extract and research. Use them over MCP or REST, and pay per call in credits. Failed calls are free.
Quick start
Add Sofya to Claude, ChatGPT, Cursor, VS Code and other MCP apps. Most sign in with your Sofya account, no key to paste.
Call the APICreate a key, then make your first request from curl, Python or JavaScript. Steps below.
1. Sign up and create a key
Sign in on the dashboard with GitHub or an email link, then click Create key under API keys.
2. Get credits
Accounts created by signing in with a GitHub account that is at least 1 year old get the free tier: 2,000 credits, reset to 2,000 every 30 days. Email sign-ups start at 0 credits on pay-as-you-go, so their first call is a 402 until they buy credits. Buy credits from the dashboard at $0.0025 each (minimum $10 = 4,000 credits). See Credits & billing.
3. Make your first call
curl -X POST https://mcp.sofya.co/v1/search \
-H "Authorization: Bearer sofya_live_..." \
-H "Content-Type: application/json" \
-d '{"query": "latest AI news"}'
import httpx
resp = httpx.post("https://mcp.sofya.co/v1/search",
headers={"Authorization": "Bearer sofya_live_..."},
json={"query": "latest AI news"},
timeout=90)
print(resp.json())
// Node 18+: save as first-call.mjs and run: node first-call.mjs
const resp = await fetch("https://mcp.sofya.co/v1/search", {
method: "POST",
headers: { "Authorization": "Bearer sofya_live_...", "Content-Type": "application/json" },
body: JSON.stringify({ query: "latest AI news" })
});
console.log(await resp.json());
{
"query": "latest AI news",
"results": [
{
"title": "...",
"url": "https://...",
"content": "Extracted page text...",
"published_date": "2026-10-27"
}
],
"credits_used": 2,
"credits_remaining": 1998
}Trimmed; the full response is under Search. Search with an AI answer, extract and research can take 5 to 60 seconds, so give your HTTP client a timeout of at least 90 seconds (most default to 5 to 30). A 402 means the account has no credits yet.
4. Next
Connect your agent over MCP, or see Search for filters like topic, freshness and include_domains. Building your own agent loop? Tool definitions has ready-made schemas for Claude and GPT.
Connect your agent MCP
Sofya runs a remote MCP server at https://mcp.sofya.co/mcp. Add it to your agent and it gets four tools: search, fetch, extract and research. There are two ways to connect:
Add the URL and sign in with your Sofya account when your app asks. This is the only way for claude.ai, Claude Desktop and ChatGPT, and it works in every app that supports MCP sign-in. If your account has no API key yet, signing in creates one.
For Claude Code, Cursor, VS Code, headless agents and CI. Send Authorization: Bearer sofya_live_.... The copy buttons on the dashboard fill in your key. The key travels in a header; the model never sees it.
Either way, calls are charged to your account's credits, exactly like API calls, and show up in your dashboard usage. Apps you signed in to are listed under Connected apps, where you can disconnect them.
- Open Customize > Connectors (older versions: Settings > Connectors) and choose Add custom connector.
- Name:
Sofya. URL:https://mcp.sofya.co/mcp. Click Add. - Sign in with your Sofya account and approve. Claude asks for this right away.
- In a chat, open the tools menu and check that Sofya is switched on. Connectors are switched on per chat.
Claude Desktop uses the same connectors as claude.ai, so add it there. Its config file only takes local servers and ignores a url entry. On Team and Enterprise plans, an owner may need to add the connector first.
Use Sofya instead of the built-in web search: turn off Web search in the chat's tools menu, or add to your instructions: Use the Sofya tools to search the web and read pages.
claude mcp add --transport http sofya https://mcp.sofya.co/mcpThen run /mcp in Claude Code, pick sofya and sign in. Or use a key instead:
claude mcp add --transport http sofya https://mcp.sofya.co/mcp \
--header "Authorization: Bearer sofya_live_..."Use Sofya instead of the built-in web search: deny Claude Code's own tools in .claude/settings.json:
{ "permissions": { "deny": ["WebSearch", "WebFetch"] } }The button adds the server; Cursor asks you to sign in the first time it uses it. To use a key instead, edit ~/.cursor/mcp.json:
{
"mcpServers": {
"sofya": {
"url": "https://mcp.sofya.co/mcp",
"headers": { "Authorization": "Bearer sofya_live_..." }
}
}
}Use Sofya instead of the built-in web search: turn off Cursor's own web search tool in its settings.
The button adds the server for GitHub Copilot in VS Code; it asks you to sign in when it starts. To use a key instead, add .vscode/mcp.json to your project:
{
"servers": {
"sofya": {
"type": "http",
"url": "https://mcp.sofya.co/mcp",
"headers": { "Authorization": "Bearer sofya_live_..." }
}
}
}- In ChatGPT settings, open Apps & Connectors, then Advanced settings, and turn on Developer mode.
- Create a connector. Name:
Sofya. URL:https://mcp.sofya.co/mcp. Authentication: OAuth. - Sign in with your Sofya account and approve.
- In a chat, pick Sofya from the tools menu.
Developer mode is only on some ChatGPT plans, and the menu names change often. If yours differ, look for custom connectors or developer mode in settings.
{
"mcpServers": {
"sofya": {
"serverUrl": "https://mcp.sofya.co/mcp",
"headers": { "Authorization": "Bearer sofya_live_..." }
}
}
}Windsurf calls the URL field serverUrl. Save the file, then press Refresh in the MCP panel.
codex mcp add sofya --url https://mcp.sofya.co/mcp
codex mcp login sofyaOr use a key in ~/.codex/config.toml:
[mcp_servers.sofya]
url = "https://mcp.sofya.co/mcp"
http_headers = { "Authorization" = "Bearer sofya_live_..." }{
"mcpServers": {
"sofya": {
"httpUrl": "https://mcp.sofya.co/mcp",
"headers": { "Authorization": "Bearer sofya_live_..." }
}
}
}Gemini CLI calls the URL field httpUrl.
Most apps take a remote server as a URL plus optional headers. The server speaks Streamable HTTP. Leave out headers to sign in instead, if the app supports MCP sign-in.
{
"mcpServers": {
"sofya": {
"url": "https://mcp.sofya.co/mcp",
"headers": { "Authorization": "Bearer sofya_live_..." }
}
}
}For an app that only runs local servers, bridge with mcp-remote (needs Node.js):
{
"mcpServers": {
"sofya": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.sofya.co/mcp", "--header", "Authorization: Bearer sofya_live_..."]
}
}
}Tools
Web search that returns page content. +2 with an AI answer. Takes query, search_depth, max_results, include_answer, include_domains, exclude_domains, topic, freshness. Reference
URLs and documents as clean markdown; failed URLs are free. Takes urls, include_raw_html. Reference
An AI answer to a question about one page. Takes url, prompt. Reference
A cited report from many searches and sources. Takes query, topic, freshness, max_sources. Reference
Credits left, and the key's monthly limit if it has one. Same as GET /v1/billing/balance. Reference
A payment link for the user to open. Charges nothing itself. Takes amount_usd. Same as POST /v1/billing/topup. Reference
MCP tools take the same parameters and return the same JSON as the REST endpoints. The four web tools only read the web; none changes anything. Errors arrive as a tool result with isError: true and a message instead of an HTTP status code, and are not charged.
Authentication
Every API request needs your API key, sent in either the Authorization header or the X-API-Key header. Keys start with sofya_live_; keys made before October 27, 2026 start with ay_live_ and keep working. If GitHub finds one of your sofya_live_ keys in a public repository, we revoke it at once and email you.
Authorization: Bearer sofya_live_your_key_here
# or
X-API-Key: sofya_live_your_key_hereExceptions: GET /v1/status and GET /v1/updates need no key, and MCP apps can sign in with your account instead (see Connect your agent). If you set an IP allowlist on a key, requests from any other IP get a 401. Keep keys on your server: calling the API from a web page would show your key to every visitor.
An agent with no key can ask for one, and you approve it in the browser. See Agent sign-up. A 401 carries a WWW-Authenticate header pointing to the OAuth metadata.
Core tools
Search
Search the web. Returns page content, not just snippets. Choose a search depth to control the quality/cost tradeoff. Add include_answer to any depth for an AI-synthesized answer (+2 credits). This is a lightweight alternative to the 50-credit research endpoint.
Search-result snippets only, no page content. Fastest.
Fetches pages, returns extracted content (~12,000 chars per result).
Parameters
| Parameter | Type | Description |
|---|---|---|
query | string | Required and not blank (an empty query is a 422, not charged). Max 2,048 characters. |
search_depth | string | "basic" (default, 2 credits) or "snippets" (1 credit). |
max_results | integer | Maximum number of results, 1-20, default 10. Out-of-range values are clamped, not rejected. You may get fewer. |
include_answer | boolean | Default false. Adds an AI answer synthesized from the results (+2 credits, so basic + answer = 4). If no answer can be produced, answer is null and the 2 credits are refunded. |
include_domains | string[] | Only return results from these domains. Bare hostnames like github.com (no scheme or path), max 10. |
exclude_domains | string[] | Leave out these domains. Bare hostnames, max 10. |
topic | string | "general" (default) or "news". Use "news" for current events, breaking news, politics, or any time-sensitive query. Returns articles with publication dates. |
freshness | string | Default null. "day", "week", "month", "year", or a range "YYYY-MM-DD:YYYY-MM-DD". |
{
"query": "latest AI news",
"search_depth": "basic",
"max_results": 10,
"include_answer": false,
"include_domains": [],
"exclude_domains": [],
"topic": "general",
"freshness": null
}{
"query": "latest AI news",
"answer": null,
"results": [
{
"title": "...",
"url": "...",
"content": "Extracted page content...",
"description": "The result's snippet",
"fetched": true,
"published_date": "2026-03-08",
"sublinks": [],
"table": {}
}
],
"search_depth": "basic",
"topic": "general",
"elapsed_ms": 4200,
"credits_used": 2,
"credits_remaining": 1998,
"altered_query": null,
"relaxed_query": null
}Response fields
queryThe query actually run. It can differ from what you sent: it includes any site: operators built from include/exclude_domains, or the looser query after a zero-result retry.
answerNull unless include_answer is true (and still null, refunded, if no answer could be produced).
contentAt basic depth, up to ~12,000 characters of page text per result. A page that doesn't load in time falls back to its snippet, with fetched: false. At snippets depth, fetched is always false.
published_dateYYYY-MM-DD from page metadata or the search result, or null.
sublinks, tablesublinks: extra links shown under a result, each {"title", "description", "url"}. table: an object, often empty.
altered_querySet if the search engine auto-corrected your query, else null. A "quoted phrase" is never corrected: it is searched exactly, and if nothing matches you get 0 results.
relaxed_querySet when the query matched nothing and was retried once with its site: operator (else its quotes) removed. The results then answer that looser query. Quotes are not dropped when the engine would respell the unquoted words. Null otherwise.
Examples
curl -X POST https://mcp.sofya.co/v1/search \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sofya_live_..." \
-d '{"query": "latest AI news", "search_depth": "basic"}'
import httpx
resp = httpx.post("https://mcp.sofya.co/v1/search",
headers={"Authorization": "Bearer sofya_live_..."},
json={"query": "latest AI news", "search_depth": "basic"},
timeout=90) # longer than the server's limit, so you get its answer or error
print(resp.json())
// Node 18+: save as example.mjs (top-level await needs a module)
const resp = await fetch("https://mcp.sofya.co/v1/search", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": "Bearer sofya_live_..." },
body: JSON.stringify({ query: "latest AI news", search_depth: "basic" })
});
console.log(await resp.json());
Fetch
Fetch up to 10 URLs in parallel and return their content as clean markdown. Works on web pages and documents: PDF, Word (DOCX, DOC), Excel (XLSX, XLS), PowerPoint (PPTX, PPT), OpenDocument, EPUB, RTF, CSV, XML and plain text. Costs 1 credit per URL that succeeds; a URL that fails comes back with success: false and is not charged.
Parameters
| Parameter | Type | Description |
|---|---|---|
urls | string[] | Required, 1-10 URLs. An empty list is a 400; more than 10 is a 422. A URL listed twice is fetched, returned and charged once. |
include_raw_html | boolean | Default false. Also return the page's raw HTML. |
{
"urls": ["https://example.com"],
"include_raw_html": false
}{
"results": [
{
"title": "Example Page",
"url": "https://example.com",
"content": "# Markdown content...",
"raw_html": null,
"published_time": null,
"success": true,
"error": null,
"low_content": false,
"notice": null
}
],
"credits_used": 1,
"credits_remaining": 1999
}Response fields
success / errorEach URL succeeds or fails on its own. A URL that hasn't finished after 50 seconds gives up and returns success: false with an error message; the other URLs are unaffected.
low_content / noticelow_content is true when a page came back with under 300 characters of readable text: it may need JavaScript, sit behind a login or cookie wall, or be image-only. notice then says so in words. Still success: true and still charged.
5 MB for HTML pages, 50 MB for documents.
raw_htmlNull unless include_raw_html is true, and may still be null for non-HTML content (such as documents) and for some sites.
published_timeYYYY-MM-DD from page metadata when available, else null.
Examples
curl -X POST https://mcp.sofya.co/v1/fetch \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sofya_live_..." \
-d '{"urls": ["https://example.com"]}'
import httpx
resp = httpx.post("https://mcp.sofya.co/v1/fetch",
headers={"Authorization": "Bearer sofya_live_..."},
json={"urls": ["https://example.com"]},
timeout=90)
print(resp.json())
// Node 18+: save as example.mjs (top-level await needs a module)
const resp = await fetch("https://mcp.sofya.co/v1/fetch", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": "Bearer sofya_live_..." },
body: JSON.stringify({ urls: ["https://example.com"] })
});
console.log(await resp.json());
Extract
Fetch a webpage and answer a question about it using AI. Costs 10 credits. content is a free-text answer (it is not JSON or structured output; ask for a format in your prompt if you need one). Only the first 50,000 characters of the page text are sent to the model.
If the page has under 40 characters of text (empty or JavaScript-rendered body), the model is not called and content is returned empty with usage.low_content: true rather than a fabricated answer. That is still a 200, but only 1 credit is charged, the cost of fetching the page; the rest is refunded. If the page itself can't be fetched (404, blocked, no DNS, timed out, refused URL), you get a 422 and are not charged.
Parameters
| Parameter | Type | Description |
|---|---|---|
url | string | Required. The page to read. |
prompt | string | Required and not blank (an empty query is a 422, not charged), max 4,096 characters. What to find or answer. |
{
"url": "https://example.com",
"prompt": "List all pricing tiers with their monthly prices"
}{
"content": "Three tiers: Free ($0), Pro ($20/month), Team ($50/month).",
"url": "https://example.com",
"credits_used": 10,
"credits_remaining": 1990,
"usage": { "input_tokens": 1420, "output_tokens": 24, "content_chars": 4820, "low_content": false },
"notice": null
}usage.content_chars is the number of characters of page text the model received; usage.low_content is true when the page had under 40 characters of text (the model was skipped, content is empty, and only 1 credit is charged), and notice says why in plain words. Gate on these to detect empty or unrenderable pages.
Examples
curl -X POST https://mcp.sofya.co/v1/extract \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sofya_live_..." \
-d '{"url": "https://example.com", "prompt": "Summarize this page"}'
import httpx
resp = httpx.post("https://mcp.sofya.co/v1/extract",
headers={"Authorization": "Bearer sofya_live_..."},
json={"url": "https://example.com", "prompt": "Summarize this page"},
timeout=90) # extract usually takes 10-20 s
print(resp.json())
// Node 18+: save as example.mjs (top-level await needs a module)
const resp = await fetch("https://mcp.sofya.co/v1/extract", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": "Bearer sofya_live_..." },
body: JSON.stringify({ url: "https://example.com", prompt: "Summarize this page" })
});
console.log(await resp.json());
Research
Deep research on any topic. Decomposes your query into sub-queries, searches and reads multiple sources in parallel, then synthesizes a structured report with citations. Costs 50 credits. Research has a 55-second time limit (504, not charged); if no sources can be found the request fails and is not charged.
Each Key Findings bullet ends with an evidence tag: [Confirmed] (several sources agree), [Reported] (one source) or [Contested] (sources disagree). Sending the same research request again while the first is still running (same key, same query and options) does not start a second run: it waits for the first and returns its report with credits_used: 0.
Parameters
| Parameter | Type | Description |
|---|---|---|
query | string | Required and not blank (an empty query is a 422, not charged), max 4,096 characters. |
topic | string | "general" (default) or "news". |
freshness | string | Default null. "day", "week", "month", "year", or "YYYY-MM-DD:YYYY-MM-DD". |
max_sources | integer | 5-30, default 20. Out-of-range values are clamped. |
{
"query": "How do modern LLMs handle long context?",
"topic": "general",
"freshness": null,
"max_sources": 20
}{
"query": "How do modern LLMs handle long context?",
"report": "## Key Findings\n\n- ...",
"sources": [
{
"title": "Scaling Transformer Context Windows",
"url": "https://arxiv.org/abs/...",
"fetched": true,
"published_date": "2026-09-14"
}
],
"sub_queries": [
"transformer context window scaling techniques",
"RoPE positional encoding extensions"
],
"credits_used": 50,
"credits_remaining": 1950,
"usage": { "input_tokens": 12400, "output_tokens": 1850 }
}Examples
curl -X POST https://mcp.sofya.co/v1/research \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sofya_live_..." \
-d '{"query": "How do modern LLMs handle long context?"}'
import httpx
resp = httpx.post("https://mcp.sofya.co/v1/research",
headers={"Authorization": "Bearer sofya_live_..."},
json={"query": "How do modern LLMs handle long context?"},
timeout=120) # research usually takes 15-25 s
print(resp.json())
// Node 18+: save as example.mjs (top-level await needs a module)
const resp = await fetch("https://mcp.sofya.co/v1/research", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": "Bearer sofya_live_..." },
body: JSON.stringify({ query: "How do modern LLMs handle long context?" })
});
console.log(await resp.json());
Errors and billing
Errors
No error is charged, so after any error it is safe to retry once you've fixed the cause.
| Status | Meaning | What to do |
|---|---|---|
| 400 | Bad request | A request-level problem the schema can't catch, such as an empty urls list on fetch. Fix the request. |
| 401 | Unauthorized | The key is missing, wrong or revoked, or the request IP isn't in the key's IP allowlist (this applies to every /v1 endpoint, /v1/auth/* included). Check the key on the dashboard. |
| 402 | Out of credits | The account doesn't have enough credits for this call. Buy credits, then retry. New email sign-ups start here. The body has "error": "insufficient_credits" and a top_up_url to give your user, or make a payment link with POST /v1/billing/topup. "error": "key_cap_reached" means the key hit the monthly limit its owner set in the dashboard; buying credits won't help. |
| 422 | Invalid parameters | A missing or wrong-type field, an unknown search_depth or topic, a bad freshness (or over 25 characters), domains that aren't bare hostnames, more than 10 urls or domains, a search query over 2,048 characters, or a research query or extract prompt over 4,096. detail lists each problem. Also extract when the page couldn't be fetched (404, blocked, no DNS, timed out, refused URL): check the URL, retrying rarely helps. |
| 429 | Too many requests | Over 30 requests per second on one key: wait the Retry-After seconds. Fetch and extract also return it when one account has over 100 URLs in progress at once, or over 600 failed (refunded) URLs in 10 minutes: send fewer URLs at a time. |
| 502 | Internal error | Something failed on our side. Retry. |
| 503 | Busy | Search capacity is briefly unavailable. Retry in a few seconds. |
| 504 | Timed out | The call hit its time limit (60 s; 55 s for research). Retry; for research, try a narrower query or fewer sources. |
Error body
Errors return JSON with a detail field. It is a string, except for parameter errors (422), where it is a list with one entry per problem. Handle both.
{
"detail": "Invalid API key"
}{
"detail": [
{
"type": "literal_error",
"loc": ["body", "search_depth"],
"msg": "Input should be 'snippets' or 'basic'"
}
]
}{
"detail": "Insufficient credits. This costs 2 credit(s). You have 0. Buy credits at https://mcp.sofya.co/dashboard?topup=10, or create a payment link for your user with POST /v1/billing/topup (MCP tool: buy_credits).",
"error": "insufficient_credits",
"credits_required": 2,
"credits_available": 0,
"top_up_url": "https://mcp.sofya.co/dashboard?topup=10",
"top_up_endpoint": "POST /v1/billing/topup"
}Billing on errors: only a 2xx response is charged, at exactly its credits_used. No error response is charged: credits are reserved when the request starts and refunded automatically if it fails or times out. Inside a 200, some parts are refunded too: on fetch, each URL that comes back success: false; on search, the 2 include_answer credits when no answer was produced (answer: null). Research that finds no sources fails and is refunded. Extract with low_content: true is a 200 and is charged. So on any non-2xx, it is safe to retry.
Over MCP, errors arrive as a tool result with isError: true and a message instead of an HTTP status code. The same billing rules apply.
Credits & billing
Every call is paid for in credits. One credit is $0.0025.
| Call | Credits | USD |
|---|---|---|
search (snippets) | 1 | $0.0025 |
search (basic) | 2 | $0.005 |
include_answer | +2 | +$0.005 |
fetch | 1 per URL | $0.0025 per URL |
extract | 10 | $0.025 |
research | 50 | $0.125 |
Buying credits: top up from the dashboard in whole dollars, from $10 (4,000 credits) up to $25,000. Purchased credits are not affected by the free-tier reset. An agent can make a payment link for you with POST /v1/billing/topup; you pay on that page.
Limits per key: in the dashboard, Limit on a key sets the most credits it can spend per calendar month (UTC). Past it, the key gets a 402 with "error": "key_cap_reached" until the 1st. Only you can change it, not an app using the key.
Free tier: accounts created by signing in with a GitHub account that is at least 1 year old get 2,000 credits, reset to 2,000 every 30 days. Unused free credits don't roll over, and they are spent before purchased credits. Linking GitHub to an existing email account does not add the free tier, and email (magic link) sign-ups start at 0 credits on pay-as-you-go.
What is charged: only a 2xx response, at exactly its credits_used. See Billing on errors.
Rate limits
Requests are rate limited to 30 requests per second per API key. REST (/v1/*) and MCP (/mcp) are counted separately, so each gets its own 30 per second.
If you exceed the limit, the API returns 429 Too Many Requests with a Retry-After header giving the whole number of seconds to wait before retrying.
HTTP/1.1 429 Too Many Requests
Retry-After: 1
{
"detail": "Rate limit exceeded. 30 requests per second."
}Rate-limited requests do not consume credits. Wait the Retry-After seconds, then retry.
Every response to a request with an API key carries X-RateLimit-Limit (requests per second), X-RateLimit-Remaining (left in the current second) and X-RateLimit-Reset (seconds until the window frees up).
Every response also carries an X-Request-Id. Send your own (up to 64 letters, digits, . _ : -) and it is echoed back; otherwise one is generated. Quote it when you contact support.
Guides
Tool definitions for Claude & GPT
Building your own agent loop on the Anthropic or OpenAI API, without MCP? Paste these schemas as your tools, and when the model calls one, POST its arguments to the matching REST endpoint. The sofya_ prefix keeps them from clashing with your other tools: sofya_search is /v1/search, and so on. If your app supports MCP, connect it instead and skip this section; the MCP tools are named search, fetch, extract and research.
The blocks are long on purpose: copy the whole block, it's meant to be pasted, not read.
Anthropic (Claude)
Pass this array as the tools parameter in your /v1/messages request. When Claude returns a tool_use block, call the matching Sofya REST endpoint and return the result as a tool_result.
[
{
"name": "sofya_search",
"description": "Search the web for current information. Returns extracted page content, not just snippets. Set topic='news' for current events. Set include_answer=true for an AI-synthesized answer (+2 credits). Returns: query (the query actually run), answer, results [{title, url, content, description, fetched, published_date, sublinks, table}], search_depth, topic, elapsed_ms, credits_used, credits_remaining, altered_query, relaxed_query (set when the query matched nothing and was retried once with its site: operator, else its quotes, removed - the results answer that looser query).",
"input_schema": {
"type": "object",
"required": ["query"],
"properties": {
"query": {"type": "string", "description": "The search query"},
"search_depth": {"type": "string", "enum": ["snippets", "basic"], "description": "\"snippets\" (1 credit) or \"basic\" (2, default)"},
"max_results": {"type": "integer", "description": "Maximum number of results, 1-20 (default 10). Out-of-range values are clamped"},
"include_answer": {"type": "boolean", "description": "Add AI answer synthesized from results (+2 credits)"},
"topic": {"type": "string", "enum": ["general", "news"], "description": "\"general\" (default) or \"news\""},
"freshness": {"type": "string", "description": "\"day\", \"week\", \"month\", \"year\", or \"YYYY-MM-DD:YYYY-MM-DD\""},
"include_domains": {"type": "array", "items": {"type": "string"}, "description": "Only these domains, bare hostnames like github.com (max 10)"},
"exclude_domains": {"type": "array", "items": {"type": "string"}, "description": "Exclude these domains, bare hostnames like pinterest.com (max 10)"}
}
}
},
{
"name": "sofya_fetch",
"description": "Fetch one or more URLs in parallel and return their content as clean markdown. Supports web pages, PDF, Word, Excel, PowerPoint, OpenDocument, EPUB, RTF, CSV, XML and plain text. 1 credit per URL, max 10 URLs. Failed URLs (success=false) are not charged. Returns: results [{title, url, content, raw_html, published_time, success, error, low_content, notice}], credits_used, credits_remaining.",
"input_schema": {
"type": "object",
"required": ["urls"],
"properties": {
"urls": {"type": "array", "items": {"type": "string"}, "description": "URLs to fetch (max 10)"},
"include_raw_html": {"type": "boolean", "description": "Include raw HTML source in response (default false)"}
}
}
},
{
"name": "sofya_extract",
"description": "Fetch a URL and answer a question about it using AI. Use when you need specific facts from a page (pricing, specs, contact info) rather than its full content. Returns a free-text answer, not structured JSON. 10 credits. If the page has under 40 chars of text it returns empty content with usage.low_content=true instead of a fabricated answer, and only 1 credit is charged. Returns: content, url, credits_used, credits_remaining, usage (input_tokens, output_tokens, content_chars, low_content), notice (null unless there was no answer).",
"input_schema": {
"type": "object",
"required": ["url", "prompt"],
"properties": {
"url": {"type": "string", "description": "The URL to extract from"},
"prompt": {"type": "string", "description": "What to extract, e.g. \"list all pricing tiers with features\""}
}
}
},
{
"name": "sofya_research",
"description": "Deep research on a topic. Decomposes query into sub-queries, searches and reads multiple sources in parallel, synthesizes a structured report with citations. 50 credits. Returns: query, report, sources [{title, url, fetched, published_date}], sub_queries, credits_used, credits_remaining, usage.",
"input_schema": {
"type": "object",
"required": ["query"],
"properties": {
"query": {"type": "string", "description": "The research question or topic"},
"topic": {"type": "string", "enum": ["general", "news"], "description": "\"general\" (default) or \"news\""},
"freshness": {"type": "string", "description": "\"day\", \"week\", \"month\", \"year\", or \"YYYY-MM-DD:YYYY-MM-DD\""},
"max_sources": {"type": "integer", "description": "Max sources to use, 5-30 (default 20). Out-of-range values are clamped"}
}
}
}
]OpenAI (GPT)
Pass this array as the tools parameter in your /chat/completions request. When the model returns tool_calls, call the matching Sofya REST endpoint and return the result as a role: "tool" message.
[
{
"type": "function",
"function": {
"name": "sofya_search",
"description": "Search the web for current information. Returns extracted page content, not just snippets. Set topic='news' for current events. Set include_answer=true for an AI-synthesized answer (+2 credits). Returns: query (the query actually run), answer, results [{title, url, content, description, fetched, published_date, sublinks, table}], search_depth, topic, elapsed_ms, credits_used, credits_remaining, altered_query, relaxed_query (set when the query matched nothing and was retried once with its site: operator, else its quotes, removed - the results answer that looser query).",
"parameters": {
"type": "object",
"required": ["query"],
"properties": {
"query": {"type": "string", "description": "The search query"},
"search_depth": {"type": "string", "enum": ["snippets", "basic"], "description": "\"snippets\" (1 credit) or \"basic\" (2, default)"},
"max_results": {"type": "integer", "description": "Maximum number of results, 1-20 (default 10). Out-of-range values are clamped"},
"include_answer": {"type": "boolean", "description": "Add AI answer synthesized from results (+2 credits)"},
"topic": {"type": "string", "enum": ["general", "news"], "description": "\"general\" (default) or \"news\""},
"freshness": {"type": "string", "description": "\"day\", \"week\", \"month\", \"year\", or \"YYYY-MM-DD:YYYY-MM-DD\""},
"include_domains": {"type": "array", "items": {"type": "string"}, "description": "Only these domains, bare hostnames like github.com (max 10)"},
"exclude_domains": {"type": "array", "items": {"type": "string"}, "description": "Exclude these domains, bare hostnames like pinterest.com (max 10)"}
},
"additionalProperties": false
}
}
},
{
"type": "function",
"function": {
"name": "sofya_fetch",
"description": "Fetch one or more URLs in parallel and return their content as clean markdown. Supports web pages, PDF, Word, Excel, PowerPoint, OpenDocument, EPUB, RTF, CSV, XML and plain text. 1 credit per URL, max 10 URLs. Failed URLs (success=false) are not charged. Returns: results [{title, url, content, raw_html, published_time, success, error, low_content, notice}], credits_used, credits_remaining.",
"parameters": {
"type": "object",
"required": ["urls"],
"properties": {
"urls": {"type": "array", "items": {"type": "string"}, "description": "URLs to fetch (max 10)"},
"include_raw_html": {"type": "boolean", "description": "Include raw HTML source in response (default false)"}
},
"additionalProperties": false
}
}
},
{
"type": "function",
"function": {
"name": "sofya_extract",
"description": "Fetch a URL and answer a question about it using AI. Use when you need specific facts from a page (pricing, specs, contact info) rather than its full content. Returns a free-text answer, not structured JSON. 10 credits. If the page has under 40 chars of text it returns empty content with usage.low_content=true instead of a fabricated answer, and only 1 credit is charged. Returns: content, url, credits_used, credits_remaining, usage (input_tokens, output_tokens, content_chars, low_content), notice (null unless there was no answer).",
"parameters": {
"type": "object",
"required": ["url", "prompt"],
"properties": {
"url": {"type": "string", "description": "The URL to extract from"},
"prompt": {"type": "string", "description": "What to extract, e.g. \"list all pricing tiers with features\""}
},
"additionalProperties": false
}
}
},
{
"type": "function",
"function": {
"name": "sofya_research",
"description": "Deep research on a topic. Decomposes query into sub-queries, searches and reads multiple sources in parallel, synthesizes a structured report with citations. 50 credits. Returns: query, report, sources [{title, url, fetched, published_date}], sub_queries, credits_used, credits_remaining, usage.",
"parameters": {
"type": "object",
"required": ["query"],
"properties": {
"query": {"type": "string", "description": "The research question or topic"},
"topic": {"type": "string", "enum": ["general", "news"], "description": "\"general\" (default) or \"news\""},
"freshness": {"type": "string", "description": "\"day\", \"week\", \"month\", \"year\", or \"YYYY-MM-DD:YYYY-MM-DD\""},
"max_sources": {"type": "integer", "description": "Max sources to use, 5-30 (default 20). Out-of-range values are clamped"}
},
"additionalProperties": false
}
}
}
]Example: wiring it up
When the model calls a tool, map the tool name to the Sofya endpoint and forward the arguments:
# Python - handle tool calls from Claude or GPT
TOOL_TO_ENDPOINT = {
"sofya_search": "/v1/search",
"sofya_fetch": "/v1/fetch",
"sofya_extract": "/v1/extract",
"sofya_research": "/v1/research",
}
def call_sofya(tool_name: str, args: dict) -> dict:
import httpx
resp = httpx.post(
f"https://mcp.sofya.co{TOOL_TO_ENDPOINT[tool_name]}",
headers={"Authorization": "Bearer sofya_live_..."},
json=args,
timeout=120,
)
return resp.json()Account API
Agent sign-up
An agent with no key can get one, with you approving it. The agent asks for a code, you open the link it shows, sign in or create an account, and approve. The agent then receives an ordinary API key, tagged Agent in your dashboard, where you can limit or revoke it. Neither call needs a key. The same flow is described for agents at /auth.md.
curl -X POST https://mcp.sofya.co/v1/agent/signup \
-H "Content-Type: application/json" \
-d '{"client_name": "My agent", "login_hint": "you@example.com"}'{
"claim_token": "clm_...",
"user_code": "BCDF-GHJK",
"verification_uri": "https://mcp.sofya.co/device",
"verification_uri_complete": "https://mcp.sofya.co/device?code=BCDF-GHJK",
"expires_in": 900,
"interval": 5,
"token_endpoint": "https://mcp.sofya.co/v1/agent/token"
}Both fields are optional: client_name is shown to you on the approval page, and login_hint is the email the agent expects you to use. The response also has the same values under claim and as device_code, for agents that follow auth.md or OAuth device flow. Then the agent polls every interval seconds:
curl -X POST https://mcp.sofya.co/v1/agent/token \
-H "Content-Type: application/json" \
-d '{"claim_token": "clm_..."}'{
"error": "authorization_pending",
"interval": 5
}{
"api_key": "sofya_live_...",
"access_token": "sofya_live_...",
"token_type": "Bearer",
"key_id": "uuid",
"key_name": "My agent",
"account": "you@example.com",
"credits": 0,
"monthly_credit_cap": null,
"message": "..."
}Other poll errors: slow_down (poll less often), access_denied (you declined), expired_token (the 15 minutes ran out; start again). The key is returned once. The poll also works as an OAuth token request on /oauth/token with grant_type=urn:workos:agent-auth:grant-type:claim or urn:ietf:params:oauth:grant-type:device_code.
Balance and payment links
With an API key, an agent can check the balance and make a payment link for you. Nothing is charged until you pay on the Stripe page the link opens. The credits are added as soon as the payment goes through. Both calls are free.
curl -X POST https://mcp.sofya.co/v1/billing/topup \
-H "Authorization: Bearer sofya_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"amount_usd": 10}'{
"checkout_url": "https://checkout.stripe.com/c/pay/cs_live_...",
"amount_usd": 10,
"credits": 4000,
"expires_in": 86400,
"message": "Open this link to pay. Credits are added to the account as soon as the payment goes through."
}amount_usd is whole US dollars, $10 to $25,000. The link works for 24 hours. At most 20 links per account per hour (429 after that).
{
"credits": 3994,
"plan_credits": 0,
"purchased_credits": 3994,
"credits_reset_at": null,
"key_monthly_cap": 5000,
"key_credits_used_this_month": 6,
"credits_per_usd": 400,
"min_topup_usd": 10,
"max_topup_usd": 25000,
"top_up_url": "https://mcp.sofya.co/dashboard?topup=10"
}key_monthly_cap and key_credits_used_this_month are about the key you called with; the cap is null when there is none.
Me
Get your account info: credit balance, tier and total requests. Free.
{
"credits": 1994,
"plan_credits": 1994,
"purchased_credits": 0,
"is_free_tier": true,
"credits_reset_at": "2026-11-04T12:00:00Z",
"total_requests": 3,
"api_key": null,
"last_login_method": "github",
"email": "user@example.com",
"email_verified": true,
"github_username": "octocat",
"has_billing_portal": false,
"new_ip_alerts_enabled": true,
"low_balance_alerts_enabled": true,
"low_balance_threshold": null,
"low_balance_threshold_auto": 1000
}creditsTotal spendable balance, plan_credits (free-tier credits) plus purchased_credits.
credits_reset_atWhen free-tier credits next reset to 2,000 (every 30 days). Purchased credits never reset.
api_keyAlways null, unless the signed-in dashboard asks for it.
Transactions
Get your last 50 top-ups and account adjustments, newest first. Per-request charges and refunds are not listed here; use usage for those.
[
{
"id": "uuid",
"type": "credit",
"amount": 4000,
"endpoint": "top-up",
"balance_after": 5994,
"created_at": "2026-10-08T12:00:00Z",
"has_receipt": true,
"amount_usd_cents": 1000
}
]type is credit or debit. amount_usd_cents is what was actually charged (negative for refunds and disputes) and null for adjustments; don't work out dollars from credits, since older top-ups were sold at a different rate. has_receipt is true for paid top-ups.
Usage
Get your daily usage breakdown. Rows are per day, endpoint and API key, newest day first, so one day can have several rows for the same endpoint if you use more than one key. Each row names its key in api_key_id and api_key_name; both are null for rows not tied to a key (older usage, and searches on search.sofya.co).
Query parameters
| Parameter | Type | Description |
|---|---|---|
days | integer | 1-90, default 7. Out-of-range values are clamped. |
offset_days | integer | Default 0. Skip this many recent days, for paging back in time. |
api_key_id | string | Optional. Only count requests made with this key (its id from the dashboard). An id that is not one of your keys returns 404. |
curl "https://mcp.sofya.co/v1/auth/usage?days=30" \
-H "Authorization: Bearer sofya_live_..."[
{
"date": "2026-10-08",
"endpoint": "/v1/search",
"request_count": 3,
"total_credits": 6,
"api_key_id": "4505fa6c-edff-eff6-7aa9-547504df43ee",
"api_key_name": "Production"
}
]Status
Status & monitoring
Per-endpoint health: GET /v1/status (no API key) reports the health of search, fetch, extract and research as measured by Sofya itself.
{
"overall": "operational",
"endpoints": {
"search": { "status": "operational", "latency_ms": 1905.9, "last_check": "2026-10-09T14:00:27+00:00" },
"fetch": { "status": "operational", "latency_ms": 4116.3, "last_check": "2026-10-09T13:59:47+00:00" },
"extract": { "status": "operational", "latency_ms": 14693.6, "last_check": "2026-10-09T13:59:18+00:00" },
"research": { "status": "operational", "latency_ms": 17904.2, "last_check": "2026-10-09T13:59:18+00:00" },
"mcp": { "status": "operational", "latency_ms": null, "last_check": "2026-10-09T13:58:41+00:00" }
},
"incidents": []
}overalloperational, degraded, partial_outage, major_outage, or checking (while checks have not reported yet).
endpoints.*.statusoperational, degraded, down, or unknown. latency_ms is a recent typical latency and may be null (always null for mcp, which reports whether the MCP server is answering). An endpoint can also carry latency_variants (e.g. search snippets vs basic).
incidentsRecent incidents.
Changelog: GET /v1/updates (no API key) returns the list of user-facing changes as JSON.
Website status: status.sofya.co runs on a separate server and checks the website (sofya.co) every 60 seconds, so it keeps reporting even if Sofya itself is down. It does not probe the individual API endpoints; use /v1/status for those. Its feeds are public, no API key needed.
status.sofya.co JSON feeds
- /api/v2/summary.json - Atlassian Statuspage-compatible, for tools that already read Statuspage feeds.
- /api/v2/status.json - Page metadata and overall indicator only. Cheapest poll if you only need up/down.
- /api/status.json - Richer feed: last latency, 24h and 90d uptime, last error.
$ curl https://status.sofya.co/api/v2/status.json
{
"page": { "id": "sofya", "name": "Sofya Status", "url": "https://status.sofya.co", "time_zone": "Etc/UTC", "updated_at": "2026-10-09T14:00:30+00:00" },
"status": {
"indicator": "none",
"description": "All Systems Operational"
}
}The indicator field follows the Statuspage convention: none (operational), minor (degraded), or major (down).