Repository: nicklaw5/helix
Pull request #77 (2021-03-09 22:27:33)

Implemented EventSub
Added Implementation for EventSub Stuff from Twitch


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a8f175b..7bff0cd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,7 +10,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        go: [ '1.16.x', '1.15.x', '1.14.x', '1.13.x', '1.12.x' ]
+        go: [ '1.15.x', '1.14.x', '1.13.x', '1.12.x' ]
     name: Test with Go v${{ matrix.go }}
     steps:
       - uses: actions/checkout@v2
diff --git a/README.md b/README.md
index 7e096ba..75c3824 100644
--- a/README.md
+++ b/README.md
@@ -71,6 +71,7 @@ looking for the Twitch API docs, see the [Twitch Developer website](https://dev.
 - [ ] Update User Extensions
 - [x] Get Videos
 - [x] Get Webhook Subscriptions
+- [x] Create / Remove / List EventSub Subscriptions
 
 ## Quick Usage Example
 
diff --git a/ads.go b/ads.go
index 11a3680..da18cd7 100644
--- a/ads.go
+++ b/ads.go
@@ -1,9 +1,7 @@
 package helix
 
-// AdLengthEnum ...
 type AdLengthEnum int
 
-// AdLen30 ...
 const (
 	_ AdLengthEnum = iota * 30
 	AdLen30
@@ -20,19 +18,17 @@ type StartCommercialParams struct {
 	Length        AdLengthEnum `query:"length"`
 }
 
-// AdDetails ...
 type AdDetails struct {
 	Length     AdLengthEnum `json:"length"`
 	Message    string       `json:"message"`
 	RetryAfter int          `json:"retry_after"`
 }
 
-// ManyAdDetails ...
 type ManyAdDetails struct {
 	AdDetails []AdDetails `json:"data"`
 }
 
-// StartCommercialResponse ...
+// StartCommercialResponse
 type StartCommercialResponse struct {
 	ResponseCommon
 	Data ManyAdDetails
diff --git a/bits.go b/bits.go
index 6b364b8..2738b9b 100644
--- a/bits.go
+++ b/bits.go
@@ -51,12 +51,10 @@ func (c *Client) GetBitsLeaderboard(params *BitsLeaderboardParams) (*BitsLeaderb
 	return bits, nil
 }
 
-// CheermotesParams ...
 type CheermotesParams struct {
 	BroadcasterID string `query:"broadcaster_id"` // optional
 }
 
-// TierImages ...
 type TierImages struct {
 	Image1   string `json:"1"`
 	Image1_5 string `json:"1.5"`
@@ -65,19 +63,16 @@ type TierImages struct {
 	Image4   string `json:"4"`
 }
 
-// TierImageTypes ...
 type TierImageTypes struct {
 	Animated TierImages `json:"animated"`
 	Static   TierImages `json:"static"`
 }
 
-// CheermoteTierImages ...
 type CheermoteTierImages struct {
 	Dark  TierImageTypes `json:"dark"`
 	Light TierImageTypes `json:"light"`
 }
 
-// CheermoteTiers ...
 type CheermoteTiers struct {
 	MinBits        uint                `json:"min_bits"`
 	ID             string              `json:"id"`
@@ -87,7 +82,6 @@ type CheermoteTiers struct {
 	ShowInBitsCard bool                `json:"show_in_bits_card"`
 }
 
-// Cheermotes ...
 type Cheermotes struct {
 	Prefix       string           `json:"prefix"`
 	Tiers        []CheermoteTiers `json:"tiers"`
@@ -97,18 +91,15 @@ type Cheermotes struct {
 	IsCharitable bool             `json:"is_charitable"`
 }
 
-// ManyCheermotes ...
 type ManyCheermotes struct {
 	Cheermotes []Cheermotes `json:"data"`
 }
 
-// CheermotesResponse ...
 type CheermotesResponse struct {
 	ResponseCommon
 	Data ManyCheermotes
 }
 
-// GetCheermotes ...
 func (c *Client) GetCheermotes(params *CheermotesParams) (*CheermotesResponse, error) {
 	resp, err := c.get("/bits/cheermotes", &ManyCheermotes{}, params)
 	if err != nil {
diff --git a/channels.go b/channels.go
index 2f2cf6a..f9c4057 100644
--- a/channels.go
+++ b/channels.go
@@ -49,43 +49,3 @@ func (c *Client) SearchChannels(params *SearchChannelsParams) (*SearchChannelsRe
 
 	return channels, nil
 }
-
-// GetChannelInformationParams ...
-type GetChannelInformationParams struct {
-	BroadcasterID string `query:"broadcaster_id"`
-}
-
-// GetChannelInformationResponse ...
-type GetChannelInformationResponse struct {
-	ResponseCommon
-	Data ManyChannelInformation
-}
-
-// ManyChannelInformation ...
-type ManyChannelInformation struct {
-	Channels []ChannelInformation `json:"data"`
-}
-
-// ChannelInformation ...
-type ChannelInformation struct {
-	BroadcasterID         string `json:"broadcaster_id"`
-	BroadcasterName    string `json:"broadcaster_name"`
-	BroadcasterLanguage   string `json:"broadcaster_language"`
-	GameID string `json:"game_id"`
-	GameName   string `json:"game_name"`
-	Title      string `json:"title"`
-}
-
-// GetChannelInformation ...
-func (c *Client) GetChannelInformation(params *GetChannelInformationParams) (*GetChannelInformationResponse, error) {
-	resp, err := c.get("/channels", &ManyChannelInformation{}, params)
-	if err != nil {
-		return nil, err
-	}
-
-	channels := &GetChannelInformationResponse{}
-	resp.HydrateResponseCommon(&channels.ResponseCommon)
-	channels.Data.Channels = resp.Data.(*ManyChannelInformation).Channels
-
-	return channels, nil
-}
diff --git a/docs/eventsub_docs.md b/docs/eventsub_docs.md
new file mode 100644
index 0000000..fad83cb
--- /dev/null
+++ b/docs/eventsub_docs.md
@@ -0,0 +1,128 @@
+# EventSub Documentation
+
+EventSub implements the Twitch EventSub notifications. It should cover everything found at https://dev.twitch.tv/docs/eventsub
+
+In contrary to webhooks, eventsub subscriptions do not expire based on time. They will expire when the token used is revoked or if the notification response failure rate is to high.
+
+## Get EventSub Subscriptions
+
+This is an example of how to get eventsub subscriptions.
+
+```go
+client, err := helix.NewClient(&helix.Options{
+    ClientID: "your-client-id",
+    AppAccessToken: "your-app-access-token",
+})
+if err != nil {
+    // handle error
+}
+
+resp, err := client.GetEventSubSubscriptions(&helix.EventSubSubscriptionsParams{
+    Status: helix.EventSubStatusEnabled // This is optional.
+})
+if err != nil {
+    // handle error
+}
+
+fmt.Printf("%+v\n", resp)
+```
+
+## Create EventSub Subscription
+
+To create a subscription call CreateEventSubSubscription with a pointer to a subscription. As of writing, Version should always be "1" except for the Channel moderator add / remove events which are still in beta and therefore you need to use Version "beta".
+Within the Transport the only supported Method currently is "webhook". Callback needs to be a https link on port 443. With the secret you can verify if notifications came from twitch. See (#verify-eventSub-notification)
+
+```go
+client, err := helix.NewClient(&helix.Options{
+    ClientID: "your-client-id",
+    AppAccessToken: "your-app-access-token",
+})
+if err != nil {
+    // handle error
+}
+
+resp, err := client.CreateEventSubSubscription(&helix.EventSubSubscription{
+    Type: helix.EventSubTypeChannelFollow,
+    Version: "1",
+    Condition &helix.EventSubCondition{
+        BroadcasterUserID: "1337"
+    }, 
+    Transport &helix.EventSubTransport{
+        Method: "webhook",
+        Callback: "https://example.com/follow",
+        Secret: "s3cre7w0rd",
+    },
+})
+if err != nil {
+    // handle error
+}
+
+fmt.Printf("%+v\n", resp)
+```
+
+## Delete EventSub Subscription
+
+To delete a subscription you need to call DeleteEventSubSubscription with the subscription id as parameter.
+
+```go
+client, err := helix.NewClient(&helix.Options{
+    ClientID: "your-client-id",
+    AppAccessToken: "your-app-access-token",
+})
+if err != nil {
+    // handle error
+}
+
+resp, err := client.DeleteEventSubSubscription("subscription-id")
+if err != nil {
+    // handle error
+}
+
+fmt.Printf("%+v\n", resp)
+```
+
+
+## Example for handling a notification
+
+```go
+
+type eventSubNotification struct {
+    Subscription    helix.EventSubSubscription `json:"subscription"`
+    Challenge       string `json:"challenge"`
+    Event           json.RawMessage `json:"event"`
+}
+
+func eventsubFollow(w http.ResponseWriter, r *http.Request) {
+    body, err := ioutil.ReadAll(r.Body)
+    if err != nil {
+        log.Println(err)
+        return
+    }
+    defer r.Body.Close()
+    // verify that the notification came from twitch using the secret.
+    if !helix.VerifyEventSubNotification("s3cre7w0rd", r.Header, string(body)) {
+        log.Println("no valid signature on subscription")
+        return
+    } else {
+        log.Println("verified signature for subscription")
+    }
+    var vals eventSubNotification
+    err = json.NewDecoder(bytes.NewReader(body)).Decode(&vals)
+    if err != nil {
+        log.Println(err)
+        return
+    }
+    // if there's a challenge in the request, respond with only the challenge to verify your eventsub.
+    if vals.Challenge != "" {
+        w.Write([]byte(vals.Challenge))
+        return
+    }
+    var followEvent helix.EventSubChannelFollowEvent
+    s, _ := vals.Event.MarshalJSON()
+    err = json.NewDecoder(bytes.NewReader(s)).Decode(&followEvent)
+    
+    log.Printf("got follow webhook: %s follows %s\n", followEvent.UserName, followEvent.BroadcasterUserName)
+    w.WriteHeader(200)
+    w.Write([]byte("ok"))
+}
+```
\ No newline at end of file
diff --git a/drops.go b/drops.go
index ca8f3ae..d787c7b 100644
--- a/drops.go
+++ b/drops.go
@@ -2,20 +2,20 @@ package helix
 
 // GetDropEntitlementsParams ...
 type GetDropEntitlementsParams struct {
-	ID     string `query:"id"`
-	UserID string `query:"user_id"`
-	GameID string `query:"game_id"`
-	After  string `query:"after"`
-	First  int    `query:"first,20"` // Limit 100
+	ID string `query:"id"`
+	UserID        string `query:"user_id"`
+	GameID        string `query:"game_id"`
+	After         string `query:"after"`
+	First         int    `query:"first,20"` // Limit 100
 }
 
 // Entitlement ...
 type Entitlement struct {
-	ID        string `json:"id"`
-	BenefitID string `json:"benefit_id"`
-	Timestamp Time   `json:"timestamp"`
-	UserID    string `json:"user_id"`
-	GameID    string `json:"game_id"`
+	ID string `json:"id"`
+	BenefitID     string `json:"benefit_id"`
+	Timestamp     Time   `json:"timestamp"`
+	UserID        string `json:"user_id"`
+	GameID        string `json:"game_id"`
 }
 
 // ManyEntitlements ...
diff --git a/entitlement_codes.go b/entitlement_codes.go
index 55c9acc..8e5edc0 100644
--- a/entitlement_codes.go
+++ b/entitlement_codes.go
@@ -38,8 +38,7 @@ type CodeResponse struct {
 	Data ManyCodes
 }
 
-/*
-GetEntitlementCodeStatus ...
+/**
 Per https://dev.twitch.tv/docs/api/reference#get-code-status
 Access is controlled via an app access token on the calling service. The client ID associated with the app access token must be approved by Twitch as part of a contracted arrangement.
 Callers with an app access token are authorized to redeem codes on behalf of any Twitch user account.
@@ -57,8 +56,7 @@ func (c *Client) GetEntitlementCodeStatus(params *CodesParams) (*CodeResponse, e
 	return codes, nil
 }
 
-/*
-RedeemEntitlementCode ...
+/**
 Per https://dev.twitch.tv/docs/api/reference/#redeem-code
 Access is controlled via an app access token on the calling service. The client ID associated with the app access token must be approved by Twitch.
 Callers with an app access token are authorized to redeem codes on behalf of any Twitch user account.
diff --git a/eventsub.go b/eventsub.go
new file mode 100644
index 0000000..b3398ad
--- /dev/null
+++ b/eventsub.go
@@ -0,0 +1,414 @@
+package helix
+
+import (
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"fmt"
+	"log"
+	"net/http"
+	"net/url"
+	"strings"
+)
+
+// EventSub Types for Parsing Requests / Responses
+
+// Represents a subscription
+type EventSubSubscription struct {
+	ID        string            `json:"id"`
+	Type      string            `json:"type"`
+	Version   string            `json:"version"`
+	Status    string            `json:"status"`
+	Condition EventSubCondition `json:"condition"`
+	Transport EventSubTransport `json:"transport"`
+	CreatedAt Time              `json:"created_at"`
+}
+
+// Conditions for a subscription, not all are necessary and some only apply to some subscription types, see https://dev.twitch.tv/docs/eventsub/eventsub-reference
+type EventSubCondition struct {
+	BroadcasterUserID     string `json:"broadcaster_user_id"`
+	FromBroadcasterUserID string `json:"from_broadcaster_user_id"`
+	ToBroadcasterUserID   string `json:"to_broadcaster_user_id"`
+	RewardID              string `json:"reward_id"`
+	ClientID              string `json:"client_id"`
+	UserID                string `json:"user_id"`
+}
+
+// Transport for the subscription, currently the only supported Method is "webhook". Secret must be between 10 and 100 characters
+type EventSubTransport struct {
+	Method   string `json:"method"`
+	Callback string `json:"callback"`
+	Secret   string `json:"secret"`
+}
+
+// Twitch Response for getting all current subscriptions
+type ManyEventSubSubscriptions struct {
+	Total                 int                    `json:"total"`
+	EventSubSubscriptions []EventSubSubscription `json:"data"`
+	Pagination            Pagination             `json:"pagination"`
+	Limit                 int                    `json:"limit"`
+}
+
+// Response for getting all current subscriptions
+type EventSubSubscriptionsResponse struct {
+	ResponseCommon
+	Data ManyEventSubSubscriptions
+}
+
+// Parameter for filtering subscriptions, currently only the status is filterable
+type EventSubSubscriptionsParams struct {
+	Status string `query:"status"`
+}
+
+// Parameter for removing a subscription.
+type RemoveEventSubSubscriptionParams struct {
+	ID string `query:"id"`
+}
+
+// Response for removing a subscription
+type RemoveEventSubSubscriptionParamsResponse struct {
+	ResponseCommon
+}
+
+// EventSub helper Variables for Types and Status
+const (
+	EventSubStatusEnabled                      = "enabled"
+	EventSubStatusPending                      = "webhook_callback_verification_pending"
+	EventSubStatusFailed                       = "webhook_callback_verification_failed"
+	EventSubStatusNotificationFailuresExceeded = "notification_failures_exceeded"
+	EventSubStatusAuthorizationRevoked         = "authorization_revoked"
+	EventSubStatusUserRemoved                  = "user_removed"
+
+	EventSubTypeChannelUpdate                             = "channel.update"
+	EventSubTypeChannelFollow                             = "channel.follow"
+	EventSubTypeChannelSubscription                       = "channel.subscribe"
+	EventSubTypeChannelCheer                              = "channel.cheer"
+	EventSubTypeChannelRaid                               = "channel.raid"
+	EventSubTypeChannelBan                                = "channel.ban"
+	EventSubTypeChannelUnban                              = "channel.unban"
+	EventSubTypeModeratorAdd                              = "channel.moderator.add"
+	EventSubTypeModeratorRemove                           = "channel.moderator.remove"
+	EventSubTypeChannelPointsCustomRewardAdd              = "channel.channel_points_custom_reward.add"
+	EventSubTypeChannelPointsCustomRewardUpdate           = "channel.channel_points_custom_reward.update"
+	EventSubTypeChannelPointsCustomRewardRemove           = "channel.channel_points_custom_reward.remove"
+	EventSubTypeChannelPointsCustomRewardRedemptionAdd    = "channel.channel_points_custom_reward_redemption.add"
+	EventSubTypeChannelPointsCustomRewardRedemptionUpdate = "channel.channel_points_custom_reward_redemption.update"
+	EventSubTypeHypeTrainBegin                            = "channel.hype_train.begin"
+	EventSubTypeHypeTrainProgress                         = "channel.hype_train.progress"
+	EventSubTypeHypeTrainEnd                              = "channel.hype_train.end"
+	EventSubTypeStreamOnline                              = "stream.online"
+	EventSubTypeStreamOffline                             = "stream.offline"
+	EventSubTypeUserAuthorizationRevoke                   = "user.authorization.revoke"
+	EventSubTypeUserUpdate                                = "user.update"
+)
+
+// Event Notification Responses
+
+// Data for a channel ban notification
+type EventSubChannelBanEvent struct {
+	UserID               string `json:"user_id"`
+	UserLogin            string `json:"user_login"`
+	UserName             string `json:"user_name"`
+	BroadcasterUserID    string `json:"broadcaster_user_id"`
+	BroadcasterUserLogin string `json:"broadcaster_user_login"`
+	BroadcasterUserName  string `json:"broadcaster_user_name"`
+	ModeratorUserID      string `json:"moderator_user_id"`
+	ModeratorUserLogin   string `json:"moderator_user_login"`
+	ModeratorUserName    string `json:"moderator_user_name"`
+	Reason               string `json:"reason"`
+	EndsAt               Time   `json:"ends_at"`
+	IsPermanent          bool   `json:"is_permanent"`
+}
+
+// Data for a channel subscribe notification
+type EventSubChannelSubscribeEvent struct {
+	UserID               string `json:"user_id"`
+	UserLogin            string `json:"user_login"`
+	UserName             string `json:"user_name"`
+	BroadcasterUserID    string `json:"broadcaster_user_id"`
+	BroadcasterUserLogin string `json:"broadcaster_user_login"`
+	BroadcasterUserName  string `json:"broadcaster_user_name"`
+	Tier                 string `json:"tier"`
+	IsGift               bool   `json:"is_gift"`
+}
+
+// Data for a channel cheer notification
+type EventSubChannelCheerEvent struct {
+	IsAnonymous          bool   `json:"is_anonymous"`
+	UserID               string `json:"user_id"`
+	UserLogin            string `json:"user_login"`
+	UserName             string `json:"user_name"`
+	BroadcasterUserID    string `json:"broadcaster_user_id"`
+	BroadcasterUserLogin string `json:"broadcaster_user_login"`
+	BroadcasterUserName  string `json:"broadcaster_user_name"`
+	Message              string `json:"message"`
+	Bits                 int    `json:"bits"`
+}
+
+// Data for a channel update notification
+type EventSubChannelUpdateEvent struct {
+	BroadcasterUserID    string `json:"broadcaster_user_id"`
+	BroadcasterUserLogin string `json:"broadcaster_user_login"`
+	BroadcasterUserName  string `json:"broadcaster_user_name"`
+	Title                string `json:"title"`
+	Language             string `json:"language"`
+	CategoryID           string `json:"category_id"`
+	CategoryName         string `json:"category_name"`
+	IsMature             string `json:"is_mature"`
+}
+
+// Data for a channel unban notification
+type EventSubChannelUnbanEvent struct {
+	UserID               string `json:"user_id"`
+	UserLogin            string `json:"user_login"`
+	UserName             string `json:"user_name"`
+	BroadcasterUserID    string `json:"broadcaster_user_id"`
+	BroadcasterUserLogin string `json:"broadcaster_user_login"`
+	BroadcasterUserName  string `json:"broadcaster_user_name"`
+	ModeratorUserID      string `json:"moderator_user_id"`
+	ModeratorUserLogin   string `json:"moderator_user_login"`
+	ModeratorUserName    string `json:"moderator_user_name"`
+}
+
+// Data for a channel follow notification
+type EventSubChannelFollowEvent struct {
+	UserID               string `json:"user_id"`
+	UserLogin            string `json:"user_login"`
+	UserName             string `json:"user_name"`
+	BroadcasterUserID    string `json:"broadcaster_user_id"`
+	BroadcasterUserLogin string `json:"broadcaster_user_login"`
+	BroadcasterUserName  string `json:"broadcaster_user_name"`
+}
+
+// Data for a channel moderator add notification, it's the same as the channel follow notification
+type EventSubModeratorAddEvent = EventSubChannelFollowEvent
+
+// Data for a channel moderator remove notification, it's the same as the channel follow notification
+type EventSubModeratorRemoveEvent = EventSubChannelFollowEvent
+
+// Data for a channel raid notification
+type EventSubChannelRaidEvent struct {
+	FromBroadcasterUserID    string `json:"from_broadcaster_user_id"`
+	FromBroadcasterUserLogin string `json:"from_broadcaster_user_login"`
+	FromBroadcasterUserName  string `json:"from_broadcaster_user_name"`
+	ToBroadcasterUserID      string `json:"to_broadcaster_user_id"`
+	ToBroadcasterUserLogin   string `json:"to_broadcaster_user_login"`
+	ToBroadcasterUserName    string `json:"to_broadcaster_user_name"`
+	Viewers                  int    `json:"viewers"`
+}
+
+// Data for a channel points custom reward notification
+type EventSubChannelPointsCustomRewardEvent struct {
+	ID                                string                 `json:"id"`
+	BroadcasterUserID                 string                 `json:"broadcaster_user_id"`
+	BroadcasterUserLogin              string                 `json:"broadcaster_user_login"`
+	BroadcasterUserName               string                 `json:"broadcaster_user_name"`
+	IsEnabled                         bool                   `json:"is_enabled"`
+	IsPaused                          bool                   `json:"is_paused"`
+	IsInStock                         bool                   `json:"is_in_stock"`
+	Title                             string                 `json:"title"`
+	Cost                              int                    `json:"cost"`
+	Prompt                            string                 `json:"prompt"`
+	IsUserInputRequired               bool                   `json:"is_user_input_required"`
+	ShouldRedemptionsSkipRequestQueue bool                   `json:"should_redemptions_skip_request_queue"`
+	MaxPerStream                      EventSubMaxPerStream   `json:"max_per_stream"`
+	MaxPerUserPerStream               EventSubMaxPerStream   `json:"max_per_user_per_stream"`
+	BackgroundColor                   string                 `json:"background_color"`
+	Image                             EventSubImage          `json:"image"`
+	DefaultImage                      EventSubImage          `json:"default_image"`
+	GlobalCooldown                    EventSubGlobalCooldown `json:"global_cooldown"`
+	CooldownExpiresAt                 Time                   `json:"cooldown_expires_at"`
+	RedemptionsRedeemedCurrentStream  int                    `json:"redemptions_redeemed_current_stream"`
+}
+
+// Data for a channel points custom reward redemption notification
+type EventSubChannelPointsCustomRewardRedemptionEvent struct {
+	ID                   string         `json:"id"`
+	BroadcasterUserID    string         `json:"broadcaster_user_id"`
+	BroadcasterUserLogin string         `json:"broadcaster_user_login"`
+	BroadcasterUserName  string         `json:"broadcaster_user_name"`
+	UserID               string         `json:"user_id"`
+	UserLogin            string         `json:"user_login"`
+	UserName             string         `json:"user_name"`
+	UserInput            string         `json:"user_input`
+	Status               string         `json:"status"`
+	Reward               EventSubReward `json:"reward"`
+	RedeemedAt           Time           `json:"redeemed_at"`
+}
+
+// Data for a hype train begin notification
+type EventSubHypeTrainBeginEvent struct {
+	BroadcasterUserID    string                 `json:"broadcaster_user_id"`
+	BroadcasterUserLogin string                 `json:"broadcaster_user_login"`
+	BroadcasterUserName  string                 `json:"broadcaster_user_name"`
+	Total                int                    `json:"total"`
+	Progress             int                    `json:"progress"`
+	Goal                 int                    `json:"goal"`
+	TopContributions     []EventSubContribution `json:"top_contributions"`
+	LastContribution     EventSubContribution   `json:"last_contribution"`
+	StartedAt            Time                   `json:"started_at"`
+	ExpiresAt            Time                   `json:"expires_at"`
+}
+
+// Data for a hype train progress notification
+type EventSubHypeTrainProgressEvent struct {
+	BroadcasterUserID    string                 `json:"broadcaster_user_id"`
+	BroadcasterUserLogin string                 `json:"broadcaster_user_login"`
+	BroadcasterUserName  string                 `json:"broadcaster_user_name"`
+	Level                int                    `json:"level"`
+	Total                int                    `json:"total"`
+	Progress             int                    `json:"progress"`
+	Goal                 int                    `json:"goal"`
+	TopContributions     []EventSubContribution `json:"top_contributions"`
+	LastContribution     EventSubContribution   `json:"last_contribution"`
+	StartedAt            Time                   `json:"started_at"`
+	ExpiresAt            Time                   `json:"expires_at"`
+}
+
+// Data for a hype train end notification
+type EventSubHypeTrainEndEvent struct {
+	BroadcasterUserID    string                 `json:"broadcaster_user_id"`
+	BroadcasterUserLogin string                 `json:"broadcaster_user_login"`
+	BroadcasterUserName  string                 `json:"broadcaster_user_name"`
+	Level                int                    `json:"level"`
+	Total                int                    `json:"total"`
+	TopContributions     []EventSubContribution `json:"top_contributions"`
+	StartedAt            Time                   `json:"started_at"`
+	ExpiresAt            Time                   `json:"expires_at"`
+	CooldownEndsAt       Time                   `json:"cooldown_ends_at"`
+}
+
+// Data for a stream online notification
+type EventSubStreamOnlineEvent struct {
+	ID                   string `json:"id"`
+	BroadcasterUserID    string `json:"broadcaster_user_id"`
+	BroadcasterUserLogin string `json:"broadcaster_user_login"`
+	BroadcasterUserName  string `json:"broadcaster_user_name"`
+	Type                 string `json:"type"`
+	StartedAt            Time   `json:"started_at"`
+}
+
+// Data for a stream offline notification
+type EventSubStreamOfflineEvent struct {
+	BroadcasterUserID    string `json:"broadcaster_user_id"`
+	BroadcasterUserLogin string `json:"broadcaster_user_login"`
+	BroadcasterUserName  string `json:"broadcaster_user_name"`
+}
+
+// Data for an user authentication revoke notification, this means the user has revoked the access token and if you need to comply with gdpr you need to delete your user data belonging to the user.
+type EventSubUserAuthenticationRevokeEvent struct {
+	ClientID  string `json:"client_id"`
+	UserID    string `json:"user_id"`
+	UserLogin string `json:"user_login"`
+	UserName  string `json:"user_name"`
+}
+
+// Data for an user update notification
+type EventSubUserUpdateEvent struct {
+	UserID      string `json:"user_id"`
+	UserLogin   string `json:"user_login"`
+	UserName    string `json:"user_name"`
+	Email       string `json:"email"`
+	Description string `json:"description"`
+}
+
+// This belongs to a custom reward and defines it's cooldown
+type EventSubGlobalCooldown struct {
+	IsEnabled bool `json:"is_enabled"`
+	Seconds   int  `json:"seconds"`
+}
+
+// This also belongs to a custom reward and defines the image urls
+type EventSubImage struct {
+	Url1X string `json:"url_1x"`
+	Url2X string `json:"url_2x"`
+	Url4X string `json:"url_4x"`
+}
+
+// This belongs to a hype train and defines a user contribution
+type EventSubContribution struct {
+	UserID    string `json:"user_id"`
+	UserLogin string `json:"user_login"`
+	UserName  string `json:"user_name"`
+	Type      string `json:"type"`
+	Total     string `json:"total"`
+}
+
+// This belongs to a custom reward and defines if it is limited per stream
+type EventSubMaxPerStream struct {
+	IsEnabled bool `json:"is_enabled"`
+	Value     int  `json:"value"`
+}
+
+// This belongs to a reward redemption and defines the reward redeemed
+type EventSubReward struct {
+	ID     string `json:"id"`
+	Title  string `json:"title"`
+	Cost   int    `json:"cost"`
+	Prompt string `json:"prompt"`
+}
+
+// Get all EventSub Subscriptions
+func (c *Client) GetEventSubSubscriptions(params *EventSubSubscriptionsParams) (*EventSubSubscriptionsResponse, error) {
+	resp, err := c.get("/eventsub/subscriptions", &ManyEventSubSubscriptions{}, params)
+	if err != nil {
+		return nil, err
+	}
+
+	eventSubs := &EventSubSubscriptionsResponse{}
+	resp.HydrateResponseCommon(&eventSubs.ResponseCommon)
+	eventSubs.Data.Total = resp.Data.(*ManyEventSubSubscriptions).Total
+	eventSubs.Data.EventSubSubscriptions = resp.Data.(*ManyEventSubSubscriptions).EventSubSubscriptions
+	eventSubs.Data.Pagination = resp.Data.(*ManyEventSubSubscriptions).Pagination
+	eventSubs.Data.Limit = resp.Data.(*ManyEventSubSubscriptions).Limit
+
+	return eventSubs, nil
+}
+
+// Remove an EventSub Subscription
+func (c *Client) RemoveEventSubSubscription(id string) (*RemoveEventSubSubscriptionParamsResponse, error) {
+
+	resp, err := c.delete("/eventsub/subscriptions", nil, &RemoveEventSubSubscriptionParams{ID: id})
+	if err != nil {
+		return nil, err
+	}
+
+	eventsub := &RemoveEventSubSubscriptionParamsResponse{}
+	resp.HydrateResponseCommon(&eventsub.ResponseCommon)
+	return eventsub, nil
+}
+
+// Creates an EventSub subscription
+func (c *Client) CreateEventSubSubscription(payload *EventSubSubscription) (*EventSubSubscriptionsResponse, error) {
+	if payload.Type == EventSubTypeModeratorAdd || payload.Type == EventSubTypeModeratorRemove {
+		log.Println("warning: the EventSub subscription type is still in Beta and may stop working at any time.")
+	}
+	if payload.Transport.Method == "webhook" && !strings.HasPrefix(payload.Transport.Callback, "https://") {
+		return nil, fmt.Errorf("error: callback must use https")
+	}
+	callbackUrl, err := url.Parse(payload.Transport.Callback)
+	if err != nil {
+		return nil, err
+	}
+	if callbackUrl.Port() != "" && callbackUrl.Port() != "443" {
+		return nil, fmt.Errorf("error: callback must use port 443")
+	}
+	resp, err := c.postAsJSON("/eventsub/subscriptions", &ManyEventSubSubscriptions{}, payload)
+	if err != nil {
+		return nil, err
+	}
+
+	eventsub := &EventSubSubscriptionsResponse{}
+	resp.HydrateResponseCommon(&eventsub.ResponseCommon)
+	eventsub.Data = *resp.Data.(*ManyEventSubSubscriptions)
+	return eventsub, nil
+}
+
+// Verifys that a notification came from twitch using the a signature and the secret used when creating the subscription
+func VerifyEventSubNotification(secret string, header http.Header, message string) bool {
+	hmacMessage := []byte(fmt.Sprintf("%s%s%s", header.Get("Twitch-Eventsub-Message-Id"), header.Get("Twitch-Eventsub-Message-Timestamp"), message))
+	mac := hmac.New(sha256.New, []byte(secret))
+	mac.Write(hmacMessage)
+	hmacsha256 := fmt.Sprintf("sha256=%s", hex.EncodeToString(mac.Sum(nil)))
+	return hmacsha256 == header.Get("Twitch-Eventsub-Message-Signature")
+}
diff --git a/helix.go b/helix.go
index 62b9b64..33bf1ab 100644
--- a/helix.go
+++ b/helix.go
@@ -140,6 +140,10 @@ func (c *Client) put(path string, respData, reqData interface{}) (*Response, err
 	return c.sendRequest(http.MethodPut, path, respData, reqData, false)
 }
 
+func (c *Client) delete(path string, respData, reqData interface{}) (*Response, error) {
+	return c.sendRequest(http.MethodDelete, path, respData, reqData, false)
+}
+
 func (c *Client) postAsJSON(path string, respData, reqData interface{}) (*Response, error) {
 	return c.sendRequest(http.MethodPost, path, respData, reqData, true)
 }
diff --git a/moderation.go b/moderation.go
index b8f7d55..a506497 100644
--- a/moderation.go
+++ b/moderation.go
@@ -21,7 +21,7 @@ type BannedUsersResponse struct {
 	Data ManyBans
 }
 
-// BannedUsersParams ...
+// BannedUsersResponse
 // BroadcasterID must match the auth tokens user_id
 type BannedUsersParams struct {
 	BroadcasterID string `query:"broadcaster_id"`
diff --git a/user_extensions.go b/user_extensions.go
index 84f2a9f..4acdce1 100644
--- a/user_extensions.go
+++ b/user_extensions.go
@@ -87,7 +87,6 @@ func (c *Client) GetUserActiveExtensions(params *UserActiveExtensionsParams) (*U
 	return userActiveExtensions, nil
 }
 
-// UpdateUserExtensionsPayload ...
 type UpdateUserExtensionsPayload struct {
 	Component map[string]UserActiveExtensionInfo `json:"component,omitempty"`
 	Overlay   map[string]UserActiveExtensionInfo `json:"overlay,omitempty"`


Test patch:
diff --git a/ads_test.go b/ads_test.go
index 0f51549..823a514 100644
--- a/ads_test.go
+++ b/ads_test.go
@@ -12,7 +12,7 @@ func TestClient_StartCommercial(t *testing.T) {
 		statusCode     int
 		options        *Options
 		count          int
-		broadcasterID  string
+		broadcasterId  string
 		adLength       AdLengthEnum
 		respBody       string
 		expectedErrMsg string
@@ -45,7 +45,7 @@ func TestClient_StartCommercial(t *testing.T) {
 		c := newMockClient(testCase.options, newMockHandler(testCase.statusCode, testCase.respBody, nil))
 
 		params := &StartCommercialParams{
-			BroadcasterID: testCase.broadcasterID,
+			BroadcasterID: testCase.broadcasterId,
 			Length:        testCase.adLength,
 		}
 
diff --git a/channels_test.go b/channels_test.go
index 775aa27..f37813e 100644
--- a/channels_test.go
+++ b/channels_test.go
@@ -21,7 +21,7 @@ func TestSearchChannels(t *testing.T) {
 			2,
 			`{"data":[{"broadcaster_language":"en","display_name":"Ninja","game_id":"33214","id":"27833742640","is_live":false,"tag_ids":[],"thumbnail_url":"https://static-cdn.jtvnw.net/previews-ttv/live_user_ninja-{width}x{height}.jpg","title":"I have lost my voice D: | twitter.com/Ninja","started_at":"2018-03-06T15:07:45Z"},{"broadcaster_language":"en","display_name":"DrDisrespect","game_id":"33214","id":"27834185424","is_live":false,"tag_ids":[],"thumbnail_url":"https://static-cdn.jtvnw.net/previews-ttv/live_user_drdisrespectlive-{width}x{height}.jpg","title":"Turbo Treehouses || @DrDisRespect","started_at":"2018-03-06T16:05:00Z"}],"pagination":{"cursor":"eyJiIjpudWxsLCJhIjp7Ik9mZnNldCI6Mn19"}}`,
 			[]Channel{
-				{
+				Channel{
 					ID:               "27833742640",
 					GameID:           "33214",
 					BroadcasterLogin: "ninja",
@@ -32,7 +32,7 @@ func TestSearchChannels(t *testing.T) {
 					IsLive:           false,
 					TagIDs:           []string{},
 				},
-				{
+				Channel{
 					ID:               "27834185424",
 					GameID:           "33214",
 					BroadcasterLogin: "drdisrespect",
@@ -109,89 +109,3 @@ func TestSearchChannels(t *testing.T) {
 		}
 	}
 }
-
-func TestGetChannelInformation(t *testing.T) {
-	t.Parallel()
-
-	testCases := []struct {
-		statusCode    int
-		options       *Options
-		BroadcasterID string
-		respBody      string
-		parsed        []ChannelInformation
-	}{
-		{
-			http.StatusOK,
-			&Options{ClientID: "my-client-id"},
-			"44445592",
-			`{"data":[{"broadcaster_id":"44445592","broadcaster_login":"pokimane","broadcaster_name":"pokimane","broadcaster_language":"en","game_id":"509658","game_name":"Just Chatting","title":"See you Wednesday 8am for Among Us ^_^"}]}`,
-			[]ChannelInformation{
-				{
-					BroadcasterID:         "44445592",
-					BroadcasterName:    "pokimane",
-					BroadcasterLanguage:   "en",
-					GameID: "509658",
-					GameName:   "Just Chatting",
-					Title:      "See you Wednesday 8am for Among Us ^_^",
-				},
-			},
-		},
-		{
-			http.StatusBadRequest,
-			&Options{ClientID: "my-client-id"},
-			"9999999999999999999",
-			`{"error":"Bad Request","status":400,"message":"Invalid broadcasterID"}`,
-			[]ChannelInformation{},
-		},
-	}
-
-	for _, testCase := range testCases {
-		c := newMockClient(testCase.options, newMockHandler(testCase.statusCode, testCase.respBody, nil))
-
-		resp, err := c.GetChannelInformation(&GetChannelInformationParams{
-			BroadcasterID: testCase.BroadcasterID,
-		})
-		if err != nil {
-			t.Error(err)
-		}
-
-		// Test Bad Request Responses
-		if resp.StatusCode == http.StatusBadRequest {
-			broadcasterIDErrStr := "Invalid broadcasterID"
-			if resp.ErrorMessage != broadcasterIDErrStr {
-				t.Errorf("expected error message to be \"%s\", got \"%s\"", broadcasterIDErrStr, resp.ErrorMessage)
-				continue
-			}
-		}
-
-		if resp.StatusCode != testCase.statusCode {
-			t.Errorf("expected status code to be \"%d\", got \"%d\"", testCase.statusCode, resp.StatusCode)
-		}
-
-		for i, channel := range resp.Data.Channels {
-			if channel.BroadcasterID != testCase.parsed[i].BroadcasterID {
-				t.Errorf("Expected struct field BroadcasterID = %s, was %s", testCase.parsed[i].BroadcasterID, channel.BroadcasterID)
-			}
-
-			if channel.BroadcasterName != testCase.parsed[i].BroadcasterName {
-				t.Errorf("Expected struct field BroadcasterName = %s, was %s", testCase.parsed[i].BroadcasterName, channel.BroadcasterName)
-			}
-
-			if channel.BroadcasterLanguage != testCase.parsed[i].BroadcasterLanguage {
-				t.Errorf("Expected struct field BroadcasterLanguage = %s, was %s", testCase.parsed[i].BroadcasterLanguage, channel.BroadcasterLanguage)
-			}
-
-			if channel.GameID != testCase.parsed[i].GameID {
-				t.Errorf("Expected struct field GameID = %s, was %s", testCase.parsed[i].GameID, channel.GameID)
-			}
-
-			if channel.GameName != testCase.parsed[i].GameName {
-				t.Errorf("Expected struct field GameName = %s, was %s", testCase.parsed[i].GameName, channel.GameName)
-			}
-
-			if channel.Title != testCase.parsed[i].Title {
-				t.Errorf("Expected struct field Title = %s, was %s", testCase.parsed[i].Title, channel.Title)
-			}
-		}
-	}
-}
diff --git a/drops_test.go b/drops_test.go
index 6f97143..bb5a16e 100644
--- a/drops_test.go
+++ b/drops_test.go
@@ -10,11 +10,11 @@ func TestGetDropsEntitlements(t *testing.T) {
 	t.Parallel()
 
 	testCases := []struct {
-		statusCode     int
-		options        *Options
-		gameID         string
-		respBody       string
-		expectedErrMsg string
+		statusCode      int
+		options         *Options
+		gameID          string
+		respBody        string
+		expectedErrMsg  string
 	}{
 		{
 			http.StatusUnauthorized,
diff --git a/entitlement_codes_test.go b/entitlement_codes_test.go
index fde0ea9..78ae4b7 100644
--- a/entitlement_codes_test.go
+++ b/entitlement_codes_test.go
@@ -12,7 +12,7 @@ func TestClient_GetEntitlementCodeStatus(t *testing.T) {
 		statusCode int
 		options    *Options
 		count      int
-		userID     string
+		userId     string
 		codes      []string
 		respBody   string
 	}{
@@ -31,7 +31,7 @@ func TestClient_GetEntitlementCodeStatus(t *testing.T) {
 		c := newMockClient(testCase.options, newMockHandler(testCase.statusCode, testCase.respBody, nil))
 
 		params := &CodesParams{
-			UserID: testCase.userID,
+			UserID: testCase.userId,
 			Codes:  testCase.codes,
 		}
 
@@ -68,7 +68,7 @@ func TestClient_RedeemEntitlementCode(t *testing.T) {
 		statusCode int
 		options    *Options
 		count      int
-		userID     string
+		userId     string
 		codes      []string
 		respBody   string
 	}{
@@ -87,7 +87,7 @@ func TestClient_RedeemEntitlementCode(t *testing.T) {
 		c := newMockClient(testCase.options, newMockHandler(testCase.statusCode, testCase.respBody, nil))
 
 		params := &CodesParams{
-			UserID: testCase.userID,
+			UserID: testCase.userId,
 			Codes:  testCase.codes,
 		}
 
diff --git a/eventsub_test.go b/eventsub_test.go
new file mode 100644
index 0000000..1158f52
--- /dev/null
+++ b/eventsub_test.go
@@ -0,0 +1,250 @@
+package helix
+
+import (
+	"net/http"
+	"testing"
+)
+
+func TestGetEventSubSubscriptions(t *testing.T) {
+	t.Parallel()
+
+	testCases := []struct {
+		statusCode int
+		options    *Options
+		params     *EventSubSubscriptionsParams
+		count      int
+		respBody   string
+	}{
+		{
+			http.StatusUnauthorized,
+			&Options{ClientID: "my-client-id"},
+			&EventSubSubscriptionsParams{},
+			0,
+			`{"error":"Unauthorized","status":401,"message":"OAuth token is missing"}`,
+		},
+		{
+			http.StatusOK,
+			&Options{ClientID: "my-client-id"},
+			&EventSubSubscriptionsParams{},
+			2,
+			`{"total":2,"data":[{"id":"832389eb-0d0b-41f8-b564-da039f6c4c75","status":"enabled","type":"channel.follow","version":"1","condition":{"broadcaster_user_id":"12345678"},"created_at":"2021-03-09T10:37:32.308415339Z","transport":{"method":"webhook","callback":"https://example.com/eventsub/follow"},"cost":1},{"id":"832389eb-0d0b-41f8-b564-da039f6c4c73","status":"enabled","type":"channel.follow","version":"1","condition":{"broadcaster_user_id":"12345679"},"created_at":"2021-03-09T10:37:32.308415339Z","transport":{"method":"webhook","callback":"https://example.com/eventsub/follow"},"cost":1}],"limit":100000000,"max_total_cost":10000,"total_cost":2,"pagination":{}}`,
+		},
+		{
+			http.StatusOK,
+			&Options{ClientID: "my-client-id"},
+			&EventSubSubscriptionsParams{Status: "webhook_callback_verification_failed"},
+			0,
+			`{"total":1,"data":[],"limit":100000000,"max_total_cost":10000,"total_cost":1,"pagination":{}}`,
+		},
+	}
+
+	for _, testCase := range testCases {
+		c := newMockClient(testCase.options, newMockHandler(testCase.statusCode, testCase.respBody, nil))
+
+		resp, err := c.GetEventSubSubscriptions(testCase.params)
+		if err != nil {
+			t.Error(err)
+		}
+
+		if resp.StatusCode != testCase.statusCode {
+			t.Errorf("expected status code to be \"%d\", got \"%d\"", testCase.statusCode, resp.StatusCode)
+		}
+
+		if resp.StatusCode == http.StatusUnauthorized {
+			if resp.Error != "Unauthorized" {
+				t.Errorf("expected error to be \"%s\", got \"%s\"", "Unauthorized", resp.Error)
+			}
+
+			if resp.ErrorStatus != http.StatusUnauthorized {
+				t.Errorf("expected error status to be \"%d\", got \"%d\"", http.StatusUnauthorized, resp.ErrorStatus)
+			}
+
+			expectedErrMsg := "OAuth token is missing"
+			if resp.ErrorMessage != expectedErrMsg {
+				t.Errorf("expected error message to be \"%s\", got \"%s\"", expectedErrMsg, resp.ErrorMessage)
+			}
+
+			continue
+		}
+
+		if len(resp.Data.EventSubSubscriptions) != testCase.count {
+			t.Errorf("expected result length to be \"%d\", got \"%d\"", testCase.count, len(resp.Data.EventSubSubscriptions))
+		}
+	}
+}
+
+func TestRemoveEventSubSubscriptions(t *testing.T) {
+	t.Parallel()
+
+	testCases := []struct {
+		statusCode int
+		options    *Options
+		params     string
+		respBody   string
+	}{
+		{
+			http.StatusUnauthorized,
+			&Options{ClientID: "my-client-id"},
+			"",
+			`{"error":"Unauthorized","status":401,"message":"OAuth token is missing"}`,
+		},
+		{
+			http.StatusBadRequest,
+			&Options{ClientID: "my-client-id"},
+			"",
+			`{"error":"Bad Request","status":400,"message":"Missing required parameter \"id\""}`,
+		},
+		{
+			http.StatusNoContent,
+			&Options{ClientID: "my-client-id"},
+			"832389eb-0d0b-41f8-b564-da039f6c4c75",
+			``,
+		},
+	}
+
+	for _, testCase := range testCases {
+		c := newMockClient(testCase.options, newMockHandler(testCase.statusCode, testCase.respBody, nil))
+
+		resp, err := c.RemoveEventSubSubscription(testCase.params)
+		if err != nil {
+			t.Error(err)
+		}
+
+		if resp.StatusCode != testCase.statusCode {
+			t.Errorf("expected status code to be \"%d\", got \"%d\"", testCase.statusCode, resp.StatusCode)
+		}
+
+		if resp.StatusCode == http.StatusUnauthorized {
+			if resp.Error != "Unauthorized" {
+				t.Errorf("expected error to be \"%s\", got \"%s\"", "Unauthorized", resp.Error)
+			}
+
+			if resp.ErrorStatus != http.StatusUnauthorized {
+				t.Errorf("expected error status to be \"%d\", got \"%d\"", http.StatusUnauthorized, resp.ErrorStatus)
+			}
+
+			expectedErrMsg := "OAuth token is missing"
+			if resp.ErrorMessage != expectedErrMsg {
+				t.Errorf("expected error message to be \"%s\", got \"%s\"", expectedErrMsg, resp.ErrorMessage)
+			}
+
+			continue
+		}
+		if resp.StatusCode == http.StatusBadRequest {
+			if resp.Error != "Bad Request" {
+				t.Errorf("expected error to be \"%s\", got \"%s\"", "Bad Request", resp.Error)
+			}
+
+			expectedErrMsg := `Missing required parameter "id"`
+			if resp.ErrorMessage != expectedErrMsg {
+				t.Errorf("expected error message to be \"%s\", got \"%s\"", expectedErrMsg, resp.ErrorMessage)
+			}
+
+			continue
+		}
+	}
+}
+
+func TestCreateEventSubSubscriptions(t *testing.T) {
+	t.Parallel()
+
+	testCases := []struct {
+		statusCode int
+		options    *Options
+		params     *EventSubSubscription
+		respBody   string
+	}{
+		{
+			http.StatusUnauthorized,
+			&Options{ClientID: "my-client-id"},
+			&EventSubSubscription{},
+			`{"error":"Unauthorized","status":401,"message":"OAuth token is missing"}`,
+		},
+		{
+			http.StatusOK,
+			&Options{ClientID: "my-client-id"},
+			&EventSubSubscription{
+				Type:    "channel.follow",
+				Version: "1",
+				Condition: EventSubCondition{
+					BroadcasterUserID: "12345678",
+				},
+				Transport: EventSubTransport{
+					Method:   "webhook",
+					Callback: "https://example.com/eventsub/follow",
+					Secret:   "s3cr37w0rd",
+				},
+			},
+			`{"data":[{"id":"4d06fabc-4cf4-4e99-a60f-b457d5c69305","status":"webhook_callback_verification_pending","type":"channel.follow","version":"1","condition":{"broadcaster_user_id":"12345678"},"created_at":"2021-03-10T23:38:50.311154721Z","transport":{"method":"webhook","callback":"https://example.com/eventsub/follow"},"cost":1}],"limit":10000,"total":1,"max_total_cost":10000,"total_cost":1}`,
+		},
+	}
+
+	for _, testCase := range testCases {
+		c := newMockClient(testCase.options, newMockHandler(testCase.statusCode, testCase.respBody, nil))
+
+		resp, err := c.CreateEventSubSubscription(testCase.params)
+		if err != nil {
+			t.Error(err)
+		}
+
+		if resp.StatusCode != testCase.statusCode {
+			t.Errorf("expected status code to be \"%d\", got \"%d\"", testCase.statusCode, resp.StatusCode)
+		}
+
+		if resp.StatusCode == http.StatusUnauthorized {
+			if resp.Error != "Unauthorized" {
+				t.Errorf("expected error to be \"%s\", got \"%s\"", "Unauthorized", resp.Error)
+			}
+
+			if resp.ErrorStatus != http.StatusUnauthorized {
+				t.Errorf("expected error status to be \"%d\", got \"%d\"", http.StatusUnauthorized, resp.ErrorStatus)
+			}
+
+			expectedErrMsg := "OAuth token is missing"
+			if resp.ErrorMessage != expectedErrMsg {
+				t.Errorf("expected error message to be \"%s\", got \"%s\"", expectedErrMsg, resp.ErrorMessage)
+			}
+
+			continue
+		}
+		if len(resp.Data.EventSubSubscriptions) != 1 {
+			t.Errorf("expected result length to be \"%d\", got \"%d\"", 1, len(resp.Data.EventSubSubscriptions))
+
+			continue
+		}
+		if resp.Data.EventSubSubscriptions[0].Transport.Method != "webhook" {
+			t.Errorf("expected result transport method to be \"%s\", got \"%s\"", "webhook", resp.Data.EventSubSubscriptions[0].Transport.Method)
+		}
+	}
+}
+
+func TestVerifyEventSubNotification(t *testing.T) {
+	t.Parallel()
+
+	testCases := []struct {
+		messageID        string
+		messageSignature string
+		messageTimestamp string
+		respBody         string
+		secret           string
+	}{
+		{
+			"e76c6bd4-55c9-4987-8304-da1588d8988b",
+			"sha256=7e5a96480c29cdf834b371e7a5b049638cba6e425ea51b9b2a9fabf69bc5d227",
+			"2019-11-16T10:11:12.123Z",
+			`{"challenge":"pogchamp-kappa-360noscope-vohiyo","subscription":{"id":"f1c2a387-161a-49f9-a165-0f21d7a4e1c4","status":"webhook_callback_verification_pending","type":"channel.follow","version":"1","condition":{"broadcaster_user_id":"12826"},"transport":{"method":"webhook","callback":"https://example.com/webhooks/callback"},"created_at":"2019-11-16T10:11:12.123Z"}}`,
+			"s3cRe7",
+		},
+	}
+
+	for _, testCase := range testCases {
+		header := http.Header{}
+		header.Add("Twitch-Eventsub-Message-Id", testCase.messageID)
+		header.Add("Twitch-Eventsub-Message-Signature", testCase.messageSignature)
+		header.Add("Twitch-Eventsub-Message-Timestamp", testCase.messageTimestamp)
+		signatureOk := VerifyEventSubNotification(testCase.secret, header, testCase.respBody)
+		if !signatureOk {
+			t.Errorf("expected signature to match \"%s\", but it didn't", testCase.messageSignature)
+		}
+	}
+}
diff --git a/user_extension_test.go b/user_extension_test.go
index b7128e8..6bd6d27 100644
--- a/user_extension_test.go
+++ b/user_extension_test.go
@@ -94,7 +94,7 @@ func TestUpdateUserExtensions(t *testing.T) {
 			&Options{ClientID: "my-client-id", UserAccessToken: "my-access-token"},
 			&UpdateUserExtensionsPayload{
 				Panel: map[string]UserActiveExtensionInfo{
-					"1": {
+					"1": UserActiveExtensionInfo{
 						Active: false,
 					},
 				},
diff --git a/webhooks_test.go b/webhooks_test.go
index 41c3251..da330a4 100644
--- a/webhooks_test.go
+++ b/webhooks_test.go
@@ -124,7 +124,7 @@ func TestPostWebhookSubscriptions(t *testing.T) {
 }
 
 func newMockWebhookRequest(header string) *http.Request {
-	return &http.Request{Header: map[string][]string{"Link": {header}}}
+	return &http.Request{Header: map[string][]string{"Link": []string{header}}}
 }
 
 func TestGetWebhookTopicFromRequest(t *testing.T) {