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_idspecialist_idchannel_endpoint_idconversationscope (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 state | Maximum phone numbers (portfolio-wide, across all WABAs) |
|---|---|
| New / unverified business portfolio | 2 |
| Business-verification approved (Meta-side messaging-quality review) OR β₯ 2,000 delivered template messages within a 30-day moving period | 20 (auto-raised) |
| Above 20 | Manual 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):
- Twilio Error 63104 β twilio.com/docs/api/errors/63104 β "Meta restricts unverified businesses to 2 phone numbers across all WABAs"
- Meta β Business phone numbers β developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/phone-numbers
- Meta β WhatsApp Business Messaging Limits β developers.facebook.com/docs/whatsapp/messaging-limits/
- Vonage support β api.support.vonage.com/hc/en-us/articles/13159743458460
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β
| Field | Source | Per-WABA or per-number |
|---|---|---|
| Verified business name | specialists.firstName + " by h.work" | per-WABA (Specialist-level) |
| Display name (sender) | org.shortName + " " + specialists.domain + " by h.work" | per-number (OSA-level) |
| Profile photo | specialists.avatar_url | per-number, synced from Specialist |
| About | specialists.description (truncated) | per-number |
| Business category | specialists.business_category | per-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:
| Object | Count formula | Bound |
|---|---|---|
| WABA (primary) | M = number of Specialists with WhatsApp | one per Specialist |
| WABA (secondary) | 0..K per Specialist | exception-driven |
| Phone number | sum(OSAs per Specialist with WhatsApp enabled) | one per OSA |
| Endpoint row | identical to phone number count | one 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:
- Resolves endpoint β persona β identity
- Pushes per-field updates to the provider (profile_picture, about, sender display name)
- Records per-field result in
osa_channel_endpoints.profile_sync_stateJSONB - Backs off and DLQs on provider 4xx/5xx
- 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_idis NOT NULL in this model. The previously-considered "fronted endpoint with NULL external_id" is deferred to ADR-028 (see Β§10).provisioning_statusandprofile_sync_stateare decoupled β an endpoint can beprovisionedwhile 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.changedevents (raised by ops when Meta business verification completes or a manual ticket is granted) - On capacity headroom available, transitions automatically to
number_acquiredand 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-
provisionedstage failure transitions tofailed; 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_acquiredstep (Twilio/Meta returns "cap reached" error) β endpoint backs off tonumber_capacity_pendingrather thanfailed. profile_sync_statefailures do not transitionprovisioning_status. The endpoint staysprovisioned; sync job retries via DLQ.suspendedis reversible.releasedis 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:
| Feature | Where | Why 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 subsystem | ADR-028 | Co-located with front-door design. |
| Meta Cloud API direct (bypass Twilio BSP) | Future ADR | provider column allows the abstraction; switch is a per-Specialist re-provision event. |
| WhatsApp template message management (HSM / approved templates) | Separate spec | Template lifecycle is independent of endpoint lifecycle. |
| WhatsApp commerce / catalog / payments | Separate product decision | Not 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:
| Reason | shard_key value | Use case |
|---|---|---|
| Region | region:<ISO> | Regional compliance or data-residency requirement that mandates a WABA in a separate business portfolio (different legal entity / regional Meta entity) |
| Compliance | compliance:<framework> | HIPAA / GDPR / PCI / etc. demands per-customer-segment isolation in a separate portfolio |
| Migration | migration:<provider> | Switching BSP (e.g., Twilio β Meta Cloud direct), with old WABA being phased out via dual-write |
| Brand-quality isolation | quality_isolation | Separating 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:
- Registered phone-number cap β see Invariant I-4 (
2default β20after verification β manual ticket beyond). - Messaging limit (proactive outbound to unique recipients per 24h) β tier ladder per Meta documentation:
| Tier | Unique recipients / 24h | Trigger |
|---|---|---|
| Unverified (starting) | 250 | New business portfolio default |
| Tier 1 | 1,000 | After Meta business verification |
| Tier 2 | 10,000 | Quality + volume threshold (β₯ 50% of current limit used within 7 days, high quality rating) |
| Tier 3 | 100,000 | Same trigger logic, further escalation |
| Tier 4 | Unlimited | Sustained 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_centsrate 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_idviamessages.osa_id(ADR-020 row 5) - Channel costs roll up by
endpoint_idand aggregate toosa_idfor 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:
- Create new persona row under target provider
- Create new endpoints under new persona (dual-write while old endpoints still serve inbound)
- Notify customers of number change (Meta allows number reuse only in limited cases)
- Cut inbound DNS/webhook traffic
- 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β
| Boundary | Isolation 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:
- 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. - 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).
- Current Meta Cloud API version + endpoint shapes for
phone_numberprofile updates. - Twilio current WhatsApp Sender onboarding API surface (Embedded Signup vs Senders API; recently changed).
- Profile update rate-limits per WABA / per number / per day.
- Display name change review requirements (some changes auto-approve, some require Meta review).
- WhatsApp Conversation Category pricing model (currently 4-category model; may evolve).
- Sender registration approval SLA (typical and worst-case).
- Whether a single Meta Business Portfolio caps the number of WABAs h.work can hold within it.
- 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_credentialsrow: create a single endpoint attached to the Org's primary OSA, markedshared_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:
- Reads
whatsapp_portfolio_capacitybefore attemptingnumber_acquiredstep. Ifregistered_phone_numbers >= cap, the endpoint goes tonumber_capacity_pending(see Β§8.2). - Subscribes to a
portfolio.capacity.changedevent (raised by ops tooling when Meta verification completes, when a Meta-side cap raise is granted, or when an existing number is released). - On event, scans
osa_channel_endpointsfor rows innumber_capacity_pendingfor that portfolio and resumes them FIFO until cap is hit again. - 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β
WhatsAppProvisioningServicewith 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.dispatchreads 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_credentialsWhatsApp rows - Remove legacy lookup paths
slack_channel_bindingsis 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_pendingstate, 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.
18. Recommended implementation issueβ
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_credentialsrows; verify in shadow mode - Phase 2.5:
whatsapp_portfolio_capacitytable + tracker;portfolio.capacity.changedevent wiring;number_capacity_pendingstate 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_isolationshard_key β NOTcapacity) - 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_failuresDLQ - Inbound handler rejects unresolved
Tonumbers 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.