Skip to main content

WhatsApp Per-OSA Provisioning with WABA-per-Specialist Identity Model

⚠️ Draft — not yet implemented. This subdocument proposes tables and workflows that do not exist in any environment. WhatsApp channel credentials are currently per-Org (integration_credentials). The osa_channel_endpoints table and per-OSA WhatsApp number provisioning proposed here are design proposals only.

Status: Draft (subdocument of ADR-030) Date: 2026-06-03 Issue: #1471 Parent ADRs: ADR-007, ADR-020 (this design extends row 6 of the matrix for WhatsApp) Implementation tracking: dedicated P2 follow-up issue (to be opened post-acceptance)


1. Product invariants

Three rules govern WhatsApp at h.work. They are h.work product decisions, not WhatsApp/Meta platform requirements.

Invariant I-1 — Specialist owns one primary WABA

Every Specialist that engages with WhatsApp owns exactly one primary WhatsApp Business Account (WABA). The WABA is the Specialist's WhatsApp identity container. It isolates:

  • sender pool
  • profile lifecycle
  • WhatsApp quality state and messaging-limit tier
  • Meta business verification artifact (verified business name, business info)
  • provisioning / suspension / deprovisioning lifecycle
  • operational ownership boundary

A Specialist MAY also own zero or more secondary WABAs for region / compliance / migration / brand-quality isolation reasons (see §11). Secondary WABAs do not increase phone-number capacity (which is portfolio-wide; see Invariant I-4) and do not change the identity ownership rule.

Invariant I-2 — Every OSA gets a dedicated phone number

By default, each (Org × Specialist) assignment — an OSA — receives one dedicated WhatsApp phone number, registered under that Specialist's primary (or assigned secondary) WABA. The phone number is the deterministic routing key for that OSA.

Invariant I-3 — Inbound To-number resolves exactly one OSA

Every WhatsApp inbound message resolves to exactly one OSA via single-row lookup on the inbound To number. From the resolved OSA endpoint the platform derives:

  • org_id
  • specialist_id
  • channel_endpoint_id
  • conversation scope (per ADR-020 row 1)
  • RLS GUC values (app.current_org_id, app.current_specialist_id)

There is no second-step routing in the default model. A To number that does not resolve to a provisioned endpoint is rejected at the inbound webhook.

Invariant I-4 — Capacity is bounded by Meta's portfolio-wide cap (not by WABA count)

Meta enforces phone-number capacity at the business portfolio level, not per WABA. As of the 2026-06-03 verification of this design:

Account stateMaximum phone numbers (portfolio-wide, across all WABAs)
New / unverified business portfolio2
Business-verification approved (Meta-side messaging-quality review) OR ≥ 2,000 delivered template messages within a 30-day moving period20 (auto-raised)
Above 20Manual Meta Direct Support ticket required (no documented SLA)

In addition, since October 2025, messaging limits also apply at the business portfolio level, not per phone number — phone numbers within a portfolio share the portfolio's daily/30-day messaging quota.

Implication for h.work:

  • Creating additional WABAs inside the same portfolio does not bypass the cap.
  • The "one WABA per Specialist" decision (I-1) is an h.work identity-isolation choice; it does not unlock additional capacity.
  • The platform-wide ceiling on simultaneously-provisioned WhatsApp OSAs equals the portfolio cap. With current state (unverified, cap = 2), the platform can support at most 2 OSAs on WhatsApp regardless of Specialist count.
  • Reaching cap = 20 requires either passing Meta business verification or a sustained outbound message-volume baseline, both of which are operational pre-requisites for any meaningful WhatsApp rollout.
  • Beyond 20, every additional number is a manual support ticket — capacity planning must treat OSA growth on WhatsApp as a slow path.

Sources (verified 2026-06-03; revalidate at implementation time):


2. Display identity model (WABA-level vs sender-level)

WhatsApp surfaces two distinct display names, and the design must be explicit about both.

2.1 WABA verified business name (one per Specialist)

The WABA's Meta-verified business name is the Specialist's platform-level identity.

WABA verified business name = "{Specialist.firstName} by h.work"
example: "Kai by h.work"

This name appears in Meta business verification artifacts, is what Meta validates against the legal entity (h.work), and is expensive to change (requires Meta re-verification). It is therefore:

  • Set at Specialist creation time
  • Owned by the Specialist (not the Org)
  • Changed only through a Specialist-rename event with Meta re-verification

It is not parameterized by Org.

2.2 Per-number sender display name (one per OSA)

The sender-level display name surfaces at the per-phone-number business profile and is what most customers see in their WhatsApp contact list when they save the number.

Sender display name = "{Org.shortName} {Specialist.domain} by h.work"
example: "Acme KYC by h.work"

