Appearance
Outbound Messaging
Agents (authenticated users) send text replies to end-users via a single unified endpoint. The system auto-detects the channel (WhatsApp, Messenger, Instagram) from the Conversation and routes the request to the correct Meta Graph API method.
There is NO feature flag — the endpoint is enabled unconditionally and gated only by authentication and CASL authorization.
Endpoint
POST /conversations/:id/messagesAuth
- Requires
@Auth()(JWT bearer token) - CASL policy:
can('create', 'communications.message') - Tenant-scoped: Conversation is resolved by
(id, orgId)from thex-org-idheader. A Conversation belonging to another org returns404— no cross-tenant existence leak.
Request Body
ts
// packages/schemas/src/communications/outbound.schema.ts
{
text: string, // z.string().trim().min(1) — whitespace-only rejected
tempId: string // UUIDv7 — client-generated idempotency key
}tempId MUST be a valid UUIDv7. The server uses it to guarantee at-most-one persist per send attempt. See Idempotency below.
Response
200 OK — NestJS defaults POST to 201; this endpoint uses @HttpCode(200) explicitly per spec R1.21.
ts
{
message: IMessageResponse, // full Message DTO (includes tempId, externalMessageId, deliveryStatus, etc.)
tempId: string // echoes the tempId from the request — for optimistic UI correlation
}Channel resolution
The client does NOT pass a channel. The server resolves it automatically:
Conversation.channelAccountId → ChannelAccount.channel (WhatsApp | Messenger | Instagram)The matching Graph API method is then dispatched:
| Channel | Graph method | externalMessageId field |
|---|---|---|
sendWhatsappMessage | body.messages[0].id | |
| Messenger | sendMessengerMessage | body.message_id |
sendInstagramMessage | body.message_id |
Idempotency
Outbound sends are idempotent on (conversationId, tempId). A partial-unique-index on the messages table enforces this at the database level:
sql
CREATE UNIQUE INDEX uq_messages_conversation_temp_id
ON communications.messages (conversation_id, temp_id)
WHERE temp_id IS NOT NULL;The WHERE temp_id IS NOT NULL clause means inbound messages (which have no tempId) never conflict with each other.
Retry flow:
- Panel generates a UUIDv7
tempIdwhen the user hits "send". - On network failure or timeout, the panel retries with the SAME
tempId. - If the first request already persisted the message, the unique-index constraint fires (
PostgreSQL error 23505). - The handler catches the violation, fetches the existing row by
(conversationId, tempId), and returns200with the original message — Graph is never called again.
Important: Two separate send attempts (different panel tabs, different messages) will generate different UUIDv7 values, so they are treated as distinct messages. Same tempId = same logical send, regardless of how many HTTP requests were made.
WhatsApp 24h Customer-Care Window
WhatsApp enforces a 24-hour window. Agents can only send free-form text messages within 24 hours of the most recent inbound message from the end-user. Outside the window, only approved Message Templates are allowed (not in scope for this iteration).
Window enforcement — two-stage check
Stage 1 — Server pre-check (saves Graph round-trip):
Before calling Graph, the handler checks Conversation.customerCareWindowExpiresAt > now. If the window is closed, it returns 422 COMMUNICATIONS.WA_WINDOW_EXPIRED immediately. Graph is never called.
Stage 2 — Race-condition reconciliation (Graph error 131047):
Meta's clock and inbound webhook delivery may lag by 1–2 minutes. The window may appear open locally but be closed at Meta. When Graph returns error code 131047 ("Re-engagement message"), the handler:
- Maps it to
422 COMMUNICATIONS.WA_WINDOW_EXPIRED(same error code as the pre-check — clients see a consistent error path). - Immediately sets
Conversation.customerCareWindowExpiresAt = nowin a separate transaction, so subsequent requests from the UI block at Stage 1 without another Graph round-trip.
Neither stage leaves a persisted Message row on failure.
Window refresh on inbound
When an inbound WhatsApp message arrives on an EXISTING conversation, the pipeline refreshes:
customerCareWindowExpiresAt = receivedAt + 24hThis happens inside PersistIncomingMessageCommand on EVERY inbound WA message — not only on conversation creation. Without this, a second inbound message would not extend the window and outbound sends during an active conversation would incorrectly be rejected.
Messenger and Instagram do NOT use the 24h window pre-check. Those platforms enforce window rules server-side and surface them as Graph error codes (Messenger 10), which are handled in Stage 2.
Per-Channel Text Limits
| Channel | Max characters | Error when exceeded |
|---|---|---|
| 4,096 | 400 COMMUNICATIONS.OUTBOUND_TEXT_TOO_LONG | |
| Messenger | 2,000 | 400 COMMUNICATIONS.OUTBOUND_TEXT_TOO_LONG |
| 1,000 | 400 COMMUNICATIONS.OUTBOUND_TEXT_TOO_LONG |
The limit is enforced AFTER channel resolution (since it varies per channel), not at the Zod boundary. The error response includes metadata:
json
{
"code": "COMMUNICATIONS.OUTBOUND_TEXT_TOO_LONG",
"metadata": { "channel": "whatsapp", "limit": 4096, "actual": 4097 }
}The Zod schema applies a hard upper bound of 4,096 characters (the WA maximum) as a coarse pre-filter. Per-channel validation is then applied server-side.
Constants live at: apps/api/src/modules/communications/domain/policies/outbound-text-limits.ts
Error Catalog
All errors follow the AppErrors.* factory pattern in communications.errors.ts. Every error has en and es messages.
| Code | HTTP | Trigger |
|---|---|---|
COMMUNICATIONS.CONVERSATION_NOT_FOUND | 404 | Conversation does not exist or belongs to another org |
COMMUNICATIONS.OUTBOUND_TEXT_EMPTY | 400 | text is empty or whitespace-only (Zod boundary) |
COMMUNICATIONS.OUTBOUND_TEXT_TOO_LONG | 400 | text exceeds per-channel character limit |
COMMUNICATIONS.OUTBOUND_CHANNEL_DISABLED | 422 | ChannelAccount.status !== 'active' (e.g. token expired) |
COMMUNICATIONS.WA_WINDOW_EXPIRED | 422 | WhatsApp 24h window closed (server pre-check or Graph 131047) |
COMMUNICATIONS.MESSENGER_OUTSIDE_ALLOWED_WINDOW | 422 | Messenger window closed (Graph error code 10) |
COMMUNICATIONS.CHANNEL_TOKEN_EXPIRED | 502 | Meta Graph returned error code 190 (access token expired or revoked) |
COMMUNICATIONS.OUTBOUND_GRAPH_FAILED | 502 | Any other non-2xx response from Meta Graph API |
Note on
CHANNEL_TOKEN_EXPIRED(Graph 190): In addition to returning502to the client, the handler marksChannelAccount.status = 'error'withlastErrorCode = '190'in a separate transaction. This surfaces the broken channel in the admin UI immediately so an operator can re-authorize the token.
Note on
OUTBOUND_CHANNEL_DISABLED: This error fires whenChannelAccount.statusis NOT'active'. This includes both the'error'state (e.g. after a token-expiry event) and any other non-active state.
Persistence
On a successful Graph call, the following is written in a SINGLE TypeORM transaction:
Messagerow with:direction = 'outbound'deliveryStatus = 'sent'externalMessageId— from Meta's responsesentAt = nowauthoredByAgentId— the authenticated user's idtempId— from the request body
Conversation.lastMessageAtandlastMessagePreviewupdated
If the transaction fails AFTER Graph already returned 2xx, the handler logs outbound_message_orphan with externalMessageId for ops reconciliation. No rollback to Meta is possible; the message was already delivered. Ops can query messages by external_message_id to detect and repair orphans.
Database column
sql
-- Added by migration: communications/AddMessageTempIdColumn
ALTER TABLE communications.messages ADD COLUMN temp_id text NULL;
CREATE UNIQUE INDEX uq_messages_conversation_temp_id
ON communications.messages (conversation_id, temp_id)
WHERE temp_id IS NOT NULL;To generate the migration (run by the user, not automated):
bash
pnpm --filter api migration:generate communications/AddMessageTempIdColumnIntegration Event
After the transaction commits, OutboundMessageSentIntegrationEvent is published to the in-process EventBus:
ts
{
orgId: string,
conversationId: string,
messageId: string,
externalMessageId: string,
channel: ChannelKind, // 'WhatsApp' | 'Messenger' | 'Instagram'
tempId: string,
sentAt: Date
}Source: apps/api/src/shared/integration-events/communications/outbound-message-sent.integration-event.ts
Schemas
Request and response Zod schemas live in packages/schemas:
ts
// packages/schemas/src/communications/outbound.schema.ts
export const SendMessageRequestSchema = z.object({
text: z.string().trim().min(1).max(4096),
tempId: z.uuid({ version: 'v7' }),
});
export const SendMessageResponseSchema = z.object({
message: MessageResponseSchema, // includes tempId: z.uuid({version:'v7'}).nullable()
tempId: z.uuid({ version: 'v7' }),
});MessageResponseSchema (in communications.schema.ts) gains a nullable tempId field — inbound messages have no tempId and return null.
Sequence Diagram
sequenceDiagram
participant Panel
participant Controller
participant Handler
participant GraphClient
participant DB
Panel->>Controller: POST /conversations/:id/messages {text, tempId}
Controller->>Controller: ZodValidationPipe (text empty/max4096)
Controller->>Controller: @Auth + CASL create:communications.message
Controller->>Handler: SendOutboundMessageCommand
Handler->>DB: findByIdInOrg(conversationId, orgId) → Conversation
Handler->>DB: findById(channelAccountId) → ChannelAccount
Handler->>Handler: guard: status=active?
Handler->>Handler: guard: text length ≤ channel cap?
Handler->>Handler: [WA only] guard: customerCareWindowExpiresAt > now?
Handler->>DB: BEGIN TRANSACTION
DB-->>Handler: existing Message? (findByTempId)
alt Duplicate tempId
Handler-->>Panel: 200 existing Message (Graph never called)
else New send
Handler->>GraphClient: send{Channel}Message(...)
GraphClient->>Meta: POST Graph API
Meta-->>GraphClient: {externalMessageId}
GraphClient-->>Handler: Result.ok({externalMessageId})
Handler->>DB: Message.createOutbound(...) + ConversationRepo.save
Handler->>DB: COMMIT
Handler->>EventBus: OutboundMessageSentIntegrationEvent (post-commit)
Handler-->>Panel: 200 {message, tempId}
end
Known Limitations (v1)
- Text messages only. No media (image, audio, video, document).
- Messenger: only
messaging_type='RESPONSE'(no approved tags for CONFIRMED_EVENT_UPDATE etc.). - Instagram: no outbound window error code mapped (Meta uses different codes; a generic
502 OUTBOUND_GRAPH_FAILEDis returned until mapped in a future iteration). - No Message Templates (MARKETING / UTILITY / AUTHENTICATION) — required for WA sends outside the 24h window.
- Panel polls at 5s intervals while a conversation pane is open; no SSE/WebSocket push.