Receiving Chatwoot Callbacks
Overview
POST /api/mbox/callback/:oaHash is the return path of the live chat system — the endpoint
Chatwoot calls into us whenever something happens on the agent's side: a reply is typed, a
case is opened or closed, or contact details are edited. It lets our system push messages back
to the LINE user and keep status in sync.
Chatwoot emits a large volume of events, many of which are irrelevant to us — chats from other
channels, or inbound messages we pushed in ourselves. This handler is therefore purely a
filter: any event that fails the criteria gets {"status":"skipped"} and is dropped without
being published.
The :oaHash parameter identifies the LINE OA (the line_oa.line_oa_hash column). The handler
does not use it for any decision; it simply attaches it to the payload as an _oaHash field
for worker-go to resolve later.
Unlike the LINE endpoint, this one is not fire-and-forget. It waits for the publish to succeed before responding, so Chatwoot can retry if our side fails.
Business Flow
- Read
:oaHashfrom the path. - Read the body and parse it as JSON. If it cannot be read or is not JSON, respond
200with{"status":"skipped"}. - Filter on
body.event, accepting only these five types:
| Event | Required conditions |
|---|---|
message_created | The channel must be Channel::Line and message_type must be "outgoing" or 1, meaning a message sent by an agent |
conversation_created | The channel must be Channel::Line, read from body.channel first and falling back to conversation.channel |
conversation_updated | The channel from body.channel must be Channel::Line |
conversation_status_changed | The channel must be Channel::Line and the status must be resolved or open |
contact_updated | Always accepted; used to sync contact attributes |
Every other event type is answered as skipped.
:::note Parity detail
The channel check is written as "non-empty and not equal to Channel::Line", which means
a request with no channel field at all passes, matching the original NestJS behaviour.
message_type accepts both the string "outgoing" and the number 1, which JSON decoding
produces as a float64.
:::
- Once past the filter, the handler attaches
_oaHashto the body. - The entire body is published as bare JSON to the
mbox_callbackqueue with a 5-second timeout, and the publish is genuinely awaited. - On success it logs
Mbox callback receivedat info level and responds200with{"status":"ok"}. On failure it logs an error and responds500with{"status":"error"}. - If AMQP is not configured — for example when booting without backends — the publish is skipped but the response is still ok.
Every log line carries the event name and the conversation id, read from conversation.id and
falling back to body.id.
Key Files & Functions
| Method | Route | Auth | Handler |
|---|---|---|---|
| POST | /api/mbox/callback/:oaHash | None | handleCallback(deps) |
The code lives in internal/mbox/handler.go. This package is a single file with no separate
service or repository layer.
| Function | Responsibility |
|---|---|
Register(r, deps) | Mounts the route, deliberately outside the api-key guard |
handleCallback(deps) gin.HandlerFunc | Runs the entire flow above |
accept(body map[string]any) bool | The five-event filter |
isOutgoing(v any) bool | Accepts "outgoing", float64(1), and json.Number("1") |
nested, str, conversationID, logInfo, logError | Helpers |
The channelLine constant | Chatwoot's discriminator value, Channel::Line |
Connections to Other Services
- RabbitMQ — the
mbox_callbackqueue, configured viaRABBITMQ_QUEUE_MBOX_CALLBACK(defaultmbox_callback), sent as bare JSON on theline_exchangeexchange. - worker-go — the consumer; it uses
_oaHashto look up the OA, sends the agent's message back through the LINE Messaging API, and updates or clears theagent_modekey when a case is resolved. - Chatwoot — the calling system; the callback URL is configured on the Chatwoot side.
- No database and no Redis are involved in this path.
- The outbound direction, from LINE to Chatwoot, is covered in Handoff to Human Agents.
- Risk worth noting — this endpoint is public, unauthenticated, and does not verify that
the request genuinely came from Chatwoot. The only protections are a hard-to-guess
oaHashand per-IP rate limiting.