Blog
 / 
API
October 5, 2026

How to Use the You.com Research API in Python

How to Use the You.com Research API in Python

TLDR: Call the You.com Research API from Python with one POST to https://api.you.com/v1/research. Send input and research_effort with your key in the X-API-Key header, then read the cited Markdown from output.content and its sources from output.sources. Frontier requires background mode; for every tier, size the timeout to the tier and check the citations before you ship an answer.

This guide builds a small Python client for the Research API with only the standard library, then covers what production needs after the first call: per-tier timeouts, background tasks, source control, structured output, retries, and a citation check. The Research API reads sources across the web and returns one cited answer. A search API returns ranked results and leaves assembly to your code, the job covered in Web Search API in Python.

Neighboring guides cover other angles: What Is a Research API? helps you decide whether you need one, Deep Research API explains the research loop and compares offerings, and Building with the Research API is a short SDK walkthrough with a sample app. This page is the Python integration reference: the client, the failure handling, and the tests.

How do you make your first Research API call from Python?

The Research API is a POST to https://api.you.com/v1/research with a JSON body and your key in the X-API-Key header. Create a key on the You.com platform and keep it in YDC_API_KEY, the variable name the Research API quickstart and the SDK use. The body needs one field, input, which holds the question and accepts up to 40,000 characters. research_effort picks one of five tiers: lite, standard (the default), deep, exhaustive, or frontier.

Mind the host. Research, Answer, and Finance Research run only on api.you.com, while Web Search and Contents run on ydc-index.io. A Research request sent to ydc-index.io returns 403 with the body Missing Authentication Token even when the key is valid, so that error means a wrong host, not a wrong key, as the error reference explains. The first block is the whole transport layer: one function, one error type that keeps the status code and the Retry-After header, and no dependencies. Save the function-defining blocks in this guide, in order, as one file named research_client.py.

import json
import os
import time
import urllib.error
import urllib.request

BASE_URL = "https://api.you.com"  # Research runs on api.you.com, not ydc-index.io


class ResearchError(Exception):
    """An HTTP error response, carrying what a retry decision needs."""

    def __init__(self, status, detail, retry_after=None):
        super().__init__(f"HTTP {status}: {detail}")
        self.status = status
        self.detail = detail
        self.retry_after = retry_after


def call(method, path, body=None, timeout=60):
    headers = {"X-API-Key": os.environ["YDC_API_KEY"], "Accept": "application/json"}
    data = None
    if body is not None:
        data = json.dumps(body).encode("utf-8")
        headers["Content-Type"] = "application/json"
    request = urllib.request.Request(BASE_URL + path, data=data, headers=headers, method=method)
    try:
        with urllib.request.urlopen(request, timeout=timeout) as response:
            return json.load(response)
    except urllib.error.HTTPError as err:
        detail = err.read().decode("utf-8", "replace")[:500]
        raise ResearchError(err.code, detail, err.headers.get("Retry-After")) from None

A synchronous call holds the connection until the answer is ready, so the client timeout must exceed the tier's ceiling. The billing page says longer than 120 seconds for deep and longer than 300 seconds for exhaustive. The next block sets a timeout per tier, with headroom that is a choice rather than a documented value, and refuses frontier locally because a synchronous frontier request returns 422.

# Documented ceilings: lite under 10s, standard about 10 to 30s, deep under 120s,
# exhaustive under 300s. These timeouts add headroom; the headroom is a choice.
SYNC_TIMEOUT_S = {"lite": 30, "standard": 60, "deep": 150, "exhaustive": 330}


def research(question, effort="standard", source_control=None, output_schema=None):
    """Synchronous call for lite through exhaustive. Returns the parsed JSON body."""
    if effort == "frontier":
        raise ValueError("frontier requires background mode; use submit() and wait()")
    body = {"input": question, "research_effort": effort}
    if source_control:
        body["source_control"] = source_control
    if output_schema:
        body["output_schema"] = output_schema
    return call("POST", "/v1/research", body, timeout=SYNC_TIMEOUT_S.get(effort, 330))

What does the response contain?

The response has one top-level object, output, with three fields. content is the answer: Markdown with inline citations such as [[1, 2]], or a JSON object when you pass an output_schema. content_type says which, text or object. sources lists the pages the API read and cited, each with a url and, optionally, a title and snippets, the excerpts it used. The API reference also defines a warnings list, empty when nothing went wrong, for source access problems or partial results.

Citation markers are one-based positions in sources: the documented example cites [[1]] through [[4]] against four sources. Keep the answer, sources, and warnings together in one record per request, so any citation can be resolved later and a partial result is never mistaken for a clean one.

def render(result):
    """Print warnings, the Markdown answer, and a numbered source list."""
    output = result["output"]
    for warning in result.get("warnings") or []:
        print("warning:", warning)
    print(output["content"])
    for number, source in enumerate(output["sources"], start=1):
        print(f"[{number}] {source.get('title') or 'Untitled'} {source['url']}")

Which research_effort tier should you use?

The tier is the main cost and latency lever. The table combines the Research API overview and the billing page as of October 2026, with per-call prices computed from the per-1,000 prices. Recheck the billing page before you budget, because prices and latencies change.

TierPrice per 1,000 callsPrice per callDocumented latencyBackground modeSynchronous timeout used here
lite$12$0.012under 10 secondsoptional30 seconds
standard (default)$50$0.05about 10 to 30 secondsoptional60 seconds
deep$100$0.10under 120 secondsoptional150 seconds
exhaustive$450$0.45under 300 secondsoptional330 seconds
frontier$1,200$1.2030 to 12,000 seconds, median 300requirednot applicable

Across 2,000 questions, lite costs $24, standard $100, deep $200, exhaustive $900, and frontier $2,400. The docs advise saving deep, exhaustive, and frontier for questions where thoroughness justifies the longer wait.

Start at standard, which the docs call balanced for most production use, then move question classes up or down based on your own evaluation: run a sample of real questions at two adjacent tiers and compare what the answers cover and cite. A higher tier cannot rescue a vague input, and the docs note that a well-scoped question produces a more focused answer.

How do you run long research in the background?

Set background to true and the call returns a task handle instead of the answer, with task_id, type, status (queued), stream_url, and created_at. A task moves from queued to running and ends as completed, failed, or cancelled. Frontier requires this mode, and the Background Requests guide recommends it for deep and exhaustive work that can outlast a client timeout.

Poll GET /v1/research/{task_id}. While the task is queued or running, result is null. When status reaches completed, result holds the same output and warnings structure a synchronous call returns, so downstream code does not care which path produced it. On failure, status is failed and error carries a diagnostic message. Two details trip people up: the submit response calls the identifier task_id while the poll response calls it id, and the answer sits under result, not at the top level of the task status response.

def submit(question, effort="deep", **options):
    """Queue a task and return its id. Pass source_control= or output_schema= as needed."""
    body = {"input": question, "research_effort": effort, "background": True}
    body.update(options)
    return call("POST", "/v1/research", body, timeout=30)["task_id"]


def wait(task_id, interval_s=5.0, deadline_s=900.0):
    """Poll until the task finishes. Returns result, shaped like a synchronous response."""
    deadline = time.monotonic() + deadline_s
    while True:
        task = call("GET", f"/v1/research/{task_id}", timeout=30)
        status = task["status"]
        if status == "completed":
            return task["result"]
        if status in ("failed", "cancelled"):
            raise RuntimeError(f"task {task_id} {status}: {task.get('error')}")
        if time.monotonic() >= deadline:
            raise TimeoutError(f"task {task_id} still {status} after {deadline_s:.0f}s")
        time.sleep(interval_s)

