Appearance
ADR-007 — Separate Webhook Controller Per Channel
Status: Accepted
Date: 2026-05-21
Context
Channel providers (Chatwoot, Freshchat, email) push events to Opsome via webhooks. Each provider has a different payload shape, HMAC signature scheme, and event taxonomy.
Decision
One dedicated webhook controller per channel. One shared job for all channels.
POST /webhook/chatwoot/{tenantId} → ChatwootWebhookController
POST /webhook/freshchat/{tenantId} → FreshchatWebhookController (future)
POST /webhook/email/{tenantId} → EmailWebhookController (future)Each controller is responsible for:
- HMAC signature verification (provider-specific)
- Event filtering — discard irrelevant events (e.g. agent replies, status changes we don't act on)
- Normalization into
InboundMessageDTO - Dispatching
ProcessInboundMessageJob
php
readonly class InboundMessageDTO
{
public string $conversationId;
public string $contactEmail;
public string $contactPhone;
public string $messageBody;
public string $channelType; // 'chatwoot' | 'freshchat' | 'email'
}ProcessInboundMessageJob is channel-agnostic — it receives only the normalized DTO.
Filtering Rule (Chatwoot)
Only act on message_created events where sender.type === 'contact'. Discard:
- Messages where sender is an agent or bot
conversation_created,conversation_resolved, assignment events
Consequences
- Adding Freshchat = one new controller + one
InboundMessageDTO::fromFreshchat()factory - No conditional logic in the job layer based on channel type
- Each controller is small, focused, and independently testable