Set at OSA provisioning time; updated when Org name or Specialist domain attribute changes. Provider-side limits on display name update frequency / review must be respected by the sync job (see §6).

2.3 Profile fields

FieldSourcePer-WABA or per-number
Verified business namespecialists.firstName + " by h.work"per-WABA (Specialist-level)
Display name (sender)org.shortName + " " + specialists.domain + " by h.work"per-number (OSA-level)
Profile photospecialists.avatar_urlper-number, synced from Specialist
Aboutspecialists.description (truncated)per-number
Business categoryspecialists.business_categoryper-WABA
Website / address / email(h.work standard contact info or per-OSA opt-in)per-number

Identity rule: the Specialist row is the source for firstName, avatar_url, description, domain. Identity-related changes to a Specialist row trigger sync to every provisioned WhatsApp endpoint of that Specialist (rate-limited; see §6).


3. Architecture overview

Meta Business Account / Portfolio: h.work

├── WABA-Kai (Specialist = Kai, Domain = KYC)
│ verified_name = "Kai by h.work"
│ role = primary
│ ├── +1-555-100-0001 → OSA(Kai, Acme) sender: "Acme KYC by h.work"
│ ├── +1-555-100-0002 → OSA(Kai, BigCorp) sender: "BigCorp KYC by h.work"
│ └── +1-555-100-0003 → OSA(Kai, DataCorp) sender: "DataCorp KYC by h.work"

├── WABA-Mei (Specialist = Mei, Domain = Finance)
│ verified_name = "Mei by h.work"
│ role = primary
│ ├── +1-555-200-0001 → OSA(Mei, Acme) sender: "Acme Finance by h.work"
│ ├── +1-555-200-0002 → OSA(Mei, DataCorp)
│ └── +1-555-200-0003 → OSA(Mei, ShopCo)

└── WABA-Lin (Specialist = Lin, Domain = E-commerce)
verified_name = "Lin by h.work"
role = primary
├── +1-555-300-0001 → OSA(Lin, BigCorp)
└── +1-555-300-0002 → OSA(Lin, ShopCo)

Cardinality:

ObjectCount formulaBound
WABA (primary)M = number of Specialists with WhatsAppone per Specialist
WABA (secondary)0..K per Specialistexception-driven
Phone numbersum(OSAs per Specialist with WhatsApp enabled)one per OSA
Endpoint rowidentical to phone number countone per OSA

4. Inbound routing

Customer (Bob) → WhatsApp → +1-555-100-0002 ("Kai for BigCorp")


Twilio BSP webhook → POST /v1/channels/whatsapp/webhook
body.To = whatsapp:+15551000002
body.From = whatsapp:<bob_phone>
body.Body = <message>
header X-Twilio-Signature = <hmac>


WhatsAppController
1. Verify HMAC signature
2. InboundDedupService.check('whatsapp', MessageSid)
3. Resolve endpoint:
SELECT *
FROM osa_channel_endpoints
WHERE channel_type = 'whatsapp'
AND external_id = 'whatsapp:+15551000002'
AND provisioning_status = 'provisioned'
AND revoked_at IS NULL;
4. Derive (org_id, specialist_id) from endpoint.osa_id
5. rls.setBoth({ org_id, specialist_id })
6. Customer.findOrCreate({ org_id, external_id: 'whatsapp:'+from, channel: 'whatsapp' })
7. Conversation.findOrCreateByChannel(...)
8. Message saved → agent dispatch → expert queue

Invariant check (enforced in inbound handler unit tests):

expect(endpoint.specialist_id).toBe(conversation.specialist_id);
expect(endpoint.org_id).toBe(conversation.org_id);
// no conversation may be created with specialist_id different from the endpoint's specialist_id

A To number that fails to resolve a provisioned endpoint logs a structured event (whatsapp.inbound.endpoint_not_found) and rejects the webhook with 404. No fallback routing.


5. Outbound dispatch

async dispatch(osaId: string, payload: WhatsAppPayload): Promise<void> {
const endpoint = await endpoints.findActive({ osaId, channel_type: 'whatsapp' });
if (!endpoint) throw new ChannelNotProvisionedError('whatsapp', osaId);

const persona = await personas.findOne(endpoint.persona_id);
const identity = await specialists.findOne(osa.specialist_id);
const creds = await secretsManager.get(endpoint.credentials_secret_ref);

await withCircuit('whatsapp', () =>
withRetry(() =>
twilio(creds).messages.create({
from: endpoint.external_id,
to: payload.to,
body: renderBody(payload, identity),
}),
{ maxAttempts: 3, baseDelayMs: 200, label: 'dispatch.whatsapp' },
),
);
}

Customer's WhatsApp client renders the sender as the per-number sender display name + profile photo + about (all derived from Specialist identity per §2).


6. Identity sync job

@OnEvent('specialist.updated')
async onSpecialistChanged({ specialistId, changedFields }: SpecialistChange) {
if (!intersects(changedFields, IDENTITY_FIELDS)) return;

const endpoints = await endpoints.findAll({
channel_type: 'whatsapp',
osa: { specialist_id: specialistId },
provisioning_status: 'provisioned',
revoked_at: IsNull(),
});

for (const ep of endpoints) {
await channelSyncQueue.add('sync-whatsapp', {
endpointId: ep.id,
changedFields,
}, {
jobId: `whatsapp-sync-${ep.id}`, // dedup
attempts: 5,
backoff: { type: 'exponential', delay: 60_000 },
});
}
}

The sync processor:

  1. Resolves endpoint → persona → identity
  2. Pushes per-field updates to the provider (profile_picture, about, sender display name)
  3. Records per-field result in osa_channel_endpoints.profile_sync_state JSONB
  4. Backs off and DLQs on provider 4xx/5xx
  5. Respects provider-side throttling (e.g., daily profile-update caps); throttle limits live in a provider-config table, not hard-coded

Fields with constraints:

  • WABA verified business name — not synced automatically; requires Specialist-rename event + Meta re-verification job
  • WABA business category — not synced automatically
  • Per-number sender display name — synced, may be subject to provider review
  • Per-number profile photo / about — synced, rate-limited

7. Data model

-- =========================================================================
-- L2: Specialist's WhatsApp identity container (one primary per Specialist)
-- =========================================================================
CREATE TABLE specialist_channel_personas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
specialist_id UUID NOT NULL REFERENCES specialists(id) ON DELETE CASCADE,
channel_type VARCHAR(32) NOT NULL,
provider VARCHAR(32) NOT NULL, -- 'twilio' | 'meta_cloud' (future)
role VARCHAR(16) NOT NULL DEFAULT 'primary',
-- 'primary' | 'secondary'
shard_key VARCHAR(64), -- 'region:US' | 'compliance:HIPAA' | 'migration:...' | 'quality_isolation' | etc.
-- NOTE: 'capacity' is NOT a valid shard_key for WhatsApp
-- because phone-number cap is portfolio-wide (Invariant I-4).
external_app_id VARCHAR(255), -- WABA ID
verified_business_name VARCHAR(255), -- "Kai by h.work"
business_category VARCHAR(64),
verification_status VARCHAR(32) NOT NULL DEFAULT 'pending',
-- 'pending' | 'in_review' | 'verified' | 'rejected' | 'suspended'
provisioning_status VARCHAR(32) NOT NULL DEFAULT 'pending',
-- 'pending' | 'provisioning' | 'provisioned' | 'suspended' | 'failed'
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX uniq_specialist_primary_persona
ON specialist_channel_personas (specialist_id, channel_type)
WHERE role = 'primary';

CREATE INDEX idx_persona_specialist ON specialist_channel_personas (specialist_id);
CREATE INDEX idx_persona_status ON specialist_channel_personas (channel_type, provisioning_status);

