Andy Morrell

Building a Type-Safe Twitch API Library in .NET

Andy Morrell ·

Building a Type-Safe Twitch API Library in .NET

I’ve been building a Twitch-integrated side project and quickly found myself in familiar territory: the same boilerplate HttpClient plumbing I’ve written a dozen times before. Twitch’s Helix API is well-documented, but that doesn’t make handrolling 29 resource groups any less tedious. I needed typed request/response models, auth headers on every request, EventSub for real-time events, and something that felt native to .NET DI. None of the existing community libraries quite hit the mark, so I spent a day building my own.

Why not just use an existing library?

There are a few C# Twitch libraries out there. Most are either unmaintained, target older .NET versions, or wrap the API in ways that feel awkward alongside modern patterns like IHttpClientFactory and constructor injection. I wanted something that felt first-class: strongly-typed everything, no static singleton clients in the background.

The other issue was magic strings. "1000" instead of SubscriptionTier.Tier1. "archive" instead of VideoType.Archive. IntelliSense is your first line of defence against typos when talking to an external API, and I didn’t want to give that up.

Refit as the foundation

Refit turns C# interfaces into HTTP clients at compile time. You declare an interface, annotate methods with attributes for the verb and path, and Refit generates the implementation. It’s been around for years and is a perfect fit here.

Every Helix resource group becomes its own interface:

public interface IStreamsApi
{
    [Get("/streams")]
    Task<TwitchResponse<TwitchStream>> GetStreamsAsync(
        [Query] GetStreamsRequest request,
        CancellationToken ct = default);

    [Post("/streams/markers")]
    Task<TwitchResponse<StreamMarker>> CreateStreamMarkerAsync(
        [Body] CreateStreamMarkerRequest request,
        CancellationToken ct = default);
}

Parameters go into typed request objects rather than a soup of optional method parameters. TwitchResponse<T> carries the data array and pagination cursor together. The whole thing is verified at compile time.

Authentication is handled by a DelegatingHandler that injects the required Client-Id and Authorization: Bearer headers on every outbound request, so none of the individual API interfaces need to think about it.

public class TwitchAuthHandler(IOptions<TwitchOptions> options) : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        request.Headers.TryAddWithoutValidation("Client-Id", _options.ClientId);
        if (!string.IsNullOrEmpty(_options.AccessToken))
            request.Headers.Authorization =
                new AuthenticationHeaderValue("Bearer", _options.AccessToken);
        return base.SendAsync(request, ct);
    }
}

All 29 interfaces are also exposed through a single ITwitchClient facade, so you can inject one thing and access everything via twitch.Streams, twitch.Chat, twitch.Bits, and so on. If you only care about one resource group, inject IStreamsApi directly. Both patterns work.

OAuth: three flows, one service

Twitch supports three OAuth flows and I needed all of them. Client Credentials for app-level tokens with no user involved. Authorization Code for the standard redirect-based web flow. Device Code for headless environments like CLI tools, where you print a URL and a short code and wait for the user to authorise in a browser.

All three are implemented in TwitchAuthService, backed by a Refit interface pointing at id.twitch.tv. The interesting one was Device Code polling. After starting the flow, you get back a device_code and an interval in seconds, then poll the token endpoint until the user completes authorisation, catching 400 Bad Request (Twitch’s signal for authorization_pending) and looping:

public async Task<AuthorizationCodeTokenResponse> PollDeviceCodeTokenAsync(
    string deviceCode, int intervalSeconds, CancellationToken ct = default)
{
    while (!ct.IsCancellationRequested)
    {
        await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), ct);
        try
        {
            return await oAuthApi.PostUserTokenAsync(new Dictionary<string, string>
            {
                ["client_id"] = _options.ClientId,
                ["device_code"] = deviceCode,
                ["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code"
            }, ct);
        }
        catch (ApiException ex) when (ex.StatusCode == HttpStatusCode.BadRequest)
        {
            // authorization_pending — keep polling
        }
    }
    throw new OperationCanceledException(ct);
}

The service also handles token validation and revocation, both of which Twitch recommends doing proactively.

EventSub: WebSocket and Webhook

EventSub is Twitch’s push notification system. There are two transports: WebSocket for persistent connections and Webhook for server-to-server HTTP delivery. I implemented both.

The WebSocket client manages the full connection lifecycle: session welcome (where you get the session_id needed for creating subscriptions), keepalives, and Twitch’s reconnect protocol. When Twitch sends a session_reconnect, the client opens a connection to the new URL before closing the old one, so there’s no gap:

private async Task ReconnectAsync(string reconnectUrl, CancellationToken ct)
{
    if (_ws.State == WebSocketState.Open)
        await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Reconnecting", CancellationToken.None);
    _ws.Dispose();
    _ws = new ClientWebSocket();
    await _ws.ConnectAsync(new Uri(reconnectUrl), ct);
    _ = Task.Run(() => ReceiveLoopAsync(ct), ct);
}

Events are routed through IEventSubDispatcher. You register typed handlers against subscription type constants, and the dispatcher deserialises the event payload to the right model and invokes the handler:

dispatcher
    .Register<StreamOnlineEvent>(SubscriptionTypes.StreamOnline, async e =>
        Console.WriteLine($"{e.BroadcasterUserName} went live!"))
    .Register<ChannelChatMessageEvent>(SubscriptionTypes.ChannelChatMessage, async e =>
        Console.WriteLine($"[{e.ChatterUserName}]: {e.Message.Text}"));

The webhook implementation is an ASP.NET Core middleware registered via app.MapTwitchWebhook(...). Twitch signs every webhook delivery with HMAC-SHA256 using a secret you supply at subscription time, computed over the message ID, timestamp, and raw request body. The middleware verifies this using CryptographicOperations.FixedTimeEquals to prevent timing attacks, then rejects messages older than ten minutes as a guard against replay attacks, before routing the payload to the dispatcher.

Modular NuGet packages

One of the decisions I’m happiest with is splitting the library into focused packages. Not every consumer needs EventSub. Not every EventSub consumer needs Webhooks. There’s no reason to pull in an ASP.NET Core dependency for a console app using WebSockets.

The split ended up as five packages plus a meta-package: TwitchLibrary.Core for OAuth and shared enums, TwitchLibrary.Api for the 29 Helix interfaces and the ITwitchClient facade, TwitchLibrary.EventSub.Core for shared EventSub models and the dispatcher, TwitchLibrary.EventSub.WebSocket and TwitchLibrary.EventSub.Webhook for the two transports, and TwitchLibrary as a convenience meta-package.

Registration follows the same modular pattern: AddTwitchAuth() for just OAuth, AddTwitchHelixApi() for the API layer, AddTwitchEventSubWebSocket() for WebSocket. Or call AddTwitchLibrary() on the meta-package if you want everything in one line.

What I’d do differently and what’s next

Building this in a single day meant some things got deferred. Token refresh is the biggest gap: the TwitchAuthHandler uses whatever token is in TwitchOptions at startup, fine for app access tokens (60-day lifetime), but a proper user token flow needs automatic refresh on a 401. I’d handle that by making the options mutable at runtime and having the handler retry transparently.

Conduits are also on the list. They let you fan out a single EventSub subscription across multiple webhook shards, which matters at scale. The IConduitsApi Helix interface exists, but the EventSub layer doesn’t wire it up yet.

If you want to use or contribute, the library is on GitHub. The fundamentals are solid and it’s already covering everything I need for the project that prompted it.