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
- 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.
- 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.
- As a platform admin, I want to subscribe to a specific EventSub event type, so that I can test individual subscriptions incrementally.
- 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.
- 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.
- 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.
- As a platform admin, I want the webhook endpoint to reject messages with timestamps older than 10 minutes, so that replay attacks are prevented.
- 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.
- As a platform admin, I want the webhook endpoint to handle Twitch's verification challenge, so that new EventSub subscriptions can be activated.
- As a platform admin, I want the webhook endpoint to handle subscription revocations, so that I'm aware when Twitch cancels a subscription.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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:
-
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
-
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)
-
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
-
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
-
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.
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-twitchsó 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 tabelatwitch_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
twitch_event_logstable, so that I have a complete data lake for future processing.twitch_message_id) to be silently ignored, so that retries from Twitch don't create duplicate records.integration-discordand benefits from Saloon's testing utilities.TwitchBaseClient,TwitchSubscribersClient) and their interfaces removed, so that there's a single HTTP pattern in the module.TwitchOAuthClientrefactored to use Saloon internally while keeping theOAuthClientContractinterface, so that the identity module's login flow continues to work without changes.TwitchEventSubTypeenum listing all subscribable event types with their version and condition fields, so that the subscribe command can iterate over all types programmatically.ETL/Actions,ETL/DTOs,ETL/Console) created but empty, so that future event processing work has a clear home.event_typeandbroadcaster_user_idindexed in the event logs table, so that future queries by event type or channel are fast.user_idextracted 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), mirroringintegration-discord. Two connectors:id.twitch.tv/oauth2, no default auth (credentials passed per-request body)api.twitch.tv/helix, default auth with App Access Token (TokenAuthenticator) +Client-Idheader. Token resolved externally and passed via constructor.Requests organized by Helix API resource:
Requests/OAuth/,Requests/Users/,Requests/EventSub/.Architecture: Module directory structure
Architecture: Facade removed
The
TwitchBaseClientfacade andTwitchServiceinterface are deleted. Callers injectTwitchHelixConnectororTwitchOAuthConnectordirectly (same pattern as Discord).Architecture: Legacy deletion
Client/TwitchBaseClient.php— deletedContracts/TwitchService.php— deletedOAuth/Contracts/TwitchOAuthService.php— deletedSubscriber/directory — deleted entirely (legacy code)IdentityProvider::Twitchupdated to resolveTwitchOAuthClient::classdirectly (like Discord does)Schema:
twitch_event_logsConfig:
config/services.phpTwo new keys added to the existing
twitcharray:eventsub_secret(TWITCH_EVENTSUB_SECRETenv) — shared secret for HMAC signing (10-100 chars)eventsub_callback(TWITCH_EVENTSUB_CALLBACKenv) — public HTTPS URL for webhook endpointWebhook security
sha256=+hash_hmac('sha256', message_id + timestamp + raw_body, secret)hash_equals()twitch_message_idunique constraint — duplicate INSERT returns 204 silentlyApp Access Token management
TwitchAppTokenService(inOAuth/) handles client_credentials flow viaTwitchOAuthConnector. Token cached withCache::remember(), TTL =expires_in - 300seconds buffer. TheTwitchHelixConnectoris 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 anExternalIdentityon the tenant (same pattern asGenerateDiscordTenantcommand). UsesIdentityProvider::Twitchwithexternal_account_id= broadcaster user ID.EventSub subscription management
twitch:subscribe {broadcaster_user_id} {--type=} {--all}command iteratesTwitchEventSubType::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. TheETL/directory structure exists but is empty. Processing will be added in future issues.Domain documentation
CONTEXT.mdcreated forintegration-twitchwith glossary (Transport, ETL, EventSub, TwitchEventLog, App Access Token)CONTEXT-MAP.mdupdated to include Integration Twitch context and dependency rulesintegration-discord/CONTEXT.mdto cover both batch and real-time transformationTesting 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:
Webhook endpoint (Feature) — the critical security and ingestion path
Transport / Saloon Requests (Feature) — test with Saloon's MockClient
OAuth refactor (Feature) — verify login flow still works
TwitchOAuthClient::auth()exchanges code for token via SaloonTwitchOAuthClient::getAuthenticatedUser()fetches user profileTwitchOAuthClient::redirectUrl()builds correct URLTwitchAppTokenService (Feature) — token caching behavior
Commands (Feature) — provisioning and subscription management
twitch:link-channelcreates ExternalIdentity on tenanttwitch:subscribe --allcreates subscriptions, reports resultstwitch:subscribe --type=stream.onlinecreates single subscriptionPrior art
app-modules/integration-discord/tests/— Discord module test patternsapp-modules/*/tests/Feature/— existing Pest feature test patterns across modulesMockClientfor testing HTTP requests without real API callsOut of Scope
Further Notes
TWITCH_EVENTSUB_CALLBACKaccordingly.twitch_event_logstable will grow fast on active channels. Future work should consider partitioning, archival, or TTL policies.