Skip to content

Twitch EventSub: ingestao de eventos via webhook (data lake) #266

@danielhe4rt

Description

@danielhe4rt

Problem Statement

A He4rt Developers precisa integrar seu canal oficial da Twitch ("danielhe4rt") ao tenant "He4rt Developers" para receber e armazenar todos os eventos que acontecem no canal — stream online/offline, follows, subs, chat messages, raids, cheers, etc. Hoje o módulo integration-twitch só possui OAuth para login de usuários e um client legado de subscribers (Guzzle). Não existe infraestrutura para receber eventos em tempo real da Twitch.

O PubSub da Twitch foi descontinuado em abril/2025. O EventSub via webhook é a única opção para receber notificações de eventos.

Solution

Implementar a camada completa de ingestão de eventos do Twitch EventSub no módulo integration-twitch, seguindo a abordagem data lake: armazenar todos os payloads crus numa tabela twitch_event_logs (JSONB) sem processamento no momento da escrita. O processamento (ETL → entidades do domínio) será adicionado em issues futuras.

Adicionalmente, migrar toda a camada HTTP do módulo de Guzzle para SaloonPHP (já usado no integration-discord), deletar código legado, e criar comandos Artisan para vincular canal ao tenant e gerenciar subscriptions do EventSub.

User Stories

  1. As a platform admin, I want to link the Twitch channel "danielhe4rt" to the "He4rt Developers" tenant, so that events from that channel are associated with the correct community.
  2. As a platform admin, I want to subscribe to all available Twitch EventSub event types for the linked channel, so that no event data is missed.
  3. As a platform admin, I want to subscribe to a specific EventSub event type, so that I can test individual subscriptions incrementally.
  4. As a platform admin, I want the system to receive Twitch EventSub webhook notifications at a public HTTPS endpoint, so that events arrive in real time.
  5. As a platform admin, I want all incoming webhook payloads stored as raw JSONB in a twitch_event_logs table, so that I have a complete data lake for future processing.
  6. As a platform admin, I want the webhook endpoint to verify Twitch's HMAC-SHA256 signature on every request, so that forged or tampered requests are rejected.
  7. As a platform admin, I want the webhook endpoint to reject messages with timestamps older than 10 minutes, so that replay attacks are prevented.
  8. As a platform admin, I want duplicate webhook deliveries (same twitch_message_id) to be silently ignored, so that retries from Twitch don't create duplicate records.
  9. As a platform admin, I want the webhook endpoint to handle Twitch's verification challenge, so that new EventSub subscriptions can be activated.
  10. As a platform admin, I want the webhook endpoint to handle subscription revocations, so that I'm aware when Twitch cancels a subscription.
  11. As a platform admin, I want the system to use an App Access Token (client credentials) to manage EventSub subscriptions, so that no user-specific token is needed for server-to-server operations.
  12. As a platform admin, I want the App Access Token to be cached and auto-renewed, so that API calls don't fail due to expired tokens.
  13. As a platform admin, I want to see which EventSub subscriptions failed due to missing scopes, so that I know which events need additional authorization from the broadcaster.
  14. As a developer, I want all Twitch HTTP calls to use SaloonPHP connectors and requests, so that the module is consistent with integration-discord and benefits from Saloon's testing utilities.
  15. As a developer, I want the legacy Guzzle clients (TwitchBaseClient, TwitchSubscribersClient) and their interfaces removed, so that there's a single HTTP pattern in the module.
  16. As a developer, I want the TwitchOAuthClient refactored to use Saloon internally while keeping the OAuthClientContract interface, so that the identity module's login flow continues to work without changes.
  17. As a developer, I want a comprehensive TwitchEventSubType enum listing all subscribable event types with their version and condition fields, so that the subscribe command can iterate over all types programmatically.
  18. As a developer, I want the ETL directory structure (ETL/Actions, ETL/DTOs, ETL/Console) created but empty, so that future event processing work has a clear home.
  19. As a developer, I want event_type and broadcaster_user_id indexed in the event logs table, so that future queries by event type or channel are fast.
  20. As a developer, I want user_id extracted from payloads into a dedicated column, so that "all events from user X" queries don't require JSONB parsing.

Implementation Decisions

Architecture: Saloon-based Transport layer

All outbound HTTP moves to SaloonPHP (saloon/saloon ^4.0), mirroring integration-discord. Two connectors:

  • TwitchOAuthConnector — base URL id.twitch.tv/oauth2, no default auth (credentials passed per-request body)
  • TwitchHelixConnector — base URL api.twitch.tv/helix, default auth with App Access Token (TokenAuthenticator) + Client-Id header. Token resolved externally and passed via constructor.

Requests organized by Helix API resource: Requests/OAuth/, Requests/Users/, Requests/EventSub/.

Architecture: Module directory structure

integration-twitch/src/
├── Console/           ← Setup/provisioning commands (link-channel, subscribe)
├── Enums/             ← TwitchEventSubType (used across module)
├── ETL/               ← Empty for MVP (Actions/, Console/, DTOs/)
├── Http/              ← Inbound webhook controller + HMAC middleware
├── Models/            ← TwitchEventLog (data lake)
├── OAuth/             ← TwitchOAuthClient (refactored), TwitchAppTokenService, DTOs
└── Transport/         ← Saloon connectors + requests (outbound HTTP)

Architecture: Facade removed

The TwitchBaseClient facade and TwitchService interface are deleted. Callers inject TwitchHelixConnector or TwitchOAuthConnector directly (same pattern as Discord).

