The HTTP QUERY method is the fix for a gap every API developer has run into: needing to send complex, structured filters to a read-only endpoint without breaking caching, retries, or REST semantics. If you’ve been building REST APIs for more than a few years, you’ve hit this wall: you need to retrieve data with complex filtering criteria, but you can’t express it cleanly with HTTP verbs as they currently stand.
Let me be direct about what happens in practice. You’re designing an endpoint that searches products. The client needs to send:
You’ve got three doors to walk through, and none of them feel right.
Go with GET. It’s safe, idempotent, and every caching layer between you and the client understands it. The problem? HTTP has a foundational constraint: GET requests should not have a request body.
So you stuff everything into query parameters. Your URL balloons. What starts as GET /api/products?name=widget becomes:
GET /api/products?name=widget&minPrice=10&maxPrice=500&categories=electronics,software&excludeVendors=vendor1,vendor2,vendor3&tags=premium,enterprise®ionBoosts=US:1.5,EU:1.2&dateRange=2024-01-01T00:00:00Z,2024-12-31T23:59:59Z&pageSize=50&pageNumber=3
Ugly? Sure. But worse than that:
Post the query as a JSON body instead. Get past the URL length problem. Clean, structured payloads. Everyone can read it.
But now you’ve violated the contract of POST. POST is for creating or modifying state. Proxies, CDNs, and load balancers refuse to cache POST responses by default. If a network hiccup occurs midway through your request, automated retry logic won’t touch it because POST is not idempotent by definition.
Your application can’t safely retry a POST without risking data duplication. So you lose a major reliability pattern that the web was built on.
And that’s just the tooling perspective. Semantically, you’re lying to downstream systems about what your endpoint does. It’s safe, it’s read-only, it’s repeatable—but you’re wrapped it in a method that says “this mutates state.” Good luck explaining to junior developers why your read endpoint uses POST.
I’ve seen teams solve this every wrong way:
All of these exist because HTTP didn’t have a verb that said: “I’m reading, this is safe, you can cache me, you can retry me automatically—and I’ve got a payload.”
In 2026, we got it. The standards bodies finally formalized what should have existed all along: an HTTP method that embodies query semantics natively.
The QUERY method is defined with these properties:
This isn’t revolutionary. It’s the HTTP method that should have been in RFC 7231 in the first place. But now that it exists, implementations across frameworks are starting to materialize. ASP.NET Core is in a position to support it cleanly, and here’s how.
ASP.NET Core’s routing and attribute system is extensible enough to support custom HTTP methods without hacks. Let’s build this properly.
If you’re planning a broader modernization of your .NET stack alongside this change, it’s worth folding it into your wider Microsoft solutions roadmap rather than shipping it in isolation.
First, we need a routing attribute that ASP.NET Core recognizes as a valid HTTP method descriptor. The framework looks for implementations of IHttpMethodMetadata, which tells the routing system “this endpoint handles these HTTP verbs.”
using Microsoft.AspNetCore.Mvc.Routing;
/// <summary>
/// Marks an action as handling the HTTP QUERY method.
/// QUERY is a safe, idempotent method that accepts a request body,
/// designed for complex filtering and search operations.
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
public class HttpQueryAttribute : Attribute, IHttpMethodMetadata
{
/// <summary>
/// Gets the HTTP methods this metadata applies to.
/// </summary>
public IEnumerable<string> HttpMethods => new[] { “QUERY” };
/// <summary>
/// Gets the order in which this metadata is applied during routing.
/// </summary>
public int Order => 0;
/// <summary>
/// Gets the route template for this endpoint (optional).
/// </summary>
public string? Template { get; }
public HttpQueryAttribute(string? template = null)
{
Template = template;
}
}
This is lightweight and follows the same pattern ASP.NET Core uses internally for [HttpGet], [HttpPost], etc. The framework now knows that when a request comes in with the QUERY verb, it should consider endpoints decorated with this attribute as valid matches.
Now let’s build a realistic search endpoint. Not a toy product filter—something that reflects actual business logic:
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route(“api/v1/products”)]
public class ProductQueryController : ControllerBase
{
private readonly IProductQueryService _queryService;
private readonly ILogger<ProductQueryController> _logger;
public ProductQueryController(
IProductQueryService queryService,
ILogger<ProductQueryController> logger)
{
_queryService = queryService;
_logger = logger;
}
/// <summary>
/// Searches products using complex filter criteria.
///
/// The QUERY method is used here because:
/// – The request carries a substantial JSON payload (multiple filters)
/// – The operation is read-only and safe
/// – Results are cacheable for performance
/// – The operation is idempotent (can be retried safely)
/// </summary>
[HttpQuery(“search”)]
[ProduceResponseType(typeof(PagedSearchResult<ProductDto>), StatusCodes.Status200OK)]
[ProduceResponseType(StatusCodes.Status400BadRequest)]
[ProduceResponseType(StatusCodes.Status415UnsupportedMediaType)]
public async Task<IActionResult> SearchProducts(
[FromBody] ProductSearchQuery query,
CancellationToken cancellationToken)
{
// Validate content type strictly per RFC 10008 semantics.
// QUERY endpoints should enforce structured payloads.
if (!Request.HasJsonContentType())
{
_logger.LogWarning(
“Search request rejected: unsupported content type {ContentType}”,
Request.ContentType);
return StatusCode(
StatusCodes.Status415UnsupportedMediaType,
new { error = “Content-Type must be application/json” });
}
// Validate the query object itself
if (!ModelState.IsValid)
{
_logger.LogWarning(
“Search request failed validation: {Errors}”,
ModelState.Values.SelectMany(v => v.Errors));
return BadRequest(ModelState);
}
try
{
// Execute the query through your service layer
var results = await _queryService.ExecuteSearchAsync(query, cancellationToken);
// Add cache headers since QUERY is idempotent
Response.Headers.CacheControl = “public, max-age=300”; // Cache for 5 minutes
Response.Headers.Vary = “Accept, Content-Type”;
return Ok(results);
}
catch (OperationCanceledException)
{
_logger.LogInformation(“Search request cancelled by client”);
return StatusCode(StatusCodes.Status499ClientClosedRequest);
}
catch (ArgumentException ex)
{
_logger.LogWarning(ex, “Invalid search criteria provided”);
return BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, “Unexpected error during product search”);
return StatusCode(
StatusCodes.Status500InternalServerError,
new { error = “An unexpected error occurred” });
}
}
}
Notice several production-grade touches:
Let’s define what we’re actually sending:
public class ProductSearchQuery
{
/// <summary>
/// Free-text search term across product names and descriptions.
/// </summary>
[StringLength(500, MinimumLength = 1)]
public string? SearchTerm { get; set; }
/// <summary>
/// Filter by product categories (e.g., “Electronics”, “Software”).
/// </summary>
public string[]? Categories { get; set; }
/// <summary>
/// Minimum price in USD. Defaults to 0.
/// </summary>
[Range(0, double.MaxValue)]
public decimal? MinPrice { get; set; }
/// <summary>
/// Maximum price in USD. If set, must be >= MinPrice.
/// </summary>
[Range(0, double.MaxValue)]
public decimal? MaxPrice { get; set; }
/// <summary>
/// Filter by supplier/vendor IDs (exact match).
/// </summary>
public int[]? VendorIds { get; set; }
/// <summary>
/// Exclude products from these vendors.
/// </summary>
public int[]? ExcludedVendorIds { get; set; }
/// <summary>
/// Filter by product tags (any match).
/// </summary>
public string[]? Tags { get; set; }
/// <summary>
/// Sort order: “relevance” (default), “price_asc”, “price_desc”, “rating”.
/// </summary>
public string SortBy { get; set; } = “relevance”;
/// <summary>
/// Page number for pagination (1-indexed).
/// </summary>
[Range(1, int.MaxValue)]
public int PageNumber { get; set; } = 1;
/// <summary>
/// Number of results per page.
/// </summary>
[Range(1, 1000)]
public int PageSize { get; set; } = 20;
/// <summary>
/// Date range filter (ISO 8601 format).
/// </summary>
public DateRange? ListedDateRange { get; set; }
/// <summary>
/// Regional boost factors for relevance scoring.
/// Key: ISO region code (e.g., “US”, “EU”)
/// Value: Multiplier (e.g., 1.5 = 50% boost)
/// </summary>
public Dictionary<string, float>? RegionBoosts { get; set; }
/// <summary>
/// Include only products that have received reviews.
/// </summary>
public bool OnlyWithReviews { get; set; } = false;
/// <summary>
/// Minimum average rating (0.0 to 5.0).
/// </summary>
[Range(0, 5)]
public decimal? MinRating { get; set; }
}
public class DateRange
{
public DateTime Start { get; set; }
public DateTime End { get; set; }
}
public class PagedSearchResult<T>
{
public List<T> Items { get; set; } = new();
public int TotalCount { get; set; }
public int PageNumber { get; set; }
public int PageSize { get; set; }
public int TotalPages => (TotalCount + PageSize – 1) / PageSize;
}
This object is self-documenting. Someone reading your API contract immediately understands:
No JSON schema document needed (though you could generate one). The domain is clear.
Imagine an admin endpoint that generates sales reports with dozens of filter combinations:
[HttpQuery(“export/sales-report”)]
public async Task<IActionResult> ExportSalesReport(
[FromBody] SalesReportQuery criteria,
CancellationToken cancellationToken)
{
// This is inherently a read operation, but the payload is substantial
// QUERY is the right verb here, not GET (no URL bombing) or POST (not mutating)
var report = await _reportService.GenerateAsync(criteria, cancellationToken);
// Cache for 10 minutes since the underlying data doesn’t change that fast
Response.Headers.CacheControl = “public, max-age=600”;
return Ok(report);
}
If someone tries this with POST, they’re giving up caching entirely. With GET, you hit URL length limits at the first moderately complex report criteria.
Security teams need to search audit logs with multiple dimensions:
[HttpQuery(“audit-logs/search”)]
public async Task<IActionResult> SearchAuditLogs(
[FromBody] AuditLogQuery query,
CancellationToken cancellationToken)
{
// Users need to filter by:
// – Date range
// – User ID or group
// – Action type
// – Resource ID
// – Success/failure status
// – IP address patterns
//
// This is clearly a read-only operation. QUERY acknowledges that directly.
var logs = await _auditService.SearchAsync(query, cancellationToken);
return Ok(logs);
}
POST would work, but you’d be confusing auditors and security tools. QUERY says “this is safe, this is read-only, you can cache and retry this.”
Business intelligence endpoints that slice data by dozens of dimensions:
[HttpQuery(“analytics/dashboard-data”)]
public async Task<IActionResult> GetDashboardData(
[FromBody] AnalyticsQuery query,
CancellationToken cancellationToken)
{
// Queries like “give me daily revenue by product category,
// filtered by region and time zone, grouped by payment method”
// require substantial payloads.
var data = await _analyticsService.QueryAsync(query, cancellationToken);
// Aggressive caching for dashboard data
Response.Headers.CacheControl = “public, max-age=3600”;
return Ok(data);
}
Again: POST would sacrifice caching. GET would explode the URL. QUERY is the idiomatic choice.
One thing to consider: if QUERY isn’t universally recognized yet, you might need middleware to handle clients that don’t understand it. Here’s a defensive approach:
/// <summary>
/// Middleware that enables the HTTP QUERY method across the application.
/// Handles any framework-level quirks and ensures QUERY is treated as cacheable.
/// </summary>
public class HttpQueryMethodMiddleware
{
private readonly RequestDelegate _next;
public HttpQueryMethodMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
// If the request is a QUERY, ensure it’s routed correctly
if (context.Request.Method == “QUERY”)
{
// QUERY should be treated like GET for caching purposes
// Some infrastructure might need this hint
context.Items[“IsReadOperation”] = true;
}
await _next(context);
}
}
// In Program.cs:
// app.UseMiddleware<HttpQueryMethodMiddleware>();
In practice, modern ASP.NET Core handles this out of the box. But this pattern is useful if you’re running behind legacy proxies or need to enforce application-wide QUERY semantics.
The beauty of QUERY is that HttpClient already supports it—it just takes a string:
public class ProductApiClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<ProductApiClient> _logger;
public ProductApiClient(HttpClient httpClient, ILogger<ProductApiClient> logger)
{
_httpClient = httpClient;
_logger = logger;
}
public async Task<PagedSearchResult<ProductDto>> SearchAsync(
ProductSearchQuery criteria,
CancellationToken cancellationToken = default)
{
// Create a QUERY request
var request = new HttpRequestMessage(
new HttpMethod(“QUERY”),
“api/v1/products/search”)
{
Content = JsonContent.Create(criteria)
};
_logger.LogDebug(
“Executing product search with filters: {@SearchCriteria}”,
criteria);
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode == System.Net.HttpStatusCode.UnsupportedMediaType)
{
_logger.LogError(“Server rejected the request content-type”);
throw new HttpRequestException(“Server does not support the provided content-type”);
}
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<PagedSearchResult<ProductDto>>(
cancellationToken: cancellationToken);
return result ?? new PagedSearchResult<ProductDto>();
}
}
Wire this into dependency injection:
services.AddHttpClient<ProductApiClient>(client =>
{
client.BaseAddress = new Uri(“https://api.example.com”);
client.DefaultRequestHeaders.Add(“Accept”, “application/json”);
});
If you’re using Refit for strongly-typed HTTP calls, you can extend it with a custom attribute:
public class QueryAttribute : HttpMethodAttribute
{
public QueryAttribute(string path = “”) : base(HttpMethod.Get)
{
Path = path;
}
public override HttpMethod Method => new HttpMethod(“QUERY”);
}
[BaseAddress(“https://api.example.com”)]
public interface IProductQueryApi
{
[Query(“api/v1/products/search”)]
Task<ApiResponse<PagedSearchResult<ProductDto>>> SearchProductsAsync(
[Body] ProductSearchQuery query,
CancellationToken cancellationToken = default);
}
Refit will handle the serialization and method routing transparently.
Postman supports custom HTTP methods natively, making it the fastest way to validate your QUERY endpoint during development:
Content-Type: application/jsonAccept: application/json
{ “searchTerm”: “enterprise software”, “minPrice”: 50.00, “maxPrice”: 5000.00, “categories”: [“software”, “enterprise”], “tags”: [“cloud”, “scalable”], “sortBy”: “relevance”, “pageSize”: 50, “pageNumber”: 1}
You’ll get a 200 OK with your results, complete with cache headers visible in the response.
For automated testing, use a real HttpClient in a test server:
[Collection(“Integration Tests”)]
public class ProductSearchEndpointTests : IAsyncLifetime
{
private WebApplicationFactory<Program> _factory = null!;
private HttpClient _client = null!;
public async Task InitializeAsync()
{
_factory = new WebApplicationFactory<Program>();
_client = _factory.CreateClient();
}
public async Task DisposeAsync()
{
_client?.Dispose();
await _factory.DisposeAsync();
}
[Fact]
public async Task SearchProducts_WithValidQuery_Returns200AndResults()
{
// Arrange
var query = new ProductSearchQuery
{
SearchTerm = “widget”,
PageSize = 10
};
var request = new HttpRequestMessage(
new HttpMethod(“QUERY”),
“/api/v1/products/search”)
{
Content = JsonContent.Create(query)
};
// Act
var response = await _client.SendAsync(request);
// Assert
response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<PagedSearchResult<ProductDto>>();
result?.Items.Should().NotBeNull();
result?.TotalCount.Should().BeGreaterThan(0);
}
[Fact]
public async Task SearchProducts_WithoutJsonContentType_Returns415()
{
// Arrange
var query = new ProductSearchQuery { SearchTerm = “widget” };
var request = new HttpRequestMessage(
new HttpMethod(“QUERY”),
“/api/v1/products/search”)
{
Content = new StringContent(
JsonSerializer.Serialize(query),
Encoding.UTF8,
“text/plain”) // Wrong content-type
};
// Act
var response = await _client.SendAsync(request);
// Assert
response.StatusCode.Should().Be(System.Net.HttpStatusCode.UnsupportedMediaType);
}
[Fact]
public async Task SearchProducts_WithInvalidPageSize_Returns400()
{
// Arrange
var query = new ProductSearchQuery
{
SearchTerm = “widget”,
PageSize = 5000 // Exceeds [Range(1, 1000)]
};
var request = new HttpRequestMessage(
new HttpMethod(“QUERY”),
“/api/v1/products/search”)
{
Content = JsonContent.Create(query)
};
// Act
var response = await _client.SendAsync(request);
// Assert
response.StatusCode.Should().Be(System.Net.HttpStatusCode.BadRequest);
}
}
These tests validate that:
Here’s where QUERY delivers tangible value beyond semantics.
With GET, you can cache. With POST, proxies typically refuse to cache. With QUERY:
Response.Headers.CacheControl = “public, max-age=300”;
Now a CDN like Cloudflare or Akamai sees a QUERY request, recognizes it as idempotent, and caches the response at the edge. Subsequent identical queries from other clients hit the CDN, not your origin. Latency drops from 200ms to 20ms. This scales.
Getting this caching layer right end-to-end — from response headers to CDN configuration — is exactly the kind of infrastructure work that falls under cloud application development services.
A network hiccup during your request? Automated infrastructure can retry safely:
var policy = Policy
.Handle<HttpRequestException>()
.OrResult<HttpResponseMessage>(r => !r.IsSuccessStatusCode)
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: attempt =>
TimeSpan.FromSeconds(Math.Pow(2, attempt)));
var response = await policy.ExecuteAsync(async () =>
await _httpClient.SendAsync(request));
With POST, retry policies need custom logic to detect if the operation is actually idempotent. With QUERY, it’s automatic. Frameworks, load balancers, and proxies understand this natively.
Some teams cache query results in-process:
public class CachedProductQueryService : IProductQueryService
{
private readonly IProductQueryService _inner;
private readonly IMemoryCache _cache;
public async Task<PagedSearchResult<ProductDto>> ExecuteSearchAsync(
ProductSearchQuery query,
CancellationToken cancellationToken)
{
var cacheKey = GenerateCacheKey(query);
if (_cache.TryGetValue(cacheKey, out PagedSearchResult<ProductDto> cached))
{
return cached;
}
var result = await _inner.ExecuteSearchAsync(query, cancellationToken);
// Cache for 5 minutes—safe to do because QUERY is idempotent
_cache.Set(cacheKey, result, TimeSpan.FromMinutes(5));
return result;
}
private string GenerateCacheKey(ProductSearchQuery query)
{
// Hash the entire query to create a unique cache key
var json = JsonSerializer.Serialize(query);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $”product_search:{Convert.ToHexString(hash)}”;
}
}
With GET-only or POST-only semantics, you need to think about cache validity differently. With QUERY, the rule is simple: idempotent operations can be cached, period.
QUERY is not a “CORS-safe” method (like GET, HEAD, POST). A browser making a cross-origin QUERY request will trigger a preflight OPTIONS request:
OPTIONS /api/v1/products/search HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: QUERY
Access-Control-Request-Headers: content-type
Your CORS middleware needs to allow it:
app.UseCors(policy =>
{
policy
.AllowAnyOrigin()
.AllowAnyHeader()
.WithMethods(“GET”, “POST”, “QUERY”) // Explicitly allow QUERY
.WithExposedHeaders(“Content-Range”, “X-Total-Count”);
});
Since QUERY isn’t CORS-safelisted and touches your CORS policy directly, it’s worth running the updated endpoint through your regular security testing services before it ships — for a broader look at what’s changed in the attack surface for modern APIs, see Protecting Applications Against Modern Cyber Threats.
Your HTTP logging middleware should handle QUERY like any other method:
app.UseHttpLogging();
// In appsettings.json:
{
“HttpLogging”: {
“LoggingFields”: “RequestPropertiesAndHeaders,ResponsePropertiesAndHeaders,RequestBody,ResponseBody”,
“RequestBodyLogLimit”: 32768,
“ResponseBodyLogLimit”: 32768
}
}
ASP.NET Core’s UseHttpLogging() will capture QUERY requests transparently. Just make sure your request body limits are high enough for your query payloads.
Older API gateways might not recognize QUERY. Test your deployment pipeline:
curl -X QUERY https://your-api.com/api/v1/products/search \
-H “Content-Type: application/json” \
-d ‘{
“searchTerm”: “widget”,
“pageSize”: 20
}’
If you get a 405 Method Not Allowed or 501 Not Implemented, your gateway needs configuration updates. Most modern gateways (Kong, AWS API Gateway, Azure API Management) support custom HTTP methods through configuration.
Getting QUERY allow-listed across gateways, proxies, and load balancers cleanly is squarely a job for solid DevOps services and solutions, not a one-off config tweak someone remembers to make.
Some legacy load balancers have hardcoded lists of allowed HTTP methods. Check your infrastructure:
Rolling out a new HTTP method across a live production API is exactly the kind of incremental, ongoing change that’s easiest to manage with dedicated maintenance and support services in place, rather than a one-time deployment.
If you’re adopting QUERY in an existing system, here’s a safe approach:
Phase 1: Support Both Verbs
Implement endpoints with [HttpGet] and [HttpQuery]:
[HttpGet(“search”)]
[HttpQuery(“search”)]
public async Task<IActionResult> SearchProducts(
[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ProductSearchQuery? query,
CancellationToken cancellationToken)
{
// Query can be null for GET (backward compat)
query ??= ProductSearchQuery.Empty;
var results = await _queryService.ExecuteSearchAsync(query, cancellationToken);
return Ok(results);
}
Clients can migrate gradually. Old code stays on GET with query parameters. New clients use QUERY with JSON bodies.
Phase 2: Monitor Usage
Track which clients are using QUERY vs. GET:
var isQuery = HttpContext.Request.Method == “QUERY”;
_telemetry.TrackEvent(“ProductSearch”, new Dictionary<string, string>
{
{ “method”, isQuery ? “QUERY” : “GET” },
{ “queryComplexity”, EstimateComplexity(query).ToString() }
});
Phase 3: Deprecate GET
Once your clients have migrated, sunset GET:
[HttpQuery(“search”)]
[ApiExplorerSettings(IgnoreApi = true)] // Hide old GET from docs
public async Task<IActionResult> SearchProducts(…)
{
// QUERY only
}
There’s a tendency in the .NET world to either become REST purists (who reject anything that doesn’t map to CRUD on a single resource) or to abandon HTTP semantics entirely (hey, it’s just POST, right?).
QUERY is neither. It’s REST done right. It respects HTTP’s original vision: standardized, composable, cacheable, retryable methods that clearly communicate intent to every layer in the stack.
We’ve been hacking around the absence of this method for twenty years. Now that it exists, use it. Your proxies, your caches, your retry logic, and your future maintainers will all thank you.
The HTTP QUERY method isn’t revolutionary. It’s just the method that should have been there all along.