Size the deadline per tier. The defaults here, a 5 second interval and a 900 second deadline, suit deep and exhaustive. Frontier's documented range runs to 12,000 seconds, so give it more. Persist task_id the moment submit returns: the task lives on the server, so a worker that dies mid-wait can resume polling, while a dead synchronous call leaves nothing to resume.

question = "Compare the pricing models of the top 3 vector databases for a 10M-document collection"
task_id = submit(question, effort="deep")
print("queued", task_id)  # persist this id before you wait
render(wait(task_id, interval_s=5, deadline_s=600))

To push instead of poll, GET /v1/research/{task_id}/stream returns Server-Sent Events. It opens with a connected event, closes at a terminal status, and accepts from_id to replay events after a reconnect. Fetch the full result with a final GET. Submissions count against the default 10 requests per second on /v1/research, per the rate limits page.

How do you control sources and output shape?

source_control is a beta object that constrains where the research agent searches and browses. The Source Control guide and the API reference define five fields.

FieldBehaviorLimits
include_domainsStrict allowlist. Results come only from these domains.Up to 500 domains. Not with exclude_domains or boost_domains.
exclude_domainsNever returns these domains, and blocks the agent from visiting them while it browses.Up to 500 domains. Not with include_domains.
boost_domainsRanking preference, not a filter. The agent may still choose other sources.Up to 500 domains. Not with include_domains.
freshnessRecency filter: day, week, month, year, or a YYYY-MM-DDtoYYYY-MM-DD range.One value.
countryGeographic focus as an ISO 3166-1 alpha-2 code such as US, GB, or DE.One value.

A conflicting combination returns 422, a bug in the request rather than a transient fault. The builder below rejects conflicts and oversized lists locally, so they fail in a unit test instead of in production.

def build_source_control(include=None, exclude=None, boost=None, freshness=None, country=None):
    """Build a source_control object and reject the combinations the API answers with 422."""
    if include and (exclude or boost):
        raise ValueError("include_domains cannot be combined with exclude_domains or boost_domains")
    for name, domains in (("include", include), ("exclude", exclude), ("boost", boost)):
        if domains and len(domains) > 500:
            raise ValueError(f"the {name} domain list is capped at 500 entries")
    fields = {
        "include_domains": include,
        "exclude_domains": exclude,
        "boost_domains": boost,
        "freshness": freshness,  # day, week, month, year, or YYYY-MM-DDtoYYYY-MM-DD
        "country": country,      # ISO 3166-1 alpha-2, for example US or DE
    }
    return {key: value for key, value in fields.items() if value}

output_schema asks for typed JSON instead of Markdown. It is a beta parameter that works with standard, deep, exhaustive, and frontier, and returns 422 with lite. The object arrives in output.content with content_type set to object, and sources still arrive in output.sources. The API does not add citation fields to your object automatically.

The schema follows a narrow JSON Schema subset. The root must be an object. Every object needs properties and additionalProperties set to false, and every property must appear in required. To make a value optional, keep it required and make it nullable with a type such as ["string", "null"], and the API returns null instead of guessing. Recursive schemas and keywords such as allOf, pattern, and minimum are not supported. The Structured Output guide lists every rule and limit, and a schema that breaks one is rejected with 422 before any model runs.

# Every property is required. A value that may be unknown is nullable, not optional.
VERDICT_SCHEMA = {
    "type": "object",
    "properties": {
        "verdict": {"type": "string", "enum": ["supported", "mixed", "unsupported"]},
        "summary": {"type": "string"},
        "primary_source": {"type": ["string", "null"]},
    },
    "required": ["verdict", "summary", "primary_source"],
    "additionalProperties": False,
}

The two controls compose in one request, and both work in background mode. This snippet scopes a question to three publishers for the last year and asks for a verdict object.

scope = build_source_control(include=["nature.com", "arxiv.org", "science.org"], freshness="year")
result = research(
    "Has room-temperature superconductivity been independently replicated?",
    effort="standard",
    source_control=scope,
    output_schema=VERDICT_SCHEMA,
)
answer = result["output"]["content"]  # a dict, because content_type is "object"
print(answer["verdict"], answer["primary_source"])

