Direct Send API — Proactive WhatsApp Utility Messaging Without Templates
| Field | Value |
|---|---|
| RFC ID | RFC-2026-001 |
| Title | Direct Send API — Proactive WhatsApp Utility Messaging Without Templates |
| Status | Draft |
| Type | Backend |
| Owner | edi.prakoso@mekari.com |
| Created | 2026-05-21 |
| Updated | 2026-07-14 (v2.8 — code-verified pass: fix migration specs to match actual DB, fix ADR-10 advisory lock placement) |
| Supersedes | direct-send-api-v2.md (v2.1) and the standalone direct-send-api-v2.6.md delta |
| PRD | Confluence — Direct Send PRD |
| Services | hub-service · hub_core |
Changelog: v2.6 → v2.7
| # | IMP | Change | Sections affected |
|---|---|---|---|
| 1 | IMP-001 | Field-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 |
| 2 | IMP-002 | Observability 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 |
| 3 | IMP-003 | PII 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) |
| 4 | IMP-004 | Structured 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 |
| 5 | IMP-005 | Idempotency 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 |
| 6 | IMP-006 | Builder 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 |
| 7 | IMP-007 | ES 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 |
| 8 | IMP-008 | Field-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 |
| 9 | IMP-009 | Per-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 |
| 10 | IMP-010 | Batch 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
| # | Change | Sections affected |
|---|---|---|
| 1 | Fix 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 |
| 2 | Partition-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 |
| 3 | Account 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 |
| 4 | Active-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 |
| 5 | Meaningful 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 |
| 6 | Template 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 |
| 7 | Category 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 |
| 8 | NEW 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) |
| 9 | NEW 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
| # | Change | Section |
|---|---|---|
| 1 | Corrected 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 |
| 2 | Updated § 5.1 reused endpoint table to reference the correct paths and their key fields. | § 5.1 |
| 3 | Added full payload and response documentation for GET /whatsapp/channel_broadcast_tier including all response fields and the channel_integration_id callout. | § 5.1 |
| 4 | Added full payload and response documentation for GET /reports/billing/additional-balance including balance logic summary and error responses. | § 5.1 |
| 5 | Added 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
| # | Change | Section |
|---|---|---|
| 1 | Replaced 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 |
| 2 | Added 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 |
| 3 | Added 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 |
| 4 | Added 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 |
| 5 | Updated ERD rooms entity to show type: string STI column | § 4.7 |
| 6 | Updated 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 |
| 7 | Added error code handling for 132015 (template paused) and 139200 (account restriction) in ADR-14 | § 3 ADR-14 |
Changelog: v2.2 → v2.3
| # | Change | Section |
|---|---|---|
| 1 | Meta 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 |
| 2 | Removed requirement to create DirectSend::Repositories::Messages::Send and Publishers::DirectSend::MessageSend | § 3 ADR-05, § 7 Chunk 2 |
| 3 | Updated 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
| # | Change | Section |
|---|---|---|
| 1 | Removed 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 |
| 2 | Added ADR-13: Balance Deduction Strategy — validate balance pre-send (read-only), rely on existing WaDeductionWorker webhook-driven path for actual deduction | § 3 ADR-13 |
| 3 | AgentSendsMessage 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 |
| 4 | Sequence diagram 6.1 updated: deduct balance step removed from transaction block | § 6.1 |
| 5 | OQ-11 resolved: no upfront deduction; existing webhook path handles deduction | § 9 |
| 6 | Non-Goals updated: "Balance refund" note clarified — no upfront deduction means no refund scenario to handle in Beta | § 1.4 |
Changelog: v1.5 → v2.0
| # | Change | Section |
|---|---|---|
| 1 | GET /direct_send/contacts response aligned to contact_list_response.json — outer envelope, avatar shape, extra structure, cursor as base64 strings | § 5.2 |
| 2 | POST /direct_send/messages request: contact_id → contact_ids: Array[String] to support sending to multiple contacts in one call | § 5.2 |
| 3 | POST /direct_send/messages response: data is now an array of room objects (one per contact), matching room_list_response.json | § 5.2 |
| 4 | AgentSendsMessage interactor contract updated to accept contact_ids array and return array of results | § 3, § 7 Chunk 2 |
| 5 | Grape params block in Resources::Messages updated for contact_ids | § 7 Chunk 3 |
| 6 | Acceptance criteria updated for array response shape | § 7 Chunk 3, § 10 |
| 7 | ADR-02 through ADR-11 inlined from v1.5 — RFC is now self-contained | § 3 |
| 8 | Sneakers consumer spec added: retry 3× exponential, DLQ, concurrency, idempotency key | § 3 ADR-05, § 7 Chunk 2 |
| 9 | Event-publish reliability gap addressed: advisory lock on local_id, recovery job for orphaned rooms | § 3 ADR-05, ADR-10 |
| 10 | CDG minimum inline spec: PII field table, retention, right-to-delete | § 9 CDG |
| 11 | GET /restriction_status request params documented | § 5.2 |
| 12 | Schema: VARCHAR(60) on messages.header/footer, CHECK constraint on source | § 4 |
Sections at a Glance
| § | Section | Purpose |
|---|---|---|
| 1 | Infrastructure Topology | Deployment + per-service responsibility |
| 2 | Repo Reading Guide | Code anchors the agent must read first |
| 3 | Architecture Decisions | ADR-format decisions for every key choice |
| 4 | Data Model Changes | Migrations + model updates |
| 5 | API Contract | New + reused endpoints with full schemas |
| 6 | Sequence Diagrams | Happy path + failure paths across full stack |
| 7 | Execution Plan | Ordered chunks with files, commands, acceptance criteria |
| 8 | Verification & Rollback Recipe | Pre-merge commands + post-deploy signals |
| 9 | Open Questions | Blockers + deferred decisions |
| 10 | Ready for Agent Execution | Gate 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
| Service | Role in Direct Send |
|---|---|
| hub-service | New 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_core | New 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 Workers | Existing 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 API | Receives POST /<PHONE_NUMBER_ID>/messages with category: "utility". Returns wamid per message. Status webhooks carry template_id. |
| Contact Service | Queried when customer_360 = true for the org. |
| Redis | Rate-limit key for Direct Send sends. Idempotency handled at DB layer via messages.local_id. |
| RabbitMQ | message.send queue: one event published per contact-room pair. |
1.3 Third-Party Connections
| Service | Connection | Auth | Timeout |
|---|---|---|---|
| Meta Cloud API | HTTPS POST (/<PHONE_NUMBER_ID>/messages) | Bearer token (WABA access token) | 30s; retry 3× with exponential back-off |
| Meta Template API | HTTPS GET (/<WABA_ID>/message_templates/{id}) — inline in waba.rb only when template_id not in DB | Bearer token | 10s; no retry |
| Contact Service | HTTPS GET | Basic 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
WaDeductionWorkeronly after Meta confirms delivery viadelivered/readstatus 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 Path | What to Learn |
|---|---|---|
| 1 | hub-service/app/services/api/core/v1/broadcasts/resources/directs.rb | Grape endpoint pattern for a one-shot send: params, interact_with, rate-limit, SOC logging |
| 2 | hub_core/app/apps/wa_cloud/interactors/agent_send_message.rb | WA Cloud send interactor: contract definition, validate_wa_balance, room/contact checks, create_message orchestration, async publish at line 238 |
| 3 | hub_core/app/core/domains/models/message_template.rb | Existing MessageTemplate model: enums, associations, settings JSONB column |
| 4 | hub_core/app/core/domains/models/room.rb | Room model: extra JSONB (line 162), is_blocked, status enum, after_commit callbacks |
| 5 | hub_core/app/core/domains/models/organization.rb | store_accessor :settings keys; org-level flag pattern |
| 6 | hub_core/app/apps/centralized_contacts/services/apis.rb | CentralizedContacts::Services::Apis HTTP client: search_segmented_filters, request structure |
| 7 | hub-service/app/services/api/core/v1/whatsapp/resources/whatsapp.rb | GET :phone_numbers endpoint (reused as-is) |
| 8 | hub-service/app/services/api/core/v1/billings/resources/billings.rb | GET /balance_remaining_status endpoint (reused as-is) |
| 9 | hub_core/app/core/domains/repositories/contacts/block/create.rb | room_set_blocked: ES sync pattern — use as template for Direct Send JSONB lock/unlock |
| 10 | hub_core/app/core/domains/models/channel_integration.rb | target_channel enum, wa_cloud key, store_accessor :settings |
| 11 | hub-service/app/services/api/webhook/resources/waba.rb | Existing WABA webhook resource — all new Direct Send handlers added here |
| 12 | hub_core/app/core/events/publishers/message_send.rb | Publisher enqueuing to message.send RabbitMQ queue |
| 13 | hub_core/app/core/events/subscribers/message_send.rb | SEND_REPOSITORIES hash — confirm wa_cloud routes to WaCloud::Repositories::Messages::Send (no DirectSend repository entry) |
| 14 | hub-service/app/services/api/core/v1/templates/resources/templates.rb | Existing WA templates endpoint; extend with is_direct_send_template filter |
2.2 Reading Order for the Agent
agent_send_message.rb— async publish pattern (line 238:Publishers::MessageSend.publish) before writingAgentSendsMessagemessage_send.rb(publisher) — payload published tomessage.sendqueuemessage_send.rb(subscriber) —SEND_REPOSITORIESdispatch map; confirmwa_cloudroute is reused for Direct Senddirects.rb— Grape send-endpoint shape to replicateroom.rb—extraJSONB column (line 162) before writing lock logicmessage_template.rb— existing columns before addingis_direct_send/sourcetemplates/resources/templates.rb— existing query params before addingis_direct_send_templatechannel_integration.rb—store_accessor :settingsbefore adding restriction keyswaba.rb(webhook) — existing handler structure before adding Direct Send event casesdatabase/core/db/migrate/— read last 3 migrations; notemessagestable is partitioned
2.3 Source Verification
| Anchor / Pattern | Evidence |
|---|---|
Models::MessageTemplate | hub_core/app/core/domains/models/message_template.rb:3 — class Models::MessageTemplate < Models::AbstractModel |
is_direct_send column does not yet exist | Grep of message_templates migrations — no such column found; must be added |
Models::Room.extra JSONB | room.rb:162 — attrs[: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 JSONB | channel_integration.rb:19-26 — store_accessor :settings confirmed |
footer already exists on message_templates | Migration 20200416081057 — do NOT add again |
header exists as hstore on message_templates | Migration 20200513084936 — do NOT add second header column |
messages has no header, footer columns | Grep of messages migrations — must be added |
messages is range-partitioned by created_at | AGENTS.md — unique index without created_at is not possible |
| Async send pattern | agent_send_message.rb:238 — Publishers::MessageSend.publish(id: message.id, ...) confirmed |
Subscribers::MessageSend on message.send | subscribers/message_send.rb:7 — from_queue 'message.send'; SEND_REPOSITORIES at line 19 |
GET /api/core/v1/templates/whatsapp | templates/resources/templates.rb:25 — confirmed; calls Interactors::Whatsapp::Template::UserListLocalTemplate |
waba.rb statuses? branch | waba.rb:87-90 — message_type.statuses? routes to SystemMessageStatusNotification |
Models::MessageTemplate.message_template_id | Confirmed — stores Meta's numeric template ID as string; unique index on [:message_template_id, :organization_id] |
| OQ-03 resolved | Use channel_integration.access_token (same field used by WaCloud::Repositories::Messages::Send) |
AbstractIteractor typo | Base class is spelled AbstractIteractor — subclassing AbstractInteractor silently creates orphan class |
WaDeductionWorker deduction path | Triggered 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:
| Option | Pros | Cons |
|---|---|---|
| A — New endpoint with runtime flag check (chosen) | Single contract for frontend; routing logic isolated in one interactor; graceful fallback | Two code paths to maintain |
| B — Two separate endpoints | Clean separation | Frontend must know which to call; doubles endpoint surface |
| C — Extend existing contacts endpoint | No new endpoint | Different 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:
| Option | Pros | Cons |
|---|---|---|
A — Add is_direct_send boolean + source string to message_templates (chosen) | Reuses existing model, indexes, Elasticsearch mapping; "Use Template" tab already queries this table | Migration needed; existing broadcast flows must not surface auto-generated templates |
B — Separate direct_send_templates table | Clean isolation; no regression risk | Duplicate model boilerplate; extra join for analytics; double maintenance |
C — Store in org settings JSONB | Zero migration | No structured querying; no pagination; hard to index |
Decision: Option A. Add two columns only — footer and header already exist and must NOT be re-added:
is_direct_send: boolean, default: false, null: false— marks auto-generated templatessource: string— stores"AUTO_GENERATED"(from Meta) orNULLfor 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:
| Option | Pros | Cons |
|---|---|---|
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? cleanly | New model file required; TYPES constant must be extended |
B — extra JSONB key direct_send: true | No new model | JSONB key collision risk; room.type still says CustomerServiceRoom; ambiguous semantics |
C — New is_direct_send: boolean column | Explicit, indexed | Migration 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 routing — WaCloud::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::TYPESupdated to include'Models::DirectSendRoom'.WaCloud::Repositories::Messages::Sendgains a 2-branchis_a?dispatch — existingNewMessagepath is unchanged.- No DB migration —
typeSTI column already exists on theroomstable.
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.
| Endpoint | Status | Justification |
|---|---|---|
GET /api/core/v1/whatsapp/channel_broadcast_tier | reused | Calls 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-balance | reused | Calls 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_numberscalls Meta's Graph API and returns Meta'sphone_number_id— it does not exposechannel_integration_id.GET /billings/balance_remaining_statusreturns 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:
| Option | Pros | Cons |
|---|---|---|
A — Publish to message.send queue via Sneakers (chosen) | Identical to existing AgentSendMessage pattern; reuses retry/DLQ infrastructure; consistent error handling via system messages | Room shows "pending" briefly; slightly more complex subscriber logic |
| B — Sidekiq background job | Simple to add | Different infrastructure from existing send path; diverges from established Sneakers architecture |
| C — Synchronous in interactor | Immediate feedback | Blocks 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):
- Validate channel, contacts (all), balance (read-only check — see ADR-13)
ApplicationRecord.transaction: create room + lock per contact, create message, assign agent- After
COMMIT: publish one event per room →Publishers::MessageSend.publish(id: message.id) - 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::MessageSend → WaCloud::Repositories::Messages::Send):
- Detect room type: if
room.is_a?(Models::DirectSendRoom)→ useWaCloud::Builders::DirectSendMessage.build(@message)(see ADR-14); otherwise useWaCloud::Builders::NewMessage.build(...) - Call
POST /<PHONE_NUMBER_ID>/messages— see ADR-14 for exact payload spec - On success →
UPDATE messages SET status='sent', external_id='wamid.XXX' - 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
| Attribute | Value |
|---|---|
| Queue | message.send (existing queue, existing wa_cloud entry in SEND_REPOSITORIES) |
| Input shape | { id: <message_uuid> } — load full message from DB on consume |
| Retry policy | 3 attempts; exponential backoff 500ms / 1500ms / 4500ms |
| DLQ | direct_send.message_send_failed — 7-day retention; alerts if depth > 50 |
| Concurrency limit | Shared with message.send consumer pool — no additional cap per channel |
| Idempotency key | Check 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 timeout | 35s (30s Meta call + 5s buffer) |
| Poison message | If 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_enabledinorganization.settingsviastore_accessor(see § 4.5). TheServices::Preferencecheck takes precedence; thestore_accessoris the backing store.
No alternative considered — Services::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_detection→DirectSend::Interactors::HandleTemplateCategoryMismatchscopes its lookup toModels::MessageTemplate.where(organization_id:, is_direct_send: true). Setsstatusto"FLAGGED". Non-Direct-Send templates with the same Metatemplate_id(or sharedname+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 intoChannelIntegration#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→ mergeis_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:
| Option | Pros | Cons |
|---|---|---|
A — Add is_direct_send_template filter to existing endpoint (chosen) | No new endpoint or route; existing pagination, scoping, and response shape reused | Existing endpoint must not break on the new optional param |
B — New GET /api/core/v1/direct_send/templates endpoint | Clean isolation | Duplicate 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):
-
Partition pruning.
messagesis range-partitioned bycreated_at. Afind_by(local_id:, organization_id:)withoutcreated_atscans every partition — unacceptable on a multi-month dataset. Use a 7-day window which is the documented idempotency horizon. -
Advisory lock placement (v2.8 fix).
pg_advisory_xact_lockis a transaction-level advisory lock — it MUST be acquired insideApplicationRecord.transactionto 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.rbdoes not yet implement thelocal_ididempotency check —local_idis accepted in the contract but unused. Task 2.2 must add the full block above inside the existing transaction. Thepg_advisory_xact_lockplacement 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_ididempotency 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 NEWlocal_idper 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:
| Option | Pros | Cons |
|---|---|---|
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 scope | SyncTemplateFromMeta HTTP call adds latency to webhook ack — mitigated by non-blocking 200 return |
| B — Sidekiq worker in hub-worker (removed) | Simple scheduling | 6h lag; introduces hub-worker dependency; diverges from Sneakers architecture |
| C — Sync on Direct Send send (eager) | Immediate | Race 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: true→Success(:already_synced)(idempotent re-sync, no Meta call). Metric:template_sync.total{status:already_synced}.is_direct_send: false→Success(:non_direct_send_template)(no Meta call, no mutation — manual broadcast template with the same Metatemplate_idis 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 withis_direct_send: false, it returnsFailure(: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
Successwhether upserted or short-circuited; webhook caller MUST always return 200 to Meta even onFailure. - On Meta API failure → log + emit
template_sync.total{status:meta_api_error}+ returnFailure.
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:
| Option | Pros | Cons |
|---|---|---|
A — Accept contact_ids: Array[String], return data: Array[Room] (chosen) | Single HTTP round-trip for multi-contact; response shape reuses existing room list entity | Breaking change vs v1.5 single-contact API; interactor must loop and collect results |
| B — Multiple sequential POST calls (one per contact) | No backend change | N HTTP round-trips from frontend; race conditions on balance deduction; poor UX for large batches |
| C — Separate bulk endpoint | Clean versioning | Extra 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:
- Confirm with frontend team that they are ready to deploy the updated request shape on the same day.
- Deploy hub-service (new params) after frontend deployment or simultaneously with a coordinated flag enable.
- If rollback is needed: feature flag disable returns 403 to all consumers without a code revert.
Any consumer passing the old
contact_idsingle param will receive422 missing required paramsuntil 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):
| Risk | Root cause | Severity |
|---|---|---|
| Double deduction | Meta 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 gap | ApplicationRecord.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 failure | deduct_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 hypotheticaldeduct_balanceare 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 samelocal_idonly — different requests with differentlocal_ids can still race. StaleObjectErrorinside a multi-DB transaction context:WhatsappPackagehaslock_version(optimistic locking). The correct handling per codebase rules is re-enqueue to Sidekiq. InsideApplicationRecord.transactionthere is no safe re-enqueue path entangled with room/message creation.- Price estimate before delivery confirmation: The existing deduction uses
pricingandconversationfields from Meta's actual webhook (the price Meta charges). An upfront estimate viaServices::Billing::V2::WaPricingmay 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:
| Option | Decision |
|---|---|
Upfront deduction inside ApplicationRecord.transaction | Rejected — double deduction, cross-DB rollback gap, no refund path |
| Upfront deduction in a separate billing DB transaction after main commit | Rejected — still causes double deduction on webhook; adds complexity for no gain |
| Read-only validation only; existing webhook path deducts | Chosen — zero new billing code; reuses proven WaDeductionWorker path; no double deduction risk |
| Skip balance validation entirely | Rejected — balance check is a required business rule per PRD |
Consequences:
deduct_balanceis 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_balanceflow — 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_urlis 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
| Field | Max length | Notes |
|---|---|---|
body | 1024 chars | Required for all types |
header.text | 60 chars | type must always be "text" — image/video headers not supported |
footer.text | 60 chars | Optional |
button display_text / reply title | 20 chars | |
reply buttons count | 3 max | |
cta_url buttons count | 1 max | |
ttl_seconds | 30–43200 | Validate before send; default 30 days when omitted |
Error Code Handling
| Code | Meaning | Action |
|---|---|---|
132015 | Template paused by Meta | UPDATE messages SET status='failed'; INSERT system message: "Message could not be delivered: Template temporarily unavailable (132015)" |
139200 | Account restricted — Direct Send utility template abuse | UPDATE messages SET status='failed'; INSERT system message: "Direct Send access restricted (139200)"; call DirectSend::Interactors::HandleAccountRestriction |
100 (invalid TTL) | ttl_seconds out of valid range | Same 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::NewMessageis untouched.- Existing WA Cloud messages are unaffected by this change.
- Direct Send message types (
text,interactive_cta_url,interactive_reply_button) must be stored inmessages.typeand 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:
DirectSend::Services::Metrics.increment('es_sync.failed', tags: { stage: 'interactor' })— ops visibility.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:
- Drop
messages.headerandmessages.footercolumns (DBA ticket required — partitioned table). - Remove
header:/footer:keys fromAgentSendsMessage#create_direct_send_message'smessage_attrs.raw_message['header']/raw_message['footer']writes remain. - Simplify
WaCloud::Builders::DirectSendMessageto drop the column-accessor fallback —extra_hashbecomes the only source. 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).
| Metric | Type | Tags | Emitter |
|---|---|---|---|
direct_send.messages.total | counter | status (success / failed:contact_count_bucket (1/2/3/other), channel | AgentSendsMessage — one increment per BATCH (not per contact), at every terminal branch |
direct_send.idempotency.hits | counter | organization_id | AgentSendsMessage — when the duplicate local_id lookup returns a row |
direct_send.template_sync.total | counter | status (already_synced / non_direct_send_template / channel_not_found / meta_api_error / upsert_failed / synced) | SyncTemplateFromMeta at every return point |
direct_send.es_sync.failed | counter | stage (interactor / reindex_worker) | AgentSendsMessage rescue + DirectSend::Workers::ReindexRoomWorker |
direct_send.pii_scrub.total | counter | source (contact / explicit_ids), count_bucket | DirectSend::Repositories::Messages::ScrubPii |
direct_send.meta_api.latency_ms | timing | status_code, template_id_present | Sneakers consumer (out of scope here — wired in WaCloud::Consumers::DirectSendDelivery follow-up) |
direct_send.mqtt.notifications.total | counter | event_type, organization_id | MQTT 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_idwas already added in migration20210202045334asVARCHAR(no length constraint). The migration below adds onlyheader,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
| Method | Path | Status | Change |
|---|---|---|---|
GET | /api/core/v1/whatsapp/channel_broadcast_tier | reused | No changes — returns channel_integration_id per channel |
GET | /api/core/v1/reports/billing/additional-balance | reused | No changes — returns WA balance (balance, additional_balance) |
GET | /api/core/v1/templates/whatsapp | extended | Add 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:
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | WaServer record UUID — not the channel_integration_id |
channel_integration_id | string (UUID) | Pass this as channel_integration_id in Direct Send requests |
account_name | string | Display name for channel selector |
channel_phone | string | Phone number (E.164 digits, no +) |
phone_number | string | Formatted display phone number |
waba_name | string|null | WABA display name |
country | string|null | Country code (e.g. "ID") |
tier | integer|null | Broadcast tier limit (e.g. 10000) |
quality_rating | string|null | "GREEN" | "YELLOW" | "RED" |
quality_score | string|null | WABA quality score |
status | string|null | WABA 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:
| Field | Type | Notes |
|---|---|---|
organization_id | string (UUID) | Org UUID |
balance | float | Current WA balance (includes postpaid_limit for billing v3 postpaid accounts) |
balance_initial | float | Initial / purchased WA balance |
additional_balance | float | balance + balance_initial when balance ≥ 0; adds postpaid_limit for v3 postpaid |
Balance logic summary:
- When
additional_balance <= 0,POST /direct_send/messagesreturns422 insufficient_balancebefore creating any rooms. - No upfront deduction occurs during the Direct Send HTTP call. Balance is deducted by the existing
WaDeductionWorkeronly after Meta confirms delivery viadelivered/readwebhook (see ADR-13).
Error responses:
| Code | Condition |
|---|---|
| 422 | Org 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):
| Param | Type | Default | Description |
|---|---|---|---|
query | string | * | Search by template name |
limit | integer | 25 | Page size |
offset | integer | 1 | Page number |
cursor | string | — | Base64 cursor from meta.pagination.cursor.next |
cursor_direction | string | before | before (next page) or after (previous page) |
status | string | — | Filter by status (e.g. APPROVED) |
category | string | — | Filter by category (e.g. UTILITY) |
statuses | string[] | — | Filter by multiple statuses |
is_direct_send_template | boolean | — | New. 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:
| Field | Type | Notes |
|---|---|---|
is_direct_send | boolean | true for auto-generated Direct Send templates; false for manually created templates |
source | string|null | "AUTO_GENERATED" for templates created by Meta when a Direct Send message is first sent; null for manual templates |
Error responses:
| Code | Condition |
|---|---|
| 422 | Non-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:
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
query | string | no | * | Full-text search across full_name, account_uniq_id, extra.email, extra.username |
limit | integer | no | 25 | Page size |
offset | integer | no | 1 | Page number |
cursor | string | no | — | Base64-encoded cursor for cursor-based pagination (value from meta.pagination.cursor.next or .prev) |
cursor_direction | string | no | before | before (next page) or after (previous page) |
order_by | string | no | created_at | ES sort field |
order_direction | string | no | desc | asc or desc |
channels | string[] | no | all known channels | Filter by channel type, e.g. ["wa_cloud"] |
channel_integration_ids | string[] | no | — | Filter by specific channel integration UUIDs |
authoritys | string[] | no | ["primary","secondary","own"] | Contact authority filter |
is_contact | boolean | no | true | true = real contacts only |
active_room | boolean | no | nil | Optional 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_counted | boolean | no | false | When true, runs separate ES count query for accurate total |
time_offsets | integer | no | — | Timezone offset in hours (used with date range) |
start_date | ISO8601 | no | — | Range filter lower bound |
end_date | ISO8601 | no | — | Range 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):
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | ES document id (contact handler id from ES _id) |
contact_handler_id | string|null | Always null in practice for ES-sourced contacts |
phone_number | string|null | E.164 digits only — no + prefix (e.g. "6281234567890"); null when not set |
full_name | string | Display name; may be masked (e.g. "L***t") when contact masking is on for org |
email | string | From extra.email or extra.email_address; empty string "" when not set |
username | string | From extra.username; empty string "" when not set |
ext_user_id | string|null | External platform user id (e.g. "ID.975066141607353") |
ext_username | string|null | External platform username (e.g. "@jp_usrnme114") |
ext_parent_user_id | string|null | External parent user id; null in most cases |
ext_country_code | string|null | ISO country code (e.g. "ID"); null when not set |
authority | string | "primary" | "secondary" | "own" |
code | string | Internal code (e.g. "CB3A2231"); empty string "" when none |
created_at | ISO8601 | Contact creation timestamp |
updated_at | ISO8601 | Last update timestamp |
last_activity_at | ISO8601 | Maps to updated_at from ES source |
channel | string | Channel type (e.g. "wa_cloud", "telegram", "web_chat", "desty_shopee") |
status | string | "success" | "failed" — builder-level field; always "success" for valid records |
error_messages | hash | Empty {} on success |
extra | hash | Always contains at minimum { "email": null|string, "username": null|string } |
account_uniq_id | string | Channel-specific identifier (phone digits, shopee ID, etc.) |
channel_integration_id | string (UUID) | UUID of the linked channel integration |
avatar | hash | { url, large: { url }, filename: null, size: 0, small: { url }, medium: { url } } — all url values may be the same CDN URL |
is_valid | boolean | Always true for ES-sourced contacts passing builder validation |
is_blocked | boolean | Whether contact is blocked |
active_room | boolean | Whether the contact currently has an active room. Direct Send eligible contact must be false. |
childs | array | Secondary handlers attached to this primary contact; [] for authority: "own" |
qontak_customer_id | string | CRM customer id; empty string "" when not linked |
Pagination field reference (Entities::Pagination):
| Field | Type | Notes |
|---|---|---|
cursor.next | string|null | Base64-encoded ES sort value of the last hit — pass as cursor + cursor_direction=before to fetch next page |
cursor.prev | string|null | Base64-encoded ES sort value of the first hit — pass as cursor + cursor_direction=after to fetch previous page |
cursor.pit | null | Point-in-time id; always null for this repository |
offset | integer | Current page number |
limit | integer | Page size |
total | integer | Count of items in this page (unless is_counted=true, in which case: full ES total across all pages) |
target_offset | integer | 0 (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:
| Code | Condition |
|---|---|
| 403 | direct_send_enabled feature flag off for org |
| 422 | ES max_result_window exceeded (deep offset pagination) |
| 429 | Rate 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:
| Param | Type | Required | Validation |
|---|---|---|---|
channel_integration_id | string (UUID) | yes | Must exist, target_channel=wa_cloud, same org |
contact_ids | string[] (UUID[]) | yes | Min 1, max 3 items; each must exist, same org, status != assigned (assigned-contact guard evaluated at org level) |
message | object | yes | See below |
message.type | string | yes | text, interactive_cta_url, interactive_reply_button |
message.body | string | yes | Max 1,024 chars |
message.header | string | no | Max 60 chars, text only |
message.footer | string | no | Max 60 chars |
message.cta_button | object | no | { label: string(20), url: string } — only when type=interactive_cta_url |
message.reply_buttons | array | no | Max 3 items, each { id: string, title: string(20) } — only when type=interactive_reply_button |
ttl_seconds | integer | no | 30–43200; default: omitted (Meta default 30 days) |
local_id | string | no | Client-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].typeMUST be"Models::DirectSendRoom"data[n].extraMUST useis_lockedfor lock statedata[n].extraMUST NOT include legacyis_direct_sendkey
{
"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):
| Rule | Expected value |
|---|---|
data[*].type | Always "Models::DirectSendRoom" |
data[*].channel | Always "wa_cloud" |
data[*].extra.is_locked | true immediately after POST success |
data[*].extra.is_direct_send | Must 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.
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | Room id |
name | string | Contact display name |
description | string | Empty string "" on creation |
status | string | "assigned" immediately after creation |
type | string | "Models::DirectSendRoom" for Direct Send POST response items (STI). Non-direct WA rooms continue using Models::CustomerServiceRoom. |
tags | array | Empty [] on creation |
channel | string | "wa_cloud" |
channel_account | string|null | Channel integration display name (not the phone number) |
organization_id | string | Org UUID |
account_uniq_id | string | Contact's WA number — digits only, no + prefix (e.g. "62857767111132") |
channel_integration_id | string | UUID of the channel integration used |
session_at | ISO8601 | WA 24-hour session window start |
unread_count | integer | 0 on creation |
created_at | ISO8601 | Room creation timestamp |
last_message_at | ISO8601|null | Timestamp of the first (direct send) message |
last_activity_at | ISO8601|null | Same as last_message_at on creation |
updated_at | ISO8601 | |
avatar | hash|null | { url, large: { url }, filename: null, size: 0, small: { url }, medium: { url } } |
resolved_at | ISO8601|null | null — room is assigned, not resolved |
external_id | string | External id; empty string "" when none |
resolved_by_id | string|null | null |
resolved_by_type | string|null | null |
note | object | Always { "text": "" } or { "text": "…" } — never null |
extra | hash|null | Always contains { "is_participant_online": false }; direct send rooms carry lock state in is_locked. Legacy key is_direct_send is not used. |
last_message | object|null | Most recent message — see sub-fields below |
is_blocked | boolean|null | false on creation |
agent_ids | array|null | [triggering_agent_id] — room is auto-assigned to sender |
email_cc | array|null | Empty [] for WA rooms |
is_unresponded | boolean|null | false on creation |
call_permission_request | array|null | null for direct send rooms |
ext_user_id | string|null | External user id or null |
ext_username | string|null | External username or null |
ext_parent_user_id | string|null | External 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).
| Field | Type | Notes |
|---|---|---|
id | string | Message UUID |
type | string | "text", "interactive_cta_url", or "interactive_reply_button" |
room_id | string | Parent room UUID |
is_campaign | boolean | Always false for direct send |
sender_id | string | Agent UUID who triggered the send |
sender_type | string | "Models::User" — class name, not role |
participant_id | string|null | Participant record UUID |
participant_type | string | "agent" | "customer" | "bot" — role label, not class name |
organization_id | string|null | Org UUID |
text | string|null | Message body text |
status | string | "created" on insert; updates to "sent" / "delivered" / "read" via Meta webhook |
external_id | string|null | Meta wamid ("wamid.HBgL…") once delivered; null before webhook arrives |
local_id | string|null | Client-provided batch local id; null if not provided |
created_at | ISO8601 | Message creation timestamp |
is_edited | boolean | false on creation |
review_star | integer | 0 (not null) |
Pagination field reference for POST response:
| Field | Type | Notes |
|---|---|---|
cursor.next | integer|null | Millisecond timestamp (epoch ms) of the last room's last_activity_at — not base64 |
cursor.prev | integer|null | Millisecond timestamp of the first room's last_activity_at — not base64 |
cursor.pit | null | Always null |
offset | integer | 1 |
limit | integer | Number of rooms returned |
total | integer | Number of rooms in data (= number of contacts successfully processed) |
target_offset | integer | 0 |
Note — cursor format differs between endpoints:
GET /direct_send/contactsuses base64-encoded strings forcursor.next/cursor.prev(Elasticsearch cursor).POST /direct_send/messagesuses integer millisecond timestamps (Elasticsearchlast_activity_atsort value). Do not mix them.
Error Responses:
| Code | Condition |
|---|---|
| 403 | direct_send_enabled feature flag off for org |
| 422 | Any contact in contact_ids has active room (contact_has_active_room) — entire batch rolled back |
| 422 | Any contact in contact_ids is already assigned (contact_already_assigned) — entire batch rolled back |
| 422 | Insufficient balance for all contacts (insufficient_balance) — read-only pre-flight check |
| 422 | Channel not WA Cloud or not found (invalid_channel) |
| 422 | Message validation failure (invalid_message_params) |
| 422 | contact_ids is empty |
| 429 | Rate 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
WaDeductionWorkerdeducts balance after Meta confirms delivery viadelivered/readstatus 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:
| Param | Type | Required | Description |
|---|---|---|---|
channel_integration_id | string (UUID) | yes | The 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:
| Code | Condition |
|---|---|
| 403 | direct_send_enabled feature flag off for org |
| 422 | Channel integration not found for org (channel_not_found) |
5.3 Webhook Event Handling — Extended in waba.rb
| Meta Event Field | Added to | Handler Interactor |
|---|---|---|
template_correct_category_detection | waba.rb — new when branch | DirectSend::Interactors::HandleTemplateCategoryMismatch |
account_update with ACCOUNT_RESTRICTION | waba.rb — new when branch | DirectSend::Interactors::HandleAccountRestriction |
messages (type=statuses), template_id present | waba.rb — inline check in existing statuses? branch | DirectSend::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
| Trigger | Code Path | MQTT Handler | Event Type |
|---|---|---|---|
| Agent sends Direct Send message (outbound) | WaCloud::Interactors::AgentSendMessage | Services::Notifications::Handlers::WhenAgent::SendMessage | agent_sent_message |
| Customer inbound message from WABA webhook | API::Webhook::Resources::Waba -> WaCloud::Interactors::CustomerSendMessage -> Publishers::WaCloudInboundMessage / Subscribers::WaCloudInboundMessage -> WaCloud::Services::TransactionCustomerSendMessage | Services::Notifications::Handlers::WhenCustomer::SendMessage | customer_sent_message |
| Customer inbound message from FB webhook | API::Webhook::Resources::FbMessenger -> Interactors::FbMessenger::CustomerSendMessage -> Publishers::FbInboundMessage / Subscribers::FbInboundMessage -> Builders::Messenger::CustomerSendMessage | Services::Notifications::Handlers::WhenCustomer::SendMessage | customer_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.datapayload is the message entity merged withevent_idandevent_type. - WABA
statuseswebhook path (SystemMessageStatusNotification) publishesupdate_message_statusKafka 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:
| File | Action |
|---|---|
hub_core/database/core/db/migrate/<timestamp>_add_direct_send_columns_to_message_templates.rb | New migration |
hub_core/database/core/db/migrate/<timestamp>_add_direct_send_columns_to_messages.rb | New migration |
hub_core/app/core/domains/models/message_template.rb | Add scopes direct_send/manual |
hub_core/app/core/domains/models/message.rb | Add header, footer, local_id accessors |
hub_core/app/core/domains/models/room.rb | Add direct_send_locked? and direct_send_room? helpers |
hub_core/app/core/domains/models/channel_integration.rb | Add store_accessor :settings, :direct_send_restriction |
hub_core/app/core/domains/models/organization.rb | Ensure 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.rbwithlimit: 50onsource. Theunless 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_idalready exists from20210202045334_add_local_id_to_messages.rb(no limit, no index).messages.headerandmessages.footerare 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:migrateexits 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::CustomerServiceRoomreturns true -
Models::Room::TYPES.include?('Models::DirectSendRoom')returns true -
Models::DirectSendRoom.create!(...)setstype='Models::DirectSendRoom'in theroomstable -
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):
| File | Action | Class Name |
|---|---|---|
hub_core/app/apps/direct_send/interactors/agent_sends_message.rb | New | DirectSend::Interactors::AgentSendsMessage |
hub_core/app/apps/direct_send/interactors/agent_lists_contacts.rb | New | DirectSend::Interactors::AgentListsContacts |
hub_core/app/apps/direct_send/interactors/admin_gets_restriction_status.rb | New | DirectSend::Interactors::AdminGetsRestrictionStatus |
hub_core/app/apps/direct_send/interactors/handle_template_category_mismatch.rb | New | DirectSend::Interactors::HandleTemplateCategoryMismatch |
hub_core/app/apps/direct_send/interactors/handle_account_restriction.rb | New | DirectSend::Interactors::HandleAccountRestriction |
hub_core/app/apps/direct_send/interactors/sync_template_from_meta.rb | New | DirectSend::Interactors::SyncTemplateFromMeta |
hub_core/app/core/events/publishers/message_send.rb | Reuse existing publisher | Publishers::MessageSend |
hub_core/app/apps/wa_cloud/builders/direct_send_message.rb | New — Direct Send payload builder (see ADR-14) | WaCloud::Builders::DirectSendMessage |
hub_core/app/apps/wa_cloud/repositories/messages/send.rb | Edit — 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.rb | No route addition required; confirm existing wa_cloud mapping | Existing |
hub_core/app/apps/wa_cloud/interactors/agent_send_message.rb | Edit — add is_a?(Models::DirectSendRoom) && extra['is_locked'] guard | Existing |
hub_core/app/core/domains/interactors/whatsapp/templates/user_list_local_template.rb | Edit — add is_direct_send_template filter | Existing |
hub_core/app/apps/direct_send/services/metrics.rb | New (v2.7 IMP-002 / ADR-17) — centralised metrics emitter | DirectSend::Services::Metrics |
hub_core/app/apps/direct_send/workers/reindex_room_worker.rb | New (v2.7 IMP-007) — self-heals partial ES state after a commit | DirectSend::Workers::ReindexRoomWorker |
hub_core/app/apps/direct_send/repositories/messages/scrub_pii.rb | New (v2.7 IMP-003) — CDG-compliant PII scrub for Direct Send messages | DirectSend::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:
-
AgentSendsMessagewithcontact_ids: [uuid1, uuid2]→Success([room1_entity, room2_entity]); publisher called once per contact -
AgentSendsMessagecalled again with samelocal_id→ returns original rooms immediately; no new rooms; no publisher calls -
AgentSendsMessagewithcontact_ids: []→ validation failure - Any contact in
contact_idsisassigned→Failure(:contact_already_assigned); NO rooms created (full rollback) - Any contact in
contact_idshas 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 -
AgentSendsMessagedoes NOT call any method nameddeduct_balance;Models::Billing::WhatsappPackageis never written during the interactor call - After a successful send,
Models::Billing::WhatsappPackage.find_by(waba_id:).balanceis unchanged immediately post-call (deduction happens later viaWaDeductionWorkeron Meta webhook) -
WaCloud::Repositories::Messages::Sendwith mocked Meta success → messagestatus='sent',external_id='wamid.XXX' -
WaCloud::Repositories::Messages::Sendwith 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 withinteractive.type == "cta_url"andcategory: "utility" -
WaCloud::Builders::DirectSendMessage.build(message)withttl_secondspresent → top-levelttl_secondsfield in payload -
WaCloud::Repositories::Messages::Senddispatches toWaCloud::Builders::DirectSendMessagewhenroom.is_a?(Models::DirectSendRoom); dispatches toWaCloud::Builders::NewMessageotherwise - Rooms created by
AgentSendsMessagehavetype == 'Models::DirectSendRoom'in the DB -
WaCloud::Interactors::AgentSendMessagereturnsFailure(:direct_send_room_locked)whenroom.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::SendMessagepublishesevent_type='agent_sent_message'throughKafkaProducers::Notifications::MqttProducers - WABA inbound message path reuses existing MQTT contract:
Services::Notifications::Handlers::WhenCustomer::SendMessagepublishesevent_type='customer_sent_message' - FB inbound message path reuses existing MQTT contract:
Services::Notifications::Handlers::WhenCustomer::SendMessagepublishesevent_type='customer_sent_message'
v2.6 acceptance additions:
-
validate_not_restrictedreturnsFailure(:direct_send_restricted)whenchannel.settings['direct_send_restriction']['is_active']is true ANDexpirationis in the future or blank (v2.6 ADR-07) -
validate_not_restrictedreturnsSuccess(true)whenexpirationis in the past, even ifis_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 seesFailure(: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_failedonly fires for non-Failurerollback causes -
validate_balancesubtracts batch cost before comparing pools — a 0-balance postpaid org cannot send N utility messages "for free" (v2.6 ADR-13) -
validate_balancefailures are symbols (:account_frozen,:package_inactive,:insufficient_balance) -
SyncTemplateFromMetashort-circuits withSuccess(:non_direct_send_template)when an existing row hasis_direct_send: false— no Meta API call, no mutation (v2.6 ADR-11) -
HandleTemplateCategoryMismatchscopes tois_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_secondsoutside [30, 43200]. Grape mirrors all caps for early 422 (specs inmessages_spec.rbandagent_sends_message_spec.rb). - IMP-002 / ADR-17:
DirectSend::Services::Metrics.incrementis called on every terminal branch ofAgentSendsMessageandSyncTemplateFromMeta— 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) replacesmessages.textwith'[REDACTED]', (b) nullifiesheader/footer, (c) stripsheader / footer / cta_button / reply_buttonsfromraw_messageJSONB while preservingttl_seconds, and (d) does NOT touch messages from other organizations (org-isolation spec passes). - IMP-004: A successful
AgentSendsMessagecall emitsRails.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 withperson.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_idsincludes the originalmessages.sender_id(not[]); duplicate response does NOT overrideroom.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) enqueuesDirectSend::Workers::ReindexRoomWorker.perform_async(message.id, sender_id), (c) the HTTP 201 still completes successfully. The reindex worker re-runses_index_document,SetAttributes,SetLastMessageidempotently. - IMP-008: 8 new spec cases in
messages_spec.rb(Grape) and 8 inagent_sends_message_spec.rb(interactor) covering each field-length / TTL boundary. - IMP-009: Grape error response for
:contact_already_assignedcarries the copy"Contact is currently assigned to another agent. Resolve or reassign their existing conversation before sending."and:contact_has_active_roomcarries"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:).countis unchanged (full rollback, all-or-nothing).
Chunk 3 — New Grape Endpoints (hub-service)
Files to create/edit:
| File | Action |
|---|---|
hub-service/app/services/api/core/v1/direct_send/routes.rb | New |
hub-service/app/services/api/core/v1/direct_send/resources/messages.rb | New |
hub-service/app/services/api/core/v1/direct_send/resources/contacts.rb | New |
hub-service/app/services/api/core/v1/direct_send/resources/restriction_status.rb | New |
hub-service/app/services/api/core_api.rb | Edit — mount API::Core::V1::DirectSend::Routes |
hub-service/app/services/api/core/v1/templates/resources/templates.rb | Edit — add is_direct_send_template param |
hub-service/spec/services/api/core/v1/direct_send/resources/messages_spec.rb | New spec |
hub-service/spec/services/api/core/v1/direct_send/resources/contacts_spec.rb | New 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/messageswithcontact_ids: [uuid]→ 201,datais array with one room object -
POST /api/core/v1/direct_send/messageswithcontact_ids: [uuid1, uuid2]→ 201,datais array with two room objects - Every room item in
datahastype: "Models::DirectSendRoom"(STI) - Every room item in
data.extrahasis_locked: trueon initial response and does not includeis_direct_send - Room object in
datamatchesroom_list_response.jsonshape: hasnote: {text:""},extra: {is_participant_online:false},avatarwithlarge/small/medium,last_message.review_star: 0,last_message.sender_type: "Models::User",last_message.participant_type: "agent" -
meta.pagination.cursor.nextis 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;datais array;meta.pagination.cursor.nextis a base64 string - Contact object in
datamatchescontact_list_response.jsonshape:contact_handler_id: null,extra: {email:null, username:null},avatarwithlarge/small/medium,qontak_customer_id: "",childs: [] - All specs use
stub_auth_deprecation+stub_interactorpattern
Chunk 4 — Webhook Handlers (hub-service: extend waba.rb)
(Identical to v1.5)
Files to create/edit:
| File | Action |
|---|---|
hub-service/app/services/api/webhook/resources/waba.rb | Edit — add template mismatch, account restriction, and template sync branches |
hub-service/spec/services/api/webhook/resources/waba_spec.rb | Edit — 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_detection→HandleTemplateCategoryMismatchcalled;MessageTemplate.status = "FLAGGED" -
POST /webhooks/...account_update+ACCOUNT_RESTRICTION→HandleAccountRestrictioncalled;channel_integration.settings['direct_send_restriction']['is_active'] = true -
POST /webhooks/...statusestype,template_idpresent + NOT in DB →SyncTemplateFromMetacalled; row created withis_direct_send=true -
POST /webhooks/...statusestype,template_idalready in DB →SyncTemplateFromMetaNOT 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
| Metric | Tags | Emitted by |
|---|---|---|
direct_send.messages.total | status:[success|failed|duplicate], org_id, contact_count | AgentSendsMessage#result |
direct_send.meta_api.latency_ms | status:[success|failed], error_code | WaCloud::Repositories::Messages::Send |
direct_send.idempotency.hits | org_id | AgentSendsMessage#result when local_id found |
direct_send.template_sync.total | status:[synced|skipped|failed] | SyncTemplateFromMeta#result |
direct_send.mqtt.notifications.total | event_type:[agent_sent_message|customer_sent_message], org_id | Services::Notifications::Handlers::WhenAgent::SendMessage and Services::Notifications::Handlers::WhenCustomer::SendMessage |
Rollback Steps
- Disable the feature flag per org:
Services::Preference.new.disable(:direct_send_enabled)— hides all endpoints (403) without deploy. - Roll back migrations:
bundle exec rails db:rollback STEP=2in hub_core. - Remove
template_idsync block fromwaba.rbstatuses?branch and redeploy hub-service. - Backfill room STI type:
UPDATE rooms SET type='Models::CustomerServiceRoom' WHERE type='Models::DirectSendRoom'; removedirect_send_room.rbmodel file; removeis_a?(Models::DirectSendRoom)branch fromWaCloud::Repositories::Messages::Send. - Stale room locks:
UPDATE rooms SET extra = extra || '{"is_locked":false}' WHERE type='Models::DirectSendRoom' AND extra->>'is_locked' = 'true' - Stale restrictions:
UPDATE channel_integrations SET settings = settings - 'direct_send_restriction' WHERE settings ? 'direct_send_restriction'
§ 9 — Open Questions
| # | Question | Owner | Impact |
|---|---|---|---|
| OQ-01 | ✅ Resolved — use existing inbound-message entrypoint; if room is Models::DirectSendRoom, update extra['is_locked']=false. | Eng | Closed |
| OQ-02 | ✅ Resolved — customer_360 already available via existing organization settings access pattern (no new store_accessor key required). | Eng | Closed |
| OQ-04 | ✅ Resolved — assigned-contact guard is evaluated at org level. | PM + Eng | Closed |
| OQ-05 | ✅ Resolved — bypass auto-assign logic for Models::DirectSendRoom. | PM + Eng | Closed |
| OQ-06 | ✅ Resolved — room assigned status must not trigger round-robin/auto-assign for Direct Send rooms. | Eng | Closed |
| OQ-07 | Language detection for unsupported language warning (US-07) — deferred to GA? | Eng | Deferred |
| 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 + Eng | Closed |
| OQ-09 | ✅ Resolved — no new client; reuse existing WA Cloud HTTP client path (WaCloud::Services::ApisAdapter → WaCloud::Services::Apis). | Eng | Closed |
| OQ-10 | ✅ Resolved — follow AgentSendMessage implementation pattern; all async processing remains Sneakers-based (no new Kafka consumer). | Eng | Closed |
| 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. | PM | Closed |
| OQ-13 | ✅ Resolved — no existing pattern is reused; create a new scheduled Sneakers recovery task. | Eng | Closed |
| 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. | Eng | Closed |
v2.6 / v2.7 follow-ups (not blocking merge):
| # | Question | Owner | Impact |
|---|---|---|---|
| OQ-2.6-01 | Centralise 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. | Eng | Open |
| OQ-2.6-02 | Configurable idempotency_horizon param for clients with multi-day retry policies. Default remains 7 days. | PM | Open |
| OQ-2.6-03 | Expose :direct_send_restricted as a typed error code in the FE error catalog so the UI shows restriction expires_at and restriction_type. | FE | Open |
| OQ-2.6-04 | Add channel_integration_id / channel_integration_ids[] filter to GET /direct_send/messages (ADR-16) once multi-WABA orgs ask for it. | PM + Eng | Open |
| OQ-2.6-05 | Migrate query= filter on GET /direct_send/messages from SQL ILIKE to Elasticsearch if FE search latency exceeds 300ms p95. | Eng | Open |
| OQ-2.6-06 | is_counted: true opt-in for exact total on GET /direct_send/messages (mirrors direct_send/contacts). | PM | Open |
| OQ-2.6-07 | Benchmark DISTINCT ON (room_id) on top-5 largest orgs. If p95 > 500ms, prioritise messages.is_first_message denormalization. | Eng | Open |
| OQ-2.7-01 | Wire 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. | Eng | Open |
| OQ-2.7-02 | Emit 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. | Eng | Open |
| OQ-2.7-03 | Decide 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. | Eng | Open |
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
| Field | Table | Classification | Basis |
|---|---|---|---|
messages.body | messages | PII — message content | May contain contact name, custom greeting |
messages.header | messages | PII — message header | Free-text; may contain personal identifier |
messages.footer | messages | PII — message footer | Free-text; may contain personal identifier |
contact_objects.full_name | ES index | PII — name | Contact display name |
contact_objects.phone_number | ES index | PII — phone | E.164 digits |
rooms.account_uniq_id | rooms | PII — phone | WA number digits |
messages.local_id | messages | Non-PII | Client-generated batch dedup key |
messages.external_id | messages | Non-PII | Meta 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_idare processed by the existing contact deletion interactor. messages.text(body),.header,.footerAND the PII keys inmessages.raw_messageJSONB (header,footer,cta_button,reply_buttons) are scrubbed viaDirectSend::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
| Gate | Status |
|---|---|
| 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_id → contact_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).