From d49aee674395cf5ce2f65157296f5d2f93032499 Mon Sep 17 00:00:00 2001
From: LosFarmosCTL <80157503+LosFarmosCTL@users.noreply.github.com>
Date: Fri, 12 Jul 2024 00:53:47 +0200
Subject: [PATCH] feat: add initial EventSub implementation (#14)

---
 Sources/Twitch/EventSub/EventSubClient.swift  | 106 ++++++++++++++
 .../Twitch/EventSub/EventSubConnection.swift  | 137 ++++++++++++++++++
 Sources/Twitch/EventSub/EventSubError.swift   |  14 ++
 Sources/Twitch/EventSub/EventSubHandler.swift |  50 +++++++
 .../EventSub/EventSubSubscriptionType.swift   |  14 --
 .../Events/Channel/ChannelFollowEvent.swift   |   8 +
 .../Events/Channel/ChannelUpdateEvent.swift   |   3 +
 .../EventSub/Events/Chat/ChatClearEvent.swift |  11 ++
 .../Events/Chat/ChatMessageEvent.swift        |   3 +
 Sources/Twitch/EventSub/Events/Event.swift    |  17 +++
 Sources/Twitch/EventSub/KeepaliveTimer.swift  |  29 ++++
 .../EventSub/Subscriptions/+channel.swift     |  19 +++
 .../Twitch/EventSub/Subscriptions/+chat.swift |  25 ++++
 .../Subscriptions/EventSubSubscription.swift  |   5 +
 .../EventSub/TwitchClient+EventSub.swift      |  58 ++++++++
 .../EventSub/message/EventSubMessage.swift    |  58 ++++++++
 .../payloads/EventSubNotification.swift       |  23 +++
 .../message/payloads/EventSubPayload.swift    |   7 +
 .../message/payloads/EventSubReconnect.swift  |  24 +++
 .../message/payloads/EventSubRevocation.swift |  29 ++++
 .../message/payloads/EventSubWelcome.swift    |  27 ++++
 .../Ads/HelixEndpoint+getAdSchedule.swift     |   9 --
 .../Ads/HelixEndpoint+snoozeNextAd.swift      |   6 -
 .../Ads/HelixEndpoint+startCommercial.swift   |   8 +-
 .../HelixEndpoint+getExtensionAnalytics.swift |   7 +-
 .../HelixEndpoint+getGameAnalytics.swift      |   7 +-
 .../HelixEndpoint+getChannelEditors.swift     |   5 +-
 .../HelixEndpoint+getChannelFollowers.swift   |   6 +-
 .../Channels/HelixEndpoint+getChannels.swift  |  15 +-
 .../HelixEndpoint+getFollowedChannels.swift   |   6 +-
 .../HelixEndpoint+updateChannel.swift         |  14 +-
 .../Chat/HelixEndpoint+getChannelBadges.swift |  15 +-
 .../Chat/HelixEndpoint+getChannelEmotes.swift |  12 +-
 .../Chat/HelixEndpoint+getChatSettings.swift  |  16 +-
 .../Chat/HelixEndpoint+getChatters.swift      |   5 +-
 .../Chat/HelixEndpoint+getEmoteSets.swift     |  13 +-
 .../Chat/HelixEndpoint+getGlobalEmotes.swift  |   8 -
 .../Chat/HelixEndpoint+getUserColor.swift     |   6 +-
 .../Chat/HelixEndpoint+sendChatMessage.swift  |  12 +-
 .../HelixEndpoint+updateChatSettings.swift    |  13 --
 ...xEndpoint+createEventSubSubscription.swift |  84 +++++------
 .../Games/HelixEndpoint+getGames.swift        |   6 +-
 .../Moderation/HelixEndpoint+banUser.swift    |  14 +-
 .../HelixEndpoint+checkAutomodStatus.swift    |   4 +-
 .../HelixEndpoint+getAutomodSettings.swift    |  16 +-
 .../HelixEndpoint+getBannedUsers.swift        |  16 +-
 .../HelixEndpoint+getBlockedTerms.swift       |  11 +-
 .../HelixEndpoint+getModeratedChannels.swift  |   6 +-
 .../HelixEndpoint+getModerators.swift         |   6 +-
 .../HelixEndpoint+getShieldModeStatus.swift   |   8 +-
 .../Moderation/HelixEndpoint+getVIPs.swift    |   6 +-
 .../HelixEndpoint+updateAutomodSettings.swift |   5 +-
 .../HelixEndpoint+searchCategories.swift      |   6 -
 .../Search/HelixEndpoint+searchChannels.swift |  32 +---
 .../Streams/HelixEndpoint+getStreams.swift    |  21 +--
 .../HelixEndpoint+checkSubscription.swift     |  12 +-
 .../HelixEndpoint+getSubscribers.swift        |  20 +--
 .../HelixEndpoint+getUserBlocklist.swift      |   6 +-
 .../Users/HelixEndpoint+getUsers.swift        |  16 --
 Sources/Twitch/Helix/HelixEndpoint.swift      |  21 +--
 Sources/Twitch/Helix/HelixResponse.swift      |  16 +-
 Sources/Twitch/Helix/TwitchClient+Helix.swift |   2 +-
 .../Extensions}/Date+formatted.swift          |   0
 ...rmatter+iso8601withFractionalSeconds.swift |   0
 .../Shared/Extensions/URL+appending.swift     |  16 ++
 .../Extensions}/URLSession+data.swift         |   0
 .../URLSessionWebSocketTask+receive.swift     |  17 +++
 .../PropertyWrappers/NilOnTypeMismatch.swift  |  13 ++
 Sources/Twitch/TwitchClient.swift             |   9 +-
 69 files changed, 879 insertions(+), 360 deletions(-)
 create mode 100644 Sources/Twitch/EventSub/EventSubClient.swift
 create mode 100644 Sources/Twitch/EventSub/EventSubConnection.swift
 create mode 100644 Sources/Twitch/EventSub/EventSubError.swift
 create mode 100644 Sources/Twitch/EventSub/EventSubHandler.swift
 delete mode 100644 Sources/Twitch/EventSub/EventSubSubscriptionType.swift
 create mode 100644 Sources/Twitch/EventSub/Events/Channel/ChannelFollowEvent.swift
 create mode 100644 Sources/Twitch/EventSub/Events/Channel/ChannelUpdateEvent.swift
 create mode 100644 Sources/Twitch/EventSub/Events/Chat/ChatClearEvent.swift
 create mode 100644 Sources/Twitch/EventSub/Events/Chat/ChatMessageEvent.swift
 create mode 100644 Sources/Twitch/EventSub/Events/Event.swift
 create mode 100644 Sources/Twitch/EventSub/KeepaliveTimer.swift
 create mode 100644 Sources/Twitch/EventSub/Subscriptions/+channel.swift
 create mode 100644 Sources/Twitch/EventSub/Subscriptions/+chat.swift
 create mode 100644 Sources/Twitch/EventSub/Subscriptions/EventSubSubscription.swift
 create mode 100644 Sources/Twitch/EventSub/TwitchClient+EventSub.swift
 create mode 100644 Sources/Twitch/EventSub/message/EventSubMessage.swift
 create mode 100644 Sources/Twitch/EventSub/message/payloads/EventSubNotification.swift
 create mode 100644 Sources/Twitch/EventSub/message/payloads/EventSubPayload.swift
 create mode 100644 Sources/Twitch/EventSub/message/payloads/EventSubReconnect.swift
 create mode 100644 Sources/Twitch/EventSub/message/payloads/EventSubRevocation.swift
 create mode 100644 Sources/Twitch/EventSub/message/payloads/EventSubWelcome.swift
 rename Sources/Twitch/{Helix/Endpoints => Shared/Extensions}/Date+formatted.swift (100%)
 rename Sources/Twitch/{Helix => Shared/Extensions}/Formatter+iso8601withFractionalSeconds.swift (100%)
 create mode 100644 Sources/Twitch/Shared/Extensions/URL+appending.swift
 rename Sources/Twitch/{Helix => Shared/Extensions}/URLSession+data.swift (100%)
 create mode 100644 Sources/Twitch/Shared/Extensions/URLSessionWebSocketTask+receive.swift
 create mode 100644 Sources/Twitch/Shared/PropertyWrappers/NilOnTypeMismatch.swift

