Overview

Channel Points are a loyalty system where viewers earn points by watching and engaging with streams. Broadcasters can create custom rewards that viewers redeem with their points.

What you can do:

  • Create, update, and delete custom rewards
  • Get pending redemptions and update their status (fulfill/cancel)
  • Listen for real-time redemption events via EventSub
  • Build automated reward fulfillment systems

Common use cases:

  • Song request systems
  • TTS (text-to-speech) messages
  • Game actions triggered by viewers
  • VIP perks and special interactions

Prerequisites

Channel Points require user authentication with these scopes:

  • channel:read:redemptions - Read redemptions
  • channel:manage:redemptions - Manage redemption status
  • channel:manage:rewards - Create/manage custom rewards (broadcaster only)

Create Custom Rewards

package main

import (
    "context"
    "fmt"
    "log"

    "github.com/Its-donkey/kappopher/helix"
)

func main() {
    ctx := context.Background()

    // Setup client with broadcaster token
    authClient := helix.NewAuthClient(helix.AuthConfig{
        ClientID:     "your-client-id",
        ClientSecret: "your-client-secret",
    })
    client := helix.NewClient("your-client-id", authClient)

    broadcasterID := "12345"

    // Create a simple reward
    reward, err := client.CreateCustomReward(ctx, &helix.CreateCustomRewardParams{
        BroadcasterID: broadcasterID,
        Title:         "Hydrate!",
        Cost:          100,
    })
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Created reward: %s (ID: %s)\n", reward.Data[0].Title, reward.Data[0].ID)

    // Create a reward with all options
    fullReward, err := client.CreateCustomReward(ctx, &helix.CreateCustomRewardParams{
        BroadcasterID:                    broadcasterID,
        Title:                            "Song Request",
        Cost:                             500,
        Prompt:                           "Enter the song name and artist",
        IsEnabled:                        boolPtr(true),
        BackgroundColor:                  "#FF0000",
        IsUserInputRequired:              true,
        IsMaxPerStreamEnabled:            true,
        MaxPerStream:                     10,
        IsMaxPerUserPerStreamEnabled:     true,
        MaxPerUserPerStream:              2,
        IsGlobalCooldownEnabled:          true,
        GlobalCooldownSeconds:            300,
        ShouldRedemptionsSkipRequestQueue: false,
    })
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Created reward: %s\n", fullReward.Data[0].Title)
}

func boolPtr(b bool) *bool { return &b }

Get Custom Rewards

// Get all custom rewards
rewards, err := client.GetCustomReward(ctx, &helix.GetCustomRewardParams{
    BroadcasterID: broadcasterID,
})
if err != nil {
    log.Fatal(err)
}

for _, reward := range rewards.Data {
    fmt.Printf("Reward: %s\n", reward.Title)
    fmt.Printf("  ID: %s\n", reward.ID)
    fmt.Printf("  Cost: %d\n", reward.Cost)
    fmt.Printf("  Enabled: %v\n", reward.IsEnabled)
    fmt.Printf("  Paused: %v\n", reward.IsPaused)
    fmt.Printf("  In Stock: %v\n", reward.IsInStock)
}

// Get specific rewards by ID
specificRewards, err := client.GetCustomReward(ctx, &helix.GetCustomRewardParams{
    BroadcasterID: broadcasterID,
    IDs:           []string{"reward-id-1", "reward-id-2"},
})

// Get only manageable rewards (created by your app)
manageableRewards, err := client.GetCustomReward(ctx, &helix.GetCustomRewardParams{
    BroadcasterID:      broadcasterID,
    OnlyManageableRewards: true,
})

Update Custom Rewards

// Update reward cost and title
updated, err := client.UpdateCustomReward(ctx, &helix.UpdateCustomRewardParams{
    BroadcasterID: broadcasterID,
    ID:            "reward-id",
    Title:         "Song Request (Updated)",
    Cost:          750,
})