How do you verify citations before using an answer?

The docs tell teams in legal, financial, or medical contexts to build a step that follows citation URLs and confirms claims before an answer reaches users. Build it in two layers.

The first layer is mechanical, and the next block implements it: every [[n]] marker must resolve to an entry in sources, and a paragraph that states numbers should carry a marker. A failure means the answer is malformed on its face, so retry once, move up a tier, or route it to review. The pattern matches the [[1, 2]] form the docs show and skips structured output, which has no inline markers.

import re

MARKER = re.compile(r"\[\[\s*(\d+(?:\s*,\s*\d+)*)\s*\]\]")


def check_citations(output):
    """Structural check of a text answer. It proves markers resolve, not that claims are true."""
    if output.get("content_type") != "text":
        return []  # structured output carries no inline markers
    source_count = len(output.get("sources") or [])
    problems = []
    for position, paragraph in enumerate(re.split(r"\n\s*\n", output["content"]), start=1):
        text = paragraph.strip()
        if not text or text.startswith("#"):
            continue
        numbers = [int(n) for group in MARKER.findall(text) for n in group.split(",")]
        for n in numbers:
            if not 1 <= n <= source_count:
                problems.append(f"paragraph {position} cites [[{n}]]; only {source_count} sources returned")
        if not numbers and re.search(r"\d", text):
            problems.append(f"paragraph {position} states numbers without a citation")
    return problems

The check proves each marker points at a returned source, not that the claim is true, so never report a green result as verification. The second layer reads the cited pages. Fetch each cited URL, for example with the Contents API, which returns clean page content for URLs you already hold, and confirm the claim appears in the text. Each source's snippets, the excerpts the API used, give a reviewer the passage to compare first. Send borderline cases to a human or a judge model with the page text beside the claim.

How do you handle errors, rate limits, and retries?

Most Research API failures are request bugs, and the status code tells you which kind. The table follows the error reference and the endpoint pages.

StatusMeaningWhat to do
401Missing, invalid, or expired API key.Fix the configuration. Do not retry.
402Out of credits on a keyed account.Add credits on the platform. Do not retry.
403The key lacks scope for the path, or the body says Missing Authentication Token (wrong host).Check scopes and the host. Do not retry.
422Invalid request: conflicting source controls, a schema with lite, a bad schema, or synchronous frontier.Fix the request and read detail. Do not retry.
429Rate limit exceeded.Wait for Retry-After when present, otherwise back off, then retry.
500Internal error, including authentication middleware failures.Retry a few times, then contact support if it persists.

The default limit for self-serve accounts is 10 requests per second on /v1/research, background submissions included, and enterprise contracts set their own. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset, a Unix timestamp. The rate limits page recommends reading them to throttle before a 429, and honoring Retry-After when it is present.

def with_retries(operation, attempts=4):
    """Retry 429 and 5xx with backoff. Other 4xx errors are request bugs, so raise at once."""
    for attempt in range(attempts):
        try:
            return operation()
        except ResearchError as err:
            retryable = err.status == 429 or err.status >= 500
            if not retryable or attempt == attempts - 1:
                raise
            try:
                delay = float(err.retry_after)
            except (TypeError, ValueError):
                delay = min(2 ** attempt, 60)
            time.sleep(delay)

The helper retries only 429 and 5xx, honors a numeric Retry-After, and otherwise uses capped exponential backoff. It never retries another 4xx, because repeating a bad request repeats the failure. Keep attempts low on expensive tiers, and do not loop on a client-side read timeout from a synchronous call: the client cannot tell whether the run finished. Move that question class to background mode, where the task id gives you something to ask about.

The last block is the entry point that ties the client together: retries, rendering, and the citation check. Run python research_client.py "your question" with YDC_API_KEY set.

