Back to blog
Backend Systemsintermediate

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.

LearnixoApril 14, 20265 min read
.NETC#RefitHttpClientRESTASP.NET CorePolly
Share:𝕏

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.

Bash
dotnet add package Refit
dotnet add package Refit.HttpClientFactory

No hand-written HttpClient code. No JsonSerializer.Deserialize. Just an interface.


Defining an API Interface

C#
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:

C#
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);
}
C#
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:

C#
[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:

C#
[Post("/payments")]
Task<Payment> CreatePaymentAsync(
    [Body] CreatePaymentRequest request,
    [Header("X-Idempotency-Key")] string idempotencyKey,
    CancellationToken ct = default);
C#
var payment = await api.CreatePaymentAsync(
    new CreatePaymentRequest { Amount = 9900, Currency = "GBP" },
    idempotencyKey: Guid.NewGuid().ToString(),
    ct: ct);

Registering with IHttpClientFactory

C#
// 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:

C#
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:

C#
builder.Services.AddTransient<PaymentApiAuthHandler>();

builder.Services
    .AddRefitClient<IPaymentApi>()
    .ConfigureHttpClient(c => c.BaseAddress = new Uri("https://payments.example.com"))
    .AddHttpMessageHandler<PaymentApiAuthHandler>()
    .AddStandardResilienceHandler();

Polly Resilience

C#
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

C#
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);
}
C#
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

C#
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 HttpClient boilerplate for well-defined external APIs
  • ApiResponse<T> lets you handle non-2xx responses without try/catch
  • AddRefitClient<T>() returns IHttpClientBuilder — Polly and DelegatingHandler chain on directly
  • Dynamic auth belongs in a DelegatingHandler, not in the interface
  • RefitSettings controls serialization — switch to System.Text.Json with snake_case for most modern APIs

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.