sofya
For agents: docs.mdllms.txtskill.md
Open in Claude Open in ChatGPT

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

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"}'
Response200
{
  "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:

Sign in (no key to paste)

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.

API key in a header

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.

Paste it into a coding agent (Claude Code, Cursor, Codex) and it sets Sofya up, runs one search and shows you a source link.
  1. Open Customize > Connectors (older versions: Settings > Connectors) and choose Add custom connector.
  2. Name: Sofya. URL: https://mcp.sofya.co/mcp. Click Add.
  3. Sign in with your Sofya account and approve. Claude asks for this right away.
  4. 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.

Tools

search1-2 credits

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

fetch1 credit per URL

URLs and documents as clean markdown; failed URLs are free. Takes urls, include_raw_html. Reference

extract10 credits

An AI answer to a question about one page. Takes url, prompt. Reference

research50 credits

A cited report from many searches and sources. Takes query, topic, freshness, max_sources. Reference

get_balancefree

Credits left, and the key's monthly limit if it has one. Same as GET /v1/billing/balance. Reference

buy_creditsfree

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.

Headers
Authorization: Bearer sofya_live_your_key_here
# or
X-API-Key: sofya_live_your_key_here

Exceptions: 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

Fetch

POST /v1/fetch 1 credit per URL MCP tool: 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

ParameterTypeDescription
urlsstring[]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_htmlbooleanDefault false. Also return the page's raw HTML.
Request body
{
  "urls": ["https://example.com"],
  "include_raw_html": false
}
Response200
{
  "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 / error

Each 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 / notice

low_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.

Size limits

5 MB for HTML pages, 50 MB for documents.

raw_html

Null unless include_raw_html is true, and may still be null for non-HTML content (such as documents) and for some sites.

published_time

YYYY-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"]}'

Extract

POST /v1/extract 10 credits MCP tool: 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

ParameterTypeDescription
urlstringRequired. The page to read.
promptstringRequired and not blank (an empty query is a 422, not charged), max 4,096 characters. What to find or answer.
Request body
{
  "url": "https://example.com",
  "prompt": "List all pricing tiers with their monthly prices"
}
Response200
{
  "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"}'

Research

POST /v1/research 50 credits MCP tool: 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

ParameterTypeDescription
querystringRequired and not blank (an empty query is a 422, not charged), max 4,096 characters.
topicstring"general" (default) or "news".
freshnessstringDefault null. "day", "week", "month", "year", or "YYYY-MM-DD:YYYY-MM-DD".
max_sourcesinteger5-30, default 20. Out-of-range values are clamped.
Request body
{
  "query": "How do modern LLMs handle long context?",
  "topic": "general",
  "freshness": null,
  "max_sources": 20
}
Response200
{
  "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?"}'

Errors and billing

Errors

No error is charged, so after any error it is safe to retry once you've fixed the cause.

StatusMeaningWhat to do
400Bad requestA request-level problem the schema can't catch, such as an empty urls list on fetch. Fix the request.
401UnauthorizedThe 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.
402Out of creditsThe 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.
422Invalid parametersA 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.
429Too many requestsOver 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.
502Internal errorSomething failed on our side. Retry.
503BusySearch capacity is briefly unavailable. Retry in a few seconds.
504Timed outThe 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.

401, 429, 5xxstring
{
  "detail": "Invalid API key"
}
422 parameter errorlist
{
  "detail": [
    {
      "type": "literal_error",
      "loc": ["body", "search_depth"],
      "msg": "Input should be 'snippets' or 'basic'"
    }
  ]
}
402 out of creditsstring
{
  "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.

CallCreditsUSD
search (snippets)1$0.0025
search (basic)2$0.005
include_answer+2+$0.005
fetch1 per URL$0.0025 per URL
extract10$0.025
research50$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.

429 Response429
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.

Anthropic tools
[
  {
    "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.

OpenAI tools
[
  {
    "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
# 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

POST /v1/agent/signupPOST /v1/agent/token

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.

Request
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"}'
Response200
{
  "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:

Poll
curl -X POST https://mcp.sofya.co/v1/agent/token \
  -H "Content-Type: application/json" \
  -d '{"claim_token": "clm_..."}'
Waiting400
{
  "error": "authorization_pending",
  "interval": 5
}
Approved200
{
  "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

GET /v1/billing/balancePOST /v1/billing/topup

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.

Request
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}'
Response200
{
  "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).

GET /v1/billing/balance200
{
  "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 /v1/auth/me

Get your account info: credit balance, tier and total requests. Free.

Response200
{
  "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
}
credits

Total spendable balance, plan_credits (free-tier credits) plus purchased_credits.

credits_reset_at

When free-tier credits next reset to 2,000 (every 30 days). Purchased credits never reset.

api_key

Always null, unless the signed-in dashboard asks for it.

Transactions

GET /v1/auth/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.

Response200
[
  {
    "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 /v1/auth/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

ParameterTypeDescription
daysinteger1-90, default 7. Out-of-range values are clamped.
offset_daysintegerDefault 0. Skip this many recent days, for paging back in time.
api_key_idstringOptional. Only count requests made with this key (its id from the dashboard). An id that is not one of your keys returns 404.
Request
curl "https://mcp.sofya.co/v1/auth/usage?days=30" \
  -H "Authorization: Bearer sofya_live_..."
Response200
[
  {
    "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.

GET /v1/status200
{
  "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": []
}
overall

operational, degraded, partial_outage, major_outage, or checking (while checks have not reported yet).

endpoints.*.status

operational, 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).

incidents

Recent 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.
Example
$ 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).