Skip to main content

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

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 go through the withCircuit + withRetry pattern (CLAUDE.md Β§"Outbound Channel Dispatch") with 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.