diff --git a/Sources/Twitch/EventSub/EventSubClient.swift b/Sources/Twitch/EventSub/EventSubClient.swift
new file mode 100644
index 0000000..a18e90e
--- /dev/null
+++ b/Sources/Twitch/EventSub/EventSubClient.swift
@@ -0,0 +1,106 @@
+import Foundation
+
+#if canImport(FoundationNetworking)
+  import FoundationNetworking
+#endif
+
+private typealias EventID = String
+private typealias SocketID = String
+
+internal class EventSubClient {
+  private static let eventSubURL = URL(string: "wss://eventsub.wss.twitch.tv/ws")!
+  private static let maxSubscriptionsPerConnection = 300
+
+  private let credentials: TwitchCredentials
+  private let urlSession: URLSession
+  private let decoder: JSONDecoder
+
+  private var connections = [SocketID: EventSubConnection]()
+  private var connectionEvents = [SocketID: [EventID]]()
+  private var eventHandlers = [EventID: EventSubHandler]()
+
+  internal init(
+    credentials: TwitchCredentials, urlSession: URLSession, decoder: JSONDecoder
+  ) {
+    self.credentials = credentials
+    self.urlSession = urlSession
+    self.decoder = decoder
+  }
+
+  internal func addHandler(
+    _ handler: EventSubHandler, for eventID: String, on socketID: String
+  ) {
+    eventHandlers[eventID] = handler
+
+    connectionEvents[socketID, default: []].append(eventID)
+  }
+
+  internal func getFreeWebsocketID() async throws -> String {
+    for (socketID, events) in connectionEvents
+    where events.count < Self.maxSubscriptionsPerConnection {
+      return socketID
+    }
+
+    return try await createConnection()
+  }
+
+  private func createConnection(url: URL = eventSubURL) async throws -> SocketID {
+    let connection = EventSubConnection(
+      credentials: credentials, urlSession: urlSession, decoder: decoder,
+      eventSubURL: url, onMessage: receiveMessage(_:))
+
+    let socketID = try await connection.resume()
+
+    connections[socketID] = connection
+    return socketID
+  }
+
+  private func receiveMessage(
+    _ result: Result<EventSubNotification, EventSubConnectionError>
+  ) {
+    switch result {
+    case .success(let notification):
+      eventHandlers[notification.subscription.id]?.yield(notification.event)
+
+    case .failure(let error):
+      switch error {
+      case .revocation(let revocation):
+        eventHandlers[revocation.subscriptionID]?.finish(
+          throwing: .revocation(revocation))
+      case .reconnectRequested(let reconnectURL, let socketID):
+        self.reconnect(socketID, reconnectURL: reconnectURL)
+      case .disconnected(let error, let socketID):
+        finishConnection(socketID, throwing: .disconnected(with: error))
+      case .timedOut(let socketID):
+        finishConnection(socketID, throwing: .timedOut)
+      }
+    }
+  }
+
+  private func reconnect(_ socketID: SocketID, reconnectURL: URL) {
+    Task {
+      do {
+        let newSocketID = try await self.createConnection()
+
+        // move all events to the new connection
+        connectionEvents[newSocketID] = connectionEvents[socketID]
+
+        connections.removeValue(forKey: socketID)
+        connectionEvents.removeValue(forKey: socketID)
+      } catch {
+        finishConnection(socketID, throwing: .disconnected(with: error))
+      }
+    }
+  }
+
+  private func finishConnection(_ socketID: SocketID, throwing error: EventSubError) {
+    for (socket, events) in connectionEvents where socket == socketID {
+      events.forEach { event in
+        eventHandlers[event]?.finish(throwing: error)
+      }
+    }
+
+    connectionEvents.removeValue(forKey: socketID)
+    connections.removeValue(forKey: socketID)
+  }
+}
diff --git a/Sources/Twitch/EventSub/EventSubConnection.swift b/Sources/Twitch/EventSub/EventSubConnection.swift
new file mode 100644
index 0000000..a94ad3b
--- /dev/null
+++ b/Sources/Twitch/EventSub/EventSubConnection.swift
@@ -0,0 +1,137 @@
+import Foundation
+
+#if canImport(FoundationNetworking)
+  import FoundationNetworking
+#endif
+
+internal class EventSubConnection {
+  private let eventSubURL: URL
+
+  private let credentials: TwitchCredentials
+  private let urlSession: URLSession
+  private let decoder: JSONDecoder
+
+  private var keepaliveTimer: KeepaliveTimer?
+  private var websocket: URLSessionWebSocketTask?
+  private var socketID: String?
+
+  private var onMessage: ((Result<EventSubNotification, EventSubConnectionError>) -> Void)
+  private var welcomeContinuation: CheckedContinuation<EventSubWelcome, any Error>?
+
+  private var receivedMessageIDs = [String]()
+
+  // TODO: look into deinitilizations
+  deinit {
+    self.websocket?.cancel(with: .goingAway, reason: nil)
+  }
+
+  init(
+    credentials: TwitchCredentials, urlSession: URLSession, decoder: JSONDecoder,
+    eventSubURL: URL,
+    onMessage: @escaping (Result<EventSubNotification, EventSubConnectionError>) -> Void
+  ) {
+    self.credentials = credentials
+    self.urlSession = urlSession
+    self.decoder = decoder
+
+    self.eventSubURL = eventSubURL
+
+    self.onMessage = onMessage
+  }
+
+  internal func resume() async throws -> String {
+    if let socketID = self.socketID { return socketID }
+
+    self.websocket = urlSession.webSocketTask(with: eventSubURL)
+    self.websocket?.receive(completionHandler: receiveMessage(_:))
+    self.websocket?.resume()
+
+    // Twitch sends keepalive messages in a specified time interval,
+    // if we don't receive a message within that interval, we should
+    // consider the connection to be timed out
+    self.keepaliveTimer = KeepaliveTimer(duration: .seconds(10)) {
+      self.onMessage(
+        .failure(EventSubConnectionError.timedOut(socketID: self.socketID ?? "")))
+      self.websocket?.cancel()
+    }
+
+    // wait for the welcome message to be received
+    let welcomeMessage = try await withCheckedThrowingContinuation { continuation in
+      self.welcomeContinuation = continuation
+    }
+
+    // use a slightly longer keepalive timeout to account for network latency
+    let timeout = welcomeMessage.keepaliveTimeout + .seconds(1)
+    await self.keepaliveTimer?.reset(duration: timeout)
+
+    self.socketID = welcomeMessage.sessionID
+    return welcomeMessage.sessionID
+  }
+
+  private func receiveMessage(
+    _ result: Result<URLSessionWebSocketTask.Message, any Error>
+  ) {
+    switch result {
+    case .success(let message):
+      // reset the keepalive timer on every message
+      Task { await self.keepaliveTimer?.reset() }
+
+      if let message = parseMessage(message) {
+
+        // ignore duplicate messages
+        if !receivedMessageIDs.contains(message.id) {
+          handleMessage(message)
+          receivedMessageIDs.append(message.id)
+
+          // only keep the last 100 message IDs
+          if receivedMessageIDs.count > 100 {
+            receivedMessageIDs.removeFirst()
+          }
+        }
+      }
+
+      // recursively receive the next message
+      self.websocket?.receive(completionHandler: receiveMessage)
+    case .failure(let error):
+      let disconnectedError = EventSubConnectionError.disconnected(
+        with: error, socketID: socketID ?? "")
+
+      if let welcomeContinuation {
+        welcomeContinuation.resume(throwing: disconnectedError)
+        self.welcomeContinuation = nil
+      }
+
+      onMessage(.failure(disconnectedError))
+    }
+  }
+
+  private func parseMessage(_ message: URLSessionWebSocketTask.Message)
+    -> EventSubMessage?
+  {
+    switch message {
+    case .string(let string):
+      return try? decoder.decode(EventSubMessage.self, from: Data(string.utf8))
+    // ignore binary messages, Twitch only sends JSON
+    case .data: return nil
+    @unknown default: return nil
+    }
+  }
+
+  private func handleMessage(_ message: EventSubMessage) {
+    switch message.payload {
+    case .keepalive: break  // nothing to do for keepalive messages
+    case .welcome(let welcome):
+      welcomeContinuation?.resume(returning: welcome)
+      welcomeContinuation = nil
+    case .notification(let notification):
+      onMessage(.success(notification))
+    case .revocation(let revocation):
+      onMessage(.failure(EventSubConnectionError.revocation(revocation)))
+    case .reconnect(let reconnect):
+      onMessage(
+        .failure(
+          EventSubConnectionError.reconnectRequested(
+            reconnectURL: reconnect.reconnectURL, socketID: socketID ?? "")))
+    }
+  }
+}
diff --git a/Sources/Twitch/EventSub/EventSubError.swift b/Sources/Twitch/EventSub/EventSubError.swift
new file mode 100644
index 0000000..b7e1482
--- /dev/null
+++ b/Sources/Twitch/EventSub/EventSubError.swift
@@ -0,0 +1,14 @@
+import Foundation
+
+public enum EventSubError: Error {
+  case revocation(EventSubRevocation)
+  case disconnected(with: Error)
+  case timedOut
+}
+
+internal enum EventSubConnectionError: Error {
+  case revocation(EventSubRevocation)
+  case reconnectRequested(reconnectURL: URL, socketID: String)
+  case timedOut(socketID: String)
+  case disconnected(with: Error, socketID: String)
+}
diff --git a/Sources/Twitch/EventSub/EventSubHandler.swift b/Sources/Twitch/EventSub/EventSubHandler.swift
new file mode 100644
index 0000000..249e280
--- /dev/null
+++ b/Sources/Twitch/EventSub/EventSubHandler.swift
@@ -0,0 +1,50 @@
+internal protocol EventSubHandler {
+  func yield(_ event: Event)
+  func finish(throwing error: EventSubError)
+}
+
+internal struct EventSubCallbackHandler<T>: EventSubHandler {
+  var callback: (Result<T, EventSubError>) -> Void
+
+  func yield(_ event: Event) {
+    if let event = event as? T {
+      callback(.success(event))
+    }
+  }
+
+  func finish(throwing error: EventSubError) {
+    callback(.failure(error))
+  }
+}
+
+internal struct EventSubContinuationHandler<T>: EventSubHandler {
+  var continuation: AsyncThrowingStream<T, any Error>.Continuation
+
+  func yield(_ event: Event) {
+    if let event = event as? T {
+      continuation.yield(event)
+    }
+  }
+
+  func finish(throwing error: EventSubError) {
+    continuation.finish(throwing: error)
+  }
+}
+
+#if canImport(Combine)
+  import Combine
+
+  internal struct EventSubSubjectHandler<T>: EventSubHandler {
+    var subject: PassthroughSubject<T, EventSubError>
+
+    func yield(_ event: Event) {
+      if let event = event as? T {
+        subject.send(event)
+      }
+    }
+
+    func finish(throwing error: EventSubError) {
+      subject.send(completion: .failure(error))
+    }
+  }
+#endif
diff --git a/Sources/Twitch/EventSub/EventSubSubscriptionType.swift b/Sources/Twitch/EventSub/EventSubSubscriptionType.swift
deleted file mode 100644
index ac86e0b..0000000
--- a/Sources/Twitch/EventSub/EventSubSubscriptionType.swift
+++ /dev/null
@@ -1,14 +0,0 @@
-public struct EventSubSubscriptionType {
-  let type: String
-  let version: String
-  let condition: [String: String]
-}
-
-extension EventSubSubscriptionType {
-  public static func channelUpdate(broadcasterID: UserID, version: String = "2") -> Self {
-    .init(
-      type: "channel.update", version: version,
-      condition: ["broadcaster_user_id": broadcasterID])
-  }
-
-}
diff --git a/Sources/Twitch/EventSub/Events/Channel/ChannelFollowEvent.swift b/Sources/Twitch/EventSub/Events/Channel/ChannelFollowEvent.swift
new file mode 100644
index 0000000..284cfdb
--- /dev/null
+++ b/Sources/Twitch/EventSub/Events/Channel/ChannelFollowEvent.swift
@@ -0,0 +1,8 @@
+public struct ChannelFollowEvent: Event {
+  public let userID: String
+  public let userName: String
+  public let userDisplayName: String
+  public let broadcasterID: String
+  public let broadcasterName: String
+  public let broadcasterDisplayName: String
+}
diff --git a/Sources/Twitch/EventSub/Events/Channel/ChannelUpdateEvent.swift b/Sources/Twitch/EventSub/Events/Channel/ChannelUpdateEvent.swift
new file mode 100644
index 0000000..f7093d4
--- /dev/null
+++ b/Sources/Twitch/EventSub/Events/Channel/ChannelUpdateEvent.swift
@@ -0,0 +1,3 @@
+public struct ChannelUpdateEvent: Event {
+
+}
diff --git a/Sources/Twitch/EventSub/Events/Chat/ChatClearEvent.swift b/Sources/Twitch/EventSub/Events/Chat/ChatClearEvent.swift
new file mode 100644
index 0000000..a078ac1
--- /dev/null
+++ b/Sources/Twitch/EventSub/Events/Chat/ChatClearEvent.swift
@@ -0,0 +1,11 @@
+public struct ChatClearEvent: Event {
+  public let broadcasterID: String
+  public let broadcasterLogin: String
+  public let broadcasterName: String
+
+  enum CodingKeys: String, CodingKey {
+    case broadcasterID = "broadcasterUserId"
+    case broadcasterLogin = "broadcasterUserLogin"
+    case broadcasterName = "broadcasterUserName"
+  }
+}
diff --git a/Sources/Twitch/EventSub/Events/Chat/ChatMessageEvent.swift b/Sources/Twitch/EventSub/Events/Chat/ChatMessageEvent.swift
new file mode 100644
index 0000000..710ccf1
--- /dev/null
+++ b/Sources/Twitch/EventSub/Events/Chat/ChatMessageEvent.swift
@@ -0,0 +1,3 @@
+public struct ChatMessageEvent: Event {
+  let chatterUserLogin: String
+}
diff --git a/Sources/Twitch/EventSub/Events/Event.swift b/Sources/Twitch/EventSub/Events/Event.swift
new file mode 100644
index 0000000..591ede0
--- /dev/null
+++ b/Sources/Twitch/EventSub/Events/Event.swift
@@ -0,0 +1,17 @@
+public protocol Event: Decodable {}
+
+internal enum EventType: String, Decodable {
+  case channelFollow = "channel.follow"
+  case chatMessage = "channel.chat.message"
+  case channelUpdate = "channel.update"
+  case chatClear = "channel.chat.clear"
+
+  var event: Event.Type {
+    switch self {
+    case .channelFollow: return ChannelFollowEvent.self
+    case .chatMessage: return ChatMessageEvent.self
+    case .channelUpdate: return ChannelUpdateEvent.self
+    case .chatClear: return ChatClearEvent.self
+    }
+  }
+}
diff --git a/Sources/Twitch/EventSub/KeepaliveTimer.swift b/Sources/Twitch/EventSub/KeepaliveTimer.swift
new file mode 100644
index 0000000..f527ed7
--- /dev/null
+++ b/Sources/Twitch/EventSub/KeepaliveTimer.swift
@@ -0,0 +1,29 @@
+internal actor KeepaliveTimer {
+  private var task: Task<Void, any Error>
+  private var duration: Duration
+
+  private let onTimeout: () -> Void
+
+  init(duration: Duration = .seconds(10), onTimeout: @escaping () -> Void) {
+    self.duration = duration
+    self.onTimeout = onTimeout
+
+    self.task = Task {
+      try await Task.sleep(for: duration)
+    }
+  }
+
+  func reset(duration: Duration? = nil) {
+    if let duration {
+      self.duration = duration
+    }
+
+    self.task.cancel()
+
+    self.task = Task {
+      try await Task.sleep(for: self.duration)
+
+      self.onTimeout()
+    }
+  }
+}
diff --git a/Sources/Twitch/EventSub/Subscriptions/+channel.swift b/Sources/Twitch/EventSub/Subscriptions/+channel.swift
new file mode 100644
index 0000000..bfb731d
--- /dev/null
+++ b/Sources/Twitch/EventSub/Subscriptions/+channel.swift
@@ -0,0 +1,19 @@
+extension EventSubSubscription where EventNotification == ChannelUpdateEvent {
+  public static func channelUpdate(broadcasterID: UserID, version: String = "2") -> Self {
+    .init(
+      type: EventType.channelUpdate.rawValue, version: version,
+      condition: [
+        "broadcaster_user_id": broadcasterID
+      ])
+  }
+}
+
+extension EventSubSubscription where EventNotification == ChannelFollowEvent {
+  public static func channelFollow(broadcasterID: UserID, version: String = "1") -> Self {
+    .init(
+      type: EventType.channelFollow.rawValue, version: version,
+      condition: [
+        "broadcaster_user_id": broadcasterID
+      ])
+  }
+}
diff --git a/Sources/Twitch/EventSub/Subscriptions/+chat.swift b/Sources/Twitch/EventSub/Subscriptions/+chat.swift
new file mode 100644
index 0000000..d56fae5
--- /dev/null
+++ b/Sources/Twitch/EventSub/Subscriptions/+chat.swift
@@ -0,0 +1,25 @@
+extension EventSubSubscription where EventNotification == ChatClearEvent {
+  public static func chatClear(
+    broadcasterID: UserID, userID: UserID, version: String = "1"
+  ) -> Self {
+    .init(
+      type: EventType.chatClear.rawValue, version: version,
+      condition: [
+        "broadcaster_user_id": broadcasterID,
+        "user_id": userID,
+      ])
+  }
+}
+
+extension EventSubSubscription where EventNotification == ChatMessageEvent {
+  public static func chatMessage(
+    broadcasterID: UserID, userID: UserID, version: String = "1"
+  ) -> Self {
+    .init(
+      type: EventType.chatMessage.rawValue, version: version,
+      condition: [
+        "broadcaster_user_id": broadcasterID,
+        "user_id": userID,
+      ])
+  }
+}
diff --git a/Sources/Twitch/EventSub/Subscriptions/EventSubSubscription.swift b/Sources/Twitch/EventSub/Subscriptions/EventSubSubscription.swift
new file mode 100644
index 0000000..0bea760
--- /dev/null
+++ b/Sources/Twitch/EventSub/Subscriptions/EventSubSubscription.swift
@@ -0,0 +1,5 @@
+public struct EventSubSubscription<EventNotification: Event> {
+  let type: String
+  let version: String
+  let condition: [String: String]
+}
diff --git a/Sources/Twitch/EventSub/TwitchClient+EventSub.swift b/Sources/Twitch/EventSub/TwitchClient+EventSub.swift
new file mode 100644
index 0000000..956d24f
--- /dev/null
+++ b/Sources/Twitch/EventSub/TwitchClient+EventSub.swift
@@ -0,0 +1,58 @@
+import Foundation
+
+#if canImport(FoundationNetworking)
+  import FoundationNetworking
+#endif
+
+#if canImport(Combine)
+  import Combine
+#endif
+
+extension TwitchClient {
+  public func eventStream<R: Decodable>(for event: EventSubSubscription<R>) async throws
+    -> AsyncThrowingStream<R, any Error>
+  {
+    let (response, socketID) = try await self.subscribe(to: event)
+
+    return AsyncThrowingStream { continuation in
+      let handler = EventSubContinuationHandler(continuation: continuation)
+      self.eventSubClient.addHandler(handler, for: response.subscription.id, on: socketID)
+    }
+  }
+
+  public func eventListener<R>(
+    on event: EventSubSubscription<R>,
+    eventHandler: @escaping (Result<R, EventSubError>) -> Void
+  ) async throws {
+    let (response, socketID) = try await self.subscribe(to: event)
+
+    let handler = EventSubCallbackHandler(callback: eventHandler)
+    self.eventSubClient.addHandler(handler, for: response.subscription.id, on: socketID)
+  }
+
+  #if canImport(Combine)
+    public func eventPublisher<R>(for event: EventSubSubscription<R>) async throws
+      -> AnyPublisher<R, EventSubError>
+    {
+      let (response, socketID) = try await self.subscribe(to: event)
+
+      let subject = PassthroughSubject<R, EventSubError>()
+      let handler = EventSubSubjectHandler(subject: subject)
+
+      self.eventSubClient.addHandler(handler, for: response.subscription.id, on: socketID)
+
+      return subject.eraseToAnyPublisher()
+    }
+  #endif
+
+  private func subscribe(to event: EventSubSubscription<some Decodable>) async throws
+    -> (response: CreateEventSubResponse, socketID: String)
+  {
+    let socketID = try await self.eventSubClient.getFreeWebsocketID()
+
+    let response = try await self.helix(
+      endpoint: .createEventSubSubscription(using: .websocket(id: socketID), type: event))
+
+    return (response, socketID)
+  }
+}
diff --git a/Sources/Twitch/EventSub/message/EventSubMessage.swift b/Sources/Twitch/EventSub/message/EventSubMessage.swift
new file mode 100644
index 0000000..48a8265
--- /dev/null
+++ b/Sources/Twitch/EventSub/message/EventSubMessage.swift
@@ -0,0 +1,58 @@
+import Foundation
+
+internal struct EventSubMessage: Decodable {
+  let id: String
+  let timestamp: Date
+
+  let payload: EventSubPayload
+
+  enum CodingKeys: String, CodingKey {
+    case metadata, payload
+  }
+
+  init(from decoder: any Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+
+    let metadata = try container.decode(Metadata.self, forKey: .metadata)
+
+    self.id = metadata.messageID
+    self.timestamp = metadata.messageTimestamp
+
+    // depending on the type of message, we need to decode a different payload
+    switch metadata.messageType {
+    case .welcome:
+      self.payload = .welcome(
+        try container.decode(EventSubWelcome.self, forKey: .payload))
+    case .keepalive:
+      self.payload = .keepalive
+    case .notification:
+      self.payload = .notification(
+        try container.decode(EventSubNotification.self, forKey: .payload))
+    case .reconnect:
+      self.payload = .reconnect(
+        try container.decode(EventSubReconnect.self, forKey: .payload))
+    case .revocation:
+      self.payload = .revocation(
+        try container.decode(EventSubRevocation.self, forKey: .payload))
+    }
+  }
+
+  internal struct Metadata: Decodable {
+    let messageID: String
+    let messageType: MessageType
+    let messageTimestamp: Date
+
+    enum CodingKeys: String, CodingKey {
+      case messageID = "messageId"
+      case messageType, messageTimestamp
+    }
+
+    enum MessageType: String, Decodable {
+      case welcome = "session_welcome"
+      case keepalive = "session_keepalive"
+      case notification = "notification"
+      case reconnect = "session_reconnect"
+      case revocation = "revocation"
+    }
+  }
+}
diff --git a/Sources/Twitch/EventSub/message/payloads/EventSubNotification.swift b/Sources/Twitch/EventSub/message/payloads/EventSubNotification.swift
new file mode 100644
index 0000000..4bcaf7e
--- /dev/null
+++ b/Sources/Twitch/EventSub/message/payloads/EventSubNotification.swift
@@ -0,0 +1,23 @@
+import Foundation
+
+internal struct EventSubNotification: Decodable {
+  let subscription: Subscription
+  let event: Event
+
+  enum CodingKeys: CodingKey {
+    case subscription, event
+  }
+
+  internal init(from decoder: Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+
+    self.subscription = try container.decode(Subscription.self, forKey: .subscription)
+    self.event = try container.decode(self.subscription.type.event, forKey: .event)
+  }
+
+  struct Subscription: Decodable {
+    let id: String
+    let type: EventType
+    let version: String
+  }
+}
diff --git a/Sources/Twitch/EventSub/message/payloads/EventSubPayload.swift b/Sources/Twitch/EventSub/message/payloads/EventSubPayload.swift
new file mode 100644
index 0000000..d54fab8
--- /dev/null
+++ b/Sources/Twitch/EventSub/message/payloads/EventSubPayload.swift
@@ -0,0 +1,7 @@
+internal enum EventSubPayload {
+  case welcome(EventSubWelcome)
+  case keepalive
+  case notification(EventSubNotification)
+  case reconnect(EventSubReconnect)
+  case revocation(EventSubRevocation)
+}
diff --git a/Sources/Twitch/EventSub/message/payloads/EventSubReconnect.swift b/Sources/Twitch/EventSub/message/payloads/EventSubReconnect.swift
new file mode 100644
index 0000000..e909ec0
--- /dev/null
+++ b/Sources/Twitch/EventSub/message/payloads/EventSubReconnect.swift
@@ -0,0 +1,24 @@
+import Foundation
+
+internal struct EventSubReconnect: Decodable {
+  let sessionID: String
+  let reconnectURL: URL
+
+  struct Session: Decodable {
+    let id: String
+    let reconnectUrl: URL
+    let connectedAt: Date
+  }
+
+  enum CodingKeys: CodingKey {
+    case session
+  }
+
+  internal init(from decoder: Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+    let session = try container.decode(Session.self, forKey: .session)
+
+    self.sessionID = session.id
+    self.reconnectURL = session.reconnectUrl
+  }
+}
diff --git a/Sources/Twitch/EventSub/message/payloads/EventSubRevocation.swift b/Sources/Twitch/EventSub/message/payloads/EventSubRevocation.swift
new file mode 100644
index 0000000..054197e
--- /dev/null
+++ b/Sources/Twitch/EventSub/message/payloads/EventSubRevocation.swift
@@ -0,0 +1,29 @@
+import Foundation
+
+public struct EventSubRevocation: Decodable {
+  internal let subscriptionID: String
+  public let status: Status
+
+  public enum Status: String, Decodable {
+    case authorizationRevoked = "authorization_revoked"
+    case userRemoved = "user_removed"
+    case versionRemoved = "version_removed"
+  }
+
+  struct Subscription: Decodable {
+    let id: String
+    let status: Status
+  }
+
+  enum CodingKeys: CodingKey {
+    case subscription
+  }
+
+  public init(from decoder: Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+    let subscription = try container.decode(Subscription.self, forKey: .subscription)
+
+    self.status = subscription.status
+    self.subscriptionID = subscription.id
+  }
+}
diff --git a/Sources/Twitch/EventSub/message/payloads/EventSubWelcome.swift b/Sources/Twitch/EventSub/message/payloads/EventSubWelcome.swift
new file mode 100644
index 0000000..9c3bbb4
--- /dev/null
+++ b/Sources/Twitch/EventSub/message/payloads/EventSubWelcome.swift
@@ -0,0 +1,27 @@
+import Foundation
+
+internal struct EventSubWelcome: Decodable {
+  let sessionID: String
+  let keepaliveTimeout: Duration
+  let connectedAt: Date
+
+  internal struct Session: Decodable {
+    let id: String
+    let keepaliveTimeoutSeconds: Int
+
+    let connectedAt: Date
+  }
+
+  enum CodingKeys: CodingKey {
+    case session
+  }
+
+  internal init(from decoder: Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+    let session = try container.decode(Session.self, forKey: .session)
+
+    self.sessionID = session.id
+    self.keepaliveTimeout = .seconds(session.keepaliveTimeoutSeconds)
+    self.connectedAt = session.connectedAt
+  }
+}
diff --git a/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+getAdSchedule.swift b/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+getAdSchedule.swift
index e271f1e..b4acb40 100644
--- a/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+getAdSchedule.swift
+++ b/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+getAdSchedule.swift
@@ -22,13 +22,4 @@ public struct AdSchedule: Decodable {
   public let prerollFreeTime: Int
   public let snoozeCount: Int
   public let snoozeRefreshAt: Date
-
-  enum CodingKeys: String, CodingKey {
-    case nextAdAt = "next_ad_at"
-    case lastAdAt = "last_ad_at"
-    case duration
-    case prerollFreeTime = "preroll_free_time"
-    case snoozeCount = "snooze_count"
-    case snoozeRefreshAt = "snooze_refresh_at"
-  }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+snoozeNextAd.swift b/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+snoozeNextAd.swift
index e0dd4d4..301462e 100644
--- a/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+snoozeNextAd.swift
+++ b/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+snoozeNextAd.swift
@@ -26,10 +26,4 @@ public struct SnoozeResult: Decodable {
   public let snoozeCount: Int
   public let snoozeRefreshAt: Date
   public let nextAdAt: Date
-
-  enum CodingKeys: String, CodingKey {
-    case snoozeCount = "snooze_count"
-    case snoozeRefreshAt = "snooze_refresh_at"
-    case nextAdAt = "next_ad_at"
-  }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+startCommercial.swift b/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+startCommercial.swift
index aa8f325..9979db8 100644
--- a/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+startCommercial.swift
+++ b/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+startCommercial.swift
@@ -26,7 +26,7 @@ private struct StartCommercialRequestBody: Encodable {
   let length: Int
 
   enum CodingKeys: String, CodingKey {
-    case broadcasterID = "broadcaster_id"
+    case broadcasterID = "broadcasterId"
     case length
   }
 }
@@ -35,10 +35,4 @@ public struct Commercial: Decodable {
   public let length: Int
   public let message: String
   public let retryAfter: Int
-
-  enum CodingKeys: String, CodingKey {
-    case length
-    case message
-    case retryAfter = "retry_after"
-  }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Analytics/HelixEndpoint+getExtensionAnalytics.swift b/Sources/Twitch/Helix/Endpoints/Analytics/HelixEndpoint+getExtensionAnalytics.swift
index c28130a..f054e6d 100644
--- a/Sources/Twitch/Helix/Endpoints/Analytics/HelixEndpoint+getExtensionAnalytics.swift
+++ b/Sources/Twitch/Helix/Endpoints/Analytics/HelixEndpoint+getExtensionAnalytics.swift
@@ -32,15 +32,14 @@ public struct ExtensionReport: Decodable {
   public let range: DateInterval
 
   enum CodingKeys: String, CodingKey {
-    case extensionID = "extension_id"
+    case extensionID = "extensionId"
     case url = "URL"
     case type
-    case range = "date_range"
+    case range = "dateRange"
   }
 
   enum DateRangeCodingKeys: String, CodingKey {
-    case startedAt = "started_at"
-    case endedAt = "ended_at"
+    case startedAt, endedAt
   }
 
   public init(from decoder: Decoder) throws {
diff --git a/Sources/Twitch/Helix/Endpoints/Analytics/HelixEndpoint+getGameAnalytics.swift b/Sources/Twitch/Helix/Endpoints/Analytics/HelixEndpoint+getGameAnalytics.swift
index 83a95a1..06c1641 100644
--- a/Sources/Twitch/Helix/Endpoints/Analytics/HelixEndpoint+getGameAnalytics.swift
+++ b/Sources/Twitch/Helix/Endpoints/Analytics/HelixEndpoint+getGameAnalytics.swift
@@ -31,15 +31,14 @@ public struct GameReport: Decodable {
   public let range: DateInterval
 
   enum CodingKeys: String, CodingKey {
-    case gameID = "game_id"
+    case gameID = "gameId"
     case url = "URL"
     case type
-    case range = "date_range"
+    case range = "dateRange"
   }
 
   enum DateRangeCodingKeys: String, CodingKey {
-    case startedAt = "started_at"
-    case endedAt = "ended_at"
+    case startedAt, endedAt
   }
 
   public init(from decoder: Decoder) throws {
diff --git a/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannelEditors.swift b/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannelEditors.swift
index e4b0bc7..caf22bf 100644
--- a/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannelEditors.swift
+++ b/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannelEditors.swift
@@ -20,8 +20,7 @@ public struct Editor: Decodable {
   public let createdAt: Date
 
   enum CodingKeys: String, CodingKey {
-    case userID = "user_id"
-    case userName = "user_name"
-    case createdAt = "created_at"
+    case userID = "userId"
+    case userName, createdAt
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannelFollowers.swift b/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannelFollowers.swift
index 242fcd6..b9c4b0d 100644
--- a/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannelFollowers.swift
+++ b/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannelFollowers.swift
@@ -61,9 +61,7 @@ public struct Follower: Decodable {
   public let followedAt: Date
 
   enum CodingKeys: String, CodingKey {
-    case userID = "user_id"
-    case userLogin = "user_login"
-    case userName = "user_name"
-    case followedAt = "followed_at"
+    case userID = "userId"
+    case userLogin, userName, followedAt
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannels.swift b/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannels.swift
index 94190d6..e15149d 100644
--- a/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannels.swift
+++ b/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannels.swift
@@ -26,14 +26,11 @@ public struct Broadcaster: Decodable {
   public let tags: [String]
 
   enum CodingKeys: String, CodingKey {
-    case id = "broadcaster_id"
-    case login = "broadcaster_login"
-    case name = "broadcaster_name"
-    case language = "broadcaster_language"
-    case gameID = "game_id"
-    case gameName = "game_name"
-    case title
-    case delay
-    case tags
+    case id = "broadcasterId"
+    case login = "broadcasterLogin"
+    case name = "broadcasterName"
+    case language = "broadcasterLanguage"
+    case gameID = "gameId"
+    case gameName, title, delay, tags
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getFollowedChannels.swift b/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getFollowedChannels.swift
index f6cf818..84accb5 100644
--- a/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getFollowedChannels.swift
+++ b/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getFollowedChannels.swift
@@ -60,9 +60,7 @@ public struct Follow: Decodable {
   public let followedAt: Date
 
   enum CodingKeys: String, CodingKey {
-    case broadcasterID = "broadcaster_id"
-    case broadcasterLogin = "broadcaster_login"
-    case broadcasterName = "broadcaster_name"
-    case followedAt = "followed_at"
+    case broadcasterID = "broadcasterId"
+    case broadcasterLogin, broadcasterName, followedAt
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+updateChannel.swift b/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+updateChannel.swift
index 2753d31..d8e138a 100644
--- a/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+updateChannel.swift
+++ b/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+updateChannel.swift
@@ -60,18 +60,14 @@ internal struct UpdateChannelRequestBody: Encodable {
   }
 
   enum CodingKeys: String, CodingKey {
-    case gameID = "game_id"
-    case broadcasterLanguage = "broadcaster_language"
-    case title
-    case delay
-    case tags
-    case contentClassificationLabels = "content_classification_labels"
-    case isBrandedContent = "is_branded_content"
+    case gameID = "gameId"
+    case broadcasterLanguage, title, delay, tags
+    case contentClassificationLabels
+    case isBrandedContent
   }
 
   enum LabelCodingKeys: String, CodingKey {
-    case id
-    case isEnabled = "is_enabled"
+    case id, isEnabled
   }
 
   func encode(to encoder: Encoder) throws {
diff --git a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChannelBadges.swift b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChannelBadges.swift
index d71cdf8..87fc779 100644
--- a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChannelBadges.swift
+++ b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChannelBadges.swift
@@ -18,7 +18,7 @@ public struct BadgeSet: Decodable {
   public let badges: [Badge]
 
   enum CodingKeys: String, CodingKey {
-    case setID = "set_id"
+    case setID = "setId"
     case badges = "versions"
   }
 }
@@ -33,15 +33,10 @@ public struct Badge: Decodable {
   public let clickUrl: String?
 
   enum CodingKeys: String, CodingKey {
-    case id = "id"
-    case imageUrl1x = "image_url_1x"
-    case imageUrl2x = "image_url_2x"
-    case imageUrl4x = "image_url_4x"
-    case title = "title"
-    case description = "description"
-
-    case clickAction = "click_action"
-    case clickUrl = "click_url"
+    case id, title, description, clickAction, clickUrl
+    case imageUrl1x = "imageUrl1X"
+    case imageUrl2x = "imageUrl2X"
+    case imageUrl4x = "imageUrl4X"
   }
 
   public init(from decoder: Decoder) throws {
diff --git a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChannelEmotes.swift b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChannelEmotes.swift
index c6e09e2..a48c1f4 100644
--- a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChannelEmotes.swift
+++ b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChannelEmotes.swift
@@ -36,14 +36,10 @@ public struct ChannelEmote: Decodable {
   public let themeMode: [Emote.ThemeMode]
 
   enum CodingKeys: String, CodingKey {
-    case id = "id"
-    case name = "name"
-    case tier = "tier"
-    case type = "emote_type"
-    case setID = "emote_set_id"
-    case format = "format"
-    case scale = "scale"
-    case themeMode = "theme_mode"
+    case id, name, tier
+    case type = "emoteType"
+    case setID = "emoteSetId"
+    case format, scale, themeMode
   }
 
   public func getURL(
diff --git a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChatSettings.swift b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChatSettings.swift
index 1ec8ac2..38636cd 100644
--- a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChatSettings.swift
+++ b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChatSettings.swift
@@ -34,14 +34,14 @@ public struct ChatSettings: Decodable {
   public let nonModeratorChatDelay: Int?
 
   enum CodingKeys: String, CodingKey {
-    case broadcasterID = "broadcaster_id"
-    case slowModeWaitTime = "slow_mode_wait_time"
-    case followerModeDuration = "follower_mode_duration"
-    case subscriberMode = "subscriber_mode"
-    case emoteMode = "emote_mode"
-    case uniqueChatMode = "unique_chat_mode"
-
-    case nonModeratorChatDelayDuration = "non_moderator_chat_delay_duration"
+    case broadcasterID = "broadcasterId"
+    case slowModeWaitTime
+    case followerModeDuration
+    case subscriberMode
+    case emoteMode
+    case uniqueChatMode
+
+    case nonModeratorChatDelayDuration
   }
 
   public init(from decoder: Decoder) throws {
diff --git a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChatters.swift b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChatters.swift
index 1bdbccb..ab9291d 100644
--- a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChatters.swift
+++ b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChatters.swift
@@ -47,8 +47,7 @@ public struct Chatter: Decodable {
   public let userName: String
 
   enum CodingKeys: String, CodingKey {
-    case userID = "user_id"
-    case userLogin = "user_login"
-    case userName = "user_name"
+    case userID = "userId"
+    case userLogin, userName
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getEmoteSets.swift b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getEmoteSets.swift
index 8b81b10..bef9c6c 100644
--- a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getEmoteSets.swift
+++ b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getEmoteSets.swift
@@ -42,14 +42,11 @@ public struct SetEmote: Decodable {
   public let themeMode: [Emote.ThemeMode]
 
   enum CodingKeys: String, CodingKey {
-    case id = "id"
-    case name = "name"
-    case type = "emote_type"
-    case setID = "emote_set_id"
-    case ownerID = "owner_id"
-    case format = "format"
-    case scale = "scale"
-    case themeMode = "theme_mode"
+    case id, name
+    case type = "emoteType"
+    case setID = "emoteSetId"
+    case ownerID = "ownerId"
+    case format, scale, themeMode
   }
 
   public func getURL(
diff --git a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getGlobalEmotes.swift b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getGlobalEmotes.swift
index e5bb93f..050e02e 100644
--- a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getGlobalEmotes.swift
+++ b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getGlobalEmotes.swift
@@ -32,14 +32,6 @@ public struct GlobalEmote: Decodable {
   public let scale: [Emote.Scale]
   public let themeMode: [Emote.ThemeMode]
 
-  enum CodingKeys: String, CodingKey {
-    case id = "id"
-    case name = "name"
-    case format = "format"
-    case scale = "scale"
-    case themeMode = "theme_mode"
-  }
-
   public func getURL(
     from templateUrl: String, format: Emote.Format = .png, scale: Emote.Scale = .large,
     themeMode: Emote.ThemeMode = .dark
diff --git a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getUserColor.swift b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getUserColor.swift
index 2e337b5..e0b777f 100644
--- a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getUserColor.swift
+++ b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getUserColor.swift
@@ -22,9 +22,7 @@ public struct UserColor: Decodable {
   public let color: String
 
   enum CodingKeys: String, CodingKey {
-    case userID = "user_id"
-    case userLogin = "user_login"
-    case userName = "user_name"
-    case color
+    case userID = "userId"
+    case userLogin, userName, color
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+sendChatMessage.swift b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+sendChatMessage.swift
index f043211..773cb6d 100644
--- a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+sendChatMessage.swift
+++ b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+sendChatMessage.swift
@@ -35,10 +35,10 @@ public struct ChatMessageResponse: Decodable {
   public let dropReason: DropReason?
 
   enum CodingKeys: String, CodingKey {
-    case messageID = "message_id"
-    case isSent = "is_sent"
+    case messageID = "messageId"
+    case isSent
 
-    case dropReason = "drop_reason"
+    case dropReason
   }
 
   public struct DropReason: Decodable {
@@ -55,10 +55,10 @@ internal struct SendChatMessageRequestBody: Encodable {
   let replyParentMessageID: String?
 
   enum CodingKeys: String, CodingKey {
-    case broadcasterID = "broadcaster_id"
-    case senderID = "sender_id"
+    case broadcasterID = "broadcasterId"
+    case senderID = "senderId"
     case message
 
-    case replyParentMessageID = "reply_parent_message_id"
+    case replyParentMessageID = "replyParentMessageId"
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+updateChatSettings.swift b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+updateChatSettings.swift
index 5acbd97..ec34edc 100644
--- a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+updateChatSettings.swift
+++ b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+updateChatSettings.swift
@@ -58,19 +58,6 @@ internal struct UpdateChatSettingsRequestBody: Encodable {
   var nonModeratorChatDelay: Bool?
   var nonModeratorChatDelayDuration: Int?
 
-  enum CodingKeys: String, CodingKey {
-    case slowMode = "slow_mode"
-    case slowModeWaitTime = "slow_mode_wait_time"
-    case followerMode = "follower_mode"
-    case followerModeDuration = "follower_mode_duration"
-    case subscriberMode = "subscriber_mode"
-    case emoteMode = "emote_mode"
-    case uniqueChatMode = "unique_chat_mode"
-
-    case nonModeratorChatDelay = "non_moderator_chat_delay"
-    case nonModeratorChatDelayDuration = "non_moderator_chat_delay_duration"
-  }
-
   init(_ settings: [ChatSetting]) {
     for setting in settings {
       switch setting {
diff --git a/Sources/Twitch/Helix/Endpoints/EventSub/HelixEndpoint+createEventSubSubscription.swift b/Sources/Twitch/Helix/Endpoints/EventSub/HelixEndpoint+createEventSubSubscription.swift
index f810c7f..4746e27 100644
--- a/Sources/Twitch/Helix/Endpoints/EventSub/HelixEndpoint+createEventSubSubscription.swift
+++ b/Sources/Twitch/Helix/Endpoints/EventSub/HelixEndpoint+createEventSubSubscription.swift
@@ -3,10 +3,11 @@ import Foundation
 extension HelixEndpoint
 where
   EndpointResponseType == HelixEndpointResponseTypes.Normal,
-  ResponseType == CreateEventSubResponse, HelixResponseType == EventSubSubscription
+  ResponseType == CreateEventSubResponse,
+  HelixResponseType == CreateEventSubResponse.EventSubSubscription
 {
   public static func createEventSubSubscription(
-    using transport: EventSubTransport, type: EventSubSubscriptionType
+    using transport: EventSubTransport, type: EventSubSubscription<some Event>
   ) -> Self {
     .init(
       method: "POST", path: "eventsub/subscriptions",
@@ -20,7 +21,7 @@ where
           throw HelixError.noDataInResponse
         }
 
-        guard let cost = $0.cost,
+        guard let total = $0.total,
           let totalCost = $0.totalCost,
           let maxTotalCost = $0.maxTotalCost
         else {
@@ -28,7 +29,7 @@ where
         }
 
         return CreateEventSubResponse(
-          subscription: subscription, cost: cost, totalCost: totalCost,
+          subscription: subscription, total: total, totalCost: totalCost,
           maxTotalCost: maxTotalCost)
       })
   }
@@ -47,10 +48,10 @@ public enum EventSubTransport {
         conduitID: nil)
     case .websocket(let id):
       return .init(
-        method: "webhook", callback: nil, secret: nil, sessionID: id, conduitID: nil)
+        method: "websocket", callback: nil, secret: nil, sessionID: id, conduitID: nil)
     case .conduit(let id):
       return .init(
-        method: "webhook", callback: nil, secret: nil, sessionID: nil, conduitID: id)
+        method: "conduit", callback: nil, secret: nil, sessionID: nil, conduitID: id)
     }
   }
 }
@@ -58,45 +59,45 @@ public enum EventSubTransport {
 public struct CreateEventSubResponse {
   public let subscription: EventSubSubscription
 
-  public let cost: Int
+  public let total: Int
   public let totalCost: Int
   public let maxTotalCost: Int
-}
 
-public struct EventSubSubscription: Decodable {
-  public let id: String
-  public let status: String
-  public let type: String
-  public let version: String
-  public let condition: [String: String]
-  public let createdAt: Date
-  public let transport: TransportResponse
-}
+  public struct EventSubSubscription: Decodable {
+    public let id: String
+    public let status: String
+    public let type: String
+    public let version: String
+    public let condition: [String: String]
+    public let createdAt: Date
+    public let transport: TransportResponse
+  }
 
-public struct TransportResponse: Decodable {
-  public let method: String
+  public struct TransportResponse: Decodable {
+    public let method: String
 
-  // only included if method is "webhook"
-  public let callback: String?
-  public let secret: String?
+    // only included if method is "webhook"
+    public let callback: String?
+    public let secret: String?
 
-  // only included if method is "websocket"
-  public let sessionID: String?
-  public let connectedAt: Date?
+    // only included if method is "websocket"
+    public let sessionID: String?
+    public let connectedAt: Date?
 
-  // only included if method is "conduit"
-  public let conduitID: String?
+    // only included if method is "conduit"
+    public let conduitID: String?
 
-  enum CodingKeys: String, CodingKey {
-    case method
+    enum CodingKeys: String, CodingKey {
+      case method
 
-    case callback
-    case secret
+      case callback
+      case secret
 
-    case sessionID = "session_id"
-    case connectedAt = "connected_at"
+      case sessionID = "sessionId"
+      case connectedAt
 
-    case conduitID = "conduit_id"
+      case conduitID = "conduitId"
+    }
   }
 }
 
@@ -106,13 +107,6 @@ private struct CreateEventSubRequestBody: Encodable {
   let condition: [String: String]
 
   let transport: Transport
-
-  enum CodingKeys: String, CodingKey {
-    case type
-    case version
-    case condition
-    case transport
-  }
 }
 
 private struct Transport: Encodable {
@@ -123,10 +117,8 @@ private struct Transport: Encodable {
   let conduitID: String?
 
   enum CodingKeys: String, CodingKey {
-    case method
-    case callback
-    case secret
-    case sessionID = "session_id"
-    case conduitID = "conduit_id"
+    case method, callback, secret
+    case sessionID = "sessionId"
+    case conduitID = "conduitId"
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Games/HelixEndpoint+getGames.swift b/Sources/Twitch/Helix/Endpoints/Games/HelixEndpoint+getGames.swift
index 1cb67ab..d62b6f3 100644
--- a/Sources/Twitch/Helix/Endpoints/Games/HelixEndpoint+getGames.swift
+++ b/Sources/Twitch/Helix/Endpoints/Games/HelixEndpoint+getGames.swift
@@ -31,9 +31,7 @@ public struct Game: Decodable {
   public let igdbID: String
 
   enum CodingKeys: String, CodingKey {
-    case id
-    case name
-    case boxArtUrl = "box_art_url"
-    case igdbID = "igdb_id"
+    case id, name, boxArtUrl
+    case igdbID = "igdbId"
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+banUser.swift b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+banUser.swift
index fa2cc82..66207da 100644
--- a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+banUser.swift
+++ b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+banUser.swift
@@ -39,9 +39,8 @@ private struct BanUserBody: Encodable {
   let duration: Duration?
 
   enum CodingKeys: String, CodingKey {
-    case userID = "user_id"
-    case reason
-    case duration
+    case userID = "userId"
+    case reason, duration
   }
 }
 
@@ -53,10 +52,9 @@ public struct Ban: Decodable {
   public let endTime: Date?
 
   enum CodingKeys: String, CodingKey {
-    case broadcasterID = "broadcaster_id"
-    case moderatorID = "moderator_id"
-    case userID = "user_id"
-    case createdAt = "created_at"
-    case endTime = "end_time"
+    case broadcasterID = "broadcasterId"
+    case moderatorID = "moderatorId"
+    case userID = "userId"
+    case createdAt, endTime
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+checkAutomodStatus.swift b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+checkAutomodStatus.swift
index f8dd0b2..84c546e 100644
--- a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+checkAutomodStatus.swift
+++ b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+checkAutomodStatus.swift
@@ -35,7 +35,7 @@ public struct AutomodStatus: Decodable {
   public let isPermitted: Bool
 
   enum CodingKeys: String, CodingKey {
-    case messageID = "msg_id"
-    case isPermitted = "is_permitted"
+    case messageID = "msgId"
+    case isPermitted
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getAutomodSettings.swift b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getAutomodSettings.swift
index 2a44964..54c69d5 100644
--- a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getAutomodSettings.swift
+++ b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getAutomodSettings.swift
@@ -39,17 +39,11 @@ public struct AutomodSettings: Decodable {
   public let sexBasedTerms: Int
 
   enum CodingKeys: String, CodingKey {
-    case broadcasterID = "broadcaster_id"
-    case moderatorID = "moderator_id"
-    case overallLevel = "overall_level"
+    case broadcasterID = "broadcasterId"
+    case moderatorID = "moderatorId"
+    case overallLevel
 
-    case disability
-    case aggression
-    case sexualitySexOrGender = "sexuality_sex_or_gender"
-    case misogyny
-    case bullying
-    case swearing
-    case raceEthnicityOrReligion = "race_ethnicity_or_religion"
-    case sexBasedTerms = "sex_based_terms"
+    case disability, aggression, sexualitySexOrGender, misogyny, bullying, swearing
+    case raceEthnicityOrReligion, sexBasedTerms
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getBannedUsers.swift b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getBannedUsers.swift
index fc59b46..e40c8a3 100644
--- a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getBannedUsers.swift
+++ b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getBannedUsers.swift
@@ -42,14 +42,12 @@ public struct BannedUser: Decodable {
   public let moderatorName: String
 
   enum CodingKeys: String, CodingKey {
-    case userID = "user_id"
-    case userLogin = "user_login"
-    case userName = "user_name"
-    case expiresAt = "expires_at"
-    case createdAt = "created_at"
-    case reason
-    case moderatorID = "moderator_id"
-    case moderatorLogin = "moderator_login"
-    case moderatorName = "moderator_name"
+    case userID = "userId"
+    case userLogin, userName
+
+    case expiresAt, createdAt, reason
+
+    case moderatorID = "moderatorId"
+    case moderatorLogin, moderatorName
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getBlockedTerms.swift b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getBlockedTerms.swift
index 2775746..6ff60db 100644
--- a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getBlockedTerms.swift
+++ b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getBlockedTerms.swift
@@ -39,14 +39,9 @@ public struct BlockedTerm: Decodable {
   public let expiresAt: Date?
 
   enum CodingKeys: String, CodingKey {
-    case broadcasterID = "broadcaster_id"
-    case moderatorID = "moderator_id"
+    case broadcasterID = "broadcasterId"
+    case moderatorID = "moderatorId"
 
-    case id, text
-
-    case createdAt = "created_at"
-    case updatedAt = "updated_at"
-
-    case expiresAt = "expires_at"
+    case id, text, createdAt, updatedAt, expiresAt
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getModeratedChannels.swift b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getModeratedChannels.swift
index 5f834d6..5e147aa 100644
--- a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getModeratedChannels.swift
+++ b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getModeratedChannels.swift
@@ -28,8 +28,8 @@ public struct ModeratedChannel: Decodable {
   public let displayName: String
 
   enum CodingKeys: String, CodingKey {
-    case id = "broadcaster_id"
-    case login = "broadcaster_login"
-    case displayName = "broadcaster_name"
+    case id = "broadcasterId"
+    case login = "broadcasterLogin"
+    case displayName = "broadcasterName"
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getModerators.swift b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getModerators.swift
index e671b8c..d7e2338 100644
--- a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getModerators.swift
+++ b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getModerators.swift
@@ -28,8 +28,8 @@ public struct Moderator: Decodable {
   public let displayName: String
 
   enum CodingKeys: String, CodingKey {
-    case id = "user_id"
-    case login = "user_login"
-    case displayName = "user_name"
+    case id = "userId"
+    case login = "userLogin"
+    case displayName = "userName"
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getShieldModeStatus.swift b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getShieldModeStatus.swift
index c6c917a..9d84f73 100644
--- a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getShieldModeStatus.swift
+++ b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getShieldModeStatus.swift
@@ -34,10 +34,8 @@ public struct ShieldModeStatus: Decodable {
   @NilOnTypeMismatch public var lastActivatedAt: Date?
 
   enum CodingKeys: String, CodingKey {
-    case isActive = "is_active"
-    case moderatorID = "moderator_id"
-    case moderatorName = "moderator_name"
-    case moderatorLogin = "moderator_login"
-    case lastActivatedAt = "last_activated_at"
+    case moderatorID = "moderatorId"
+    case moderatorName, moderatorLogin
+    case isActive, lastActivatedAt
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getVIPs.swift b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getVIPs.swift
index 861fef6..b2d9f2d 100644
--- a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getVIPs.swift
+++ b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getVIPs.swift
@@ -28,8 +28,8 @@ public struct VIP: Decodable {
   public let displayName: String
 
   enum CodingKeys: String, CodingKey {
-    case id = "user_id"
-    case login = "user_login"
-    case displayName = "user_name"
+    case id = "userId"
+    case login = "userLogin"
+    case displayName = "userName"
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+updateAutomodSettings.swift b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+updateAutomodSettings.swift
index 9dcd8d1..53cefb7 100644
--- a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+updateAutomodSettings.swift
+++ b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+updateAutomodSettings.swift
@@ -49,9 +49,6 @@ public struct AutomodConfiguration: Encodable {
 
   enum CodingKeys: String, CodingKey {
     case aggression, bullying, disability, misogyny, swearing
-
-    case raceEthnicityOrReligion = "race_ethnicity_or_religion"
-    case sexBasedTerms = "sex_based_terms"
-    case sexualitySexOrGender = "sexuality_sex_or_gender"
+    case raceEthnicityOrReligion, sexBasedTerms, sexualitySexOrGender
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Search/HelixEndpoint+searchCategories.swift b/Sources/Twitch/Helix/Endpoints/Search/HelixEndpoint+searchCategories.swift
index 9fff95d..69d0282 100644
--- a/Sources/Twitch/Helix/Endpoints/Search/HelixEndpoint+searchCategories.swift
+++ b/Sources/Twitch/Helix/Endpoints/Search/HelixEndpoint+searchCategories.swift
@@ -28,10 +28,4 @@ public struct Category: Decodable {
   public let id: String
   public let name: String
   public let boxArtUrl: String
-
-  enum CodingKeys: String, CodingKey {
-    case id
-    case name
-    case boxArtUrl = "box_art_url"
-  }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Search/HelixEndpoint+searchChannels.swift b/Sources/Twitch/Helix/Endpoints/Search/HelixEndpoint+searchChannels.swift
index 68a28bc..3f63122 100644
--- a/Sources/Twitch/Helix/Endpoints/Search/HelixEndpoint+searchChannels.swift
+++ b/Sources/Twitch/Helix/Endpoints/Search/HelixEndpoint+searchChannels.swift
@@ -45,32 +45,16 @@ public struct Channel: Decodable {
 
   enum CodingKeys: String, CodingKey {
     case id
-    case login = "broadcaster_login"
-    case name = "display_name"
-    case language = "broadcaster_language"
+    case login = "broadcasterLogin"
+    case name = "displayName"
+    case language = "broadcasterLanguage"
 
-    case gameID = "game_id"
-    case gameName = "game_name"
+    case gameID = "gameId"
+    case gameName
 
-    case isLive = "is_live"
-    case tags
+    case isLive, tags
 
-    case profilePictureURL = "thumbnail_url"
-    case title
-    case startedAt = "started_at"
-  }
-}
-
-@propertyWrapper public struct NilOnTypeMismatch<Value> {
-  public var wrappedValue: Value?
-  public init(wrappedValue: Value?) {
-    self.wrappedValue = wrappedValue
-  }
-}
-
-extension NilOnTypeMismatch: Decodable where Value: Decodable {
-  public init(from decoder: Decoder) throws {
-    let container = try decoder.singleValueContainer()
-    self.wrappedValue = try? container.decode(Value.self)
+    case profilePictureURL = "thumbnailUrl"
+    case title, startedAt
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Streams/HelixEndpoint+getStreams.swift b/Sources/Twitch/Helix/Endpoints/Streams/HelixEndpoint+getStreams.swift
index bede6e5..eeb49ee 100644
--- a/Sources/Twitch/Helix/Endpoints/Streams/HelixEndpoint+getStreams.swift
+++ b/Sources/Twitch/Helix/Endpoints/Streams/HelixEndpoint+getStreams.swift
@@ -61,21 +61,16 @@ public struct Stream: Decodable {
 
   enum CodingKeys: String, CodingKey {
     case id
-    case userID = "user_id"
-    case userLogin = "user_login"
-    case userName = "user_name"
+    case userID = "userId"
+    case userLogin, userName
 
-    case gameID = "game_id"
-    case gameName = "game_name"
+    case gameID = "gameId"
+    case gameName
 
-    case type
-    case title
-    case language
-    case tags
-    case isMature = "is_mature"
+    case type, title, language, tags, isMature
 
-    case viewerCount = "viewer_count"
-    case startedAt = "started_at"
-    case thumbnailURL = "thumbnail_url"
+    case viewerCount
+    case startedAt
+    case thumbnailURL = "thumbnailUrl"
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Subscriptions/HelixEndpoint+checkSubscription.swift b/Sources/Twitch/Helix/Endpoints/Subscriptions/HelixEndpoint+checkSubscription.swift
index 95843d3..95a86af 100644
--- a/Sources/Twitch/Helix/Endpoints/Subscriptions/HelixEndpoint+checkSubscription.swift
+++ b/Sources/Twitch/Helix/Endpoints/Subscriptions/HelixEndpoint+checkSubscription.swift
@@ -31,14 +31,12 @@ public struct Subscription: Decodable {
   public let tier: SubTier
 
   enum CodingKeys: String, CodingKey {
-    case broadcasterID = "broadcaster_id"
-    case broadcasterLogin = "broadcaster_login"
-    case broadcasterName = "broadcaster_name"
+    case broadcasterID = "broadcasterId"
+    case broadcasterLogin, broadcasterName
 
-    case isGift = "is_gift"
-    case gifterID = "gifter_id"
-    case gifterLogin = "gifter_login"
-    case gifterName = "gifter_name"
+    case isGift
+    case gifterID = "gifterId"
+    case gifterLogin, gifterName
 
     case tier
   }
diff --git a/Sources/Twitch/Helix/Endpoints/Subscriptions/HelixEndpoint+getSubscribers.swift b/Sources/Twitch/Helix/Endpoints/Subscriptions/HelixEndpoint+getSubscribers.swift
index d115106..debe85b 100644
--- a/Sources/Twitch/Helix/Endpoints/Subscriptions/HelixEndpoint+getSubscribers.swift
+++ b/Sources/Twitch/Helix/Endpoints/Subscriptions/HelixEndpoint+getSubscribers.swift
@@ -59,21 +59,17 @@ public struct Subscriber: Decodable {
   public let tier: SubTier
 
   enum CodingKeys: String, CodingKey {
-    case userID = "user_id"
-    case userLogin = "user_login"
-    case userName = "user_name"
+    case userID = "userId"
+    case userLogin, userName
 
-    case broadcasterID = "broadcaster_id"
-    case broadcasterLogin = "broadcaster_login"
-    case broadcasterName = "broadcaster_name"
+    case broadcasterID = "broadcasterId"
+    case broadcasterLogin, broadcasterName
 
-    case isGift = "is_gift"
-    case gifterID = "gifter_id"
-    case gifterLogin = "gifter_login"
-    case gifterName = "gifter_name"
+    case isGift
+    case gifterID = "gifterId"
+    case gifterLogin, gifterName
 
-    case planName = "plan_name"
-    case tier
+    case planName, tier
   }
 
   public init(from decoder: Decoder) throws {
diff --git a/Sources/Twitch/Helix/Endpoints/Users/HelixEndpoint+getUserBlocklist.swift b/Sources/Twitch/Helix/Endpoints/Users/HelixEndpoint+getUserBlocklist.swift
index 1142b96..2071347 100644
--- a/Sources/Twitch/Helix/Endpoints/Users/HelixEndpoint+getUserBlocklist.swift
+++ b/Sources/Twitch/Helix/Endpoints/Users/HelixEndpoint+getUserBlocklist.swift
@@ -29,8 +29,8 @@ public struct BlockedUser: Decodable {
   public let displayName: String
 
   enum CodingKeys: String, CodingKey {
-    case userID = "user_id"
-    case userLogin = "user_login"
-    case displayName = "display_name"
+    case userID = "userId"
+    case userLogin
+    case displayName
   }
 }
diff --git a/Sources/Twitch/Helix/Endpoints/Users/HelixEndpoint+getUsers.swift b/Sources/Twitch/Helix/Endpoints/Users/HelixEndpoint+getUsers.swift
index c4d014b..42e94fb 100644
--- a/Sources/Twitch/Helix/Endpoints/Users/HelixEndpoint+getUsers.swift
+++ b/Sources/Twitch/Helix/Endpoints/Users/HelixEndpoint+getUsers.swift
@@ -36,20 +36,4 @@ public struct User: Decodable {
     case affiliate
     case none = ""
   }
-
-  enum CodingKeys: String, CodingKey {
-    case id
-    case login
-    case displayName = "display_name"
-
-    case type
-    case broadcasterType = "broadcaster_type"
-
-    case description
-    case profileImageUrl = "profile_image_url"
-    case offlineImageUrl = "offline_image_url"
-    case createdAt = "created_at"
-
-    case email
-  }
 }
diff --git a/Sources/Twitch/Helix/HelixEndpoint.swift b/Sources/Twitch/Helix/HelixEndpoint.swift
index 43fe088..ca74d49 100644
--- a/Sources/Twitch/Helix/HelixEndpoint.swift
+++ b/Sources/Twitch/Helix/HelixEndpoint.swift
@@ -31,7 +31,10 @@ public struct HelixEndpoint<
     self.makeResponse = makeResponse
   }
 
-  internal func makeRequest(using authentication: TwitchCredentials) -> URLRequest {
+  internal func makeRequest(
+    using authentication: TwitchCredentials,
+    encoder: JSONEncoder
+  ) -> URLRequest {
     var url = baseURL.appending(path: path)
 
     let queryItems =
@@ -51,7 +54,7 @@ public struct HelixEndpoint<
 
     if let body = makeBody(authentication) {
       urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
-      urlRequest.httpBody = try? JSONEncoder().encode(body)
+      urlRequest.httpBody = try? encoder.encode(body)
     }
 
     return urlRequest
@@ -85,17 +88,3 @@ public enum HelixEndpointResponseTypes {
 
 public typealias UserID = String
 public typealias PaginationCursor = String
-
-#if canImport(FoundationNetworking)
-  extension URL {
-    internal func appending(queryItems: [URLQueryItem]) -> URL {
-      var components = URLComponents(url: self, resolvingAgainstBaseURL: false)!
-      components.queryItems = (components.queryItems ?? []) + queryItems
-      return components.url!
-    }
-
-    internal func appending(path: String) -> URL {
-      appendingPathComponent(path)
-    }
-  }
-#endif
diff --git a/Sources/Twitch/Helix/HelixResponse.swift b/Sources/Twitch/Helix/HelixResponse.swift
index fad1b62..7e7dd40 100644
--- a/Sources/Twitch/Helix/HelixResponse.swift
+++ b/Sources/Twitch/Helix/HelixResponse.swift
@@ -1,26 +1,14 @@
+// TODO: include raw data in response for better errors
 internal struct HelixResponse<T: Decodable>: Decodable {
   let data: [T]
 
   let pagination: Pagination?
-  let total: Int?
   let points: Int?
   let template: String?
-
-  let cost: Int?
+  let total: Int?
   let totalCost: Int?
   let maxTotalCost: Int?
 
-  enum CodingKeys: String, CodingKey {
-    case data
-    case pagination
-    case total
-    case points
-    case template
-    case cost
-    case totalCost = "total_cost"
-    case maxTotalCost = "max_total_cost"
-  }
-
   internal struct Pagination: Decodable { let cursor: String? }
 }
 
diff --git a/Sources/Twitch/Helix/TwitchClient+Helix.swift b/Sources/Twitch/Helix/TwitchClient+Helix.swift
index 3fcf072..2dfcab5 100644
--- a/Sources/Twitch/Helix/TwitchClient+Helix.swift
+++ b/Sources/Twitch/Helix/TwitchClient+Helix.swift
@@ -106,7 +106,7 @@ extension TwitchClient {
   private func data(
     for endpoint: HelixEndpoint<some Any, some Decodable, some HelixEndpointResponseType>
   ) async throws -> Data {
-    let request = endpoint.makeRequest(using: self.authentication)
+    let request = endpoint.makeRequest(using: self.authentication, encoder: self.encoder)
 
     let (data, response): (Data, URLResponse)
     do {
diff --git a/Sources/Twitch/Helix/Endpoints/Date+formatted.swift b/Sources/Twitch/Shared/Extensions/Date+formatted.swift
similarity index 100%
rename from Sources/Twitch/Helix/Endpoints/Date+formatted.swift
rename to Sources/Twitch/Shared/Extensions/Date+formatted.swift
diff --git a/Sources/Twitch/Helix/Formatter+iso8601withFractionalSeconds.swift b/Sources/Twitch/Shared/Extensions/Formatter+iso8601withFractionalSeconds.swift
similarity index 100%
rename from Sources/Twitch/Helix/Formatter+iso8601withFractionalSeconds.swift
rename to Sources/Twitch/Shared/Extensions/Formatter+iso8601withFractionalSeconds.swift
diff --git a/Sources/Twitch/Shared/Extensions/URL+appending.swift b/Sources/Twitch/Shared/Extensions/URL+appending.swift
new file mode 100644
index 0000000..f829ba6
--- /dev/null
+++ b/Sources/Twitch/Shared/Extensions/URL+appending.swift
@@ -0,0 +1,16 @@
+#if canImport(FoundationNetworking)
+  import Foundation
+  import FoundationNetworking
+
+  extension URL {
+    internal func appending(queryItems: [URLQueryItem]) -> URL {
+      var components = URLComponents(url: self, resolvingAgainstBaseURL: false)!
+      components.queryItems = (components.queryItems ?? []) + queryItems
+      return components.url!
+    }
+
+    internal func appending(path: String) -> URL {
+      appendingPathComponent(path)
+    }
+  }
+#endif
diff --git a/Sources/Twitch/Helix/URLSession+data.swift b/Sources/Twitch/Shared/Extensions/URLSession+data.swift
similarity index 100%
rename from Sources/Twitch/Helix/URLSession+data.swift
rename to Sources/Twitch/Shared/Extensions/URLSession+data.swift
diff --git a/Sources/Twitch/Shared/Extensions/URLSessionWebSocketTask+receive.swift b/Sources/Twitch/Shared/Extensions/URLSessionWebSocketTask+receive.swift
new file mode 100644
index 0000000..9cb741c
--- /dev/null
+++ b/Sources/Twitch/Shared/Extensions/URLSessionWebSocketTask+receive.swift
@@ -0,0 +1,17 @@
+#if canImport(FoundationNetworking)
+  import FoundationNetworking
+
+  extension URLSessionWebSocketTask {
+    func receive(completionHandler: @escaping (Result<Message, Error>) -> Void) {
+      Task {
+        do {
+          let message = try await self.receive()
+          completionHandler(.success(message))
+        } catch {
+          completionHandler(.failure(error))
+        }
+      }
+    }
+  }
+
+#endif
diff --git a/Sources/Twitch/Shared/PropertyWrappers/NilOnTypeMismatch.swift b/Sources/Twitch/Shared/PropertyWrappers/NilOnTypeMismatch.swift
new file mode 100644
index 0000000..db4c814
--- /dev/null
+++ b/Sources/Twitch/Shared/PropertyWrappers/NilOnTypeMismatch.swift
@@ -0,0 +1,13 @@
+@propertyWrapper public struct NilOnTypeMismatch<Value> {
+  public var wrappedValue: Value?
+  public init(wrappedValue: Value?) {
+    self.wrappedValue = wrappedValue
+  }
+}
+
+extension NilOnTypeMismatch: Decodable where Value: Decodable {
+  public init(from decoder: Decoder) throws {
+    let container = try decoder.singleValueContainer()
+    self.wrappedValue = try? container.decode(Value.self)
+  }
+}
diff --git a/Sources/Twitch/TwitchClient.swift b/Sources/Twitch/TwitchClient.swift
index d8fc455..fd20125 100644
--- a/Sources/Twitch/TwitchClient.swift
+++ b/Sources/Twitch/TwitchClient.swift
@@ -11,6 +11,8 @@ public actor TwitchClient {
   internal let encoder = JSONEncoder()
   internal let decoder = JSONDecoder()
 
+  internal var eventSubClient: EventSubClient
+
   public init(
     authentication: TwitchCredentials,
     urlSession: URLSession = URLSession(configuration: .default)
@@ -20,6 +22,11 @@ public actor TwitchClient {
 
     self.encoder.dateEncodingStrategy = .iso8601withFractionalSeconds
     self.decoder.dateDecodingStrategy = .iso8601withFractionalSeconds
-  }
 
+    self.decoder.keyDecodingStrategy = .convertFromSnakeCase
+    self.encoder.keyEncodingStrategy = .convertToSnakeCase
+
+    self.eventSubClient = EventSubClient(
+      credentials: authentication, urlSession: urlSession, decoder: self.decoder)
+  }
 }