Skip to main content

Direct Send API — Proactive WhatsApp Utility Messaging Without Templates

FieldValue
RFC IDRFC-2026-001
TitleDirect Send API — Proactive WhatsApp Utility Messaging Without Templates
StatusDraft
TypeBackend
Owneredi.prakoso@mekari.com
Created2026-05-21
Updated2026-07-14 (v2.8 — code-verified pass: fix migration specs to match actual DB, fix ADR-10 advisory lock placement)
Supersedesdirect-send-api-v2.md (v2.1) and the standalone direct-send-api-v2.6.md delta
PRDConfluence — Direct Send PRD
Serviceshub-service · hub_core

Changelog: v2.6 → v2.7

#IMPChangeSections affected
1IMP-001Field-length validation in interactor + Grape. AgentSendsMessage contract now enforces RFC § 5.2 caps: body ≤ 1024, header ≤ 60, footer ≤ 60, cta_button.label ≤ 20, reply_buttons[].title ≤ 20, local_id ≤ 64, ttl_seconds ∈ [30, 43200]. Grape mirrors the caps for early 422. Constants BODY_MAX_LEN etc. exported from the interactor.§ 3 ADR-14, § 5.2, § 7 Chunk 2 acceptance criteria
2IMP-002Observability metrics — NEW ADR-17. Five Datadog counters wired through new DirectSend::Services::Metrics helper: messages.total (success / failed:* / idempotent_replay), idempotency.hits, template_sync.total, es_sync.failed, pii_scrub.total. Latency timings emitted from Sneakers consumer (out of scope here). Helper degrades gracefully when no statsd client is registered.§ 3 ADR-17 (new), § 8 Observability Contract
3IMP-003PII scrub repository for Direct Send — CDG. New DirectSend::Repositories::Messages::ScrubPii scrubs messages.text, .header, .footer and the PII keys in raw_message JSONB (header, footer, cta_button, reply_buttons) for any Models::DirectSendRoom row matching a contact_id or explicit message_ids. Resolves OQ-14 with code, not just a TODO. ttl_seconds preserved (non-PII).§ 9 CDG (OQ-14 closure), § 7 Chunk 5 (new helper)
4IMP-004Structured audit log + Rollbar scope. AgentSendsMessage emits a structured Rails.logger.info line with event: 'direct_send.message_created' and the full CDG-mandated field set (org_id, sender_id, channel_integration_id, contact_count, local_id, message_ids, room_ids). Rollbar.scope! is called early so any exception in the interactor body carries the same context tags.§ 9 CDG audit logging, § 7 Chunk 2
5IMP-005Idempotency duplicate response preserves original sender's agent_ids. Duplicate path now passes assigned_agent_id: message.sender_id AND fresh_send: false to build_room_entity — so the second response carries the original agent attribution AND does NOT lie about a since-resolved room's status as "assigned". Closes a subtle FE "agent disappeared" bug on retry.§ 3 ADR-10, § 7 Chunk 2
6IMP-006Builder constructor signature documented. ADR-14 now declares the two-arg signature WaCloud::Builders::DirectSendMessage.new(source, phone) to match the in-flight code. phone is sourced from the room's contact in the Sneakers consumer.§ 3 ADR-14
7IMP-007ES sync recovery worker. Post-transaction ES failures now emit direct_send.es_sync.failed and enqueue DirectSend::Workers::ReindexRoomWorker to self-heal — replaces the previous fire-and-forget rescue that left rooms invisible in the V2 inbox if ES was degraded. ADR-15 ES-consistency model documented.§ 3 ADR-15, § 7 Chunk 2
8IMP-008Field-length / TTL spec coverage. 8 new spec cases in each of the interactor and Grape request specs covering every boundary (body > 1024, header > 60, etc., ttl_seconds boundaries).§ 7 Chunk 2 acceptance criteria
9IMP-009Per-symbol Grape error copy. Grape messages.rb now maps :contact_already_assigned and :contact_has_active_room to distinct user-facing strings via a DIRECT_SEND_FAILURE_COPY table, so the FE can render the right banner without inspecting the symbol itself. Other symbols fall through unchanged.§ 5.2 error response table
10IMP-010Batch atomicity documented. ADR-10 now states that any one in-transaction validate_contact_no_active_room failure rolls back the entire batch — deliberate all-or-nothing semantics. Partial responses are out of scope until the FE models them.§ 3 ADR-10

No data-model migrations are introduced in v2.7. All changes are behavioural or new in-app helpers.


Changelog: v2.5 → v2.6

#ChangeSections affected
1Fix postpaid balance check. ADR-13 validate_balance subtracts total_required from each pool first and fails only when all pools are negative, matching canonical Interactors::AbstractIteractor#validate_wa_balance. Failures are symbols (:account_frozen, :package_inactive, :insufficient_balance).§ 3 ADR-13, § 7 Chunk 2
2Partition-pruning filter on idempotency lookup. messages is range-partitioned by created_at, so the lookup MUST include created_at: 7.days.ago..Time.zone.now. 7-day window documented as the idempotency horizon.§ 3 ADR-10, § 7 Chunk 2
3Account restriction pre-check in AgentSendsMessage. New validate_not_restricted(channel) reads channel.settings['direct_send_restriction'] and returns Failure(:direct_send_restricted) while active — closes the gap where Meta would have returned error 139200 mid-batch.§ 3 ADR-07, § 6.1, § 7 Chunk 2
4Active-room race recheck inside transaction. validate_contact_no_active_room runs again inside the transaction after the advisory lock — closes the cross-local_id race where two concurrent batches for the same contact both passed the pre-tx check.§ 3 ADR-10, § 7 Chunk 2
5Meaningful failure propagation on rollback. Last per-contact failure is captured inside the transaction and surfaced (:contact_already_assigned, :contact_has_active_room, etc.) instead of the opaque :transaction_failed. :transaction_failed retained only for non-Failure rollback causes.§ 7 Chunk 2
6Template sync — refuse to reclassify existing non-Direct-Send templates. SyncTemplateFromMeta#upsert_template checks for an existing row; if present with is_direct_send: false, returns Success(:non_direct_send_template) without mutating. Pre-Meta-API short-circuit distinguishes :already_synced (existing direct-send) from :non_direct_send_template (existing manual).§ 3 ADR-11
7Category mismatch scoped to Direct Send templates. HandleTemplateCategoryMismatch#find_template now scopes to where(organization_id:, is_direct_send: true). Webhooks referencing regular broadcast templates no-op with :template_not_found.§ 3 ADR-07
8NEW ADR-15: Entity-Fed Builder Compatibility. Documents the dual-storage workaround (messages.header/footer columns + raw_message JSONB) because Entities::Message doesn't declare :header/:footer/:ttl_seconds. Eventual column drop tracked in qc-22448-G.§ 3 ADR-15 (new)
9NEW endpoint GET /api/core/v1/direct_send/messages — Send-History. Returns the first (outbound) message of each DirectSendRoom, filterable by query / sender_id / status[es]. Agent → own; supervisor/admin/owner/bot → org-wide. Uses DISTINCT ON (room_id) over a 90-day partition window.§ 3 ADR-16 (new), § 6.5 (new), § 7 Chunk 5 (new)

Changelog: v2.4 → v2.5

#ChangeSection
1Corrected ADR-04 reused endpoints. GET /whatsapp/phone_numbers replaced by GET /whatsapp/channel_broadcast_tier — the former returns Meta's phone_number_id only and does not expose channel_integration_id. GET /billings/balance_remaining_status replaced by GET /reports/billing/additional-balance — the latter is scoped to :agent/:member and returns focused WA balance fields.§ 3 ADR-04
2Updated § 5.1 reused endpoint table to reference the correct paths and their key fields.§ 5.1
3Added full payload and response documentation for GET /whatsapp/channel_broadcast_tier including all response fields and the channel_integration_id callout.§ 5.1
4Added full payload and response documentation for GET /reports/billing/additional-balance including balance logic summary and error responses.§ 5.1
5Added full payload and response documentation for GET /templates/whatsapp including new is_direct_send_template param, example request, response with is_direct_send/source fields, admin-only guard note, and ADR-11 sync flow explanation.§ 5.1

Changelog: v2.3 → v2.4

#ChangeSection
1Replaced ADR-03 JSONB room flagging (extra['direct_send'] = true) with Models::DirectSendRoom STI subclass. Room identification is now room.is_a?(Models::DirectSendRoom). Lock state (extra['is_locked']) unchanged. No new migration.§ 3 ADR-03, § 4.3, § 4.8, § 7 Chunk 1
2Added hub_core/app/core/domains/models/direct_send_room.rb to Chunk 1 file list and Models::Room::TYPES update§ 4.3, § 4.8, § 7 Chunk 1
3Added ADR-14: WaCloud::Builders::DirectSendMessage — dedicated payload builder with category: "utility", ttl_seconds, text / cta_url / interactive reply button support per Meta Direct Send Beta API contract§ 3 ADR-14
4Added hub_core/app/apps/wa_cloud/builders/direct_send_message.rb to Chunk 2 file list; WaCloud::Repositories::Messages::Send updated to dispatch is_a?(Models::DirectSendRoom) branch§ 7 Chunk 2
5Updated ERD rooms entity to show type: string STI column§ 4.7
6Updated all sequence diagrams, post-deploy signals, rollback steps, and acceptance criteria to reflect STI type and Direct Send builder§ 6.4, § 7 Chunk 1 & 2, § 8
7Added error code handling for 132015 (template paused) and 139200 (account restriction) in ADR-14§ 3 ADR-14

Changelog: v2.2 → v2.3

#ChangeSection
1Meta Direct Send API call path aligned to wa_group/wa_cloud implementation: continue using Publishers::MessageSend + existing Subscribers::MessageSend routing to WaCloud::Repositories::Messages::Send based on channel wa_cloud§ 1.2, § 3 ADR-05, § 7 Chunk 2
2Removed requirement to create DirectSend::Repositories::Messages::Send and Publishers::DirectSend::MessageSend§ 3 ADR-05, § 7 Chunk 2
3Updated sequence diagrams, recovery snippet, acceptance criteria, and observability emitter references to existing WA Cloud send repository§ 6.1, § 7 Chunk 2, § 8

Changelog: v2.1 → v2.2

#ChangeSection
1Removed deduct_balance from AgentSendsMessage transaction — upfront deduction carries three critical risks: (a) double deduction when Meta delivered/read webhook fires WaDeductionWorker on the same message, (b) billing DB update not rolled back when ApplicationRecord.transaction rolls back (cross-database boundary), (c) no refund path if async Meta call fails post-commit§ 3 ADR-13, § 7 Chunk 2
2Added ADR-13: Balance Deduction Strategy — validate balance pre-send (read-only), rely on existing WaDeductionWorker webhook-driven path for actual deduction§ 3 ADR-13
3AgentSendsMessage interactor contract updated: deduct_balance(...) call removed from transaction; validate_balance is now a read-only guard reusing Services::Billing::V2::WaPricing§ 7 Chunk 2
4Sequence diagram 6.1 updated: deduct balance step removed from transaction block§ 6.1
5OQ-11 resolved: no upfront deduction; existing webhook path handles deduction§ 9
6Non-Goals updated: "Balance refund" note clarified — no upfront deduction means no refund scenario to handle in Beta§ 1.4

Changelog: v1.5 → v2.0

#ChangeSection
1GET /direct_send/contacts response aligned to contact_list_response.json — outer envelope, avatar shape, extra structure, cursor as base64 strings§ 5.2
2POST /direct_send/messages request: contact_idcontact_ids: Array[String] to support sending to multiple contacts in one call§ 5.2
3POST /direct_send/messages response: data is now an array of room objects (one per contact), matching room_list_response.json§ 5.2
4AgentSendsMessage interactor contract updated to accept contact_ids array and return array of results§ 3, § 7 Chunk 2
5Grape params block in Resources::Messages updated for contact_ids§ 7 Chunk 3
6Acceptance criteria updated for array response shape§ 7 Chunk 3, § 10
7ADR-02 through ADR-11 inlined from v1.5 — RFC is now self-contained§ 3
8Sneakers consumer spec added: retry 3× exponential, DLQ, concurrency, idempotency key§ 3 ADR-05, § 7 Chunk 2
9Event-publish reliability gap addressed: advisory lock on local_id, recovery job for orphaned rooms§ 3 ADR-05, ADR-10
10CDG minimum inline spec: PII field table, retention, right-to-delete§ 9 CDG
11GET /restriction_status request params documented§ 5.2
12Schema: VARCHAR(60) on messages.header/footer, CHECK constraint on source§ 4

Sections at a Glance

§SectionPurpose
1Infrastructure TopologyDeployment + per-service responsibility
2Repo Reading GuideCode anchors the agent must read first
3Architecture DecisionsADR-format decisions for every key choice
4Data Model ChangesMigrations + model updates
5API ContractNew + reused endpoints with full schemas
6Sequence DiagramsHappy path + failure paths across full stack
7Execution PlanOrdered chunks with files, commands, acceptance criteria
8Verification & Rollback RecipePre-merge commands + post-deploy signals
9Open QuestionsBlockers + deferred decisions
10Ready for Agent ExecutionGate checklist

§ 1 — Infrastructure Topology

1.1 Deployment Diagram

graph LR
FE["Frontend\n(Qontak Web UI)"]
LB["Load Balancer\n(nginx)"]
HS["hub-service\n(Grape API)"]
HC["hub_core\n(Rails Engine Gem)"]
PG["PostgreSQL\n(primary + replica)"]
RD["Redis\n(cache + rate-limit)"]
META["Meta Cloud API\n(WhatsApp Direct Send)"]
CSVC["Contact Service\n(external HTTP)"]
RMQ["RabbitMQ\n(Sneakers)"]
SNK["Sneakers Workers\n(message.send queue)"]
ES["Elasticsearch"]

FE -->|REST + OAuth2| LB
LB -->|forward| HS
HS -->|Interactors| HC
HC -->|read/write| PG
HC -->|rate-limit| RD
HC -->|index| ES
HC -->|publish events| RMQ
RMQ -->|consume message.send| SNK
SNK -->|Direct Send API per contact| META
META -.->|"status webhooks\n(template sync inline)"| HS
HC -->|customer_360 contacts| CSVC

1.2 Per-Service Responsibility

ServiceRole in Direct Send
hub-serviceNew Grape endpoints: contacts listing, send message (multi-contact), admin template list. Extends waba.rb webhook handlers for template category mismatch, account restriction, and inline template sync on status webhooks.
hub_coreNew interactors: AgentSendsMessage (loops over contact_ids), AgentListsContacts, AdminGetsRestrictionStatus, SyncTemplateFromMeta. DB migrations for message_templates and messages. Room lock/unlock logic via extra JSONB. Idempotency check via messages.local_id DB column.
Sneakers WorkersExisting Subscribers::MessageSend + existing WaCloud::Repositories::Messages::Send; Direct Send messages keep wa_cloud channel routing and are sent asynchronously once per room (one event published per contact).
Meta Cloud APIReceives POST /<PHONE_NUMBER_ID>/messages with category: "utility". Returns wamid per message. Status webhooks carry template_id.
Contact ServiceQueried when customer_360 = true for the org.
RedisRate-limit key for Direct Send sends. Idempotency handled at DB layer via messages.local_id.
RabbitMQmessage.send queue: one event published per contact-room pair.

1.3 Third-Party Connections

ServiceConnectionAuthTimeout
Meta Cloud APIHTTPS POST (/<PHONE_NUMBER_ID>/messages)Bearer token (WABA access token)30s; retry 3× with exponential back-off
Meta Template APIHTTPS GET (/<WABA_ID>/message_templates/{id}) — inline in waba.rb only when template_id not in DBBearer token10s; no retry
Contact ServiceHTTPS GETBasic Auth (ENV['CONTACT_SERVICE_URL'])10s; fail-open: fall back to internal contacts

1.4 Non-Goals

The following are explicitly out of scope for this RFC (v1 / Beta):

  • Language detection UI (US-07): deferred; no library decision needed for Beta
  • Frontend changes: backend-only RFC; UI is a separate workstream
  • Balance refund on async Meta failure: not applicable — no upfront balance deduction occurs. Balance is deducted by the existing WaDeductionWorker only after Meta confirms delivery via delivered/read status webhook. A message that fails to reach Meta therefore triggers no deduction at all (see ADR-13).
  • Scheduled Direct Send: send-now only; scheduling deferred
  • UU PDP compliance analysis: deferred to GA; Beta orgs must sign DPA before enablement

§ 2 — Repo Reading Guide

2.1 Existing Code Anchors

The agent MUST open and read each file below before writing any code.

#File PathWhat to Learn
1hub-service/app/services/api/core/v1/broadcasts/resources/directs.rbGrape endpoint pattern for a one-shot send: params, interact_with, rate-limit, SOC logging
2hub_core/app/apps/wa_cloud/interactors/agent_send_message.rbWA Cloud send interactor: contract definition, validate_wa_balance, room/contact checks, create_message orchestration, async publish at line 238
3hub_core/app/core/domains/models/message_template.rbExisting MessageTemplate model: enums, associations, settings JSONB column
4hub_core/app/core/domains/models/room.rbRoom model: extra JSONB (line 162), is_blocked, status enum, after_commit callbacks
5hub_core/app/core/domains/models/organization.rbstore_accessor :settings keys; org-level flag pattern
6hub_core/app/apps/centralized_contacts/services/apis.rbCentralizedContacts::Services::Apis HTTP client: search_segmented_filters, request structure
7hub-service/app/services/api/core/v1/whatsapp/resources/whatsapp.rbGET :phone_numbers endpoint (reused as-is)
8hub-service/app/services/api/core/v1/billings/resources/billings.rbGET /balance_remaining_status endpoint (reused as-is)
9hub_core/app/core/domains/repositories/contacts/block/create.rbroom_set_blocked: ES sync pattern — use as template for Direct Send JSONB lock/unlock
10hub_core/app/core/domains/models/channel_integration.rbtarget_channel enum, wa_cloud key, store_accessor :settings
11hub-service/app/services/api/webhook/resources/waba.rbExisting WABA webhook resource — all new Direct Send handlers added here
12hub_core/app/core/events/publishers/message_send.rbPublisher enqueuing to message.send RabbitMQ queue
13hub_core/app/core/events/subscribers/message_send.rbSEND_REPOSITORIES hash — confirm wa_cloud routes to WaCloud::Repositories::Messages::Send (no DirectSend repository entry)
14hub-service/app/services/api/core/v1/templates/resources/templates.rbExisting WA templates endpoint; extend with is_direct_send_template filter

2.2 Reading Order for the Agent

  1. agent_send_message.rb — async publish pattern (line 238: Publishers::MessageSend.publish) before writing AgentSendsMessage
  2. message_send.rb (publisher) — payload published to message.send queue
  3. message_send.rb (subscriber) — SEND_REPOSITORIES dispatch map; confirm wa_cloud route is reused for Direct Send
  4. directs.rb — Grape send-endpoint shape to replicate
  5. room.rbextra JSONB column (line 162) before writing lock logic
  6. message_template.rb — existing columns before adding is_direct_send/source
  7. templates/resources/templates.rb — existing query params before adding is_direct_send_template
  8. channel_integration.rbstore_accessor :settings before adding restriction keys
  9. waba.rb (webhook) — existing handler structure before adding Direct Send event cases
  10. database/core/db/migrate/ — read last 3 migrations; note messages table is partitioned

2.3 Source Verification

Anchor / PatternEvidence
Models::MessageTemplatehub_core/app/core/domains/models/message_template.rb:3class Models::MessageTemplate < Models::AbstractModel
is_direct_send column does not yet existGrep of message_templates migrations — no such column found; must be added
Models::Room.extra JSONBroom.rb:162attrs[:extra] = extra if is_a?(Models::GroupServiceRoom) confirms column exists
OQ-02 resolved (customer_360)Existing organization settings access pattern is already available (see Builders::AbstractBuilder); no additional store_accessor key required for customer_360
channel_integrations.settings JSONBchannel_integration.rb:19-26store_accessor :settings confirmed
footer already exists on message_templatesMigration 20200416081057 — do NOT add again
header exists as hstore on message_templatesMigration 20200513084936 — do NOT add second header column
messages has no header, footer columnsGrep of messages migrations — must be added
messages is range-partitioned by created_atAGENTS.md — unique index without created_at is not possible
Async send patternagent_send_message.rb:238Publishers::MessageSend.publish(id: message.id, ...) confirmed
Subscribers::MessageSend on message.sendsubscribers/message_send.rb:7from_queue 'message.send'; SEND_REPOSITORIES at line 19
GET /api/core/v1/templates/whatsapptemplates/resources/templates.rb:25 — confirmed; calls Interactors::Whatsapp::Template::UserListLocalTemplate
waba.rb statuses? branchwaba.rb:87-90message_type.statuses? routes to SystemMessageStatusNotification
Models::MessageTemplate.message_template_idConfirmed — stores Meta's numeric template ID as string; unique index on [:message_template_id, :organization_id]
OQ-03 resolvedUse channel_integration.access_token (same field used by WaCloud::Repositories::Messages::Send)
AbstractIteractor typoBase class is spelled AbstractIteractor — subclassing AbstractInteractor silently creates orphan class
WaDeductionWorker deduction pathTriggered by WaCloud::Interactors::SystemMessageStatusNotification on every status event and Interactors::Whatsapp::Webhooks::MessageStatusNotification on delivered/read; calls Repositories::V2::Billings::NewPricingWaDeduction — fires for Direct Send messages via the same WABA channel (see ADR-13)

§ 3 — Architecture Decisions

ADR-01: Contact Listing — Dual-Path Strategy

Context: The Direct Send modal requires a contact search/filter endpoint. Some organizations use internal hub_core contacts; others (customer_360 = true) use an external Contact Service.

Options:

OptionProsCons
A — New endpoint with runtime flag check (chosen)Single contract for frontend; routing logic isolated in one interactor; graceful fallbackTwo code paths to maintain
B — Two separate endpointsClean separationFrontend must know which to call; doubles endpoint surface
C — Extend existing contacts endpointNo new endpointDifferent pagination/response shape; regression risk

Decision: Option A. New endpoint GET /api/core/v1/direct_send/contacts. Interactor DirectSend::Interactors::AgentListsContacts checks organization.settings['customer_360'] at runtime. Fail-open on Contact Service timeout: rescue → Rollbar warning → fall back to internal query → add X-Contact-Source: internal-fallback response header.

Consequences: customer_360 is read from the existing organization.settings access pattern (no new store_accessor key required). Direct Send keeps fail-open fallback to internal contact query when Contact Service fails.

Reversibility: High.


ADR-02: Direct Send Template Storage — Flag on Existing Table

Context: Meta auto-generates WhatsApp templates when a Direct Send message is sent. The admin view (US-08) must show these auto-generated templates. The existing message_templates table already holds UTILITY templates with category, status, and org scoping.

Options:

OptionProsCons
A — Add is_direct_send boolean + source string to message_templates (chosen)Reuses existing model, indexes, Elasticsearch mapping; "Use Template" tab already queries this tableMigration needed; existing broadcast flows must not surface auto-generated templates
B — Separate direct_send_templates tableClean isolation; no regression riskDuplicate model boilerplate; extra join for analytics; double maintenance
C — Store in org settings JSONBZero migrationNo structured querying; no pagination; hard to index

Decision: Option A. Add two columns onlyfooter and header already exist and must NOT be re-added:

  • is_direct_send: boolean, default: false, null: false — marks auto-generated templates
  • source: string — stores "AUTO_GENERATED" (from Meta) or NULL for manual templates. Valid values: "AUTO_GENERATED" or NULL. A CHECK constraint enforces this.

All existing queries that do not filter by is_direct_send are unaffected. Broadcast flows add where(is_direct_send: false) where needed.

Consequences: Models::MessageTemplate gains named scopes direct_send and manual. Admin template view queries where(is_direct_send: true). "Use Template" tab queries where(is_direct_send: false, category: 'UTILITY').

Reversibility: High. Column can be nullable-migrated; existing data unaffected.


ADR-03: Room Identification — Models::DirectSendRoom STI Type

Context: Rooms created via Direct Send must be identifiable for agent-send guards, unlock logic, and payload routing. The existing codebase already uses Ruby STI (Models::CustomerServiceRoom, Models::GroupServiceRoom, Models::CommentServiceRoom) for room-type semantics — the type column on rooms holds the STI class name and is already present. Lock state (waiting for first customer reply) is a separate concern handled in extra JSONB.

Options:

OptionProsCons
A — New Models::DirectSendRoom < Models::CustomerServiceRoom STI class (chosen)Semantic room type; room.is_a?(Models::DirectSendRoom) is unambiguous; zero migration — uses existing type STI column; consistent with Models::GroupServiceRoom pattern; builder dispatch can use is_a? cleanlyNew model file required; TYPES constant must be extended
B — extra JSONB key direct_send: trueNo new modelJSONB key collision risk; room.type still says CustomerServiceRoom; ambiguous semantics
C — New is_direct_send: boolean columnExplicit, indexedMigration required on wide rooms table

Decision: Option A. New STI class:

# frozen_string_literal: true
# hub_core/app/core/domains/models/direct_send_room.rb

class Models::DirectSendRoom < Models::CustomerServiceRoom
end

Models::Room::TYPES extended to include 'Models::DirectSendRoom'.

Room identification:

room.is_a?(Models::DirectSendRoom) # → true for Direct Send rooms only

Lock lifecycle (lock state in extra JSONB — no migration needed):

# Lock (inside AgentSendsMessage transaction, after room creation as DirectSendRoom)
room.update!(extra: (room.extra || {}).merge('is_locked' => true))

# Check — room IS a DirectSendRoom AND is pending first customer reply
room.is_a?(Models::DirectSendRoom) && room.extra&.dig('is_locked')

# Unlock (on first inbound customer message)
room.update!(extra: (room.extra || {}).merge('is_locked' => false))

The direct_send: true JSONB key is no longer written or checked — the STI type is the canonical Direct Send signal.

Guard while locked — added to WaCloud::Interactors::AgentSendMessage:

if room.is_a?(Models::DirectSendRoom) && room.extra&.dig('is_locked')
return Failure(:direct_send_room_locked)
end

Unlock trigger — inbound customer message subscriber:

if room.is_a?(Models::DirectSendRoom) && room.extra&.dig('is_locked')
room.update!(extra: (room.extra || {}).merge('is_locked' => false))
Services::Elasticsearch::Rooms::SetAttributes.new(
id: room.id, organization_id: room.organization_id,
extra: room.reload.extra
).call
end

Payload routingWaCloud::Repositories::Messages::Send dispatches to the Direct Send builder:

payload = if @room.is_a?(Models::DirectSendRoom)
WaCloud::Builders::DirectSendMessage.build(@message)
else
WaCloud::Builders::NewMessage.build(@message, recipient_identifier: recipient_identifier, is_wa_group: @is_wa_group)
end

Consequences:

  • Models::Room::TYPES updated to include 'Models::DirectSendRoom'.
  • WaCloud::Repositories::Messages::Send gains a 2-branch is_a? dispatch — existing NewMessage path is unchanged.
  • No DB migration — type STI column already exists on the rooms table.

Reversibility: High. UPDATE rooms SET type='Models::CustomerServiceRoom' WHERE type='Models::DirectSendRoom'; remove model file; remove is_a? branch.


ADR-04: Reuse vs New for WA Integration Listing and Balance

Context: PRD requires the Direct Send modal to display available WA accounts and the current messaging balance. Both endpoints already exist.

Decision: Both are reused as-is with no code changes.

EndpointStatusJustification
GET /api/core/v1/whatsapp/channel_broadcast_tierreusedCalls WaManager::Interactors::UserGetChannelWaServer; returns all WA/WA Cloud channel integrations for the org with channel_integration_id, account_name, phone_number, quality_rating, and tier. The channel_integration_id field is the UUID required by all Direct Send endpoints.
GET /api/core/v1/reports/billing/additional-balancereusedCalls Interactors::Reports::Billings::AdditionalBalance; returns balance, balance_initial, and additional_balance (the total usable WA balance). Scopes include :agent and :member. No change needed.

Correction from v2.4: The previously listed endpoints (phone_numbers and balance_remaining_status) were incorrect.

  • GET /whatsapp/phone_numbers calls Meta's Graph API and returns Meta's phone_number_id — it does not expose channel_integration_id.
  • GET /billings/balance_remaining_status returns a broader billing info object; the reports billing endpoint is more focused and has wider scope coverage.

Consequences: None. No implementation work. Frontend must call channel_broadcast_tier to obtain channel_integration_id.

Reversibility: N/A.


ADR-05: Meta Direct Send API Call — Async via RabbitMQ Sneakers

Context: The actual Meta Cloud API call must be asynchronous — same architecture as WaCloud::Interactors::AgentSendMessage (line 238: Publishers::MessageSend.publish), which publishes to the message.send queue and returns immediately. A synchronous call would block the Grape thread and couple delivery reliability to the HTTP lifecycle.

Options:

OptionProsCons
A — Publish to message.send queue via Sneakers (chosen)Identical to existing AgentSendMessage pattern; reuses retry/DLQ infrastructure; consistent error handling via system messagesRoom shows "pending" briefly; slightly more complex subscriber logic
B — Sidekiq background jobSimple to addDifferent infrastructure from existing send path; diverges from established Sneakers architecture
C — Synchronous in interactorImmediate feedbackBlocks Grape thread; couples delivery to HTTP lifecycle; Meta timeouts return 502

Decision: Option A. Direct Send follows the exact same async pattern as WaCloud::Interactors::AgentSendMessage:

Synchronous (in AgentSendsMessage#result):

  1. Validate channel, contacts (all), balance (read-only check — see ADR-13)
  2. ApplicationRecord.transaction: create room + lock per contact, create message, assign agent
  3. After COMMIT: publish one event per room → Publishers::MessageSend.publish(id: message.id)
  4. Return Success([...rooms]) — HTTP 201 sent before any Meta call

v2.0 addendum — multi-contact: For each contact in contact_ids, one room + one message is created and one event is published. The Sneakers subscriber processes each event independently.

Asynchronous (existing Sneakers consumer path Subscribers::MessageSendWaCloud::Repositories::Messages::Send):

  1. Detect room type: if room.is_a?(Models::DirectSendRoom) → use WaCloud::Builders::DirectSendMessage.build(@message) (see ADR-14); otherwise use WaCloud::Builders::NewMessage.build(...)
  2. Call POST /<PHONE_NUMBER_ID>/messages — see ADR-14 for exact payload spec
  3. On success → UPDATE messages SET status='sent', external_id='wamid.XXX'
  4. On failure (4xx/5xx/timeout, all retries exhausted) → UPDATE messages SET status='failed' + INSERT system message in room

Sneakers Consumer Spec — Existing WaCloud::Repositories::Messages::Send Path

AttributeValue
Queuemessage.send (existing queue, existing wa_cloud entry in SEND_REPOSITORIES)
Input shape{ id: <message_uuid> } — load full message from DB on consume
Retry policy3 attempts; exponential backoff 500ms / 1500ms / 4500ms
DLQdirect_send.message_send_failed — 7-day retention; alerts if depth > 50
Concurrency limitShared with message.send consumer pool — no additional cap per channel
Idempotency keyCheck message.status == 'created' before calling Meta; skip (return success) if status is already 'sent' or 'failed' — prevents duplicate Meta API calls on Sneakers re-delivery
Job timeout35s (30s Meta call + 5s buffer)
Poison messageIf message record not found in DB → log Rollbar warning, ack message (do not requeue)

Event-Publish Reliability

Publisher.publish is called after ApplicationRecord.transaction, not inside it. If the process crashes after COMMIT but before publish, rooms and messages exist in the DB with status='created' but no async jobs are queued.

Recovery path: A periodic recovery job (existing pattern or new lightweight Sneakers scheduled task) scans for orphaned messages:

Models::Message
.where(status: 'created', organization_id: organization_id)
.where('created_at < ?', 30.seconds.ago)
.where.not(local_id: nil)
.find_each do |message|
Publishers::MessageSend.publish(id: message.id)
end

OQ-13 resolved: no existing scheduled recovery-task pattern is reused for this flow. Implement a new lightweight scheduled Sneakers task to republish orphaned messages in status='created'.

Consequences: Room and message exist immediately after HTTP 201; agent sees the room before delivery confirmation. Meta failures surface as system messages in the affected room — never as HTTP error codes. Balance is not deducted upfront; the existing WaDeductionWorker deducts balance after Meta confirms delivery via delivered/read webhook — identical to the regular WA Cloud send path (see ADR-13).

Reversibility: High. No new send repository class or publisher class is introduced for this path.


ADR-06: Direct Send Enabled Feature Flag

Context: Direct Send requires Meta Beta access per WABA. Not all organizations are onboarded. We need a per-org gate.

Decision: Use the existing feature-flag pattern via Services::Preference:

Services::Preference.new.enabled?(:direct_send_enabled, organization_id: org_id)
  • All three new Direct Send endpoints check this flag and return HTTP 403 if disabled.
  • Default state: disabled (false) for all orgs until explicitly enabled per org by admin tooling.
  • Kill switch: disabling the flag per org hides all endpoints immediately — no deploy required.
  • The flag is also stored as direct_send_enabled in organization.settings via store_accessor (see § 4.5). The Services::Preference check takes precedence; the store_accessor is the backing store.

No alternative consideredServices::Preference is the established gating pattern for this codebase.

Consequences: Feature can be enabled per org without code deploys. Disabling is instant.

Reversibility: Instant — toggle flag per org.


ADR-07: Compliance Webhooks — Existing Tables Only, No New Table

Amended in v2.6: category-mismatch scoped to Direct Send templates only; restriction is now enforced at send time, not just displayed.

Context: Meta sends template_correct_category_detection and account_update (ACCOUNT_RESTRICTION) webhooks. We need to process these and surface them in the UI. An earlier RFC draft proposed a new direct_send_restrictions table.

Decision: No new table. Both event types write to existing JSONB columns:

  • template_correct_category_detectionDirectSend::Interactors::HandleTemplateCategoryMismatch scopes its lookup to Models::MessageTemplate.where(organization_id:, is_direct_send: true). Sets status to "FLAGGED". Non-Direct-Send templates with the same Meta template_id (or shared name+waba_id) are deliberately ignored — flagging a regular broadcast template would surface a misleading status to the broadcast UI. (v2.6)
  • account_update / ACCOUNT_RESTRICTION → write restriction state into ChannelIntegration#settings['direct_send_restriction'] (see § 4.4):
channel_integration.update!(
settings: channel_integration.settings.merge(
'direct_send_restriction' => {
'violation_type' => violation_type,
'restriction_type' => restriction_type,
'expiration' => expiration,
'is_active' => true
}
)
)
  • account_update / ACCOUNT_RESTRICTION unban → merge is_active: false.

New in v2.6 — restriction enforced at send time: AgentSendsMessage#validate_not_restricted(channel) reads the same JSONB and returns Failure(:direct_send_restricted) while is_active: true and expiration (if any) is in the future. Honors lapsed restrictions (past expiration → allow send) even if the unban webhook never fired. Avoids the wasted Meta HTTP round-trip per contact that would otherwise return error 139200 mid-batch.

def validate_not_restricted(channel)
restriction = channel.settings&.dig('direct_send_restriction')
return Success(true) unless restriction.is_a?(Hash) && restriction['is_active']

expiration = restriction['expiration']
return Failure(:direct_send_restricted) if expiration.blank?
parsed = Time.zone.parse(expiration.to_s) rescue nil
return Success(true) if parsed && parsed <= Time.zone.now # lapsed — allow

Failure(:direct_send_restricted)
end

The GET /direct_send/restriction_status endpoint reads channel_integration.settings['direct_send_restriction'] directly — no separate table query. Both surfaces now agree on what "restricted" means: is_active: true && (expiration.blank? || expiration > now).

No alternative considered — removing the dedicated table keeps the schema flat and avoids a migration. settings JSONB is already present and indexed on channel_integrations.

Consequences: No new table or migration for restrictions. All webhook handlers added to existing waba.rb (see ADR-08).

Reversibility: High. JSONB key can be removed with no schema change.


ADR-08: Webhook Handlers — Add to Existing waba.rb, Not New Files

Context: The original RFC draft proposed new webhook resource files for Direct Send events. Existing WABA events are handled in hub-service/app/services/api/webhook/resources/waba.rb.

Decision: No new webhook resource files. All Direct Send event handling (template category mismatch, account restriction, inline template sync) is added as new when/if branches inside the existing waba.rb handler. Interactors remain in hub_core — only the dispatch logic lives in waba.rb.

No alternative considered — this follows the project's established pattern of centralizing WABA events in one file (waba.rb). New resource files would fragment the webhook dispatch logic.

Consequences: waba.rb grows with 2–3 new event branches. All new branches follow the existing interact_with pattern with error_code: 200 (webhooks always return 200 to Meta).

Reversibility: High — removing the branches is a single-block deletion.


ADR-09: Direct Send Template Listing — Extend Existing Endpoint, Not New

Context: The admin template view (US-08) needs to show auto-generated Direct Send templates. The existing GET /api/core/v1/templates/whatsapp endpoint (at templates/resources/templates.rb:25, calling Interactors::Whatsapp::Template::UserListLocalTemplate) already provides template listing with filtering and pagination.

Options:

OptionProsCons
A — Add is_direct_send_template filter to existing endpoint (chosen)No new endpoint or route; existing pagination, scoping, and response shape reusedExisting endpoint must not break on the new optional param
B — New GET /api/core/v1/direct_send/templates endpointClean isolationDuplicate route surface; response shape would diverge; double maintenance

Decision: Option A. Add optional is_direct_send_template: boolean query param to the existing GET /api/core/v1/templates/whatsapp. When true, the interactor adds where(is_direct_send: true). When absent or false, behavior is unchanged.

Admin-only guard for is_direct_send_template: true enforced inside the interactor (return Failure(:unauthorized) unless admin? or owner?).

No alternative seriously considered — the existing endpoint already handles pagination, filtering, and correct scopes; duplicating it would introduce drift.

Consequences: Interactors::Whatsapp::Template::UserListLocalTemplate updated to accept the new optional param. No new route or resource file.

Reversibility: High — remove the optional param; existing behavior unchanged.


ADR-10: Idempotency for POST /direct_send/messages — DB Column messages.local_id

Context: v2.0 extends the existing idempotency design for multi-contact sends.

Multi-contact idempotency semantics: local_id is optional and applies at the batch level, not per-contact. If local_id is provided, the interactor checks whether any messages row with that local_id + organization_id already exists. If found, the entire batch is considered a duplicate and the existing rooms are returned. If not found, all contacts are processed fresh.

Reasoning: The alternative (per-contact local_id array) adds request complexity and is not required by the PRD. A single client-generated batch key is sufficient to prevent double-submits from the UI.

Index (unchanged):

CREATE INDEX idx_messages_local_id_org
ON messages (local_id, organization_id)
WHERE local_id IS NOT NULL;

TOCTOU race mitigation (v2.6 / v2.8 corrected): Two requirements plus a documented batch-atomicity contract (v2.7 IMP-010):

  1. Partition pruning. messages is range-partitioned by created_at. A find_by(local_id:, organization_id:) without created_at scans every partition — unacceptable on a multi-month dataset. Use a 7-day window which is the documented idempotency horizon.

  2. Advisory lock placement (v2.8 fix). pg_advisory_xact_lock is a transaction-level advisory lock — it MUST be acquired inside ApplicationRecord.transaction to be held until commit/rollback. If called outside a transaction the lock is released immediately, which defeats the purpose. The entire advisory lock + idempotency check + per-contact INSERT loop must live inside one transaction block:

Models::AbstractModel.transaction do
if local_id.present?
lock_key = Zlib.crc32("#{organization_id}:#{local_id}") & 0x7FFFFFFFFFFFFFFF
Models::AbstractModel.connection.execute(
Models::AbstractModel.sanitize_sql_array(['SELECT pg_advisory_xact_lock(?)', lock_key])
)
idempotency_window = 7.days.ago..Time.zone.now
existing_messages = Models::Message.where(
local_id: local_id,
organization_id: organization_id,
created_at: idempotency_window
).to_a
if existing_messages.any?
rooms_by_id = Models::Room.where(id: existing_messages.map(&:room_id)).index_by(&:id)
# IMP-005: preserve original sender attribution; fresh_send: false keeps real status
return Success(existing_messages.map { |m|
build_room_entity(rooms_by_id[m.room_id], m, assigned_agent_id: m.sender_id, fresh_send: false)
})
end
end

# Active-room recheck — closes the race for concurrent requests without local_id, or
# with different local_ids targeting the same contact. Runs inside the transaction so the
# loser sees the winner's INSERT before rolling back.
contacts.each do |contact|
recheck = validate_contact_no_active_room(contact, organization_id, channel_integration_id)
if recheck.failure?
last_failure = recheck.failure
raise ActiveRecord::Rollback
end
...
end
end

Implementation status (v2.8): The current agent_sends_message.rb does not yet implement the local_id idempotency check — local_id is accepted in the contract but unused. Task 2.2 must add the full block above inside the existing transaction. The pg_advisory_xact_lock placement bug was identified in the v2.5 RFC review (reviewer: direct-send-api-review.md §DIC); the corrected sample above places the lock inside the transaction.

The advisory lock is held until the transaction commits or rolls back. Two concurrent requests with the same local_id serialise — the second finds the row inserted by the first. Two concurrent requests without local_id (or with different local_ids) targeting the same contact rely on the in-transaction active-room recheck; the loser sees the winner's committed row and rolls back.

Idempotency window — documented contract:

Direct Send local_id idempotency applies for 7 days after the original send. Retries beyond 7 days fall through and produce a fresh batch. Clients with retry policies extending past 7 days must generate a NEW local_id per retry batch.

Idempotency response shape (v2.7 IMP-005): Duplicate replay returns the rooms as they exist NOW (so status may show 'resolved' if the conversation has since closed), but agent_ids always carries the original sender's user UUID — sourced from messages.sender_id on the duplicate row. This keeps the FE's "who owns this room" rendering stable across retries even when the room has moved on.

Batch atomicity (v2.7 IMP-010): If any one contact in a batch fails the in-transaction validate_contact_no_active_room recheck, the whole batch rolls back (intentional all-or-nothing). Partial success would require a per-contact failure-reason response shape, which the FE does not model. Clients should retry the batch with the failed contact removed. If a multi-contact partial-success contract is later required, it will land in its own ADR — until then, agents see the room they expected or none of them.

Reversibility: High.


ADR-11: Template Sync — Webhook-Driven in waba.rb, Not Scheduled Worker

Context: When a Direct Send message is sent, Meta auto-generates a reusable template. The admin template view must surface these. An earlier RFC draft proposed a Sidekiq worker in hub-worker that polls Meta every 6h.

Options:

OptionProsCons
A — Inline sync in waba.rb statuses branch on template_id present (chosen)Near-zero lag (template in DB within seconds of first delivery); no new service dependency; reuses existing webhook payload; keeps hub-worker out of scopeSyncTemplateFromMeta HTTP call adds latency to webhook ack — mitigated by non-blocking 200 return
B — Sidekiq worker in hub-worker (removed)Simple scheduling6h lag; introduces hub-worker dependency; diverges from Sneakers architecture
C — Sync on Direct Send send (eager)ImmediateRace condition — Meta template generation is async; template may not exist yet

Decision: Option A. Meta status webhooks include a template_id field when the message used an auto-generated template. In the existing waba.rb:87-90 statuses? branch, add a conditional check: if template_id is present and not in DB, call DirectSend::Interactors::SyncTemplateFromMeta inline.

elsif message_type.statuses?
interact_with(WaCloud::Interactors::SystemMessageStatusNotification, error_code: 200)

template_id = params.dig(:value, :statuses, 0, :template_id)
if template_id.present? && !Models::MessageTemplate.exists?(message_template_id: template_id)
params[:template_id] = template_id
params[:organization_id] = waba_organization_id
interact_with(DirectSend::Interactors::SyncTemplateFromMeta, error_code: 200)
end

DirectSend::Interactors::SyncTemplateFromMeta (updated v2.6 — refuses to reclassify; emits template_sync.total metric in v2.7 IMP-002):

  • Calls GET /<WABA_ID>/message_templates/{template_id} from Meta (10s timeout, no retry).
  • Pre-Meta-API short-circuit (v2.6) — looks up Models::MessageTemplate.find_by(message_template_id:, organization_id:). If row exists:
    • is_direct_send: trueSuccess(:already_synced) (idempotent re-sync, no Meta call). Metric: template_sync.total{status:already_synced}.
    • is_direct_send: falseSuccess(:non_direct_send_template) (no Meta call, no mutation — manual broadcast template with the same Meta template_id is left alone). Metric: template_sync.total{status:non_direct_send_template}.
  • If no row exists, fetch from Meta and call upsert_template. The upsert ALSO re-checks: if a row was created by a concurrent path with is_direct_send: false, it returns Failure(:not_direct_send_template) rather than flipping the classification. Defense in depth.
  • Otherwise create the row with is_direct_send: true, source: 'AUTO_GENERATED'. Metric: template_sync.total{status:synced}.
  • Returns Success whether upserted or short-circuited; webhook caller MUST always return 200 to Meta even on Failure.
  • On Meta API failure → log + emit template_sync.total{status:meta_api_error} + return Failure.

Rationale (v2.6): Direct Send templates are created by Meta under the auto_generated_* namespace. A non-Direct-Send row with the same Meta template_id is anomalous; flipping its classification would corrupt the broadcast UI's view of its own templates. The classification is immutable downstream.

No hub-worker changes. No sidekiq_schedule.yml entry. No new queue.

Consequences: Template appears in admin view within seconds of first delivery status webhook. If webhook missed, sync triggers on next delivery status for any message using the same template.

Reversibility: Very high — removing the template_id check from waba.rb is a single-block deletion.


ADR-12: Multi-Contact Send — Single Request, Array Response (new in v2.0)

Context: The PRD requires Direct Send to support sending the same message to multiple contacts in one operation. v1.5 only supported a single contact_id. The frontend needs to display one room per contact in the response.

Options:

OptionProsCons
A — Accept contact_ids: Array[String], return data: Array[Room] (chosen)Single HTTP round-trip for multi-contact; response shape reuses existing room list entityBreaking change vs v1.5 single-contact API; interactor must loop and collect results
B — Multiple sequential POST calls (one per contact)No backend changeN HTTP round-trips from frontend; race conditions on balance deduction; poor UX for large batches
C — Separate bulk endpointClean versioningExtra endpoint surface; response shape diverges from room list

Decision: Option A. contact_id (single UUID, required) is replaced by contact_ids (array of UUIDs, required, min 1, max 3). AgentSendsMessage wraps the per-contact loop in a single ApplicationRecord.transaction: if any contact fails validation, all writes are rolled back. If Meta API call fails asynchronously (post-201), the failure surfaces as a system message in the affected room — not a partial HTTP error.

Response: data is an array of room objects (Entities::RoomList::RoomList), one per contact, in the same order as contact_ids. Pagination meta reflects the number of rooms returned in this request (not a persistent cursor — all rooms are created and returned in one call). meta.pagination.total equals the number of rooms in data.

Consequences: The Grape params block changes from requires :contact_id, type: String to requires :contact_ids, type: Array[String] (min 1, max 3). The interactor result changes from Success({ room_id, message_id }) to Success([{ room_id, message_id }, ...]). The Grape presenter maps each result to a full room entity via Builders::RoomList::Room.

Reversibility: Medium — breaking change for any consumer that passes contact_id (single). Requires FE alignment before deployment.

Breaking change migration plan: contact_id (single UUID) is removed; contact_ids (array) is required. No backward-compat shim. Deploy order:

  1. Confirm with frontend team that they are ready to deploy the updated request shape on the same day.
  2. Deploy hub-service (new params) after frontend deployment or simultaneously with a coordinated flag enable.
  3. If rollback is needed: feature flag disable returns 403 to all consumers without a code revert. Any consumer passing the old contact_id single param will receive 422 missing required params until updated.

ADR-13: Balance Deduction Strategy — Validate-Only; Webhook-Driven Deduction

Context: v2.1 proposed calling deduct_balance(organization_id, channel, contact_ids.size) inside the ApplicationRecord.transaction block in AgentSendsMessage. Analysis of the existing billing infrastructure revealed three blocking risks with that approach.

Risks of upfront deduction (why it was removed):

RiskRoot causeSeverity
Double deductionMeta sends delivered/read status webhooks for every Direct Send message. WaDeductionWorker fires on those webhooks and calls NewPricingWaDeduction, which deducts balance. An upfront deduction + a later webhook deduction = balance charged twice. The WaUniqConvIdLog idempotency gate (unique on conversation_id) does NOT protect against this — it only prevents the webhook path from running twice; it has no knowledge of an upfront deduction.Critical
Cross-database rollback gapApplicationRecord.transaction operates on the main DB. Models::Billing::WhatsappPackage inherits from Models::AbstractModelBilling which connects_to the billing DB — a separate database connection. Rails transactions are per-connection. If create_room_and_lock or create_message raises ActiveRecord::Rollback, the main DB rolls back cleanly but the billing DB UPDATE is already committed and cannot be reversed. Balance is deducted even though no rooms were created.Critical
No refund on async Meta failurededuct_balance would run before Publishers::MessageSend.publish, which runs before the Sneakers consumer calls Meta. If Meta returns a 4xx/5xx and all retries are exhausted, the message fails but the balance deduction is permanent. There is no compensating transaction or credit-back mechanism anywhere in the codebase today.High

Additional risks (documented, not blocking alone):

  • TOCTOU race on concurrent requests: validate_balance + a hypothetical deduct_balance are not atomic. Two concurrent requests for the same org can both pass the validation check before either commits the deduction. The advisory lock in ADR-10 serialises requests with the same local_id only — different requests with different local_ids can still race.
  • StaleObjectError inside a multi-DB transaction context: WhatsappPackage has lock_version (optimistic locking). The correct handling per codebase rules is re-enqueue to Sidekiq. Inside ApplicationRecord.transaction there is no safe re-enqueue path entangled with room/message creation.
  • Price estimate before delivery confirmation: The existing deduction uses pricing and conversation fields from Meta's actual webhook (the price Meta charges). An upfront estimate via Services::Billing::V2::WaPricing may differ for auth-intl pricing or free-tier service windows.

Decision: Remove deduct_balance entirely. AgentSendsMessage performs a read-only balance validation pre-send, then relies on the existing WaDeductionWorker webhook-driven path for actual deduction.

Why the existing webhook path already works for Direct Send: Direct Send messages are sent through the same WABA channel as regular WA Cloud messages. Meta emits delivered/read status webhooks for each Direct Send message — identical to regular messages. WaDeductionWorker (triggered by WaCloud::Interactors::SystemMessageStatusNotification) deducts balance exactly as it does today, using the pricing and conversation fields Meta includes in the status payload. No additional deduction code is needed.

validate_balance implementation (read-only, corrected in v2.6):

# Private method in AgentSendsMessage. Mirrors canonical Interactors::AbstractIteractor
# #validate_wa_balance (abstract_iteractor.rb:65–76). Does NOT write to billing DB.
def validate_balance(organization_id, channel_integration, count)
organization = Services::Redis::Organizations::Get.new(organization_id).call
return Success(true) unless organization&.billing_enabled?

package = organization.package
return Success(true) unless package.present?

return Failure(:account_frozen) if package.status.to_s == 'freeze'
return Failure(:package_inactive) unless %w[grace active].include?(package.status)

wa_package = find_whatsapp_package(organization, package)
return Success(true) unless wa_package.present?

phone = channel_integration.settings['phone_number'] || channel_integration.settings['server_wa_id']
billing_service = Services::Billing::V2::WaPricing.new(phone, 'BI', 'utility')
price_per_msg = billing_service.total_price(package.organization_id, package.id)
total_required = price_per_msg * count

# Subtract the batch cost from each pool first, then fail only when ALL pools are negative.
# The v2.5 form used `||` and skipped subtraction on postpaid entirely — a 0-balance postpaid
# org could send N utility messages "for free" (Meta delivers, balance never recovers it).
current_balance = wa_package.balance - total_required
current_balance_initial = wa_package.balance_initial - total_required

sufficient = if package.billing_v3? && package.postpaid?
current_postpaid_limit = wa_package.postpaid_limit - total_required
!(current_balance < 0 && current_balance_initial < 0 && current_postpaid_limit < 0)
else
!(current_balance < 0 && current_balance_initial < 0)
end

sufficient ? Success(true) : Failure(:insufficient_balance)
end

Failure symbols (v2.6): Replaced legacy string failures with symbols (:account_frozen, :package_inactive, :insufficient_balance) for pattern-matching at callers. The Grape layer maps symbols → human copy via DIRECT_SEND_FAILURE_COPY (v2.7 IMP-009).

Options considered:

OptionDecision
Upfront deduction inside ApplicationRecord.transactionRejected — double deduction, cross-DB rollback gap, no refund path
Upfront deduction in a separate billing DB transaction after main commitRejected — still causes double deduction on webhook; adds complexity for no gain
Read-only validation only; existing webhook path deductsChosen — zero new billing code; reuses proven WaDeductionWorker path; no double deduction risk
Skip balance validation entirelyRejected — balance check is a required business rule per PRD

Consequences:

  • deduct_balance is not implemented. The word does not appear in any Direct Send production code.
  • Balance deduction happens asynchronously after Meta confirms delivery — same timing as all other WA Cloud messages.
  • "Insufficient balance" validation is a best-effort pre-flight check. A small window exists where balance could drop between validation and delivery (concurrent sends). This is the same race condition present in the current validate_wa_balance flow — it is accepted as-is.
  • OQ-11 (balance refund on async Meta failure) is resolved: no upfront deduction means no refund scenario for v1. If Meta fails, balance is simply never deducted.

Reversibility: High. Validation logic is read-only; removing or adjusting it requires no migration.


ADR-14: Direct Send Message Payload Builder — WaCloud::Builders::DirectSendMessage

Context: Direct Send messages sent to Meta require a specific payload contract that differs from regular WA Cloud messages in three ways: (1) a top-level "category": "utility" field must always be present; (2) message types are limited to text, interactive/cta_url, and interactive/button; (3) an optional ttl_seconds field controls expiry. Adding these fields to WaCloud::Builders::NewMessage would pollute the existing builder with Direct-Send-specific branching. A dedicated builder keeps each path clean.

Decision: New WaCloud::Builders::DirectSendMessage used only when room.is_a?(Models::DirectSendRoom). WaCloud::Builders::NewMessage is unchanged.

Payload Contract — POST /<PHONE_NUMBER_ID>/messages

All Direct Send payloads share this envelope:

{
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "<INTERNATIONAL_PHONE>",
"category": "utility"
}

ttl_seconds is optional. When present, it is placed at the top level alongside category. Valid range: 30–43200. Omit for default (30 days).

1. Text message
{
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "<PHONE>",
"type": "text",
"text": { "body": "<BODY_UP_TO_1024_CHARS>" },
"category": "utility",
"ttl_seconds": 3600
}

preview_url is not supported in Direct Send — do not set it.

2. Interactive CTA URL button
{
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "<PHONE>",
"type": "interactive",
"interactive": {
"type": "cta_url",
"header": { "type": "text", "text": "<HEADER_UP_TO_60_CHARS>" },
"body": { "text": "<BODY_UP_TO_1024_CHARS>" },
"footer": { "text": "<FOOTER_UP_TO_60_CHARS>" },
"action": {
"name": "cta_url",
"parameters": {
"display_text": "<BUTTON_LABEL_UP_TO_20_CHARS>",
"url": "<URL>"
}
}
},
"category": "utility"
}
3. Interactive reply buttons (up to 3)
{
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "<PHONE>",
"type": "interactive",
"interactive": {
"type": "button",
"header": { "type": "text", "text": "<HEADER_UP_TO_60_CHARS>" },
"body": { "text": "<BODY_UP_TO_1024_CHARS>" },
"footer": { "text": "<FOOTER_UP_TO_60_CHARS>" },
"action": {
"buttons": [
{
"type": "reply",
"reply": { "id": "<ID>", "title": "<TITLE_UP_TO_20_CHARS>" }
},
{
"type": "reply",
"reply": { "id": "<ID>", "title": "<TITLE_UP_TO_20_CHARS>" }
}
]
}
},
"category": "utility"
}

Field Limits

FieldMax lengthNotes
body1024 charsRequired for all types
header.text60 charstype must always be "text" — image/video headers not supported
footer.text60 charsOptional
button display_text / reply title20 chars
reply buttons count3 max
cta_url buttons count1 max
ttl_seconds30–43200Validate before send; default 30 days when omitted

Error Code Handling

CodeMeaningAction
132015Template paused by MetaUPDATE messages SET status='failed'; INSERT system message: "Message could not be delivered: Template temporarily unavailable (132015)"
139200Account restricted — Direct Send utility template abuseUPDATE messages SET status='failed'; INSERT system message: "Direct Send access restricted (139200)"; call DirectSend::Interactors::HandleAccountRestriction
100 (invalid TTL)ttl_seconds out of valid rangeSame as generic 4xx: status='failed' + system message with raw error; fix payload before retry

Builder Spec

# frozen_string_literal: true
# hub_core/app/apps/wa_cloud/builders/direct_send_message.rb

class WaCloud::Builders::DirectSendMessage
# @param [Entities::Message] source — Direct Send message entity (Sneakers path)
# or Models::Message (sync test path)
# @param [String] phone — recipient phone in E.164 digits (sourced from room.contact in the
# Sneakers consumer; the entity does not carry it). v2.7 IMP-006.
def initialize(source, phone)
@source = source
@phone = phone
end

def self.build(source, phone:)
new(source, phone).build
end

def build
payload = {
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: @source.room.contact.phone.to_phone,
category: 'utility'
}
payload[:ttl_seconds] = @source.ttl_seconds if @source.ttl_seconds.present?
payload.merge!(build_message_body)
payload
end

private

def build_message_body
case @source.type
when 'text'
{ type: 'text', text: { body: @source.text } }
when 'interactive_cta_url'
{ type: 'interactive', interactive: build_cta_url_interactive }
when 'interactive_reply_button'
{ type: 'interactive', interactive: build_reply_button_interactive }
else
raise NotImplementedError, "DirectSendMessage: unsupported type #{@source.type}"
end
end

def build_cta_url_interactive
result = {
type: 'cta_url',
body: { text: @source.text },
action: {
name: 'cta_url',
parameters: {
display_text: @source.extra&.dig('cta_button', 'label'),
url: @source.extra&.dig('cta_button', 'url')
}
}
}
result[:header] = { type: 'text', text: @source.header } if @source.header.present?
result[:footer] = { text: @source.footer } if @source.footer.present?
result
end

def build_reply_button_interactive
buttons = Array(@source.extra&.dig('reply_buttons')).map do |btn|
{ type: 'reply', reply: { id: btn['id'], title: btn['title'] } }
end
result = {
type: 'button',
body: { text: @source.text },
action: { buttons: buttons }
}
result[:header] = { type: 'text', text: @source.header } if @source.header.present?
result[:footer] = { text: @source.footer } if @source.footer.present?
result
end
end

WaCloud::Repositories::Messages::Send — Routing Change

Add a 2-branch dispatch in the send repository (existing file, minimal change):

# Inside WaCloud::Repositories::Messages::Send#call, before calling service.send_message

payload = if @room.is_a?(Models::DirectSendRoom)
WaCloud::Builders::DirectSendMessage.build(@message)
else
WaCloud::Builders::NewMessage.build(@message,
recipient_identifier: recipient_identifier,
is_wa_group: @is_wa_group
)
end

No other changes to the repository are required.

Consequences:

  • WaCloud::Builders::NewMessage is untouched.
  • Existing WA Cloud messages are unaffected by this change.
  • Direct Send message types (text, interactive_cta_url, interactive_reply_button) must be stored in messages.type and mapped to builder methods.

Reversibility: High. Remove the is_a?(Models::DirectSendRoom) branch; delete the builder file.


ADR-15: Entity-Fed Builder Compatibility (new in v2.6, ES recovery added in v2.7)

Context: Entities::Message (hub_core/app/core/domains/entities/message.rb) is the canonical immutable representation of a message, used wherever messages cross layer boundaries — most importantly the Sneakers consumer that calls Meta. Its schema does NOT declare :header, :footer, or :ttl_seconds, so paths reading the entity (rather than the AR row) see nil for those fields. Meta rejects the resulting payload with (#100) Invalid parameter.

Decision: Dual storage. AgentSendsMessage#create_direct_send_message writes interactive fields to BOTH the dedicated messages.header/messages.footer columns AND the messages.raw_message JSONB. WaCloud::Builders::DirectSendMessage reads through extra_hash (a helper that resolves @source.extra for Entity input or @source.raw_message for AR input). The JSONB is the authoritative read source; the columns are read-only mirrors used by ad-hoc SQL and the legacy spec assertions.

ES consistency model (v2.7 IMP-007): The interactor commits the DB transaction, then runs three post-commit ES operations (message.es_index_document, Services::Elasticsearch::Rooms::SetAttributes(status: 'assigned', agent_ids: [sender_id]), SetLastMessage). These are NOT atomic with the DB write: a transient ES failure leaves the room in PG but invisible or wrong-statused in the V2 inbox (which is ES-backed). v2.7 wraps the rescue with:

  1. DirectSend::Services::Metrics.increment('es_sync.failed', tags: { stage: 'interactor' }) — ops visibility.
  2. DirectSend::Workers::ReindexRoomWorker.perform_async(message.id, sender_id) — Sidekiq self-heals via the same three ES operations on its standard 3× exponential retry.

The client always sees HTTP 201 even if ES is degraded; the inbox tile may take a few seconds to appear. Documented as eventual consistency, not strong consistency.

Cleanup plan (qc-22448-G): After Direct Send is stable in production for ≥1 week:

  1. Drop messages.header and messages.footer columns (DBA ticket required — partitioned table).
  2. Remove header: / footer: keys from AgentSendsMessage#create_direct_send_message's message_attrs. raw_message['header'] / raw_message['footer'] writes remain.
  3. Simplify WaCloud::Builders::DirectSendMessage to drop the column-accessor fallback — extra_hash becomes the only source.
  4. DirectSend::Repositories::Messages::ScrubPii (v2.7 IMP-003) already scrubs both surfaces; no change needed there.

Reversibility: Partial. Columns droppable; the builder enrichment for entity.extra and the ReindexRoomWorker are permanent — FE and observability depend on them.


ADR-16: Direct Send Send-History Endpoint (new in v2.6)

Context: v2.5 shipped the write path (POST /direct_send/messages) and the per-channel restriction read (GET /direct_send/restriction_status). It deliberately punted on a Direct Send-scoped read path. The FE has been falling back to the generic GET /api/core/v1/rooms?type[]=Models::DirectSendRoom filter, which is wrong on three counts: (1) it returns rooms, not sends — rooms outlive their initial outbound; (2) it doesn't honor the direct_send_enabled flag; (3) the agent variant lists rooms assigned to the agent, not rooms whose outbound was sent by the agent (diverges after auto-routing).

Decision: Add GET /api/core/v1/direct_send/messages (GET counterpart of POST on the same path). Returns a flat list, one row per DirectSendRoom = the first outbound message in that room, with room + contact + channel snippet embedded inline. Agent role → sender_id forced to me.id; supervisor/admin/owner/bot → sender_id honored. Default 90-day created_at window. Implementation uses DISTINCT ON (room_id) ... ORDER BY room_id, created_at ASC, id ASC against the partitioned messages table.

Performance escape hatch: If DISTINCT ON proves slow on the largest orgs (>100k DirectSendRooms), fall back to a denormalized messages.is_first_message boolean populated at write time. Decision deferred to post-rollout benchmarking — start with DISTINCT ON.

Reversibility: High. Purely additive — removing the endpoint doesn't affect any write path.


ADR-17: Observability Contract Implementation (new in v2.7)

Context: v2.1 § 8 promised five Datadog metrics. An audit of the in-flight code found zero of them implemented. Without these, operations cannot detect a silent regression — an org whose sends all start failing because Meta restricted the WABA, a buggy FE retry loop manifesting as an idempotency spike, or a degraded ES cluster eating into the inbox view.

Decision: Centralise emission in DirectSend::Services::Metrics (hub_core/app/apps/direct_send/services/metrics.rb). All counters live under the direct_send. namespace and degrade gracefully when no statsd client is present in the host process (warns to logger, never raises).

MetricTypeTagsEmitter
direct_send.messages.totalcounterstatus (success / failed: / idempotent_replay), contact_count_bucket (1/2/3/other), channelAgentSendsMessage — one increment per BATCH (not per contact), at every terminal branch
direct_send.idempotency.hitscounterorganization_idAgentSendsMessage — when the duplicate local_id lookup returns a row
direct_send.template_sync.totalcounterstatus (already_synced / non_direct_send_template / channel_not_found / meta_api_error / upsert_failed / synced)SyncTemplateFromMeta at every return point
direct_send.es_sync.failedcounterstage (interactor / reindex_worker)AgentSendsMessage rescue + DirectSend::Workers::ReindexRoomWorker
direct_send.pii_scrub.totalcountersource (contact / explicit_ids), count_bucketDirectSend::Repositories::Messages::ScrubPii
direct_send.meta_api.latency_mstimingstatus_code, template_id_presentSneakers consumer (out of scope here — wired in WaCloud::Consumers::DirectSendDelivery follow-up)
direct_send.mqtt.notifications.totalcounterevent_type, organization_idMQTT publisher path (follow-up — wired in the same Sneakers consumer task)

Cardinality budget: organization_id is the only high-cardinality tag, used on two counters. contact_count_bucket literally only takes values 1/2/3/other (ADR-12 caps the array at 3). Failure symbols are a finite enum from the interactor contract.

Reversibility: Very high — DirectSend::Services::Metrics calls are no-ops when no statsd is configured; removing emission is a single-file revert.

Consequences: Dashboards + alerts can be wired off these tags. Initial alert recommendations:

  • p95 messages.total{status:success} rate drops below baseline by >30% for 10 min → page.
  • idempotency.hits{organization_id:*} > 100/min for any single org → indicates FE retry-loop bug.
  • es_sync.failed > 5/min sustained → escalate to platform-ES owner.
  • template_sync.total{status:meta_api_error} > 10/hour → Meta integration degraded.

§ 4 — Data Model Changes

(Identical to v1.5 — migrations, ERD, model updates unchanged)

4.1 message_templates Table — New Columns

ALTER TABLE message_templates
ADD COLUMN is_direct_send BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN source VARCHAR(50) CHECK (source IS NULL OR source = 'AUTO_GENERATED');

CREATE INDEX idx_message_templates_is_direct_send
ON message_templates (is_direct_send)
WHERE is_direct_send = TRUE;

4.2 messages Table — New Columns

Note (v2.8): local_id was already added in migration 20210202045334 as VARCHAR (no length constraint). The migration below adds only header, footer, and the partition-pruning index.

-- local_id already exists (no length constraint) — skip ADD COLUMN for it
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS header VARCHAR(60),
ADD COLUMN IF NOT EXISTS footer VARCHAR(60);

CREATE INDEX IF NOT EXISTS idx_messages_local_id_org
ON messages (local_id, organization_id)
WHERE local_id IS NOT NULL;

4.3 rooms Table — New STI Subclass, No Migration Required

Direct Send rooms are identified via the existing Rails STI type column as Models::DirectSendRoom. Lock state (waiting for first customer reply) is stored in the existing extra JSONB column. No DB migration needed — both type and extra columns already exist.

# Create a Direct Send room (type column set automatically by Rails STI)
Models::DirectSendRoom.create!(
organization_id: org.id,
channel_integration_id: channel.id,
contact_id: contact.id,
status: 'opened',
extra: { 'is_locked' => true } # no 'direct_send' key — STI type handles identity
)

# Identify a Direct Send room
room.is_a?(Models::DirectSendRoom) # → true

# Lock / unlock
room.update!(extra: (room.extra || {}).merge('is_locked' => true))
room.update!(extra: (room.extra || {}).merge('is_locked' => false))

Models::Room::TYPES constant updated in room.rb:

TYPES = %w[
Models::CustomerServiceRoom
Models::CommentServiceRoom
Models::ReviewServiceRoom
Models::GroupServiceRoom
Models::DirectSendRoom
].freeze

4.4 channel_integrations — No Migration Required

Restriction state in existing settings JSONB under key direct_send_restriction.

4.5 organization.rb — Settings Access Pattern

store_accessor :settings,
:direct_send_enabled

customer_360 is read from existing organization.settings usage pattern; no new store_accessor key is required for this RFC.

4.6 channel_integration.rb — New store_accessor Key

store_accessor :settings, :direct_send_restriction

4.8 Models::DirectSendRoom — New STI Class

New file: hub_core/app/core/domains/models/direct_send_room.rb

# frozen_string_literal: true

class Models::DirectSendRoom < Models::CustomerServiceRoom
# index_name [Rails.env[0..3], 'models_rooms'].join('_')
end

This follows the exact same pattern as Models::GroupServiceRoom and Models::CommentServiceRoom. No associations or additional logic are needed — the STI type column on rooms is the only mechanism used for room identification.

4.7 ERD

erDiagram
organizations ||--o{ message_templates : "has_many"
organizations ||--o{ rooms : "has_many"
organizations ||--o{ channel_integrations : "has_many"
rooms ||--o{ messages : "has_many"
message_templates ||--o{ message_broadcasts : "has_many"

message_templates {
uuid id PK
uuid organization_id FK
string name
string category
string previous_category
string status
hstore header
string footer
boolean is_direct_send
string source
uuid waba_id
}

messages {
uuid id PK
uuid room_id FK
string header
string footer
string local_id
string external_id
timestamp created_at
}

rooms {
uuid id PK
uuid organization_id FK
string type
string status
boolean is_blocked
jsonb extra
}

channel_integrations {
uuid id PK
uuid organization_id FK
string target_channel
jsonb settings
}

§ 5 — API Contract

5.1 Reused / Extended Endpoints

MethodPathStatusChange
GET/api/core/v1/whatsapp/channel_broadcast_tierreusedNo changes — returns channel_integration_id per channel
GET/api/core/v1/reports/billing/additional-balancereusedNo changes — returns WA balance (balance, additional_balance)
GET/api/core/v1/templates/whatsappextendedAdd optional is_direct_send_template: boolean filter param

GET /api/core/v1/whatsapp/channel_broadcast_tier

Purpose: List WA/WA Cloud channel integrations with broadcast tier and quality info.
Used in the Direct Send modal for channel selection.
The channel_integration_id field is the UUID required by all Direct Send endpoints.
Scopes: :admin, :owner, :supervisor, :agent, :member, :campaign_general_view
No params.

Response (200):

{
"status": "success",
"data": [
{
"id": "9f3e1a2b-7c4d-5e6f-8901-234567890abc",
"channel_integration_id": "db46ac35-0e6c-4439-a38b-cfc7e472a957",
"account_name": "Qontak Support",
"channel_phone": "628123456789",
"phone_number": "+62 812-3456-7890",
"waba_name": "Qontak WABA",
"country": "ID",
"tier": 10000,
"quality_rating": "GREEN",
"quality_score": "GREEN",
"status": "COMPLETED"
}
]
}

Response field reference:

FieldTypeNotes
idstring (UUID)WaServer record UUID — not the channel_integration_id
channel_integration_idstring (UUID)Pass this as channel_integration_id in Direct Send requests
account_namestringDisplay name for channel selector
channel_phonestringPhone number (E.164 digits, no +)
phone_numberstringFormatted display phone number
waba_namestring|nullWABA display name
countrystring|nullCountry code (e.g. "ID")
tierinteger|nullBroadcast tier limit (e.g. 10000)
quality_ratingstring|null"GREEN" | "YELLOW" | "RED"
quality_scorestring|nullWABA quality score
statusstring|nullWABA status (e.g. "COMPLETED")

GET /api/core/v1/reports/billing/additional-balance

Purpose: Return the organisation's current WA messaging balance.
Used in the Direct Send modal to warn agents if balance is insufficient.
Scopes: :admin, :owner, :supervisor, :agent, :member
No params.

Response (200):

{
"status": "success",
"data": {
"organization_id": "7e6e0187-6707-464f-8b99-59b094caae0b",
"balance": 250000.0,
"balance_initial": 500000.0,
"additional_balance": 750000.0
}
}

Response field reference:

FieldTypeNotes
organization_idstring (UUID)Org UUID
balancefloatCurrent WA balance (includes postpaid_limit for billing v3 postpaid accounts)
balance_initialfloatInitial / purchased WA balance
additional_balancefloatbalance + balance_initial when balance ≥ 0; adds postpaid_limit for v3 postpaid

Balance logic summary:

  • When additional_balance <= 0, POST /direct_send/messages returns 422 insufficient_balance before creating any rooms.
  • No upfront deduction occurs during the Direct Send HTTP call. Balance is deducted by the existing WaDeductionWorker only after Meta confirms delivery via delivered/read webhook (see ADR-13).

Error responses:

CodeCondition
422Org has no WA package configured

GET /api/core/v1/templates/whatsapp (extended)

Purpose: List WhatsApp message templates. Extended for Direct Send with the optional
is_direct_send_template filter to surface auto-generated templates.
Scopes: :admin, :owner, :supervisor, :has_broadcast_access, :agent, :member, :bot
Change: New optional param is_direct_send_template (boolean) — admin/owner only.

Request parameters (Direct Send relevant):

ParamTypeDefaultDescription
querystring*Search by template name
limitinteger25Page size
offsetinteger1Page number
cursorstringBase64 cursor from meta.pagination.cursor.next
cursor_directionstringbeforebefore (next page) or after (previous page)
statusstringFilter by status (e.g. APPROVED)
categorystringFilter by category (e.g. UTILITY)
statusesstring[]Filter by multiple statuses
is_direct_send_templatebooleanNew. true = auto-generated Direct Send templates only. Admin/owner only — returns 422 unauthorized for other roles.

Request — list auto-generated Direct Send templates:

GET /api/core/v1/templates/whatsapp?is_direct_send_template=true&limit=10
Authorization: Bearer <admin_or_owner_token>

Response (200):

{
"status": "success",
"data": [
{
"id": "9e1f2c3a-4b5d-6e7f-8901-234567890abc",
"name": "auto_ds_order_update_20260526",
"category": "UTILITY",
"status": "APPROVED",
"language": "en",
"is_direct_send": true,
"source": "AUTO_GENERATED",
"waba_id": "102290129340023",
"organization_id": "7e6e0187-6707-464f-8b99-59b094caae0b",
"message_template_id": "999888777",
"quality_rating": "GREEN",
"created_at": "2026-05-26T10:05:00.000Z",
"updated_at": "2026-05-26T10:05:00.000Z"
}
],
"meta": {
"pagination": {
"cursor": {
"next": "MTc3OTI2OTY1NC4zMTg=",
"prev": null,
"pit": null
},
"offset": 1,
"limit": 10,
"total": 1,
"target_offset": 0
}
}
}

Direct Send specific response fields:

FieldTypeNotes
is_direct_sendbooleantrue for auto-generated Direct Send templates; false for manually created templates
sourcestring|null"AUTO_GENERATED" for templates created by Meta when a Direct Send message is first sent; null for manual templates

Error responses:

CodeCondition
422Non-admin/owner attempts is_direct_send_template=true"unauthorized"

How templates are populated (ADR-11): When a Direct Send message is delivered, Meta's status webhook carries a template_id. DirectSend::Interactors::SyncTemplateFromMeta is called inline in waba.rb — it fetches the template from Meta's API and upserts it into message_templates with is_direct_send: true, source: "AUTO_GENERATED". Templates appear in the admin view within seconds of first delivery.


5.2 New Endpoints

GET /api/core/v1/direct_send/contacts

Purpose: Search contacts for recipient selection in Direct Send modal.
Routes to internal Elasticsearch or external Contact Service
based on org customer_360 flag.
Interactor: Interactors::ContactObjects::UserListContact
→ Repositories::Contacts::ContactObjects::All (Elasticsearch)
→ Builders::ContactObject → Entities::ContactObject
Scopes: :admin, :owner, :supervisor, :agent, :has_broadcast_access, :bot
Guard: Services::Preference.enabled?(:direct_send_enabled, organization_id:) → 403 if off
Source file: contact_list_response.json (verified 2026-05-26)

Business rule (PRD):
- Direct Send is allowed only for contacts without an active room.
- If `active_room` param is omitted (`nil`), endpoint does not apply active-room filtering.

Request Parameters:

ParamTypeRequiredDefaultDescription
querystringno*Full-text search across full_name, account_uniq_id, extra.email, extra.username
limitintegerno25Page size
offsetintegerno1Page number
cursorstringnoBase64-encoded cursor for cursor-based pagination (value from meta.pagination.cursor.next or .prev)
cursor_directionstringnobeforebefore (next page) or after (previous page)
order_bystringnocreated_atES sort field
order_directionstringnodescasc or desc
channelsstring[]noall known channelsFilter by channel type, e.g. ["wa_cloud"]
channel_integration_idsstring[]noFilter by specific channel integration UUIDs
authoritysstring[]no["primary","secondary","own"]Contact authority filter
is_contactbooleannotruetrue = real contacts only
active_roombooleannonilOptional active-room filter. If omitted (nil), do not filter by active-room state. If false, return only contacts without active room (Direct Send eligible). If true, return only contacts with active room (diagnostics/admin checks).
is_countedbooleannofalseWhen true, runs separate ES count query for accurate total
time_offsetsintegernoTimezone offset in hours (used with date range)
start_dateISO8601noRange filter lower bound
end_dateISO8601noRange filter upper bound

Response (success 200):

Envelope: { "status": "success", "data": [...], "meta": { "pagination": {...} } }

{
"status": "success",
"data": [
{
"id": "433ff841-6485-4242-b4ed-08cecdf1d2b2",
"contact_handler_id": null,
"phone_number": "62857767111132",
"full_name": "JP Full 1",
"email": "",
"username": "",
"ext_user_id": "ID.975066141607353",
"ext_username": "@jp_usrnme114",
"ext_parent_user_id": null,
"ext_country_code": "ID",
"authority": "own",
"code": "",
"created_at": "2026-05-22T06:25:25.259Z",
"updated_at": "2026-05-22T06:25:25.300Z",
"last_activity_at": "2026-05-22T06:25:25.300Z",
"channel": "wa_cloud",
"status": "success",
"error_messages": {},
"extra": {
"email": null,
"username": null
},
"account_uniq_id": "62857767111132",
"channel_integration_id": "db46ac35-0e6c-4439-a38b-cfc7e472a957",
"avatar": {
"url": "https://cdn.qontak.com/uploads/user/avatar/bf328531-ffc9-4f93-a7f4-f2f707970e7c/avatar.png",
"large": {
"url": "https://cdn.qontak.com/uploads/user/avatar/bf328531-ffc9-4f93-a7f4-f2f707970e7c/avatar.png"
},
"filename": null,
"size": 0,
"small": {
"url": "https://cdn.qontak.com/uploads/user/avatar/bf328531-ffc9-4f93-a7f4-f2f707970e7c/avatar.png"
},
"medium": {
"url": "https://cdn.qontak.com/uploads/user/avatar/bf328531-ffc9-4f93-a7f4-f2f707970e7c/avatar.png"
}
},
"is_valid": true,
"is_blocked": false,
"active_room": false,
"childs": [],
"qontak_customer_id": ""
}
],
"meta": {
"pagination": {
"cursor": {
"next": "MTc3OTI2OTY1NC4zMTg=",
"prev": "MTc3OTc2OTkwMi44NDI=",
"pit": null
},
"offset": 1,
"limit": 25,
"total": 123308,
"target_offset": 0
}
}
}

Contact item field reference (Entities::ContactObject / Builders::ContactObject):

FieldTypeNotes
idstring (UUID)ES document id (contact handler id from ES _id)
contact_handler_idstring|nullAlways null in practice for ES-sourced contacts
phone_numberstring|nullE.164 digits only — no + prefix (e.g. "6281234567890"); null when not set
full_namestringDisplay name; may be masked (e.g. "L***t") when contact masking is on for org
emailstringFrom extra.email or extra.email_address; empty string "" when not set
usernamestringFrom extra.username; empty string "" when not set
ext_user_idstring|nullExternal platform user id (e.g. "ID.975066141607353")
ext_usernamestring|nullExternal platform username (e.g. "@jp_usrnme114")
ext_parent_user_idstring|nullExternal parent user id; null in most cases
ext_country_codestring|nullISO country code (e.g. "ID"); null when not set
authoritystring"primary" | "secondary" | "own"
codestringInternal code (e.g. "CB3A2231"); empty string "" when none
created_atISO8601Contact creation timestamp
updated_atISO8601Last update timestamp
last_activity_atISO8601Maps to updated_at from ES source
channelstringChannel type (e.g. "wa_cloud", "telegram", "web_chat", "desty_shopee")
statusstring"success" | "failed" — builder-level field; always "success" for valid records
error_messageshashEmpty {} on success
extrahashAlways contains at minimum { "email": null|string, "username": null|string }
account_uniq_idstringChannel-specific identifier (phone digits, shopee ID, etc.)
channel_integration_idstring (UUID)UUID of the linked channel integration
avatarhash{ url, large: { url }, filename: null, size: 0, small: { url }, medium: { url } } — all url values may be the same CDN URL
is_validbooleanAlways true for ES-sourced contacts passing builder validation
is_blockedbooleanWhether contact is blocked
active_roombooleanWhether the contact currently has an active room. Direct Send eligible contact must be false.
childsarraySecondary handlers attached to this primary contact; [] for authority: "own"
qontak_customer_idstringCRM customer id; empty string "" when not linked

Pagination field reference (Entities::Pagination):

FieldTypeNotes
cursor.nextstring|nullBase64-encoded ES sort value of the last hit — pass as cursor + cursor_direction=before to fetch next page
cursor.prevstring|nullBase64-encoded ES sort value of the first hit — pass as cursor + cursor_direction=after to fetch previous page
cursor.pitnullPoint-in-time id; always null for this repository
offsetintegerCurrent page number
limitintegerPage size
totalintegerCount of items in this page (unless is_counted=true, in which case: full ES total across all pages)
target_offsetinteger0 (scroll-to-page hint; not applicable for cursor pagination)

Response (failure — Contact Service down, internal fallback): Returns internal contacts with 200; header X-Contact-Source: internal-fallback added.

Eligibility Rule: Even if active_room=true records are requested explicitly, POST /api/core/v1/direct_send/messages MUST reject those contacts and return validation failure (contact_has_active_room).

Error Responses:

CodeCondition
403direct_send_enabled feature flag off for org
422ES max_result_window exceeded (deep offset pagination)
429Rate limit exceeded

POST /api/core/v1/direct_send/messages

Purpose: Send a Direct Send utility message to one or more contacts.
Creates one room per contact, assigns to triggering agent, locks
room. Publishes one async event per room to Meta Cloud API.
Interactor: DirectSend::Interactors::AgentSendsMessage
→ loops over contact_ids → creates room + message per contact
→ Builders::RoomList::Room → Entities::RoomList::RoomList
Scopes: :admin, :owner, :supervisor, :agent, :has_broadcast_access
Guard: Services::Preference.enabled?(:direct_send_enabled, organization_id:) → 403 if off
v2.0 change: contact_id (string) → contact_ids (array); data is now an array of rooms
Source file: room_list_response.json (verified 2026-05-26)

Request Body:

ParamTypeRequiredValidation
channel_integration_idstring (UUID)yesMust exist, target_channel=wa_cloud, same org
contact_idsstring[] (UUID[])yesMin 1, max 3 items; each must exist, same org, status != assigned (assigned-contact guard evaluated at org level)
messageobjectyesSee below
message.typestringyestext, interactive_cta_url, interactive_reply_button
message.bodystringyesMax 1,024 chars
message.headerstringnoMax 60 chars, text only
message.footerstringnoMax 60 chars
message.cta_buttonobjectno{ label: string(20), url: string } — only when type=interactive_cta_url
message.reply_buttonsarraynoMax 3 items, each { id: string, title: string(20) } — only when type=interactive_reply_button
ttl_secondsintegerno30–43200; default: omitted (Meta default 30 days)
local_idstringnoClient-provided batch deduplication key, max 64 chars. If provided and any messages row with the same local_id + organization_id exists, returns the original rooms without creating duplicates. Applies to the entire batch.

Request Example:

{
"channel_integration_id": "db46ac35-0e6c-4439-a38b-cfc7e472a957",
"contact_ids": [
"433ff841-6485-4242-b4ed-08cecdf1d2b2",
"30389e7f-af16-478f-8218-9fdfc91a10dd"
],
"message": {
"type": "text",
"body": "Hello, this is a direct send message",
"header": "Important Update",
"footer": "Qontak Support Team"
},
"local_id": "client-batch-uuid-2026-05-26"
}

Response (success 201):

Envelope: { "status": "success", "data": [...rooms], "meta": { "pagination": {...} } }

data is an array of room objects — one per contact in contact_ids, in the same order. Each room has the same shape as an item in the room list response from Interactors::V2::Rooms::SupervisorListRoom.

STI contract for Direct Send response items:

  • data[n].type MUST be "Models::DirectSendRoom"
  • data[n].extra MUST use is_locked for lock state
  • data[n].extra MUST NOT include legacy is_direct_send key
{
"status": "success",
"data": [
{
"id": "8cfd6d2a-0e43-4806-a883-3194dcc28119",
"name": "JP Full 1",
"description": "",
"status": "assigned",
"type": "Models::DirectSendRoom",
"tags": [],
"channel": "wa_cloud",
"channel_account": "Qontak Brother",
"organization_id": "7e6e0187-6707-464f-8b99-59b094caae0b",
"account_uniq_id": "62857767111132",
"channel_integration_id": "db46ac35-0e6c-4439-a38b-cfc7e472a957",
"session_at": "2026-05-26T10:00:00.000Z",
"unread_count": 0,
"created_at": "2026-05-26T10:00:00.000Z",
"last_message_at": "2026-05-26T10:00:00.000Z",
"last_activity_at": "2026-05-26T10:00:00.000Z",
"updated_at": "2026-05-26T10:00:00.000Z",
"avatar": {
"url": "https://cdn.qontak.com/uploads/user/avatar/bf328531-ffc9-4f93-a7f4-f2f707970e7c/avatar.png",
"large": {
"url": "https://cdn.qontak.com/uploads/user/avatar/bf328531-ffc9-4f93-a7f4-f2f707970e7c/avatar.png"
},
"filename": null,
"size": 0,
"small": {
"url": "https://cdn.qontak.com/uploads/user/avatar/bf328531-ffc9-4f93-a7f4-f2f707970e7c/avatar.png"
},
"medium": {
"url": "https://cdn.qontak.com/uploads/user/avatar/bf328531-ffc9-4f93-a7f4-f2f707970e7c/avatar.png"
}
},
"resolved_at": null,
"external_id": "",
"resolved_by_id": null,
"resolved_by_type": null,
"note": {
"text": ""
},
"extra": {
"is_participant_online": false,
"is_locked": true
},
"last_message": {
"id": "550e8400-e29b-41d4-a716-446655440030",
"type": "text",
"room_id": "8cfd6d2a-0e43-4806-a883-3194dcc28119",
"is_campaign": false,
"sender_id": "f74bdb07-624b-4194-a6cd-1e66ecac4106",
"sender_type": "Models::User",
"participant_id": "adca3f56-80a8-4efa-afd8-580f5ed5902c",
"participant_type": "agent",
"organization_id": "7e6e0187-6707-464f-8b99-59b094caae0b",
"text": "Hello, this is a direct send message",
"status": "created",
"external_id": null,
"local_id": "client-batch-uuid-2026-05-26",
"created_at": "2026-05-26T10:00:00.000Z",
"is_edited": false,
"review_star": 0
},
"is_blocked": false,
"agent_ids": ["f74bdb07-624b-4194-a6cd-1e66ecac4106"],
"email_cc": [],
"is_unresponded": false,
"call_permission_request": null,
"ext_user_id": "ID.975066141607353",
"ext_username": "@jp_usrnme114",
"ext_parent_user_id": null
}
],
"meta": {
"pagination": {
"cursor": {
"next": 1779766581659,
"prev": 1779696366280,
"pit": null
},
"offset": 1,
"limit": 10,
"total": 1,
"target_offset": 0
}
}
}

Room field reference (Entities::RoomList::RoomList):

Response Validation Rules (Direct Send specific):

RuleExpected value
data[*].typeAlways "Models::DirectSendRoom"
data[*].channelAlways "wa_cloud"
data[*].extra.is_lockedtrue immediately after POST success
data[*].extra.is_direct_sendMust be absent
data[*].last_message.status"created" at response time (async send not completed yet)

These rules apply only to POST /api/core/v1/direct_send/messages responses. Generic room-list endpoints may return other STI room types.

FieldTypeNotes
idstring (UUID)Room id
namestringContact display name
descriptionstringEmpty string "" on creation
statusstring"assigned" immediately after creation
typestring"Models::DirectSendRoom" for Direct Send POST response items (STI). Non-direct WA rooms continue using Models::CustomerServiceRoom.
tagsarrayEmpty [] on creation
channelstring"wa_cloud"
channel_accountstring|nullChannel integration display name (not the phone number)
organization_idstringOrg UUID
account_uniq_idstringContact's WA number — digits only, no + prefix (e.g. "62857767111132")
channel_integration_idstringUUID of the channel integration used
session_atISO8601WA 24-hour session window start
unread_countinteger0 on creation
created_atISO8601Room creation timestamp
last_message_atISO8601|nullTimestamp of the first (direct send) message
last_activity_atISO8601|nullSame as last_message_at on creation
updated_atISO8601
avatarhash|null{ url, large: { url }, filename: null, size: 0, small: { url }, medium: { url } }
resolved_atISO8601|nullnull — room is assigned, not resolved
external_idstringExternal id; empty string "" when none
resolved_by_idstring|nullnull
resolved_by_typestring|nullnull
noteobjectAlways { "text": "" } or { "text": "…" } — never null
extrahash|nullAlways contains { "is_participant_online": false }; direct send rooms carry lock state in is_locked. Legacy key is_direct_send is not used.
last_messageobject|nullMost recent message — see sub-fields below
is_blockedboolean|nullfalse on creation
agent_idsarray|null[triggering_agent_id] — room is auto-assigned to sender
email_ccarray|nullEmpty [] for WA rooms
is_unrespondedboolean|nullfalse on creation
call_permission_requestarray|nullnull for direct send rooms
ext_user_idstring|nullExternal user id or null
ext_usernamestring|nullExternal username or null
ext_parent_user_idstring|nullExternal parent user id or null

last_message sub-fields (Entities::RoomList::MessageInRoomList):

For direct send, the first message is always text. Fields file_uniq_id and file are absent for text-type messages (only present on image/media messages).

FieldTypeNotes
idstringMessage UUID
typestring"text", "interactive_cta_url", or "interactive_reply_button"
room_idstringParent room UUID
is_campaignbooleanAlways false for direct send
sender_idstringAgent UUID who triggered the send
sender_typestring"Models::User" — class name, not role
participant_idstring|nullParticipant record UUID
participant_typestring"agent" | "customer" | "bot" — role label, not class name
organization_idstring|nullOrg UUID
textstring|nullMessage body text
statusstring"created" on insert; updates to "sent" / "delivered" / "read" via Meta webhook
external_idstring|nullMeta wamid ("wamid.HBgL…") once delivered; null before webhook arrives
local_idstring|nullClient-provided batch local id; null if not provided
created_atISO8601Message creation timestamp
is_editedbooleanfalse on creation
review_starinteger0 (not null)

Pagination field reference for POST response:

FieldTypeNotes
cursor.nextinteger|nullMillisecond timestamp (epoch ms) of the last room's last_activity_at — not base64
cursor.previnteger|nullMillisecond timestamp of the first room's last_activity_at — not base64
cursor.pitnullAlways null
offsetinteger1
limitintegerNumber of rooms returned
totalintegerNumber of rooms in data (= number of contacts successfully processed)
target_offsetinteger0

Note — cursor format differs between endpoints: GET /direct_send/contacts uses base64-encoded strings for cursor.next/cursor.prev (Elasticsearch cursor). POST /direct_send/messages uses integer millisecond timestamps (Elasticsearch last_activity_at sort value). Do not mix them.

Error Responses:

CodeCondition
403direct_send_enabled feature flag off for org
422Any contact in contact_ids has active room (contact_has_active_room) — entire batch rolled back
422Any contact in contact_ids is already assigned (contact_already_assigned) — entire batch rolled back
422Insufficient balance for all contacts (insufficient_balance) — read-only pre-flight check
422Channel not WA Cloud or not found (invalid_channel)
422Message validation failure (invalid_message_params)
422contact_ids is empty
429Rate limit exceeded

Note on Meta API failures: HTTP 201 is returned before any Meta API call (all calls are async via Sneakers). Meta failures surface as system messages in the affected room(s). They are never returned as HTTP error codes.

Note on balance deduction: No balance is deducted during this HTTP call. The existing WaDeductionWorker deducts balance after Meta confirms delivery via delivered/read status webhook — identical to all other WA Cloud messages (see ADR-13).


GET /api/core/v1/direct_send/restriction_status

Purpose: Return current Direct Send restriction state for the org's WABA.
Scopes: :admin, :owner, :supervisor, :agent, :member, :bot

Request Parameters:

ParamTypeRequiredDescription
channel_integration_idstring (UUID)yesThe WA Cloud channel integration to check restriction status for. Must exist, target_channel=wa_cloud, same org as caller.

Response (200):

{
"status": "success",
"data": {
"is_restricted": false,
"violation_type": null,
"restriction_type": null,
"expires_at": null
}
}

Error Responses:

CodeCondition
403direct_send_enabled feature flag off for org
422Channel integration not found for org (channel_not_found)

5.3 Webhook Event Handling — Extended in waba.rb

Meta Event FieldAdded toHandler Interactor
template_correct_category_detectionwaba.rb — new when branchDirectSend::Interactors::HandleTemplateCategoryMismatch
account_update with ACCOUNT_RESTRICTIONwaba.rb — new when branchDirectSend::Interactors::HandleAccountRestriction
messages (type=statuses), template_id presentwaba.rb — inline check in existing statuses? branchDirectSend::Interactors::SyncTemplateFromMeta (conditional)

5.4 MQTT Notification Contract (Existing Flow Reuse)

Direct Send reuses existing MQTT notification handlers and payload envelope. No new MQTT producer class, topic, or event schema is introduced.

Emission Points

TriggerCode PathMQTT HandlerEvent Type
Agent sends Direct Send message (outbound)WaCloud::Interactors::AgentSendMessageServices::Notifications::Handlers::WhenAgent::SendMessageagent_sent_message
Customer inbound message from WABA webhookAPI::Webhook::Resources::Waba -> WaCloud::Interactors::CustomerSendMessage -> Publishers::WaCloudInboundMessage / Subscribers::WaCloudInboundMessage -> WaCloud::Services::TransactionCustomerSendMessageServices::Notifications::Handlers::WhenCustomer::SendMessagecustomer_sent_message
Customer inbound message from FB webhookAPI::Webhook::Resources::FbMessenger -> Interactors::FbMessenger::CustomerSendMessage -> Publishers::FbInboundMessage / Subscribers::FbInboundMessage -> Builders::Messenger::CustomerSendMessageServices::Notifications::Handlers::WhenCustomer::SendMessagecustomer_sent_message

Transport

MQTT notifications are published via Kafka producer:

  • Producer: KafkaProducers::Notifications::MqttProducers
  • Kafka topic: "#{ENV['KAFKA_TOPIC_PREFIX']}mqtt_notifications"
  • Organization scope: is_organization_level: false
  • Gate: Services::Notifications::AbstractNotification.new.global_mqtt_notification_enabled?

Producer Envelope Contract

{
"recipient_ids": ["<user_id>", "<user_id>"],
"organization_id": "<organization_id>",
"is_organization_level": false,
"triggered_at": "2026-05-29T10:00:00Z",
"data": {
"event_id": "<uuid>",
"event_type": "agent_sent_message | customer_sent_message",
"data": {
"...message fields...": "...",
"event_id": "<uuid>",
"event_type": "agent_sent_message | customer_sent_message"
}
}
}

Notes:

  • The nested data.data payload is the message entity merged with event_id and event_type.
  • WABA statuses webhook path (SystemMessageStatusNotification) publishes update_message_status Kafka event and does not directly emit MQTT in this interactor.

§ 6 — Sequence Diagrams

6.1 Happy Path — Multi-Contact Direct Send (Async, Text)

sequenceDiagram
participant FE as Frontend
participant LB as Load Balancer
participant HS as hub-service\n(Grape)
participant INT as DirectSend::\nAgentSendsMessage
participant PG as PostgreSQL
participant RMQ as RabbitMQ
participant SUB as Subscribers::\nMessageSend\n(Sneakers)
participant META as Meta Cloud API

FE->>LB: POST /api/core/v1/direct_send/messages\n{contact_ids:[...], local_id, ...}
LB->>HS: forward (OAuth2 validated)
HS->>HS: feature flag check (direct_send_enabled)
HS->>INT: interact_with(params)

opt local_id present
INT->>PG: SELECT pg_advisory_xact_lock (TOCTOU guard)
INT->>PG: SELECT messages WHERE local_id=? AND organization_id=?
PG-->>INT: no existing record (proceed)
end

INT->>PG: validate channel, all contacts, balance (read-only)
PG-->>INT: all valid

Note over INT,PG: Single transaction — all contacts or none
INT->>PG: BEGIN
loop for each contact_id in contact_ids
INT->>PG: INSERT INTO rooms + lock extra JSONB
INT->>PG: INSERT INTO participants (assign to sender)
INT->>PG: INSERT INTO messages (body, header, footer, local_id)
end
INT->>PG: COMMIT

loop for each created message
INT->>RMQ: Publishers::MessageSend.publish(id: message.id)
end
Note over INT,RMQ: Returns after all events published — HTTP 201 before Meta calls

INT-->>HS: Success([{room_id, message_id}, ...])
HS-->>FE: 201 {status:"success", data:[...rooms], meta:{pagination:{...}}}

Note over SUB,META: Async — one Sneakers job per room
loop per room event
RMQ-->>SUB: consume message.send event (wa_cloud channel route)
SUB->>META: POST /<PHONE_ID>/messages {category:"utility", ...}
META-->>SUB: 200 {messages:[{id:"wamid.XXX"}]}
SUB->>PG: UPDATE messages SET status='sent', external_id='wamid.XXX'
META-->>HS: POST webhook (status: delivered/read)
HS->>HS: WaDeductionWorker.perform_async (billing deduction — existing path)
end

6.1b Failure Path — Meta API Fails (Async)

sequenceDiagram
participant RMQ as RabbitMQ
participant SUB as Subscribers::\nMessageSend
participant META as Meta Cloud API
participant PG as PostgreSQL

RMQ-->>SUB: consume message.send event (direct_send)
SUB->>META: POST /<PHONE_ID>/messages
META-->>SUB: 400 {error:{code:132015}} OR timeout after 30s
Note over SUB: All retries exhausted
SUB->>PG: UPDATE messages SET status='failed'
SUB->>PG: INSERT INTO messages\n(type:'system', body:'Message could not be delivered:\nTemplate temporarily unavailable (132015)')
Note over SUB: Room remains; agent sees system message.\nNo balance deducted — WaDeductionWorker\nnever fires for undelivered messages.

6.2 Failure Path — Validation Error (Sync, Before Any Room Created)

sequenceDiagram
participant FE as Frontend
participant HS as hub-service
participant INT as DirectSend::\nAgentSendsMessage
participant PG as PostgreSQL

FE->>HS: POST /api/core/v1/direct_send/messages
HS->>INT: interact_with(params)
INT->>PG: SELECT contacts WHERE id IN (contact_ids)
PG-->>INT: one contact status = 'assigned'
INT-->>HS: Failure(:contact_already_assigned) — transaction rolled back
HS-->>FE: 422 {status:"error", error:{code:422, messages:["contact_already_assigned"]}}
Note over FE: No rooms created; modal shows inline error

6.3 Contact Listing — customer_360 Routing

sequenceDiagram
participant FE as Frontend
participant HS as hub-service
participant INT as AgentListsContacts
participant ES as Elasticsearch
participant CSVC as Contact Service

FE->>HS: GET /api/core/v1/direct_send/contacts?query=ressy
HS->>INT: interact_with(params)
INT->>INT: check org.settings['customer_360']

alt customer_360 is true
INT->>CSVC: GET /contacts?search=ressy&org_id=?
Note over CSVC: 10s timeout
alt Contact Service OK
CSVC-->>INT: contact list
else timeout/5xx
Note over INT: Fail-open — fall back to ES
INT->>ES: search contacts (org_id routing)
ES-->>INT: contacts (header: X-Contact-Source: internal-fallback)
end
else customer_360 false/nil
INT->>ES: search contacts (org_id routing)
ES-->>INT: contacts
end

INT-->>HS: Success({response:[...], pagination:{...}})
HS-->>FE: 200 {status:"success", data:[...], meta:{pagination:{cursor:{next:"base64",prev:"base64",...}}}}

6.4 Room Unlock — First Customer Reply

sequenceDiagram
participant WH as Meta Webhook
participant HS as hub-service
participant SUB as Sneakers Subscriber
participant PG as PostgreSQL
participant ES as Elasticsearch

WH->>HS: POST /webhooks/whatsapp {inbound message}
HS->>SUB: process inbound message event
SUB->>PG: SELECT rooms WHERE id=? (load room)
PG-->>SUB: room.type = 'Models::DirectSendRoom', room.extra = {"is_locked":true}
SUB->>PG: UPDATE rooms SET extra=extra||'{"is_locked":false}'
SUB->>ES: SetAttributes(room_id, extra:{is_locked:false})
Note over SUB: Normal message processing continues

§ 7 — Execution Plan

Work is split into 4 chunks. Execute in order.


Chunk 1 — Database Migrations + Model Updates (hub_core)

(Identical to v1.5 — 2 migrations; no change from multi-contact feature)

Files to create/edit:

FileAction
hub_core/database/core/db/migrate/<timestamp>_add_direct_send_columns_to_message_templates.rbNew migration
hub_core/database/core/db/migrate/<timestamp>_add_direct_send_columns_to_messages.rbNew migration
hub_core/app/core/domains/models/message_template.rbAdd scopes direct_send/manual
hub_core/app/core/domains/models/message.rbAdd header, footer, local_id accessors
hub_core/app/core/domains/models/room.rbAdd direct_send_locked? and direct_send_room? helpers
hub_core/app/core/domains/models/channel_integration.rbAdd store_accessor :settings, :direct_send_restriction
hub_core/app/core/domains/models/organization.rbEnsure direct_send_enabled settings access is available; customer_360 already exists via current settings access pattern

Migration 1 — message_templates:

# frozen_string_literal: true

class AddDirectSendColumnsToMessageTemplates < ActiveRecord::Migration[6.1]
def change
unless column_exists?(:message_templates, :is_direct_send)
add_column :message_templates, :is_direct_send, :boolean, default: false, null: false
end
unless column_exists?(:message_templates, :source)
add_column :message_templates, :source, :string, limit: 50
end
unless index_exists?(:message_templates, :is_direct_send, name: 'idx_message_templates_is_direct_send')
add_index :message_templates, :is_direct_send,
where: 'is_direct_send = TRUE',
name: 'idx_message_templates_is_direct_send'
end
end
end

Note (v2.8): This migration already ran as 20260603000001_add_direct_send_columns_to_message_templates.rb with limit: 50 on source. The unless column_exists? guards are idempotent. Do NOT add a new migration for these columns.

Migration 2 — messages header/footer + local_id index:

Note (v2.8): messages.local_id already exists from 20210202045334_add_local_id_to_messages.rb (no limit, no index). messages.header and messages.footer are not yet added — Migration 2 adds them plus the partition-pruning index needed for ADR-10 idempotency.

# frozen_string_literal: true

class AddDirectSendColumnsToMessages < ActiveRecord::Migration[6.1]
def change
add_column :messages, :header, :string, limit: 60 unless column_exists?(:messages, :header)
add_column :messages, :footer, :string, limit: 60 unless column_exists?(:messages, :footer)
# local_id already exists — add the partition-pruning index for idempotency lookups
unless index_exists?(:messages, [:local_id, :organization_id], name: 'idx_messages_local_id_org')
add_index :messages, [:local_id, :organization_id],
where: 'local_id IS NOT NULL',
name: 'idx_messages_local_id_org'
end
end
end

Commands:

cd hub_core
bundle exec rails db:migrate
bundle exec rspec spec/apps/
bundle exec rubocop --no-color app/core/domains/models/

Acceptance Criteria:

  • bundle exec rails db:migrate exits 0
  • Models::MessageTemplate.respond_to?(:direct_send) returns true
  • Models::MessageTemplate.column_names.include?('is_direct_send') and 'source'already true (migration 20260603000001 ran)
  • Models::Message.column_names.include?('local_id') returns true — already true (migration 20210202045334 ran)
  • Models::Message.column_names.include?('header') && Models::Message.column_names.include?('footer') returns true — pending Migration 2
  • ActiveRecord::Base.connection.indexes(:messages).any? { |i| i.name == 'idx_messages_local_id_org' } returns true — pending Migration 2
  • Models::DirectSendRoom.superclass == Models::CustomerServiceRoom returns true
  • Models::Room::TYPES.include?('Models::DirectSendRoom') returns true
  • Models::DirectSendRoom.create!(...) sets type='Models::DirectSendRoom' in the rooms table
  • room.is_a?(Models::DirectSendRoom) && room.extra&.dig('is_locked') returns true for a newly locked Direct Send room

Chunk 2 — New Interactors (hub_core)

v2.6 / v2.7 update: the reference Ruby snippet later in this chunk is the v2.5-era shape — it predates the v2.6 rewrite (partition pruning, restriction pre-check, in-tx recheck, meaningful failure propagation, ES Entity-fed builder compat) and the v2.7 polish (field-length contract constants, observability metric emission, audit log, Rollbar scope, IMP-005 duplicate response). For the canonical current code see hub_core/app/apps/direct_send/interactors/agent_sends_message.rb. The chunk below is preserved for historical context — diff against the live file when reviewing the v2.6 + v2.7 changelog above.

Files to create/edit (v2.7 update — adds metrics helper, reindex worker, scrub repository):

FileActionClass Name
hub_core/app/apps/direct_send/interactors/agent_sends_message.rbNewDirectSend::Interactors::AgentSendsMessage
hub_core/app/apps/direct_send/interactors/agent_lists_contacts.rbNewDirectSend::Interactors::AgentListsContacts
hub_core/app/apps/direct_send/interactors/admin_gets_restriction_status.rbNewDirectSend::Interactors::AdminGetsRestrictionStatus
hub_core/app/apps/direct_send/interactors/handle_template_category_mismatch.rbNewDirectSend::Interactors::HandleTemplateCategoryMismatch
hub_core/app/apps/direct_send/interactors/handle_account_restriction.rbNewDirectSend::Interactors::HandleAccountRestriction
hub_core/app/apps/direct_send/interactors/sync_template_from_meta.rbNewDirectSend::Interactors::SyncTemplateFromMeta
hub_core/app/core/events/publishers/message_send.rbReuse existing publisherPublishers::MessageSend
hub_core/app/apps/wa_cloud/builders/direct_send_message.rbNew — Direct Send payload builder (see ADR-14)WaCloud::Builders::DirectSendMessage
hub_core/app/apps/wa_cloud/repositories/messages/send.rbEdit — add is_a?(Models::DirectSendRoom) branch to dispatch DirectSendMessage builder (see ADR-14)WaCloud::Repositories::Messages::Send
hub_core/app/core/events/subscribers/message_send.rbNo route addition required; confirm existing wa_cloud mappingExisting
hub_core/app/apps/wa_cloud/interactors/agent_send_message.rbEdit — add is_a?(Models::DirectSendRoom) && extra['is_locked'] guardExisting
hub_core/app/core/domains/interactors/whatsapp/templates/user_list_local_template.rbEdit — add is_direct_send_template filterExisting
hub_core/app/apps/direct_send/services/metrics.rbNew (v2.7 IMP-002 / ADR-17) — centralised metrics emitterDirectSend::Services::Metrics
hub_core/app/apps/direct_send/workers/reindex_room_worker.rbNew (v2.7 IMP-007) — self-heals partial ES state after a commitDirectSend::Workers::ReindexRoomWorker
hub_core/app/apps/direct_send/repositories/messages/scrub_pii.rbNew (v2.7 IMP-003) — CDG-compliant PII scrub for Direct Send messagesDirectSend::Repositories::Messages::ScrubPii

Key interactor contract — AgentSendsMessage (v2.5 reference — superseded by v2.6 rewrite + v2.7 polish in the live file):

# frozen_string_literal: true

module DirectSend
module Interactors
class AgentSendsMessage < Interactors::AbstractIteractor
attribute :organization_id, Types::UUID
attribute :channel_integration_id, Types::UUID
attribute :contact_ids, Types::Array.of(Types::UUID) # v2.0: array
attribute :sender_id, Types::UUID
attribute :local_id, Types::String.optional # batch-level dedup key
attribute :message, Types::Hash # type, body, header, footer, cta_button, reply_buttons

def result
# Batch-level idempotency check (ADR-10)
if local_id.present?
lock_key = Zlib.crc32("#{organization_id}:#{local_id}")
ActiveRecord::Base.connection.execute("SELECT pg_advisory_xact_lock(#{lock_key})")
existing = Models::Message.find_by(local_id: local_id, organization_id: organization_id)
if existing
rooms = Models::Room.where(id: Models::Message
.where(local_id: local_id, organization_id: organization_id)
.select(:room_id))
return Success(rooms.map { |r| Builders::RoomList::Room.new(Models::Model.new(r.attributes)).build })
end
end

channel = validate_channel(channel_integration_id, organization_id)
return channel if channel.failure?

contacts = validate_contacts(contact_ids, organization_id)
return contacts if contacts.failure?

# Read-only balance pre-flight check (ADR-13).
# Does NOT write to the billing DB.
# Actual deduction happens via WaDeductionWorker on Meta delivered/read webhook.
balance = validate_balance(organization_id, channel.value!, contact_ids.size)
return balance if balance.failure?

rooms = []
ApplicationRecord.transaction do
contact_ids.each do |contact_id|
room_result = create_room_and_lock(channel.value!, contacts.value!.find { |c| c.id == contact_id }, sender_id)
raise ActiveRecord::Rollback unless room_result.success?
msg_result = create_message(room_result.value!, message.merge(local_id: local_id))
raise ActiveRecord::Rollback unless msg_result.success?
rooms << { room: room_result.value!, message: msg_result.value! }
end
# NOTE: No deduct_balance call here — see ADR-13.
# Balance deduction is handled by the existing WaDeductionWorker
# when Meta sends the delivered/read status webhook.
end
return Failure(:transaction_failed) if rooms.empty?

rooms.each { |r| Publishers::MessageSend.publish(id: r[:message].id) }

Success(rooms.map { |r| Builders::RoomList::Room.new(Models::Model.new(r[:room].attributes)).build })
end

private

# Read-only balance validation. Uses 'BI'/'utility' category — all Direct Send
# messages are Business-Initiated utility conversations.
# Does NOT write to Models::Billing::WhatsappPackage or any billing table.
def validate_balance(organization_id, channel_integration, count)
organization = Services::Redis::Organizations::Get.new(organization_id).call
return Success(true) unless organization.billing_enabled?

package = organization.package
return Success(true) unless package.present?

wa_package = find_whatsapp_package(organization, package)
return Success(true) unless wa_package.present?

return Failure('Your account is currently in frozen mode') if package.status.to_s == 'freeze'
return Failure('cannot send message, package is inactive') unless ['grace', 'active'].include?(package.status)

phone = channel_integration.settings['phone_number'] || channel_integration.settings['server_wa_id']
billing_service = Services::Billing::V2::WaPricing.new(phone, 'BI', 'utility')
price_per_msg = billing_service.total_price(package.organization_id, package.id)
total_required = price_per_msg * count

sufficient = if package.billing_v3? && package.postpaid?
wa_package.balance >= 0 || wa_package.balance_initial >= 0 || wa_package.postpaid_limit >= 0
else
wa_package.balance >= total_required || wa_package.balance_initial >= total_required
end

sufficient ? Success(true) : Failure('cannot send message, insufficient balance')
end
end
end
end

Subscriber mapping note:

SEND_REPOSITORIES = {
'wa_cloud' => WaCloud::Repositories::Messages::Send
}.freeze

Direct Send reuses the existing wa_cloud routing key and does not add a direct_send repository entry.

Commands:

cd hub_core
bundle exec rspec spec/apps/direct_send/
bundle exec rubocop --no-color app/apps/direct_send/
bundle exec brakeman --no-exit-on-warn --no-exit-on-error

Acceptance Criteria:

  • AgentSendsMessage with contact_ids: [uuid1, uuid2]Success([room1_entity, room2_entity]); publisher called once per contact
  • AgentSendsMessage called again with same local_id → returns original rooms immediately; no new rooms; no publisher calls
  • AgentSendsMessage with contact_ids: [] → validation failure
  • Any contact in contact_ids is assignedFailure(:contact_already_assigned); NO rooms created (full rollback)
  • Any contact in contact_ids has active room → Failure(:contact_has_active_room); NO rooms created (full rollback)
  • Insufficient balance (read-only pre-flight check) → Failure('cannot send message, insufficient balance'); NO rooms created; NO billing DB writes
  • AgentSendsMessage does NOT call any method named deduct_balance; Models::Billing::WhatsappPackage is never written during the interactor call
  • After a successful send, Models::Billing::WhatsappPackage.find_by(waba_id:).balance is unchanged immediately post-call (deduction happens later via WaDeductionWorker on Meta webhook)
  • WaCloud::Repositories::Messages::Send with mocked Meta success → message status='sent', external_id='wamid.XXX'
  • WaCloud::Repositories::Messages::Send with mocked Meta 4xx → status='failed' + system message inserted
  • WaCloud::Builders::DirectSendMessage.build(message) for a text message produces { messaging_product: "whatsapp", recipient_type: "individual", to: <phone>, type: "text", text: { body: "..." }, category: "utility" }
  • WaCloud::Builders::DirectSendMessage.build(message) for an interactive CTA URL produces payload with interactive.type == "cta_url" and category: "utility"
  • WaCloud::Builders::DirectSendMessage.build(message) with ttl_seconds present → top-level ttl_seconds field in payload
  • WaCloud::Repositories::Messages::Send dispatches to WaCloud::Builders::DirectSendMessage when room.is_a?(Models::DirectSendRoom); dispatches to WaCloud::Builders::NewMessage otherwise
  • Rooms created by AgentSendsMessage have type == 'Models::DirectSendRoom' in the DB
  • WaCloud::Interactors::AgentSendMessage returns Failure(:direct_send_room_locked) when room.is_a?(Models::DirectSendRoom) && room.extra['is_locked']
  • After first inbound customer message, room.extra['is_locked'] = false
  • Direct Send outbound reuses existing MQTT contract: Services::Notifications::Handlers::WhenAgent::SendMessage publishes event_type='agent_sent_message' through KafkaProducers::Notifications::MqttProducers
  • WABA inbound message path reuses existing MQTT contract: Services::Notifications::Handlers::WhenCustomer::SendMessage publishes event_type='customer_sent_message'
  • FB inbound message path reuses existing MQTT contract: Services::Notifications::Handlers::WhenCustomer::SendMessage publishes event_type='customer_sent_message'

v2.6 acceptance additions:

  • validate_not_restricted returns Failure(:direct_send_restricted) when channel.settings['direct_send_restriction']['is_active'] is true AND expiration is in the future or blank (v2.6 ADR-07)
  • validate_not_restricted returns Success(true) when expiration is in the past, even if is_active: true (lapsed restriction → allow send)
  • Idempotency lookup includes created_at: 7.days.ago..Time.zone.now (v2.6 ADR-10 partition pruning); query plan shows partition pruning via EXPLAIN
  • Concurrent batches targeting the same contact with different local_ids — only one room is created; loser sees Failure(:contact_has_active_room) from the in-transaction recheck (v2.6 ADR-10)
  • Per-contact failures inside the transaction surface the specific symbol (:contact_already_assigned, :contact_has_active_room, etc.); :transaction_failed only fires for non-Failure rollback causes
  • validate_balance subtracts batch cost before comparing pools — a 0-balance postpaid org cannot send N utility messages "for free" (v2.6 ADR-13)
  • validate_balance failures are symbols (:account_frozen, :package_inactive, :insufficient_balance)
  • SyncTemplateFromMeta short-circuits with Success(:non_direct_send_template) when an existing row has is_direct_send: false — no Meta API call, no mutation (v2.6 ADR-11)
  • HandleTemplateCategoryMismatch scopes to is_direct_send: true — a webhook for a regular broadcast template no-ops with :template_not_found

v2.7 acceptance additions:

  • IMP-001: Contract rejects message.body > 1024 chars; header / footer > 60; cta_button.label / reply_buttons[].title > 20; local_id > 64; ttl_seconds outside [30, 43200]. Grape mirrors all caps for early 422 (specs in messages_spec.rb and agent_sends_message_spec.rb).
  • IMP-002 / ADR-17: DirectSend::Services::Metrics.increment is called on every terminal branch of AgentSendsMessage and SyncTemplateFromMeta — verify in a spec that the helper receives the expected (metric, tags:) argument shape for success, failure, and idempotent_replay paths.
  • IMP-003 / OQ-14: DirectSend::Repositories::Messages::ScrubPii.new(organization_id:, contact_id:).call (a) replaces messages.text with '[REDACTED]', (b) nullifies header / footer, (c) strips header / footer / cta_button / reply_buttons from raw_message JSONB while preserving ttl_seconds, and (d) does NOT touch messages from other organizations (org-isolation spec passes).
  • IMP-004: A successful AgentSendsMessage call emits Rails.logger.info(event: 'direct_send.message_created', …) with the full CDG field set (organization_id, sender_id, channel_integration_id, contact_count, local_id, message_ids, room_ids).
  • IMP-004: Rollbar.scope! is called after params validation with person.id = sender_id, custom.organization_id, custom.channel_integration_id, custom.direct_send = true — verify the scope is populated when any rescue path raises.
  • IMP-005: Duplicate idempotency replay returns rooms where agent_ids includes the original messages.sender_id (not []); duplicate response does NOT override room.status (e.g. if the original room is now 'resolved', the duplicate response still reflects that).
  • IMP-007: Post-commit ES sync failure (a) emits direct_send.es_sync.failed{stage:interactor}, (b) enqueues DirectSend::Workers::ReindexRoomWorker.perform_async(message.id, sender_id), (c) the HTTP 201 still completes successfully. The reindex worker re-runs es_index_document, SetAttributes, SetLastMessage idempotently.
  • IMP-008: 8 new spec cases in messages_spec.rb (Grape) and 8 in agent_sends_message_spec.rb (interactor) covering each field-length / TTL boundary.
  • IMP-009: Grape error response for :contact_already_assigned carries the copy "Contact is currently assigned to another agent. Resolve or reassign their existing conversation before sending." and :contact_has_active_room carries "Contact has an open conversation. Resolve or close it before sending a new Direct Send." — verify by JSON-string-match in a request spec.
  • IMP-010: Multi-contact send where 1 of 3 contacts already has an active room → 422; verify Models::Room.where(organization_id:).count is unchanged (full rollback, all-or-nothing).

Chunk 3 — New Grape Endpoints (hub-service)

Files to create/edit:

FileAction
hub-service/app/services/api/core/v1/direct_send/routes.rbNew
hub-service/app/services/api/core/v1/direct_send/resources/messages.rbNew
hub-service/app/services/api/core/v1/direct_send/resources/contacts.rbNew
hub-service/app/services/api/core/v1/direct_send/resources/restriction_status.rbNew
hub-service/app/services/api/core_api.rbEdit — mount API::Core::V1::DirectSend::Routes
hub-service/app/services/api/core/v1/templates/resources/templates.rbEdit — add is_direct_send_template param
hub-service/spec/services/api/core/v1/direct_send/resources/messages_spec.rbNew spec
hub-service/spec/services/api/core/v1/direct_send/resources/contacts_spec.rbNew spec

Resources::Messages endpoint (v2.0 — contact_ids array):

# frozen_string_literal: true

module API::Core::V1::DirectSend
class Resources::Messages < API::Core::V1::ApplicationResource
helpers API::Core::V1::Helpers

oauth2 :admin, :owner, :supervisor, :agent, :has_broadcast_access

desc 'Send a Direct Send utility message to one or more contacts'
params do
requires :channel_integration_id, type: String
requires :contact_ids, type: Array[String], documentation: { param_type: 'body' } # min: 1, max: 3
requires :message, type: Hash do
requires :type, type: String, values: %w[text interactive_cta_url interactive_reply_button]
requires :body, type: String, max_length: 1024
optional :header, type: String, max_length: 60
optional :footer, type: String, max_length: 60
optional :cta_button, type: Hash do
optional :label, type: String, max_length: 20
optional :url, type: String
end
optional :reply_buttons, type: Array do
optional :id, type: String
optional :title, type: String, max_length: 20
end
end
optional :ttl_seconds, type: Integer
optional :local_id, type: String, max_length: 64
end

post do
params[:organization_id] = me.organization_id
params[:sender_id] = me.id
parameters = DirectSend::Interactors::AgentSendsMessage.parameters(params.to_hash)
result = DirectSend::Interactors::AgentSendsMessage.new(parameters).result
Dry::Matcher::ResultMatcher.call(result) do |matcher|
yield matcher if block_given?
matcher.success do |rooms|
status 201
present(
response: rooms,
meta: {
pagination: {
cursor: { next: rooms.last&.last_activity_at&.to_i&.*(1000), prev: rooms.first&.last_activity_at&.to_i&.*(1000), pit: nil },
offset: 1,
limit: rooms.size,
total: rooms.size,
target_offset: 0
}
}
)
end
matcher.failure { |errors| then_raise_error! errors, 422 }
end
end
end
end

Commands:

cd hub-service
bundle exec rspec spec/services/api/core/v1/direct_send/
bundle exec rubocop --no-color app/services/api/core/v1/direct_send/

Acceptance Criteria:

  • POST /api/core/v1/direct_send/messages with contact_ids: [uuid] → 201, data is array with one room object
  • POST /api/core/v1/direct_send/messages with contact_ids: [uuid1, uuid2] → 201, data is array with two room objects
  • Every room item in data has type: "Models::DirectSendRoom" (STI)
  • Every room item in data.extra has is_locked: true on initial response and does not include is_direct_send
  • Room object in data matches room_list_response.json shape: has note: {text:""}, extra: {is_participant_online:false}, avatar with large/small/medium, last_message.review_star: 0, last_message.sender_type: "Models::User", last_message.participant_type: "agent"
  • meta.pagination.cursor.next is an integer (ms timestamp), not a string
  • Interactor failure :contact_has_active_room → 422
  • Interactor failure :contact_already_assigned → 422
  • Missing contact_ids → 422
  • Empty contact_ids: [] → 422
  • GET /api/core/v1/direct_send/contacts → 200; data is array; meta.pagination.cursor.next is a base64 string
  • Contact object in data matches contact_list_response.json shape: contact_handler_id: null, extra: {email:null, username:null}, avatar with large/small/medium, qontak_customer_id: "", childs: []
  • All specs use stub_auth_deprecation + stub_interactor pattern

Chunk 4 — Webhook Handlers (hub-service: extend waba.rb)

(Identical to v1.5)

Files to create/edit:

FileAction
hub-service/app/services/api/webhook/resources/waba.rbEdit — add template mismatch, account restriction, and template sync branches
hub-service/spec/services/api/webhook/resources/waba_spec.rbEdit — add new webhook event scenarios

waba.rb statuses branch extension:

elsif message_type.statuses?
interact_with(WaCloud::Interactors::SystemMessageStatusNotification, error_code: 200)

template_id = params.dig(:value, :statuses, 0, :template_id)
if template_id.present? && !Models::MessageTemplate.exists?(message_template_id: template_id)
params[:template_id] = template_id
params[:organization_id] = waba_organization_id
interact_with(DirectSend::Interactors::SyncTemplateFromMeta, error_code: 200)
end

Commands:

cd hub-service
bundle exec rspec spec/services/api/webhook/resources/waba_spec.rb
bundle exec rubocop --no-color app/services/api/webhook/resources/waba.rb

Acceptance Criteria:

  • POST /webhooks/... template_correct_category_detectionHandleTemplateCategoryMismatch called; MessageTemplate.status = "FLAGGED"
  • POST /webhooks/... account_update + ACCOUNT_RESTRICTIONHandleAccountRestriction called; channel_integration.settings['direct_send_restriction']['is_active'] = true
  • POST /webhooks/... statuses type, template_id present + NOT in DB → SyncTemplateFromMeta called; row created with is_direct_send=true
  • POST /webhooks/... statuses type, template_id already in DB → SyncTemplateFromMeta NOT called
  • Meta API failure in SyncTemplateFromMeta → logged to Rollbar; webhook returns 200 (not propagated)

§ 8 — Verification & Rollback Recipe

Pre-Merge Checks

# hub_core
cd hub_core
bundle exec rspec spec/apps/direct_send/
bundle exec rspec spec/apps/wa_cloud/interactors/agent_send_message_spec.rb # baseline
bundle exec rubocop --no-color app/apps/direct_send/ app/core/domains/models/
bundle exec brakeman --no-exit-on-warn --no-exit-on-error

# hub-service
cd hub-service
bundle exec rspec spec/services/api/core/v1/direct_send/
bundle exec rspec spec/services/api/webhook/
bundle exec rubocop --no-color app/services/api/core/v1/direct_send/ app/services/api/webhook/resources/waba.rb

Post-Deploy Signals (Beta)

| Signal | Expected | Tool | | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------- | | POST /api/core/v1/direct_send/messages with 2 contacts returns 201 | data array length = 2; two rooms created in DB | curl / Postman | | data[0] room shape matches room_list_response.json | note.text="", extra.is_participant_online=false, last_message.review_star=0, avatar.large present | Postman / jq | | GET /api/core/v1/direct_send/contacts returns meta.pagination.cursor.next as base64 | echo "MTc3..." | base64 -d decodes to a float timestamp | curl / jq | | POST idempotency: same local_id → same rooms, no new rows | SELECT COUNT(*) FROM rooms WHERE organization_id=? AND created_at > ? unchanged | psql | | Room type set correctly | SELECT type FROM rooms WHERE id=?'Models::DirectSendRoom' | psql | | Room lock written in extra | SELECT extra FROM rooms WHERE id=?{is_locked:true} | psql | | Room unlock after customer reply | Same query after reply → {is_locked:false} | psql | | Balance NOT deducted immediately after POST | SELECT balance FROM whatsapp_packages WHERE waba_id=? unchanged after 201 response | psql (billing DB) | | Balance deducted after Meta delivered webhook | Same query after Meta delivers message → balance decremented by WaDeductionWorker | psql (billing DB) |

Observability Contract

MetricTagsEmitted by
direct_send.messages.totalstatus:[success|failed|duplicate], org_id, contact_countAgentSendsMessage#result
direct_send.meta_api.latency_msstatus:[success|failed], error_codeWaCloud::Repositories::Messages::Send
direct_send.idempotency.hitsorg_idAgentSendsMessage#result when local_id found
direct_send.template_sync.totalstatus:[synced|skipped|failed]SyncTemplateFromMeta#result
direct_send.mqtt.notifications.totalevent_type:[agent_sent_message|customer_sent_message], org_idServices::Notifications::Handlers::WhenAgent::SendMessage and Services::Notifications::Handlers::WhenCustomer::SendMessage

Rollback Steps

  1. Disable the feature flag per org: Services::Preference.new.disable(:direct_send_enabled) — hides all endpoints (403) without deploy.
  2. Roll back migrations: bundle exec rails db:rollback STEP=2 in hub_core.
  3. Remove template_id sync block from waba.rb statuses? branch and redeploy hub-service.
  4. Backfill room STI type: UPDATE rooms SET type='Models::CustomerServiceRoom' WHERE type='Models::DirectSendRoom'; remove direct_send_room.rb model file; remove is_a?(Models::DirectSendRoom) branch from WaCloud::Repositories::Messages::Send.
  5. Stale room locks: UPDATE rooms SET extra = extra || '{"is_locked":false}' WHERE type='Models::DirectSendRoom' AND extra->>'is_locked' = 'true'
  6. Stale restrictions: UPDATE channel_integrations SET settings = settings - 'direct_send_restriction' WHERE settings ? 'direct_send_restriction'

§ 9 — Open Questions

#QuestionOwnerImpact
OQ-01✅ Resolved — use existing inbound-message entrypoint; if room is Models::DirectSendRoom, update extra['is_locked']=false.EngClosed
OQ-02✅ Resolved — customer_360 already available via existing organization settings access pattern (no new store_accessor key required).EngClosed
OQ-04✅ Resolved — assigned-contact guard is evaluated at org level.PM + EngClosed
OQ-05✅ Resolved — bypass auto-assign logic for Models::DirectSendRoom.PM + EngClosed
OQ-06✅ Resolved — room assigned status must not trigger round-robin/auto-assign for Direct Send rooms.EngClosed
OQ-07Language detection for unsupported language warning (US-07) — deferred to GA?EngDeferred
OQ-08✅ Resolved — GET /direct_send/contacts applies active_room filter only when param is provided; if omitted (nil), no active-room filtering is applied.PM + EngClosed
OQ-09✅ Resolved — no new client; reuse existing WA Cloud HTTP client path (WaCloud::Services::ApisAdapterWaCloud::Services::Apis).EngClosed
OQ-10✅ Resolved — follow AgentSendMessage implementation pattern; all async processing remains Sneakers-based (no new Kafka consumer).EngClosed
OQ-11✅ Resolved (v2.2) — No upfront deduction occurs (ADR-13). Balance is deducted by WaDeductionWorker only after Meta confirms delivery. If Meta fails after all retries, no deduction fires and no refund is needed.Closed
OQ-12✅ Resolved — maximum allowed contact_ids per request is 3.PMClosed
OQ-13✅ Resolved — no existing pattern is reused; create a new scheduled Sneakers recovery task.EngClosed
OQ-14✅ Resolved (v2.7 IMP-003) — new DirectSend::Repositories::Messages::ScrubPii repository scrubs messages.text, .header, .footer columns AND the PII keys in raw_message JSONB (header, footer, cta_button, reply_buttons) for any Models::DirectSendRoom row matching a contact_id or explicit message_ids. ttl_seconds is preserved (non-PII). Org-isolated, partition-pruned (90-day lookback). Wired into the centralised deletion path in a follow-up PR; the repository is callable directly by support tools today.EngClosed

v2.6 / v2.7 follow-ups (not blocking merge):

#QuestionOwnerImpact
OQ-2.6-01Centralise the "active room blocking statuses" set (%w[resolved deleted campaign]) as a Models::Room constant. Currently inlined in validate_contact_no_active_room and active_room_account_uniq_ids.EngOpen
OQ-2.6-02Configurable idempotency_horizon param for clients with multi-day retry policies. Default remains 7 days.PMOpen
OQ-2.6-03Expose :direct_send_restricted as a typed error code in the FE error catalog so the UI shows restriction expires_at and restriction_type.FEOpen
OQ-2.6-04Add channel_integration_id / channel_integration_ids[] filter to GET /direct_send/messages (ADR-16) once multi-WABA orgs ask for it.PM + EngOpen
OQ-2.6-05Migrate query= filter on GET /direct_send/messages from SQL ILIKE to Elasticsearch if FE search latency exceeds 300ms p95.EngOpen
OQ-2.6-06is_counted: true opt-in for exact total on GET /direct_send/messages (mirrors direct_send/contacts).PMOpen
OQ-2.6-07Benchmark DISTINCT ON (room_id) on top-5 largest orgs. If p95 > 500ms, prioritise messages.is_first_message denormalization.EngOpen
OQ-2.7-01Wire DirectSend::Repositories::Messages::ScrubPii into the centralised contact-deletion interactor (currently callable but not auto-triggered by right-to-delete flow). Adds end-to-end CDG coverage.EngOpen
OQ-2.7-02Emit direct_send.meta_api.latency_ms timing + direct_send.mqtt.notifications.total counter from the Sneakers consumer (WaCloud::Consumers::DirectSendDelivery). These two metrics from ADR-17 are still pending — the interactor-side metrics already shipped.EngOpen
OQ-2.7-03Decide whether the AR-column-fallback in WaCloud::Builders::DirectSendMessage#extra_hash (@source.respond_to?(:header) ? @source.header : extra_hash[...]) can be dropped once qc-22448-G ships. Keep until DBA confirms messages.header/footer columns are gone.EngOpen

CDG / Compliance Note

messages.header and messages.footer contain PII transmitted to Meta US-based Cloud API. Classified as PII fields under UU PDP. Beta orgs must sign DPA before direct_send_enabled = true. Full CDG analysis deferred to GA.

CDG — Compliance & Data Governance (Inline Minimum Spec for Beta)

Full CDG analysis is deferred to GA. The following minimum spec applies to Beta.

PII Field Classification

FieldTableClassificationBasis
messages.bodymessagesPII — message contentMay contain contact name, custom greeting
messages.headermessagesPII — message headerFree-text; may contain personal identifier
messages.footermessagesPII — message footerFree-text; may contain personal identifier
contact_objects.full_nameES indexPII — nameContact display name
contact_objects.phone_numberES indexPII — phoneE.164 digits
rooms.account_uniq_idroomsPII — phoneWA number digits
messages.local_idmessagesNon-PIIClient-generated batch dedup key
messages.external_idmessagesNon-PIIMeta wamid reference

Cross-Border Transfer

messages.body, messages.header, messages.footer are transmitted to Meta Cloud API (US-based infrastructure). Classification: cross-border PII transfer under UU PDP (Indonesia Personal Data Protection Law). Beta requirement: all Beta orgs must sign a DPA before direct_send_enabled = true is set. Full UU PDP article analysis deferred to GA.

Interim Retention Policy

Direct Send messages inherit the existing messages table retention policy. No separate Direct Send retention period is introduced for Beta. Retention review deferred to GA.

Right-to-Delete Path (Beta)

Direct Send rooms and messages are covered by the existing user/contact deletion flow. When a contact is deleted:

  • Rooms linked to the contact via rooms.account_uniq_id are processed by the existing contact deletion interactor.
  • messages.text (body), .header, .footer AND the PII keys in messages.raw_message JSONB (header, footer, cta_button, reply_buttons) are scrubbed via DirectSend::Repositories::Messages::ScrubPii (v2.7 IMP-003).
# Call shape — both arguments accepted; org_id is required.
DirectSend::Repositories::Messages::ScrubPii.new(
organization_id: organization_id,
contact_id: contact_id, # OR: message_ids: [uuid, uuid, ...]
lookback: 90.days # default; partition-prune window
).call # => Success(scrubbed: N) | Failure(:scrub_failed)

ttl_seconds and Meta status envelope keys in raw_message are preserved (non-PII).

OQ-14 resolved (v2.7 IMP-003) — code shipped in hub_core/app/apps/direct_send/repositories/messages/scrub_pii.rb. Wiring into the centralised contact-deletion interactor is a follow-up; support tools and right-to-delete workflows can call the repository directly today.

Audit Logging (Beta Minimum, implemented v2.7 IMP-004)

AgentSendsMessage emits a structured Rails.logger.info line on every successful send:

Rails.logger.info(
event: 'direct_send.message_created',
organization_id: organization_id,
sender_id: sender_id,
channel_integration_id: channel_integration_id,
contact_count: contact_ids.size,
local_id: local_id,
message_ids: created.map { |r| r[:message].id },
room_ids: created.map { |r| r[:room].id }
)

Rollbar context is tagged early in the interactor (after params validation) via Rollbar.scope! so any exception below carries organization_id, channel_integration_id, direct_send: true, contact_count, and has_local_id. Safe in a Grape request context — Rollbar's Rails middleware clears thread-local scope between requests.

Log the following fields (the structured-log line above already includes them all):

  • organization_id, sender_id (agent UUID), contact_count (number of contacts), channel_integration_id, local_id (if provided)
  • Do NOT log message.body, message.header, message.footer (PII fields)

§ 10 — Ready for Agent Execution

GateStatus
All PRD sections extracted and covered (or marked n/a)
Every code anchor read and verified in Source Verification table
Reused vs new endpoints tagged for each API
Every ADR has context, options, decision, consequences
ADR-12 covers multi-contact design (v2.0 addition)
ADR-13 covers balance deduction strategy — validate-only, webhook-driven deduction
ADR-14 covers Direct Send message payload builder — WaCloud::Builders::DirectSendMessage with category: "utility", text/cta_url/reply button types, ttl_seconds, error codes 132015/139200
Models::DirectSendRoom STI class documented; room identification via is_a? not JSONB flag
WaCloud::Builders::NewMessage left unchanged; Direct Send dispatch isolated in is_a?(Models::DirectSendRoom) branch
Sequence diagrams cover happy path + failure + webhook path
Execution plan has ordered chunks with files, commands, acceptance criteria
Acceptance criteria are assertable (query returns, test passes, response shape match)
Rollback recipe is concrete and does not require a deploy for gating
GET contacts response JSON matches contact_list_response.json exactly
POST messages response data is an array of rooms matching room_list_response.json
Cursor format difference documented: base64 (contacts) vs ms-integer (rooms)
contact_idcontact_ids breaking change documented in ADR-12 and changelog
AbstractIteractor typo acknowledged in Chunk 2
frozen_string_literal: true required on all new files
params[:organization_id] = me.organization_id required in all endpoints
Idempotency is batch-level via messages.local_id; partition constraint noted
Template sync webhook-driven; no Sidekiq/hub-worker
ApplicationRecord.transaction {} wraps all contact loop writes
deduct_balance is NOT called inside the transaction — balance deduction is webhook-driven via existing WaDeductionWorker (ADR-13)
validate_balance is read-only — no writes to billing DB during HTTP request lifecycle
Sneakers consumer documented (one event per room, not per batch)
OQ-12 resolved — contact_ids limit set to max 3 in API validation and interactor contract
OQ-01/OQ-09/OQ-10 resolved — existing inbound unlock entrypoint and existing WA Cloud HTTP client path are confirmed; async path remains Sneakers-only
OQ-13 resolved — implement new scheduled Sneakers recovery task for orphaned created messages before production
OQ-14 resolved — implement deletion-flow scrubbing for messages.body/header/footer before Beta launch

Ready for agent execution: YES — blocking OQs for implementation are resolved; OQ-07 remains deferred to GA and is non-blocking for Beta.


RFC-2026-001 v2.3 — Updated 2026-05-29. Supersedes v2.1 (direct-send-api-v2.md). Key changes: removed upfront deduct_balance; ADR-13 added; Meta Direct Send API call path reuses existing WaCloud::Repositories::Messages::Send via existing message.send flow (no DirectSend message repository).