Architecture: Legacy deletion

  • Client/TwitchBaseClient.php — deleted
  • Contracts/TwitchService.php — deleted
  • OAuth/Contracts/TwitchOAuthService.php — deleted
  • Subscriber/ directory — deleted entirely (legacy code)
  • IdentityProvider::Twitch updated to resolve TwitchOAuthClient::class directly (like Discord does)

Schema: twitch_event_logs

Column Type Constraints
id bigint PK
event_type string indexed
broadcaster_user_id string, nullable indexed
user_id string, nullable
twitch_message_id string, nullable unique (dedup)
payload jsonb
created_at timestamp
updated_at timestamp

Config: config/services.php

Two new keys added to the existing twitch array:

  • eventsub_secret (TWITCH_EVENTSUB_SECRET env) — shared secret for HMAC signing (10-100 chars)
  • eventsub_callback (TWITCH_EVENTSUB_CALLBACK env) — public HTTPS URL for webhook endpoint

Webhook security

  • HMAC-SHA256: sha256= + hash_hmac('sha256', message_id + timestamp + raw_body, secret)
  • Timing-safe comparison via hash_equals()
  • Replay protection: reject timestamps older than 10 minutes
  • Deduplication: twitch_message_id unique constraint — duplicate INSERT returns 204 silently

App Access Token management

TwitchAppTokenService (in OAuth/) handles client_credentials flow via TwitchOAuthConnector. Token cached with Cache::remember(), TTL = expires_in - 300 seconds buffer. The TwitchHelixConnector is registered as a singleton in the ServiceProvider, receiving the resolved token.

Tenant linking

twitch:link-channel {login} {--tenant=} command resolves the broadcaster's user ID via Helix API, then creates an ExternalIdentity on the tenant (same pattern as GenerateDiscordTenant command). Uses IdentityProvider::Twitch with external_account_id = broadcaster user ID.

EventSub subscription management

twitch:subscribe {broadcaster_user_id} {--type=} {--all} command iterates TwitchEventSubType::cases(), creates subscriptions via Helix API, handles 403 (missing scopes) gracefully, and reports results in a table.

No Observer/processing in MVP

The data lake stores everything. No TwitchEventLogObserver, no ETL Actions, no event handlers. The ETL/ directory structure exists but is empty. Processing will be added in future issues.

Domain documentation

  • CONTEXT.md created for integration-twitch with glossary (Transport, ETL, EventSub, TwitchEventLog, App Access Token)
  • CONTEXT-MAP.md updated to include Integration Twitch context and dependency rules
  • ETL definition broadened in integration-discord/CONTEXT.md to cover both batch and real-time transformation

Testing Decisions

Good tests verify external behavior — HTTP responses, database state, cache interactions — not internal method calls or class structure.

Modules to test

All modules will have tests:

  1. Webhook endpoint (Feature) — the critical security and ingestion path

    • Valid signature → 204, TwitchEventLog created
    • Invalid signature → 403
    • Missing headers → 403
    • Expired timestamp → 403
    • Challenge verification → 200 with challenge body
    • Duplicate twitch_message_id → 204, no error
    • Revocation → 204, TwitchEventLog created
    • Helper trait to generate HMAC-signed requests
  2. Transport / Saloon Requests (Feature) — test with Saloon's MockClient

    • TwitchHelixConnector sends correct auth headers
    • TwitchOAuthConnector sends no auth by default
    • Each Request class hits the correct endpoint, method, and body
    • Error responses handled correctly (403, 401, 429)
  3. OAuth refactor (Feature) — verify login flow still works

    • TwitchOAuthClient::auth() exchanges code for token via Saloon
    • TwitchOAuthClient::getAuthenticatedUser() fetches user profile
    • TwitchOAuthClient::redirectUrl() builds correct URL
  4. TwitchAppTokenService (Feature) — token caching behavior

    • First call fetches token from API, caches it
    • Subsequent calls return cached token
    • Cache TTL respects expires_in minus buffer
  5. Commands (Feature) — provisioning and subscription management

    • twitch:link-channel creates ExternalIdentity on tenant
    • twitch:subscribe --all creates subscriptions, reports results
    • twitch:subscribe --type=stream.online creates single subscription

Prior art

  • app-modules/integration-discord/tests/ — Discord module test patterns
  • app-modules/*/tests/Feature/ — existing Pest feature test patterns across modules
  • Saloon's MockClient for testing HTTP requests without real API calls

Out of Scope

  • Event processing / ETL Actions — no Observer, no handlers, no domain entity creation from events. Data lake only.
  • Twitch chat bot — no WebSocket connection to Twitch IRC/chat. Events come via webhook only.
  • Admin panel UI — no Filament resources for viewing/managing Twitch event logs or subscriptions.
  • Gamification — no XP/badges/ranking from Twitch events.
  • Discord notifications — no "channel went live" messages in Discord from Twitch events.
  • Multiple channels per tenant — current scope is one official channel per tenant.
  • Token refresh for user OAuth — existing OAuth refresh flow is untouched; only app token (client credentials) is new.

Further Notes

  • The webhook endpoint must be publicly accessible via HTTPS. For local development, use ngrok or similar tunnel and set TWITCH_EVENTSUB_CALLBACK accordingly.
  • Some EventSub event types require the broadcaster to have authorized the app with specific scopes. The subscribe command handles 403 responses gracefully and reports which subscriptions need additional authorization.
  • Twitch requires webhook responses within 10 seconds. The controller does a single INSERT and returns 204 — well within the limit.
  • The twitch_event_logs table will grow fast on active channels. Future work should consider partitioning, archival, or TTL policies.

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentFully specified, ready for an AFK agent

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions