Skip to content

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/messages

Auth

  • Requires @Auth() (JWT bearer token)
  • CASL policy: can('create', 'communications.message')
  • Tenant-scoped: Conversation is resolved by (id, orgId) from the x-org-id header. A Conversation belonging to another org returns 404 — 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:

ChannelGraph methodexternalMessageId field
WhatsAppsendWhatsappMessagebody.messages[0].id
MessengersendMessengerMessagebody.message_id
InstagramsendInstagramMessagebody.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:

  1. Panel generates a UUIDv7 tempId when the user hits "send".
  2. On network failure or timeout, the panel retries with the SAME tempId.
  3. If the first request already persisted the message, the unique-index constraint fires (PostgreSQL error 23505).
  4. The handler catches the violation, fetches the existing row by (conversationId, tempId), and returns 200 with 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:

  1. Maps it to 422 COMMUNICATIONS.WA_WINDOW_EXPIRED (same error code as the pre-check — clients see a consistent error path).
  2. Immediately sets Conversation.customerCareWindowExpiresAt = now in 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 + 24h

This 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

ChannelMax charactersError when exceeded
WhatsApp4,096400 COMMUNICATIONS.OUTBOUND_TEXT_TOO_LONG
Messenger2,000400 COMMUNICATIONS.OUTBOUND_TEXT_TOO_LONG
Instagram1,000400 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.

CodeHTTPTrigger
COMMUNICATIONS.CONVERSATION_NOT_FOUND404Conversation does not exist or belongs to another org
COMMUNICATIONS.OUTBOUND_TEXT_EMPTY400text is empty or whitespace-only (Zod boundary)
COMMUNICATIONS.OUTBOUND_TEXT_TOO_LONG400text exceeds per-channel character limit
COMMUNICATIONS.OUTBOUND_CHANNEL_DISABLED422ChannelAccount.status !== 'active' (e.g. token expired)
COMMUNICATIONS.WA_WINDOW_EXPIRED422WhatsApp 24h window closed (server pre-check or Graph 131047)
COMMUNICATIONS.MESSENGER_OUTSIDE_ALLOWED_WINDOW422Messenger window closed (Graph error code 10)
COMMUNICATIONS.CHANNEL_TOKEN_EXPIRED502Meta Graph returned error code 190 (access token expired or revoked)
COMMUNICATIONS.OUTBOUND_GRAPH_FAILED502Any other non-2xx response from Meta Graph API

Note on CHANNEL_TOKEN_EXPIRED (Graph 190): In addition to returning 502 to the client, the handler marks ChannelAccount.status = 'error' with lastErrorCode = '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 when ChannelAccount.status is 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:

  • Message row with:
    • direction = 'outbound'
    • deliveryStatus = 'sent'
    • externalMessageId — from Meta's response
    • sentAt = now
    • authoredByAgentId — the authenticated user's id
    • tempId — from the request body
  • Conversation.lastMessageAt and lastMessagePreview updated

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/AddMessageTempIdColumn

Integration 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_FAILED is 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.