
TLDR: To call the You.com Web Search API from C#, POST a JSON body to https://ydc-index.io/v1/search with your key in the X-API-Key header, then bind results.web into System.Text.Json records. Keep one long-lived HttpClient, treat results.web as optional, retry only 429 and 5xx responses, and fail fast on 401, 402, 403, and 422. This guide gives a complete client, ASP.NET Core wiring, and offline tests.
The You.com docs ship an official Python SDK and list no .NET SDK (checked October 2026), so a C# service calls the documented REST contract directly with HttpClient and System.Text.Json. The contract is small: one endpoint, one header, one JSON body, and one JSON response. What deserves care is everything around it in .NET: client lifetime, JSON binding, status handling, resilience, and tests.
This guide is the C# companion to the cURL guide, which shows the raw HTTP contract, and the Python guide, which covers the official SDK. For the endpoint, limits, and pricing in one place, read the You.com Web Search API reference article; for category-level background, read What Is a Web Search API?
What do you need before you call the Web Search API from C#?
You need an API key from the You.com platform, a project created from a current .NET template, and one environment variable. The docs use YDC_API_KEY as the canonical name, and they authenticate with the X-API-Key header, not Authorization: Bearer. A bearer header fails here, so check the header name first when a call returns 401. Set the variable in the environment of the process that runs the code; in a container, that is not your IDE's shell.
The request is POST https://ydc-index.io/v1/search with a JSON body, and only query is required. GET still works, but it no longer receives new features, and the extraction and knowledge parameters exist on POST only, per the Web Search API overview. POST also keeps long domain lists out of the URL, where length limits apply on GET.
The samples assume implicit usings, which new C# projects get starting in .NET 6 and which cover System.Net.Http, System.Threading, System.Threading.Tasks, and System.Collections.Generic, per Microsoft's SDK overview. The files import only the namespaces outside that set.
How do you make the first request?
Put the models and the client in one file and the entry point in another. A file with top-level statements must place them before any type declaration, or the compiler reports CS8803, as Microsoft's top-level statements documentation explains. Keeping the types in their own file avoids that ordering problem and lets the same file drop unchanged into a console app, an ASP.NET Core project, or a test project. The first file holds the records, an exception type, and the client:
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
public sealed record SearchRequest(
string Query,
int? Count = null,
string? Freshness = null,
[property: JsonPropertyName("include_domains")] List<string>? IncludeDomains = null);
public sealed record SearchResponse(SearchResults? Results, SearchMetadata? Metadata);
public sealed record SearchResults(List<WebResult>? Web, List<NewsResult>? News);
public sealed record WebResult(
string? Url,
string? Title,
string? Description,
List<string>? Snippets,
[property: JsonPropertyName("page_age")] string? PageAge,
WebContents? Contents);
public sealed record NewsResult(
string? Url,
string? Title,
string? Description,
[property: JsonPropertyName("page_age")] string? PageAge);
public sealed record WebContents(List<string>? Highlights, string? Markdown, string? Html);
public sealed record SearchMetadata(
[property: JsonPropertyName("search_uuid")] string? SearchUuid,
string? Query);
public sealed class YdcApiException : Exception
{
public YdcApiException(HttpStatusCode statusCode, string body, TimeSpan? retryAfter)
: base($"You.com API returned HTTP {(int)statusCode}")
{
StatusCode = statusCode;
Body = body.Length > 500 ? body.Substring(0, 500) : body;
RetryAfter = retryAfter;
}
public HttpStatusCode StatusCode { get; }
public string Body { get; }
public TimeSpan? RetryAfter { get; }
}
public sealed class YdcSearchClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly HttpClient _http;
public YdcSearchClient(HttpClient http) => _http = http;
public async Task<SearchResponse> SearchAsync(
SearchRequest request, CancellationToken cancellationToken = default)
{
using var message = new HttpRequestMessage(HttpMethod.Post, "/v1/search")
{
Content = JsonContent.Create(request, options: JsonOptions)
};
using var response = await _http.SendAsync(message, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken);
throw new YdcApiException(response.StatusCode, body, ReadRetryAfter(response));
}
return await response.Content.ReadFromJsonAsync<SearchResponse>(JsonOptions, cancellationToken)
?? throw new InvalidOperationException("The search response had no body.");
}
private static TimeSpan? ReadRetryAfter(HttpResponseMessage response)
{
var header = response.Headers.RetryAfter;
if (header?.Delta is { } delta) return delta;
if (header?.Date is { } date) return date - DateTimeOffset.UtcNow;
return null;
}
}
Five choices in that file carry weight. The snake_case fields (page_age, search_uuid, include_domains) get explicit [property: JsonPropertyName] attributes, which Microsoft documents as the way to apply attributes to positional record parameters. The options use JsonSerializerDefaults.Web because matching is case-sensitive by default, so a member named Url would not bind the JSON field url, and the options instance is static because Microsoft asks you to reuse JsonSerializerOptions instances. WhenWritingNull keeps unset optional parameters out of the body. And the client calls SendAsync with an HttpRequestMessage instead of PostAsJsonAsync: both serialize the request object, and PostAsJsonAsync accepts the same options and cancellation token, but SendAsync keeps the status check, the Retry-After parsing, and the exception in one method.
The entry point builds a client by hand and prints the top five hits for a query limited to the last year:
var apiKey = Environment.GetEnvironmentVariable("YDC_API_KEY");
if (string.IsNullOrWhiteSpace(apiKey))
{
Console.Error.WriteLine("Set YDC_API_KEY before running.");
return 1;
}
using var http = new HttpClient(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2)
})
{
BaseAddress = new Uri("https://ydc-index.io"),
Timeout = TimeSpan.FromSeconds(15)
};
http.DefaultRequestHeaders.Add("X-API-Key", apiKey);
var client = new YdcSearchClient(http);
try
{
var response = await client.SearchAsync(new SearchRequest(
Query: "dotnet httpclient pooledconnectionlifetime",
Count: 5,
Freshness: "year"));
var hits = response.Results?.Web ?? new List<WebResult>();
if (hits.Count == 0)
{
Console.WriteLine("No web results.");
}
foreach (var hit in hits)
{
Console.WriteLine($"{hit.Title}\n {hit.Url}");
}
return 0;
}
catch (YdcApiException ex)
{
Console.Error.WriteLine($"HTTP {(int)ex.StatusCode}: {ex.Body}");
return 2;
}
Two details matter beyond a demo. The SocketsHttpHandler sets PooledConnectionLifetime because HttpClient resolves DNS only when it opens a connection and does not track DNS time-to-live, so a long-lived client without that setting can keep using an address that no longer serves the host. And the explicit 15-second timeout replaces the 100-second default. Fifteen seconds is an example value, not a measured latency or a service guarantee.
Which request and response fields should the records declare?
The request record covers the parameters most services use. count is the maximum number of results per section (web and news), from 1 to 100 with a default of 10. freshness takes day, week, month, year, or a YYYY-MM-DDtoYYYY-MM-DD range, and the date filter guide covers how a time phrase inside the query interacts with it. The domain filters are JSON arrays: include_domains is a strict allowlist, exclude_domains removes domains, and boost_domains raises matching domains without excluding others. Combining include_domains with exclude_domains or boost_domains returns a 422, so reject that combination in your own code. The request controls page lists every option and its limits.
| JSON path | C# member | What to know |
|---|---|---|
results.web | SearchResults.Web | Optional. Declare it nullable. The docs tell you to check it before iterating. |
results.news | SearchResults.News | Optional. The service decides from query intent whether to include news results. |
url, title, description | string? | None is marked required in the OpenAPI schema, so every member is nullable. |
snippets | List<string>? | Short keyword-centered fragments, returned by default. Omitted when you request highlights. |
contents.highlights, markdown, html | WebContents | Present only when the request sets extraction. Highlights are query-relevant passages; markdown and html carry the whole page. |
page_age | string? | For news, a UTC publication timestamp. For web results the spec says only "the age of the search result." The docs' examples show no UTC offset, so the sample keeps a string. |
metadata.search_uuid | string? | An identifier returned with each search. Log it next to the query. |
Declare every member as nullable. Neither results nor results.web is marked required in the OpenAPI schema, and the guide tells you to check before iterating, so a response with no results key, or with results but no web, is a shape to plan for. A non-nullable member turns it into an exception or a NullReferenceException far from the cause, and the offline tests below pin both cases. To add extraction, declare a nested record with extraction_mode and an optional full_page object; the retrieve page content guide documents the options, and the JSON response guide covers parsing pitfalls.
Which status codes need handling, and which are worth retrying?
The docs give each status a specific meaning in this API, and the retry decision follows from it. The client throws YdcApiException for any non-success status, with the status code, a truncated body, and the parsed Retry-After value, so the caller decides. The error reference and rate limits page are the sources for this table.
| Status | What it means here | Retry? |
|---|---|---|
| 401 | Missing, expired, or revoked key, or the wrong header name. Bodies read "API key is required" or "Invalid or expired API key". | No. Fix configuration. |
| 402 | Payment required. For keyed access the body reads "Insufficient credits". | No. Add credits. |
| 403 | The key lacks scope for the path ("Missing required scopes"), or the path is on the wrong host ("Missing Authentication Token"). | No. Fix the key or the host. |
| 422 | Invalid parameter combination, such as include_domains with exclude_domains. | No. Fix the request. |
| 429 | Over the per-second limit. The default for self-serve accounts is 10 requests per second on /v1/search, as of October 2026. | Yes, after Retry-After when present, with backoff. |
| 500 | Server error. It also covers authentication middleware failures, with bodies such as "Internal authentication error". | Yes, a few times with backoff. If it persists, check the key and contact support. |
Two rows deserve a closer look. A 403 whose body says Missing Authentication Token means the path does not exist on that host: Web Search and Contents run on ydc-index.io, while the Answer, Research, and Finance Research APIs run on api.you.com, so check the host first if you share one BaseAddress across several You.com clients. And a 401 is almost always configuration, so fail fast on it. A retry loop that treats an authentication failure as transient spends its whole backoff schedule on a problem that waiting cannot fix.
Do not forward these statuses unchanged to your own callers. When You.com returns 401 or 402, your caller's credentials are fine and your service's are not, so map those to a 502 Bad Gateway or 503 Service Unavailable and alert your team (RFC 9110 defines both in sections 15.6.3 and 15.6.4). The client reads Retry-After through RetryConditionHeaderValue because the value can be a number of seconds or an HTTP date, per section 10.2.3 of that RFC, and the class exposes both as Delta and Date.
Timeouts and transport failures arrive as exceptions, not status codes. Per Microsoft's HttpClient documentation, a failed request throws HttpRequestException, and an expired HttpClient.Timeout throws TaskCanceledException with a TimeoutException as its inner exception, while a canceled token is a separate case. Log these separately from YdcApiException, because "the API said no" and "the API did not answer" lead to different fixes.
How do you register the client in ASP.NET Core?
In a service, register the client through IHttpClientFactory as a typed client. Microsoft recommends either a long-lived client with PooledConnectionLifetime or short-lived clients from the factory, and suggests the typed-client approach when you want the factory's configurability. The factory recycles handlers on a default two-minute lifetime, which does the DNS job that PooledConnectionLifetime did in the console sample.
var builder = WebApplication.CreateBuilder(args);
var apiKey = builder.Configuration["YDC_API_KEY"]
?? throw new InvalidOperationException("YDC_API_KEY is not configured.");
builder.Services
.AddHttpClient<YdcSearchClient>(client =>
{
client.BaseAddress = new Uri("https://ydc-index.io");
client.DefaultRequestHeaders.Add("X-API-Key", apiKey);
})
.RedactLoggedHeaders(new[] { "X-API-Key" })
.AddStandardResilienceHandler();
var app = builder.Build();
app.MapGet("/search", async (string q, YdcSearchClient search, CancellationToken ct) =>
{
var response = await search.SearchAsync(new SearchRequest(q, Count: 5), ct);
return Results.Ok(response.Results?.Web ?? new List<WebResult>());
});
app.Run();
Four calls do real work. builder.Configuration["YDC_API_KEY"] reads an environment variable in a container and user secrets on a development machine, because ASP.NET Core's default configuration loads both. The registration lambda sets BaseAddress and the key header once. RedactLoggedHeaders redacts the X-API-Key value before the factory logs headers. And AddStandardResilienceHandler, from the Microsoft.Extensions.Http.Resilience package (dotnet add package Microsoft.Extensions.Http.Resilience), adds retries, timeouts, and a circuit breaker.
Know what that handler does before you trust it. Per Microsoft's resilience documentation, the defaults are a 30-second total timeout that includes retries, up to three retries with exponential backoff and jitter, a 10-second timeout per attempt, and a circuit breaker. It retries HTTP 500 and above, 408, and 429, plus HttpRequestException and timeout exceptions, and the ShouldRetryAfterHeader reference says it uses the Retry-After header for the retry delay by default, which matches the You.com guidance. It retries every HTTP method by default, which is acceptable here because a search POST only reads. A retry that succeeds is another call on the bill ($5.00 per 1,000 as of October 2026), so keep the retry count low.
Two interactions deserve a test. The handler's rate limiter caps concurrent requests at 1,000 by default; it does not limit requests per second, so it does nothing for the 10 requests per second default on /v1/search. Cap your own fan-out, for example with a SemaphoreSlim, and rely on the 429 path for the rest. Second, a 10-second attempt timeout can cut short a call that requests full_page extraction with a crawl_timeout above 10, so raise options.AttemptTimeout and options.TotalRequestTimeout together when you turn extraction on.
One lifetime rule remains. The factory documentation warns that a typed client captured in a singleton service can stop reacting to DNS changes, because the factory has no control over a client once it exists. Inject YdcSearchClient into transient or scoped services, or into endpoint handlers as above, and not into singletons.
How do you test the client without calling the API?
Subclass HttpMessageHandler, return canned responses, and hand the handler to a real HttpClient. The client code runs unchanged, and no key, network, or billing is involved. Each live call is billed, so keep live calls out of the default test run and in a separate, manually triggered smoke test.
using System.Net;
using System.Net.Http.Headers;
using System.Text;
var failures = 0;
void Check(string name, bool passed)
{
Console.WriteLine($"{(passed ? "PASS" : "FAIL")} {name}");
if (!passed) failures++;
}
(YdcSearchClient Client, StubHandler Stub) Build(
HttpStatusCode status, string json, TimeSpan? retryAfter = null)
{
var stub = new StubHandler(() =>
{
var reply = new HttpResponseMessage(status)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
if (retryAfter is { } wait)
{
reply.Headers.RetryAfter = new RetryConditionHeaderValue(wait);
}
return reply;
});
var http = new HttpClient(stub) { BaseAddress = new Uri("https://ydc-index.io") };
return (new YdcSearchClient(http), stub);
}
// Request shape: POST /v1/search, optional parameters sent only when set.
var (shapeClient, shapeStub) = Build(HttpStatusCode.OK, "{}");
await shapeClient.SearchAsync(new SearchRequest(
"q", IncludeDomains: new List<string> { "example.com" }));
Check("posts to /v1/search",
shapeStub.Method == HttpMethod.Post && shapeStub.Path == "/v1/search");
Check("sends include_domains in snake_case",
shapeStub.Body!.Contains("\"include_domains\":[\"example.com\"]"));
Check("omits unset freshness", !shapeStub.Body!.Contains("freshness"));
// Response shapes: results and results.web are both optional.
var (noResultsClient, _) = Build(HttpStatusCode.OK, "{}");
var noResults = await noResultsClient.SearchAsync(new SearchRequest("q"));
Check("missing results is null, not a crash", noResults.Results is null);
var (noWebClient, _) = Build(HttpStatusCode.OK, "{\"results\":{\"news\":[]}}");
var noWeb = await noWebClient.SearchAsync(new SearchRequest("q"));
Check("missing web is null", noWeb.Results?.Web is null);
var (bindClient, _) = Build(HttpStatusCode.OK,
"{\"results\":{\"web\":[{\"url\":\"https://example.com\",\"title\":\"T\"," +
"\"page_age\":\"2026-01-01T00:00:00\"}]}}");
var bound = await bindClient.SearchAsync(new SearchRequest("q"));
Check("page_age binds to PageAge",
bound.Results?.Web?[0].PageAge == "2026-01-01T00:00:00");
// Errors: a 429 carries its Retry-After value.
var (limitedClient, _) = Build(
HttpStatusCode.TooManyRequests, "{}", TimeSpan.FromSeconds(3));
try
{
await limitedClient.SearchAsync(new SearchRequest("q"));
Check("429 throws YdcApiException", false);
}
catch (YdcApiException ex)
{
Check("429 exposes Retry-After",
ex.StatusCode == HttpStatusCode.TooManyRequests
&& ex.RetryAfter == TimeSpan.FromSeconds(3));
}
return failures;
public sealed class StubHandler : HttpMessageHandler
{
private readonly Func<HttpResponseMessage> _respond;
public StubHandler(Func<HttpResponseMessage> respond) => _respond = respond;
public HttpMethod? Method { get; private set; }
public string? Path { get; private set; }
public string? Body { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Method = request.Method;
Path = request.RequestUri?.AbsolutePath;
Body = request.Content is null
? null
: await request.Content.ReadAsStringAsync(cancellationToken);
return _respond();
}
}
The fixtures matter more than the stub. The empty object and the results object with no web key are the shapes that crash code written only against the happy-path response. The snake_case fixture proves that the page_age attribute binds, and the 429 fixture pins the Retry-After parsing. These checks verify how your client handles documented response shapes. They say nothing about relevance, latency, or current pricing, so keep a small set of live queries that you judge by hand.
What breaks in production?
Per-request clients exhaust ports. Creating and disposing an HttpClient for every call leaves TCP connections waiting to close, and under load the operating system can run out of ports, per Microsoft's guidelines. Detection: new HttpClient() inside the request method, and connection errors that rise with traffic.
A null Web list. Code that reads response.Results.Web.Count works in the demo and fails on the first query with no web results. Detection: a NullReferenceException that appears only for rare queries, which the empty-object and no-web fixtures reproduce in a test.
Cache keys that ignore parameters. The overview suggests caching frequent queries for 5 to 15 minutes. Key the cache on the whole serialized request, not the query string alone, or a result fetched with freshness set to day is served to a caller who asked for year, and a result scoped by include_domains leaks into unscoped calls. Detection: identical queries with different parameters returning identical results. At $5.00 per 1,000 calls (billing page, as of October 2026), one million uncached calls a month cost $5,000, and a cache that absorbs 30% of them, an assumed hit rate you should measure, brings that to $3,500.
The key in logs. Keep the key in an environment variable or a secrets manager, never in source, as the authentication page says, and redact the header in factory logging as shown above. YdcApiException carries only the status, a body truncated to 500 characters, and the retry delay, so request headers never enter an exception message. Detection: search your log sink for the header name.
Fan-out that trips the rate limit. The limit is per second, and every response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers that the docs tell you to read so you can throttle before a 429. As a sizing heuristic, if a call takes about half a second (an assumption, so measure yours), five concurrent calls land near 10 requests per second. Detection: bursts of 429s that line up with batch jobs.
Related Guides
LI Test
LI Test
Share Article:





