Refit — Define HTTP APIs as C# Interfaces
Stop writing HttpClient boilerplate. Refit turns a C# interface into a fully working HTTP client — attributes, response handling, auth headers, Polly resilience, and real-world examples.
What Is Refit?
Refit is a compile-time REST client library inspired by Square's Retrofit. You declare an interface with attributes describing HTTP method, path, and parameters — Refit generates the implementation.
dotnet add package Refit
dotnet add package Refit.HttpClientFactoryNo hand-written HttpClient code. No JsonSerializer.Deserialize. Just an interface.
Defining an API Interface
using Refit;
public interface IPaymentApi
{
[Get("/payments/{id}")]
Task<Payment> GetPaymentAsync(string id, CancellationToken ct = default);
[Get("/payments")]
Task<PagedResult<Payment>> ListPaymentsAsync(
[Query] int page = 1,
[Query] int pageSize = 20,
CancellationToken ct = default);
[Post("/payments")]
Task<Payment> CreatePaymentAsync([Body] CreatePaymentRequest request, CancellationToken ct = default);
[Put("/payments/{id}")]
Task<Payment> UpdatePaymentAsync(string id, [Body] UpdatePaymentRequest request, CancellationToken ct = default);
[Delete("/payments/{id}")]
Task DeletePaymentAsync(string id, CancellationToken ct = default);
}Attribute reference:
| Attribute | Purpose |
|---|---|
| [Get("/path/{id}")] | HTTP GET, {id} bound from method param |
| [Post("/path")] | HTTP POST |
| [Put("/path/{id}")] | HTTP PUT |
| [Delete("/path/{id}")] | HTTP DELETE |
| [Patch("/path/{id}")] | HTTP PATCH |
| [Body] | Serializes parameter as JSON request body |
| [Query] | Appends parameter as query string |
| [Header("X-Name")] | Sends parameter as a request header |
| [AliasAs("snake_case")] | Maps C# name to different JSON/query name |
Handling Responses with ApiResponse<T>
Raw Task<T> throws on non-2xx. ApiResponse<T> gives you the status code, headers, and error content without exceptions:
public interface IPaymentApi
{
[Get("/payments/{id}")]
Task<ApiResponse<Payment>> GetPaymentAsync(string id, CancellationToken ct = default);
[Post("/payments")]
Task<IApiResponse<Payment>> CreatePaymentAsync([Body] CreatePaymentRequest request, CancellationToken ct = default);
[Delete("/payments/{id}")]
Task<IApiResponse> DeletePaymentAsync(string id, CancellationToken ct = default);
}public class PaymentService(IPaymentApi api)
{
public async Task<Payment?> GetAsync(string id, CancellationToken ct = default)
{
var response = await api.GetPaymentAsync(id, ct);
if (response.IsSuccessStatusCode)
return response.Content;
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
// response.Error contains the deserialized error body
throw new PaymentApiException(
$"Payment API error {(int)response.StatusCode}: {response.Error?.Content}");
}
}Static and Dynamic Headers
Static headers in the interface definition:
[Headers("Accept: application/json", "X-Api-Version: 2")]
public interface IPaymentApi
{
[Get("/payments/{id}")]
[Headers("X-Idempotency-Key: static-key")] // per-method override
Task<Payment> GetPaymentAsync(string id, CancellationToken ct = default);
}Dynamic headers per call — pass as method parameter:
[Post("/payments")]
Task<Payment> CreatePaymentAsync(
[Body] CreatePaymentRequest request,
[Header("X-Idempotency-Key")] string idempotencyKey,
CancellationToken ct = default);var payment = await api.CreatePaymentAsync(
new CreatePaymentRequest { Amount = 9900, Currency = "GBP" },
idempotencyKey: Guid.NewGuid().ToString(),
ct: ct);Registering with IHttpClientFactory
// Program.cs
builder.Services
.AddRefitClient<IPaymentApi>(new RefitSettings
{
ContentSerializer = new SystemTextJsonContentSerializer(new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
})
})
.ConfigureHttpClient(c =>
{
c.BaseAddress = new Uri(builder.Configuration["PaymentApi:BaseUrl"]!);
c.Timeout = TimeSpan.FromSeconds(30);
});AddRefitClient<T>() returns an IHttpClientBuilder — the same builder used by AddHttpClient<T>(), so everything that works there works here.
Adding Auth Headers via DelegatingHandler
Don't hardcode auth in the interface. Use a DelegatingHandler:
public class PaymentApiAuthHandler(ITokenService tokens) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct)
{
var token = await tokens.GetTokenAsync("payment-api", ct);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await base.SendAsync(request, ct);
}
}Register the handler and attach it:
builder.Services.AddTransient<PaymentApiAuthHandler>();
builder.Services
.AddRefitClient<IPaymentApi>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri("https://payments.example.com"))
.AddHttpMessageHandler<PaymentApiAuthHandler>()
.AddStandardResilienceHandler();Polly Resilience
builder.Services
.AddRefitClient<IPaymentApi>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri("https://payments.example.com"))
.AddHttpMessageHandler<PaymentApiAuthHandler>()
.AddResilienceHandler("payment-pipeline", pipeline =>
{
pipeline
.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromMilliseconds(300),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
ShouldHandle = args => args.Outcome switch
{
{ Exception: HttpRequestException } => PredicateResult.True(),
{ Result.StatusCode: HttpStatusCode.TooManyRequests } => PredicateResult.True(),
{ Result.StatusCode: HttpStatusCode.ServiceUnavailable } => PredicateResult.True(),
_ => PredicateResult.False()
}
})
.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
{
SamplingDuration = TimeSpan.FromSeconds(30),
MinimumThroughput = 5,
FailureRatio = 0.5,
BreakDuration = TimeSpan.FromSeconds(15)
})
.AddTimeout(TimeSpan.FromSeconds(10));
});Real Example: Weather API
public record WeatherForecast(string City, double TempC, string Condition, DateTime ForecastDate);
public record ForecastListResponse(IReadOnlyList<WeatherForecast> Forecasts, int Total);
[Headers("Accept: application/json")]
public interface IOpenWeatherApi
{
[Get("/data/2.5/weather")]
Task<ApiResponse<WeatherForecast>> GetCurrentAsync(
[Query("q")] string city,
[Query("appid")] string apiKey,
[Query("units")] string units = "metric",
CancellationToken ct = default);
[Get("/data/2.5/forecast")]
Task<ApiResponse<ForecastListResponse>> Get5DayForecastAsync(
[Query("q")] string city,
[Query("appid")] string apiKey,
[Query("cnt")] int count = 40,
CancellationToken ct = default);
}public class WeatherService(IOpenWeatherApi api, IOptions<WeatherOptions> opts)
{
public async Task<WeatherForecast> GetCurrentWeatherAsync(string city, CancellationToken ct = default)
{
var response = await api.GetCurrentAsync(city, opts.Value.ApiKey, ct: ct);
response.EnsureSuccessStatusCode(); // throws ApiException on error
return response.Content!;
}
}Error Handling Patterns
try
{
var payment = await api.GetPaymentAsync(id, ct);
}
catch (ApiException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return null; // 404 — not an error in your domain
}
catch (ApiException ex) when (ex.StatusCode == HttpStatusCode.UnprocessableEntity)
{
var problem = await ex.GetContentAsAsync<ProblemDetails>();
throw new ValidationException(problem?.Detail ?? "Validation failed");
}
catch (ApiException ex)
{
logger.LogError(ex, "Payment API call failed with {StatusCode}", ex.StatusCode);
throw;
}Or use ApiResponse<T> and avoid exceptions entirely — preferred for business logic that treats 404 as a normal outcome.
Key Takeaways
- Refit eliminates
HttpClientboilerplate for well-defined external APIs ApiResponse<T>lets you handle non-2xx responses without try/catchAddRefitClient<T>()returnsIHttpClientBuilder— Polly andDelegatingHandlerchain on directly- Dynamic auth belongs in a
DelegatingHandler, not in the interface RefitSettingscontrols serialization — switch toSystem.Text.Jsonwith snake_case for most modern APIs
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.