-- =========================================================================
-- L3: Per-OSA WhatsApp endpoint (one phone number per OSA, default mode)
-- =========================================================================
CREATE TABLE osa_channel_endpoints (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
osa_id UUID NOT NULL REFERENCES org_specialist_assignments(id) ON DELETE CASCADE,
org_id UUID NOT NULL,
specialist_id UUID NOT NULL,
channel_type VARCHAR(32) NOT NULL,
persona_id UUID NOT NULL REFERENCES specialist_channel_personas(id),
provider VARCHAR(32) NOT NULL, -- mirrors persona.provider
external_id VARCHAR(64) NOT NULL, -- 'whatsapp:+15551000001'
external_phone_number_id VARCHAR(64), -- Meta phone_number_id
twilio_sender_sid VARCHAR(64),
twilio_messaging_service_sid VARCHAR(64),
sender_display_name VARCHAR(255), -- "Acme KYC by h.work"
credentials_secret_ref TEXT NOT NULL, -- Secrets Manager ARN
webhook_secret_ref TEXT NOT NULL,
provisioning_status VARCHAR(32) NOT NULL DEFAULT 'pending',
-- 'pending' | 'number_acquired' | 'sender_registered'
-- | 'profile_configured' | 'webhook_configured'
-- | 'provisioned' | 'suspended' | 'failed' | 'released'
profile_sync_state JSONB NOT NULL DEFAULT '{}',
-- per-field {status, last_synced_at, last_error}
provisioned_at TIMESTAMPTZ,
suspended_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- One active endpoint per OSA per channel
CREATE UNIQUE INDEX uniq_osa_channel_active
ON osa_channel_endpoints (osa_id, channel_type)
WHERE revoked_at IS NULL;

-- Inbound reverse-lookup key
CREATE UNIQUE INDEX uniq_endpoint_external_id
ON osa_channel_endpoints (channel_type, external_id)
WHERE revoked_at IS NULL;

CREATE INDEX idx_endpoints_persona ON osa_channel_endpoints (persona_id);
CREATE INDEX idx_endpoints_specialist ON osa_channel_endpoints (specialist_id, channel_type);
CREATE INDEX idx_endpoints_provisioning ON osa_channel_endpoints (channel_type, provisioning_status);

-- =========================================================================
-- Pricing rate cards (cost model abstraction)
-- =========================================================================
CREATE TABLE channel_pricing_rate_cards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider VARCHAR(32) NOT NULL, -- 'twilio' | 'meta_cloud'
channel_type VARCHAR(32) NOT NULL, -- 'whatsapp'
market_code VARCHAR(8) NOT NULL, -- ISO country code or '*' for global
category VARCHAR(32), -- 'marketing' | 'utility' | 'service' | 'authentication' (WhatsApp-specific)
unit VARCHAR(32) NOT NULL, -- 'per_conversation_24h' | 'per_message' | 'phone_number_monthly'
amount_cents INTEGER NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
effective_from TIMESTAMPTZ NOT NULL,
effective_until TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_rate_cards_lookup
ON channel_pricing_rate_cards (provider, channel_type, market_code, category, effective_from);

Schema notes

  • osa_channel_endpoints.external_id is NOT NULL in this model. The previously-considered "fronted endpoint with NULL external_id" is deferred to ADR-028 (see §10).
  • provisioning_status and profile_sync_state are decoupled — an endpoint can be provisioned while individual profile fields are mid-retry.
  • All cost queries go through channel_pricing_rate_cards. No hard-coded vendor pricing in code.

8. State machines

8.1 Persona (WABA) lifecycle

created


provisioning ──(Meta business verification submitted)──► in_review


┌── verified ──┐
│ │
▼ ▼
provisioned rejected (terminal until edit)


suspended ◄──► provisioned


deprecated (no new endpoints; existing keep running until revoked)

8.2 Endpoint (phone number) lifecycle

pending


number_capacity_pending ───────► number_acquired
▲ (waiting for portfolio │
│ phone-number cap to free ▼
│ up or be raised — see I-4) sender_registered (BSP/Meta sender approval)
│ │
│ ▼
└── re-check on portfolio-cap- profile_configured (display name + photo + about set)
changed event │

webhook_configured


provisioned ◄──► suspended


released (terminal; number returned to
provider; row stays for audit
with revoked_at set)

The number_capacity_pending state (added per Meta-cap reality, see Invariant I-4):

When AM enables WhatsApp for an OSA but the portfolio is at its phone-number cap (registered_numbers >= cap), the endpoint row is created in number_capacity_pending rather than failing. This state:

  • Surfaces in the AM dashboard as "waiting for WhatsApp number capacity"
  • Listens for portfolio.capacity.changed events (raised by ops when Meta business verification completes or a manual ticket is granted)
  • On capacity headroom available, transitions automatically to number_acquired and resumes the normal provisioning path
  • Is reversible: the AM can cancel an OSA waiting in this state without consuming a number slot

Failure & recovery:

  • Any pre-provisioned stage failure transitions to failed; recovery is "retry in place" (same row, advance) for transient failures, or "abandon + re-provision" (new row) for permanent (e.g., Meta rejection).
  • Capacity-related rejection at number_acquired step (Twilio/Meta returns "cap reached" error) → endpoint backs off to number_capacity_pending rather than failed.
  • profile_sync_state failures do not transition provisioning_status. The endpoint stays provisioned; sync job retries via DLQ.
  • suspended is reversible. released is terminal.

9. Provisioning flow (default per-OSA mode)

9.1 One-time per Specialist (WABA creation)

Trigger: SuperAdmin creates Specialist + opts in to WhatsApp

1. Create specialist_channel_personas row (provisioning_status = 'pending')
2. POST to Meta (via Twilio or direct) to create WABA
name = "Kai by h.work"
business_category = specialists.business_category
3. Submit Meta business verification artifacts
4. Persona transitions: pending → provisioning → in_review (async; days possible)
5. On Meta callback: in_review → verified → provisioned
6. Persona now usable as parent for OSA endpoints

Operational note: until the persona reaches provisioned, no OSA endpoints can be created. AM Setup Wizard surfaces persona state and prevents WhatsApp enablement for OSAs of an unprovisioned Specialist.

9.2 Per-OSA (number provisioning)

Trigger: AM enables WhatsApp for OSA (e.g., Kai-for-Acme)

1. Pre-check: persona.provisioning_status = 'provisioned'
2. Create osa_channel_endpoints row (provisioning_status = 'pending')
3. Number acquisition:
POST /v2/Accounts/{sid}/IncomingPhoneNumbers
country = osa.org.preferred_market or default
→ external_id = 'whatsapp:+15551000001'
→ status = 'number_acquired'
4. Sender registration:
POST /v2/Senders (or current Twilio WhatsApp onboarding flow)
waba_id = persona.external_app_id
phone = endpoint.external_id (digit form)
display_name = compute_sender_display_name(org, specialist)
→ wait for Meta sender approval (async, hours-days)
→ status = 'sender_registered'
5. Profile configuration:
Set photo, about per §2
→ status = 'profile_configured'
6. Webhook configuration:
POST webhook URL with per-endpoint secret (stored in Secrets Manager)
→ status = 'webhook_configured'
7. Final transition: 'provisioned'
8. Emit channel.endpoint.provisioned event

All vendor API calls use the ChannelDispatcherService withCircuit + withRetry path and persist a channel_failures row on terminal failure.

9.3 Revocation

Trigger: OSA termination, customer churn, compliance event

1. revoked_at = now()
2. Release number with provider (POST .../IncomingPhoneNumbers/{sid}/release)
3. Deregister sender from WABA
4. Mark Secrets Manager secret for deletion (30-day soft delete)
5. Endpoint row preserved for audit; inbound to released number returns 404

10. Out of scope (deferred to other ADRs)

The following are intentionally not part of this design and are deferred:

FeatureWhereWhy deferred
Org-shared front-door mode (one WhatsApp number per Org with internal topic-based routing to multiple Specialists)ADR-028 (future)Requires topic classifier design, conflicts with ADR-020 row 1 specialist_id NOT NULL invariant. Should be designed once with explicit reassignment audit path.
Topic classifier subsystemADR-028Co-located with front-door design.
Meta Cloud API direct (bypass Twilio BSP)Future ADRprovider column allows the abstraction; switch is a per-Specialist re-provision event.
WhatsApp template message management (HSM / approved templates)Separate specTemplate lifecycle is independent of endpoint lifecycle.
WhatsApp commerce / catalog / paymentsSeparate product decisionNot in scope of channel infrastructure.

The current ADR commits only to the default per-OSA dedicated number model. Front-door is acknowledged as a future option, not promised.


11. Secondary WABA exception

A Specialist MAY have additional WABAs beyond the primary. Important: secondary WABAs do NOT increase phone-number capacity, because the cap is enforced at the business portfolio level (see Invariant I-4). The capacity ceiling on WhatsApp is raised via Meta business verification + messaging-quality history, not by adding WABAs. Secondary WABAs are therefore reserved for genuinely-separate-deployment reasons:

Reasonshard_key valueUse case
Regionregion:<ISO>Regional compliance or data-residency requirement that mandates a WABA in a separate business portfolio (different legal entity / regional Meta entity)
Compliancecompliance:<framework>HIPAA / GDPR / PCI / etc. demands per-customer-segment isolation in a separate portfolio
Migrationmigration:<provider>Switching BSP (e.g., Twilio → Meta Cloud direct), with old WABA being phased out via dual-write
Brand-quality isolationquality_isolationSeparating a high-quality-rated WABA from a new/untested one to protect messaging-limit tier from cross-contamination

Notably absent from this list: capacity. Adding a secondary WABA in the same business portfolio does not unlock more phone numbers; it splits the same portfolio-wide pool across more containers. The path to more numbers is at the portfolio level (verification, message-volume baseline, or Meta support ticket per Invariant I-4).

Rules:

  • Each Specialist has exactly one primary WABA (role='primary'). Enforced by partial unique index in §7.
  • Secondary WABAs do not change Specialist identity ownership. Verified business name remains "{Specialist.firstName} by h.work" across all WABAs.
  • Endpoint creation may pick a secondary WABA explicitly during provisioning; the choice is recorded on the endpoint.
  • If a secondary WABA lives in a separate business portfolio (region / compliance separation), it has an independent phone-number cap and messaging-limit pool — but that requires Meta-side legal-entity separation, which is not a default operational lever.

12. Cost model

All vendor pricing lives in channel_pricing_rate_cards. No constants in code, no fixed assumptions in docs.

Cost dimensions:

  • Per phone number monthly — Twilio rental fee, varies by market
  • Per conversation 24h window — Meta WhatsApp Business Platform charge, varies by message category and market (Marketing / Utility / Service / Authentication)
  • Per platform feature (e.g., embedded signup, dedicated IP) — provider-specific

Portfolio-wide quota constraint (since Meta's October 2025 change):

Two distinct Meta-imposed limits both apply at the business portfolio level:

  1. Registered phone-number cap — see Invariant I-4 (2 default → 20 after verification → manual ticket beyond).
  2. Messaging limit (proactive outbound to unique recipients per 24h) — tier ladder per Meta documentation:
TierUnique recipients / 24hTrigger
Unverified (starting)250New business portfolio default
Tier 11,000After Meta business verification
Tier 210,000Quality + volume threshold (≥ 50% of current limit used within 7 days, high quality rating)
Tier 3100,000Same trigger logic, further escalation
Tier 4UnlimitedSustained high quality + volume

Tier upgrades are evaluated by Meta every 6 hours (post-Oct 2025; previously 24-48h). Throughput on standard accounts is approximately 80 messages-per-second; unlimited-tier accounts can be auto-upgraded to up to 1,000 MPS.

Implications for h.work billing and capacity modeling:

  • LLM cost rollup is still per OSA (ADR-020 row 5), so per-OSA accounting works fine.
  • Messaging-quota planning is portfolio-wide, not per OSA. A high-volume OSA can exhaust the portfolio's quota and rate-limit other OSAs even when their own per-conversation cost would be low.
  • The billing rate-card design must allow modeling a shared portfolio messaging quota alongside per-conversation costs (e.g., a quota_overage_cents rate card row that fires once the portfolio quota is exceeded).
  • Capacity decisions ("should we add OSA #21 on WhatsApp?") become a portfolio-quality decision, not a per-OSA decision.
  • Reaching Tier 4 (unlimited) is the only state where per-OSA messaging volume is effectively decoupled — until then, every WhatsApp OSA is competing with siblings for the same daily 24h budget.

Billing aggregation:

  • LLM costs continue to roll up by osa_id via messages.osa_id (ADR-020 row 5)
  • Channel costs roll up by endpoint_id and aggregate to osa_id for invoicing
  • Rate-card lookup at billing-run time, not at message-send time (avoids price drift in historical messages)

13. Provider abstraction

persona.provider and endpoint.provider enumerate the backing provider:

  • twilio — current default; Twilio acts as BSP between h.work and Meta. All API calls go through Twilio.
  • meta_cloud — future; direct Meta Cloud API. Skipped today because Twilio's embedded signup flow handles BSP onboarding ergonomically.

Switching providers for an existing Specialist is a re-provision event, not an in-place mutation:

  1. Create new persona row under target provider
  2. Create new endpoints under new persona (dual-write while old endpoints still serve inbound)
  3. Notify customers of number change (Meta allows number reuse only in limited cases)
  4. Cut inbound DNS/webhook traffic
  5. Revoke old endpoints + persona

This is expensive enough that provider migration should be a deliberate Specialist-level decision, not an OSA-level toggle.


14. Security and compliance boundaries

BoundaryIsolation provided by
Kai-Acme vs Kai-BigCorp (same Specialist, different Org)Different phone number + different endpoint row + different org_id + different RLS GUC + different webhook secret + different credentials secret ref
Kai-Acme vs Mei-Acme (same Org, different Specialist)Different WABA + different phone number + different specialist_id + different expert_access path (ADR-007)
Kai-Acme vs Mei-BigCorp (different Org, different Specialist)Both of the above, composed

Threat model coverage:

  • Twilio account-level credential compromise → all WhatsApp endpoints affected. Mitigated by Twilio sub-account isolation + Secrets Manager per-endpoint secrets.
  • Per-endpoint webhook secret compromise → single OSA affected. Rotate that endpoint's secret.
  • Per-endpoint credentials ref compromise → single OSA affected. Rotate the Secrets Manager secret.
  • WABA-level Meta compromise (e.g., bad actor takes over the Meta business) → all of one Specialist's endpoints affected. Mitigated by Meta 2FA + h.work-only Business Portfolio admin set.

15. Open questions to verify against current vendor docs

Before implementation, the following facts must be confirmed against current Meta / Twilio / WhatsApp documentation. Treat the design above as architecturally correct but vendor-API details are indicative:

  1. Portfolio-wide phone-number cap — verified 2026-06-03 as 2 (unverified) → 20 (after business verification or 2,000-message volume baseline) → manual Meta support ticket beyond. Reconfirm at implementation time; Meta has changed these thresholds historically.
  2. Meta's October 2025 shift to portfolio-wide messaging limits — confirm the exact mechanism by which a portfolio's quota is consumed and whether/how it can be partitioned across phone numbers (or whether partition is impossible by design).
  3. Current Meta Cloud API version + endpoint shapes for phone_number profile updates.
  4. Twilio current WhatsApp Sender onboarding API surface (Embedded Signup vs Senders API; recently changed).
  5. Profile update rate-limits per WABA / per number / per day.
  6. Display name change review requirements (some changes auto-approve, some require Meta review).
  7. WhatsApp Conversation Category pricing model (currently 4-category model; may evolve).
  8. Sender registration approval SLA (typical and worst-case).
  9. Whether a single Meta Business Portfolio caps the number of WABAs h.work can hold within it.
  10. Whether a portfolio's tier upgrade (e.g., from unverified → verified) is reversible if quality drops, and what the recovery path is.

16. Implementation plan

The work is sized for 4-6 PRs, sequenced as expand-contract:

Phase 1 — Schema (Expand)

  • New tables: specialist_channel_personas, osa_channel_endpoints, channel_pricing_rate_cards
  • Indexes + partial unique constraints
  • FK constraints
  • Migration applies cleanly to active-active cluster (no DDL on existing tables yet)

Phase 2 — Backfill (read-only)

  • For each existing Org with WhatsApp integration_credentials row: create a single endpoint attached to the Org's primary OSA, marked shared_with_org=true (legacy marker)
  • Verify endpoint reverse-lookup matches existing routing in shadow mode
  • No production traffic switched

Phase 2.5 — Portfolio capacity tracking (NEW; required by Invariant I-4)

Add a small operational table + tracker to make the Meta cap visible and gate provisioning correctly:

CREATE TABLE whatsapp_portfolio_capacity (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
portfolio_external_id VARCHAR(255) NOT NULL UNIQUE, -- Meta Business Portfolio ID
registered_phone_numbers INTEGER NOT NULL DEFAULT 0,
cap INTEGER NOT NULL DEFAULT 2, -- 2 / 20 / manually-raised value
cap_source VARCHAR(32) NOT NULL, -- 'default' | 'business_verified' | 'volume_threshold' | 'manual_ticket'
business_verification_status VARCHAR(32), -- 'unverified' | 'in_review' | 'verified' | 'rejected'
messaging_tier VARCHAR(32) NOT NULL DEFAULT 'unverified_250',
-- 'unverified_250' | 'tier_1_1k' | 'tier_2_10k' | 'tier_3_100k' | 'tier_4_unlimited'
last_meta_sync_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

The provisioning service:

  1. Reads whatsapp_portfolio_capacity before attempting number_acquired step. If registered_phone_numbers >= cap, the endpoint goes to number_capacity_pending (see §8.2).
  2. Subscribes to a portfolio.capacity.changed event (raised by ops tooling when Meta verification completes, when a Meta-side cap raise is granted, or when an existing number is released).
  3. On event, scans osa_channel_endpoints for rows in number_capacity_pending for that portfolio and resumes them FIFO until cap is hit again.
  4. Surfaces portfolio state in the AM ops dashboard so AMs know capacity headroom before promising customers a Setup Wizard date.

This table is NOT the same as osa_channel_endpoints — it is a small operational mirror of Meta's portfolio-level state, refreshed on a schedule and on relevant webhook events.

Phase 3 — Provisioning service + AM Setup Wizard

  • WhatsAppProvisioningService with state machine
  • BullMQ jobs: WABA creation, sender registration polling, profile sync, webhook config
  • AM Setup Wizard adds "Enable WhatsApp" per non-primary OSA
  • New customer signup uses per-OSA model; existing customers remain on legacy until migrated

Phase 4 — Dual-write inbound + outbound

  • Inbound: osa_channel_endpoints.findOne({channel_type, external_id}) is the primary path; legacy lookup is fallback with metric
  • Outbound: ChannelDispatcher.dispatch reads endpoint + persona + identity
  • 7-day production soak; alert on any legacy fallback hit

Phase 5 — Identity sync

  • @OnEvent('specialist.updated') listener
  • Per-channel sync processors for WhatsApp (photo, about, sender display name)
  • Rate-limited via provider config; DLQ on persistent failure

Phase 6 — Switch read + contract

  • Remove legacy integration_credentials WhatsApp rows
  • Remove legacy lookup paths
  • slack_channel_bindings is unaffected by this WhatsApp work

Phase 7 — Pricing rate card

  • Seed initial rate cards from current Twilio + Meta pricing
  • Billing service reads rate cards at invoice-run time

17. ADR decision text (drop-in)

For ADR-030 §Per-channel application / WhatsApp:

For WhatsApp, h.work uses a WABA-per-Specialist and phone-number-per-OSA model.

Every Specialist that engages with WhatsApp owns exactly one primary WhatsApp Business Account. This is an h.work product-level identity and isolation invariant, not a WhatsApp platform requirement. The WABA acts as the Specialist's WhatsApp identity container, isolating sender pool, profile lifecycle, quality state, provisioning lifecycle, and operational ownership.

Every Org × Specialist assignment (OSA) requires one dedicated WhatsApp phone number registered under that Specialist's WABA. The phone number is the deterministic routing key for inbound messages. However, actual provisioning is capacity-gated by Meta's portfolio-wide registered-phone-number cap (Invariant I-4): new Meta Business Portfolios are initially capped at 2 phone numbers; the cap auto-raises to 20 after Meta business verification or sustained messaging-volume baseline; beyond 20 requires manual Meta support tickets. h.work cannot assume immediate per-OSA number provisioning during early rollout.

When the portfolio cap is reached, new WhatsApp OSA endpoints are NOT marked failed. They enter the number_capacity_pending state, surface in the AM dashboard as waiting for capacity, and automatically resume provisioning when Meta-side capacity becomes available (verification completes, manual ticket granted, or an existing number is released).

WhatsApp inbound messages resolve to exactly one OSA via single-row lookup on the inbound To number. The platform derives org_id, specialist_id, channel_endpoint_id, and conversation scope from the resolved endpoint. There is no second-step routing in the default model.

The WABA's verified business name takes the form "{Specialist.firstName} by h.work" and is platform-owned (not parameterized by Org). The per-number sender display name takes the form "{Org.shortName} {Specialist.domain} by h.work" and is set at OSA provisioning time. Identity fields (photo, about, name) are sourced from the Specialist row and synced to provisioned endpoints via an event-driven job.

A Specialist MAY own additional secondary WABAs for region, compliance, migration, or brand-quality-isolation reasons. Secondary WABAs do NOT increase phone-number capacity (which is portfolio-wide; see Invariant I-4) and do not change Specialist identity ownership.

Org-shared front-door mode (one WhatsApp number per Org with internal topic-based routing to multiple Specialists) is deferred to ADR-028 and is not part of the default model. Vendor-API specifics (Twilio Senders API shape, Meta Cloud API version, exact pricing categories) are indicative and must be verified against current provider documentation at implementation time.


Title: [P2][feat] WhatsApp per-OSA provisioning with WABA-per-Specialist identity model Parent: #1471 (ADR-030) Labels: priority:p2 type:feature area:backend Scope checklist:

  • Phase 1: schema migration (specialist_channel_personas, osa_channel_endpoints, channel_pricing_rate_cards)
  • Phase 2: backfill legacy WhatsApp integration_credentials rows; verify in shadow mode
  • Phase 2.5: whatsapp_portfolio_capacity table + tracker; portfolio.capacity.changed event wiring; number_capacity_pending state in endpoint lifecycle; AM dashboard shows portfolio cap headroom
  • Phase 3: WhatsAppProvisioningService + state machine + AM Setup Wizard "Enable WhatsApp" (gates on portfolio cap)
  • Phase 4: dual-write inbound + outbound; 7-day production soak
  • Phase 5: identity sync job (@OnEvent('specialist.updated') + WhatsApp processor)
  • Phase 6: switch read + contract (remove legacy paths)
  • Phase 7: pricing rate card seed + billing integration (including portfolio-wide messaging quota model)
  • Secondary WABA support (region / compliance / migration / quality_isolation shard_key — NOT capacity)
  • Provider abstraction interface (Twilio adapter; future Meta Cloud adapter)
  • Audit log: endpoint provisioning + revocation + capacity-pending-resumed events
  • Operational dashboards: persona state + endpoint state aggregates + portfolio cap utilization
  • Runbook: Meta business verification process + cap-raise ticket procedure

Verification gates before merge:

  • All vendor API calls behind withCircuit + withRetry + channel_failures DLQ
  • Inbound handler rejects unresolved To numbers with 404
  • Identity sync respects provider rate-limits via config-driven throttle
  • Specialist rename event triggers Meta re-verification job, does not silently fail
  • E2E test: provision Kai-for-Acme + Kai-for-BigCorp on same WABA, verify inbound routing isolation
  • E2E test: revoke Kai-for-Acme, verify Kai-for-BigCorp continues to function

One-line summary

One WABA per Specialist as platform-level identity container; one dedicated phone number per OSA as deterministic routing key; provisioning is capacity-gated by Meta's portfolio-wide phone-number cap (2 default → 20 after verification → manual ticket beyond) and OSAs blocked by the cap park in number_capacity_pending until headroom opens; inbound always resolves single OSA via To number; identity sourced from Specialist row, synced to provisioned endpoints; Org-shared front door explicitly deferred to ADR-028.