def main(argv):
    question = " ".join(argv) or "How do RISC-V and ARM differ in licensing and extensibility?"
    result = with_retries(lambda: research(question, "standard"))
    render(result)
    for problem in check_citations(result["output"]):
        print("citation check:", problem)


if __name__ == "__main__":
    import sys

    main(sys.argv[1:])

Should you use the Python SDK instead?

The official SDK, youdotcom, covers the same endpoints with typed models and is published on PyPI. Version 3.5.0, released September 22, 2026, requires Python 3.10 or later and reads YDC_API_KEY from the environment, as the Python SDK page documents. Pin the version and rerun your tests before upgrading. Choose the SDK when you want typed responses that track new API fields, and the standard-library client when you want one dependency-free file with explicit control of timeouts and retries.

# pip install youdotcom==3.5.0   (Python 3.10 or later)
from youdotcom import You
from youdotcom.models import ResearchEffort

# Set timeout_ms: without it the HTTP client's default of 5 seconds applies.
with You(timeout_ms=60_000) as you:  # reads YDC_API_KEY from the environment
    response = you.research(
        input="How do RISC-V and ARM differ in licensing and extensibility?",
        research_effort=ResearchEffort.STANDARD,
    )
    print(response.output.content)
    for number, source in enumerate(response.output.sources, start=1):
        print(f"[{number}] {source.title or 'Untitled'} {source.url}")
    for warning in response.warnings:
        print("warning:", warning)

The setting people miss is the timeout. Without timeout_ms, the client falls back to the HTTPX default of five seconds, which is shorter than a typical standard-tier answer. In an offline run against a mock that answered after six seconds, a call without timeout_ms failed with a ReadTimeout after 5.0 seconds.

For background work, 3.5.0 ships helpers in youdotcom.research_helpers. research_background submits a task, poll_research_task polls it with a default 2 second interval and 600 second timeout, and research_and_wait does both, defaulting to 600 seconds, or 14,400 for frontier. Failed or cancelled tasks raise RuntimeError, and an elapsed timeout raises TimeoutError. On a task, result.output is a plain dict, so read output["content"] and output["sources"] rather than using attribute access.

What should you test before shipping?

Test the client without paying for calls. The code blocks were syntax-checked, and the client blocks, assembled into one module, passed 28 unit tests on Python 3.9 against a local mock server that returns the documented response shapes: synchronous and structured answers, tasks that complete, fail, cancel, or stall, and 401, 403, 422, 429, and 500 errors. A mutation check confirmed the tests fail when the retry, conflict, citation, or result-handling logic breaks. The SDK snippet ran against the same mock, also on Python 3.9, below the SDK's stated minimum, so read that as a check of the call shape rather than of version support. No real key and no live call were used.

That verifies client logic, not live latency, answer quality, citation accuracy, billing, or rate limits. Run an authorized test against each tier you plan to use before you rely on these timeouts, then work through this list:

  • Timeouts: keep every synchronous timeout above the documented ceiling, and send frontier only in background mode.
  • Task ids: persist them before you wait, and log warnings next to the answer.
  • Controls: unit-test the conflicting source_control combinations, and keep one fixture for each content_type.
  • Citations: run the structural check on every text answer, and sample answers for the second-layer read.
  • Cost: multiply expected calls by the tier price, and recheck the billing page, because prices and latencies change.

Related Guides

    Share Article:

  1. LI Test

  2. LI Test

Related resources.

How to Feed Real-Time Data to an LLM with the You.com Web Search API

October 9, 2026

Blog

How to Parse Search Results JSON in Python with the You.com Web Search API

October 9, 2026

Blog

How to Retrieve Version-Specific Library Docs with the You.com Web Search and Contents APIs

October 9, 2026

Blog

How to Work Around LLM Knowledge Cutoffs with the You.com Web Search API

October 9, 2026

Blog

How to Get Search Results Programmatically with the You.com Web Search API

October 8, 2026

Blog