Outbound webhook events
CRM publishes outbound integration events onto the RabbitMQ exchange
under the webhook.{event_type} routing key. The downstream
tenant-backend-webhook service fans these out to per-tenant HTTP
subscribers.
This is distinct from the inbound identity events (where CRM is a consumer) — see events.md for that direction. It is also distinct from the WebSocket fan-out done via the ws-proxy (also documented below) which targets the SPA, not external subscribers.
Transport
| Exchange | RabbitMQ default exchange via RabbitMQService::publish |
| Routing key shape | webhook.{event_type} |
| Publisher class | App\Services\WebhookEventPublisher |
| Sync method | publish(string $eventType, array $data): void |
| Async method | publishAsync(...) — defers to app()->terminating() so the HTTP response ships first |
| Envelope | { event, data, timestamp, service: 'crm' } |
A failure to publish is logged at error level and the request still
succeeds — webhook delivery is best-effort. Subscribers must tolerate
missing events; ordering is not guaranteed.
Event catalogue
The list below is generated from grep publishAsync over
app/Http/Controllers/. When you add a webhook publisher call, add the
event name here in the same commit.
Chat
| Event | Source controller | Payload |
|---|---|---|
chat.channel.created | ChatController | ChatChannel model array |
chat.message.sent | ChatMessageController, ChatFileController | ChatMessage model array (file or text) |
chat.message.updated | ChatMessageController | ChatMessage model array |
chat.message.deleted | ChatMessageController | { id, channel_id } |
chat.message.recalled | ChatMessageController | { id, channel_id, recalled_at } |
chat.participant.added | ChatParticipantController | { channel_id, user_id, added_by } |
chat.participant.removed | ChatParticipantController | { channel_id, user_id, removed_by } |
Content (Blog / Page / KB)
| Event | Source controller | Payload |
|---|---|---|
blog_post.created | BlogPostController | BlogPost model array |
blog_post.updated | BlogPostController | BlogPost model array |
blog_post.deleted | BlogPostController | { id } |
blog_category.created | BlogCategoryController | BlogCategory model array |
blog_category.updated | BlogCategoryController | BlogCategory model array |
blog_category.deleted | BlogCategoryController | { id } |
page.created | PageController | Page model array |
page.updated | PageController | Page model array |
page.deleted | PageController | { id } |
page_category.created | PageCategoryController | PageCategory model array |
page_category.updated | PageCategoryController | PageCategory model array |
page_category.deleted | PageCategoryController | { id } |
Publish/unpublish of blog_post and page is currently emitted as
*.updated (the published_at field flips). The publish lifecycle is
mirrored in ActivityLog rows for the compliance trail — see
security.md § audit trail.
SMS templates
| Event | Source controller | Payload |
|---|---|---|
sms_template.created | SmsTemplateController | SmsTemplate model array |
sms_template.updated | SmsTemplateController | SmsTemplate model array |
sms_template.deleted | SmsTemplateController | { id } |
CRM core
| Event | Source controller | Payload |
|---|---|---|
crm.company.types_changed | CompanyController | { company_id, previous, current } |
Envelope shape
{
"event": "blog_post.updated",
"data": { "id": "01J6...", "title": "...", "...": "..." },
"timestamp": 1715600000,
"service": "crm"
}
Subscriber-side deduplication should key on (event, data.id, timestamp)
because the publisher does not mint per-event UUIDs.
WebSocket fan-out (ws-proxy, distinct from HTTP webhooks)
These are emitted by App\Services\EventPublisher onto the
ws-proxy.{channel}.{event} (or ws-proxy.user.{userId}.{event})
routing key. The ws-proxy service forwards them to subscribed
browser clients via the websocket connection — they are not external
webhook deliveries.
| Event | Target |
|---|---|
chat.message.new | per-participant user channel |
chat.message.updated | per-participant user channel |
chat.message.deleted | per-participant user channel |
chat.message.recalled | per-participant user channel |
chat.reaction.updated | per-participant user channel |
The SPA's useChatStore subscribes to the user channel on login and
applies these patches locally.
Adding a new webhook event
- Pick a name that follows
{resource}.{verb}(task.creatednotcreated_task). - Inject
WebhookEventPublisher(or pull from$this->webhookPublisherif the parent controller has it). - Call
publishAsync($name, $payload)after the DB commit — theterminating()defer means a publish failure can't 500 the response. - Add a row to the relevant table above in the same PR.
- If the payload includes sensitive fields, scrub them at the publish site (the downstream webhook service has no field-level filtering).
Where to learn more
- events.md — inbound identity → CRM events
- api.md — REST endpoints that trigger these emits
tenant/tenant-backend-crm/app/Services/WebhookEventPublisher.php— the publishertenant/tenant-backend-crm/app/Services/EventPublisher.php— the ws-proxy publisherdocumentations/docs/tenant-services/webhook.md— the downstream subscriber service