Skip to content

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:

  1. HMAC signature verification (provider-specific)
  2. Event filtering — discard irrelevant events (e.g. agent replies, status changes we don't act on)
  3. Normalization into InboundMessageDTO
  4. 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