// Pause a reward
paused, err := client.UpdateCustomReward(ctx, &helix.UpdateCustomRewardParams{
    BroadcasterID: broadcasterID,
    ID:            "reward-id",
    IsPaused:      boolPtr(true),
})

// Disable a reward
disabled, err := client.UpdateCustomReward(ctx, &helix.UpdateCustomRewardParams{
    BroadcasterID: broadcasterID,
    ID:            "reward-id",
    IsEnabled:     boolPtr(false),
})

Delete Custom Rewards

// Delete a reward (only rewards created by your app can be deleted)
err := client.DeleteCustomReward(ctx, broadcasterID, "reward-id")
if err != nil {
    log.Printf("Failed to delete reward: %v", err)
}

Get Redemptions

// Get unfulfilled redemptions
redemptions, err := client.GetCustomRewardRedemption(ctx, &helix.GetCustomRewardRedemptionParams{
    BroadcasterID: broadcasterID,
    RewardID:      "reward-id",
    Status:        "UNFULFILLED",
})
if err != nil {
    log.Fatal(err)
}

for _, redemption := range redemptions.Data {
    fmt.Printf("Redemption by %s\n", redemption.UserName)
    fmt.Printf("  ID: %s\n", redemption.ID)
    fmt.Printf("  User Input: %s\n", redemption.UserInput)
    fmt.Printf("  Redeemed At: %s\n", redemption.RedeemedAt)
    fmt.Printf("  Status: %s\n", redemption.Status)
}

// Get redemptions with pagination
allRedemptions, err := client.GetCustomRewardRedemption(ctx, &helix.GetCustomRewardRedemptionParams{
    BroadcasterID: broadcasterID,
    RewardID:      "reward-id",
    Status:        "UNFULFILLED",
    First:         50,
    Sort:          "NEWEST",
})

Update Redemption Status

// Fulfill a redemption
fulfilled, err := client.UpdateCustomRewardRedemptionStatus(ctx, &helix.UpdateRedemptionStatusParams{
    BroadcasterID: broadcasterID,
    RewardID:      "reward-id",
    IDs:           []string{"redemption-id"},
    Status:        "FULFILLED",
})

// Cancel a redemption (refunds points)
cancelled, err := client.UpdateCustomRewardRedemptionStatus(ctx, &helix.UpdateRedemptionStatusParams{
    BroadcasterID: broadcasterID,
    RewardID:      "reward-id",
    IDs:           []string{"redemption-id"},
    Status:        "CANCELED",
})

// Batch update multiple redemptions
batchUpdated, err := client.UpdateCustomRewardRedemptionStatus(ctx, &helix.UpdateRedemptionStatusParams{
    BroadcasterID: broadcasterID,
    RewardID:      "reward-id",
    IDs:           []string{"id-1", "id-2", "id-3"},
    Status:        "FULFILLED",
})

Real-Time Redemptions with EventSub

Handle redemptions in real-time:

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "log"

    "github.com/Its-donkey/kappopher/helix"
)

func main() {
    ctx := context.Background()

    // Setup clients
    authClient := helix.NewAuthClient(helix.AuthConfig{
        ClientID:     "your-client-id",
        ClientSecret: "your-client-secret",
    })
    client := helix.NewClient("your-client-id", authClient)

    broadcasterID := "12345"

    // Create WebSocket client
    ws := helix.NewEventSubWebSocket(client)
    if err := ws.Connect(ctx); err != nil {
        log.Fatal(err)
    }
    defer ws.Close()

    // Subscribe to all redemptions
    ws.Subscribe(ctx, helix.EventSubTypeChannelPointsRedemptionAdd, "1",
        map[string]string{"broadcaster_user_id": broadcasterID},
        func(event json.RawMessage) {
            e, _ := helix.ParseWSEvent[helix.ChannelPointsRedemptionEvent](event)
            handleRedemption(ctx, client, e)
        },
    )

    // Subscribe to specific reward redemptions
    ws.Subscribe(ctx, helix.EventSubTypeChannelPointsRedemptionAdd, "1",
        map[string]string{
            "broadcaster_user_id": broadcasterID,
            "reward_id":           "specific-reward-id",
        },
        func(event json.RawMessage) {
            e, _ := helix.ParseWSEvent[helix.ChannelPointsRedemptionEvent](event)
            fmt.Printf("Specific reward redeemed by %s\n", e.UserName)
        },
    )

    fmt.Println("Listening for redemptions...")
    select {}
}

func handleRedemption(ctx context.Context, client *helix.Client, e *helix.ChannelPointsRedemptionEvent) {
    fmt.Printf("Redemption: %s by %s\n", e.Reward.Title, e.UserName)

    switch e.Reward.Title {
    case "Song Request":
        // Process song request
        fmt.Printf("Song requested: %s\n", e.UserInput)
        // Fulfill after processing
        client.UpdateCustomRewardRedemptionStatus(ctx, &helix.UpdateRedemptionStatusParams{
            BroadcasterID: e.BroadcasterUserID,
            RewardID:      e.Reward.ID,
            IDs:           []string{e.ID},
            Status:        "FULFILLED",
        })

    case "Hydrate!":
        // Auto-fulfill simple rewards
        client.UpdateCustomRewardRedemptionStatus(ctx, &helix.UpdateRedemptionStatusParams{
            BroadcasterID: e.BroadcasterUserID,
            RewardID:      e.Reward.ID,
            IDs:           []string{e.ID},
            Status:        "FULFILLED",
        })

    default:
        fmt.Printf("Unhandled reward: %s\n", e.Reward.Title)
    }
}

Reward Queue Manager

Complete example for managing a reward queue:

package main

import (
    "context"
    "fmt"
    "sync"

    "github.com/Its-donkey/kappopher/helix"
)

type RewardQueue struct {
    client        *helix.Client
    broadcasterID string
    queue         []helix.ChannelPointsRedemptionEvent
    mu            sync.Mutex
}

func NewRewardQueue(client *helix.Client, broadcasterID string) *RewardQueue {
    return &RewardQueue{
        client:        client,
        broadcasterID: broadcasterID,
        queue:         make([]helix.ChannelPointsRedemptionEvent, 0),
    }
}

func (q *RewardQueue) Add(redemption helix.ChannelPointsRedemptionEvent) {
    q.mu.Lock()
    defer q.mu.Unlock()
    q.queue = append(q.queue, redemption)
    fmt.Printf("Added to queue: %s by %s (queue size: %d)\n",
        redemption.Reward.Title, redemption.UserName, len(q.queue))
}

func (q *RewardQueue) ProcessNext(ctx context.Context) *helix.ChannelPointsRedemptionEvent {
    q.mu.Lock()
    if len(q.queue) == 0 {
        q.mu.Unlock()
        return nil
    }
    redemption := q.queue[0]
    q.queue = q.queue[1:]
    q.mu.Unlock()

    return &redemption
}

func (q *RewardQueue) Fulfill(ctx context.Context, redemption *helix.ChannelPointsRedemptionEvent) error {
    _, err := q.client.UpdateCustomRewardRedemptionStatus(ctx, &helix.UpdateRedemptionStatusParams{
        BroadcasterID: q.broadcasterID,
        RewardID:      redemption.Reward.ID,
        IDs:           []string{redemption.ID},
        Status:        "FULFILLED",
    })
    return err
}

func (q *RewardQueue) Cancel(ctx context.Context, redemption *helix.ChannelPointsRedemptionEvent) error {
    _, err := q.client.UpdateCustomRewardRedemptionStatus(ctx, &helix.UpdateRedemptionStatusParams{
        BroadcasterID: q.broadcasterID,
        RewardID:      redemption.Reward.ID,
        IDs:           []string{redemption.ID},
        Status:        "CANCELED",
    })
    return err
}

func (q *RewardQueue) Size() int {
    q.mu.Lock()
    defer q.mu.Unlock()
    return len(q.queue)
}