A completed WhatsApp Embedded Signup dialog does not mean that a customer is ready to send and receive messages.
The browser flow may have finished while the backend still has one of these problems:
- The result was attached to the wrong tenant
- The access token belongs to another app
- The wrong WhatsApp Business Account was selected
- The system user lacks the required access
- The phone number is not ready
- The app is not subscribed to WABA webhooks
- The first webhook cannot be routed to the customer
A SaaS application should display Connected only after every backend gate has passed.
Model onboarding as a state machine
Avoid representing onboarding with one Boolean field such as:
connected: true
Use explicit states instead:
type WhatsAppOnboardingStatus =
| "started"
| "browser_finished"
| "token_validated"
| "waba_resolved"
| "access_verified"
| "phone_ready"
| "app_subscribed"
| "webhook_pending"
| "active"
| "verification_required"
| "failed";
A safe transition path is:
started
↓
browser_finished
↓
token_validated
↓
waba_resolved
↓
access_verified
↓
phone_ready
↓
app_subscribed
↓
webhook_pending
↓
active
Each transition should store evidence, not just a timestamp.
Define the acceptance gates
Meta's official Embedded Signup collection separates the browser flow from the Graph API work required afterward.
| Gate | Evidence | Failure risk |
|---|---|---|
| Session correlation | Tenant ID, state, configuration ID | Assets attached to the wrong customer |
| Token validation | App ID, scopes, expiry metadata | Token exists but cannot manage the WABA |
| WABA resolution | Exact WABA ID and business mapping | First list result belongs to another customer |
| System-user access | System-user ID and required task | Later API operations fail |
| Phone readiness | Phone-number ID and onboarding path | WABA exists but messaging is unavailable |
| App subscription | App appears in subscribed_apps
|
Meta receives messages but sends no webhook |
| Delivery proof | One correctly routed webhook | Configuration passes without real delivery |
A tenant should remain unavailable until all required gates pass.
1. Correlate the browser result with a server session
Create the onboarding session on your server before opening Embedded Signup.
Store:
type OnboardingSession = {
id: string;
tenantId: string;
startedByUserId: string;
state: string;
configurationId: string;
onboardingPath: "cloud_api" | "coexistence";
expiresAt: string;
consumedAt?: string;
};
The state value should be:
- Random
- Single-use
- Bound to one tenant
- Bound to the initiating user
- Short-lived
- Validated on the server
When the browser reports completion:
- Look up the server session
- Verify the state
- Reject expired sessions
- Reject already-consumed sessions
- Verify that the current user can modify the tenant
- Mark the result as consumed atomically
Do not trust a tenant ID submitted by the browser if it can be derived from the authenticated server session.
If the Coexistence flow emits FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING, treat it as evidence that the dialog finished—not evidence that the backend is active.
2. Keep credentials out of the browser
Credential exchange and Graph API verification belong on the server.
Do not place access tokens in:
- Browser storage
- Query strings
- Client-side analytics
- Error-reporting breadcrumbs
- Support screenshots
- Application logs
- Database fields returned by public APIs
Store the credential in a secret manager or encrypted server-side store.
Persist only the audit metadata your application needs:
type TokenAuditMetadata = {
tenantId: string;
appId: string;
tokenType: string;
grantedScopes: string[];
issuedAt?: string;
expiresAt?: string;
lastValidatedAt: string;
};
A non-empty token is not proof that it belongs to the expected Meta app or has the permissions required for the selected WABA.
3. Resolve the intended WABA deterministically
The official collection exposes this endpoint for retrieving client WABAs:
GET /{business-id}/client_whatsapp_business_accounts
A business can return multiple WABAs. Never select the first array element:
// Unsafe
const waba = response.data[0];
Match against information captured for the current onboarding session:
function resolveWaba(
availableWabas: Array<{ id: string; name?: string }>,
expectedWabaId: string,
) {
const matches = availableWabas.filter(
(candidate) => candidate.id === expectedWabaId,
);
if (matches.length !== 1) {
throw new Error("expected_waba_not_resolved");
}
return matches[0];
}
Store the relationship explicitly:
type TenantWabaMapping = {
tenantId: string;
metaBusinessId: string;
wabaId: string;
sourceSessionId: string;
verifiedAt: string;
};
If no exact match exists, move the tenant to verification_required. Do not silently attach another WABA.
4. Verify system-user access
Meta documents the following endpoint for checking assigned users:
GET /{waba-id}/assigned_users?business={business-id}
Verify:
- The expected system user is present
- It belongs to the expected business
- It has the task required by your integration
- Your backend credential can perform the required WABA operations
Do not treat any returned system user as sufficient.
A system user added for a different operational role may not have the access your message or template workflow requires.
5. Branch by onboarding path
Standard Cloud API onboarding and WhatsApp Business App Coexistence are not the same phone-number path.
Standard Cloud API
Depending on the current onboarding contract, the backend may need to register the phone number and verify that its status permits messaging.
Coexistence
Coexistence uses a number already connected to the WhatsApp Business app.
Do not automatically repeat the standard registration operation. Instead, validate:
- The number returned by the Coexistence flow
- Its current status
- Its WABA association
- The intended synchronization behavior
- The supported message-history boundary
- Which surface owns each business operation
Keep the path in your tenant configuration:
type WhatsAppConnection = {
tenantId: string;
wabaId: string;
phoneNumberId: string;
mode: "cloud_api" | "coexistence";
status: "verification_required" | "active" | "failed";
};
This prevents later jobs from applying Cloud API assumptions to a Coexistence number.
6. Subscribe the app to the WABA
A valid WABA and phone number do not prove that webhook delivery is configured.
Meta's official collection uses:
POST /{waba-id}/subscribed_apps
Verify the result afterward:
GET /{waba-id}/subscribed_apps
The expected Meta app must appear in the returned subscription list.
Treat subscription as a separate gate:
async function verifyAppSubscription(
subscriptions: Array<{
whatsapp_business_api_data?: { id?: string };
}>,
expectedAppId: string,
) {
const subscribed = subscriptions.some(
(item) =>
item.whatsapp_business_api_data?.id === expectedAppId,
);
if (!subscribed) {
throw new Error("waba_app_subscription_missing");
}
}
Registration can succeed while subscription fails. Subscription can also exist while the selected phone number is not operational.
Do not combine these conditions into one status.
7. Prove one real webhook delivery
Configuration reads are necessary but not sufficient.
Before marking the tenant active, require one controlled webhook to reach a production-equivalent receiver.
The acceptance path should be:
Controlled WhatsApp message
↓
Meta webhook delivery
↓
Signature verification
↓
WABA and phone-number lookup
↓
Tenant resolution
↓
Idempotency check
↓
Durable event storage
↓
Successful acknowledgement
For a Meta webhook, verify the signature against the raw request body before parsing or routing it.
An illustrative Node.js helper:
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyMetaSignature(
rawBody: Buffer,
signatureHeader: string,
appSecret: string,
): boolean {
const prefix = "sha256=";
if (!signatureHeader.startsWith(prefix)) {
return false;
}
const expected = createHmac("sha256", appSecret)
.update(rawBody)
.digest();
const actual = Buffer.from(
signatureHeader.slice(prefix.length),
"hex",
);
return (
actual.length === expected.length &&
timingSafeEqual(actual, expected)
);
}
Never log the app secret, token, or complete sensitive webhook payload while debugging verification.
8. Route by provider assets, not browser state
The webhook receiver must resolve the tenant from trusted provider identifiers.
Relevant identifiers can include:
- WABA ID
- Phone-number ID
- Meta app ID
- Internal connection ID
Do not route a production webhook using a tenant ID previously stored in browser state without verifying its asset mapping.
A routing table can look like:
CREATE TABLE whatsapp_connections (
id VARCHAR(64) PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL,
meta_app_id VARCHAR(64) NOT NULL,
waba_id VARCHAR(64) NOT NULL,
phone_number_id VARCHAR(64) NOT NULL,
onboarding_mode VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL,
UNIQUE (meta_app_id, waba_id, phone_number_id)
);
If the mapping is missing or ambiguous:
- Do not guess
- Do not assign the event to the most recently onboarded tenant
- Store it in a restricted reconciliation queue
- Alert the integration owner
- Keep the tenant out of
active
9. Make webhook processing idempotent
Webhook delivery can be retried.
Choose an idempotency key from stable provider fields appropriate to the event type. For inbound messages, the provider message ID is usually part of that identity.
Store the event before running slow business logic:
Verify signature
↓
Resolve tenant
↓
Insert event if absent
↓
Return success
↓
Process asynchronously
A duplicate should not create:
- Two conversations
- Two automated replies
- Two billing records
- Two workflow executions
- Two customer notifications
Operational readiness means that both the first delivery and a repeated delivery are safe.
10. Treat credit-line attachment as conditional
The official Embedded Signup collection includes credit-line sharing for provider-paid arrangements.
That does not make credit-line attachment a universal readiness gate.
Apply it only when:
- Your business owns the billing relationship with Meta
- Your partner model requires credit sharing
- The customer WABA is expected to use that credit line
For customer-paid or other supported billing arrangements, document the appropriate billing check separately.
Do not block every tenant on a credit-line operation that does not belong to its commercial model.
11. Separate platform approval from operational readiness
This checklist does not prove:
- Meta business eligibility
- App Review approval
- Advanced Access approval
- Display-name approval
- Number quality
- Template approval
- Messaging-limit tier
- Policy compliance
Those are separate platform states.
Maintain them independently:
type WhatsAppReadiness = {
embeddedSignup: "pending" | "completed";
appReview: "unknown" | "approved" | "rejected";
wabaAccess: "unknown" | "verified" | "failed";
phoneStatus: "unknown" | "ready" | "failed";
webhookSubscription: "unknown" | "verified" | "failed";
deliveryTest: "pending" | "passed" | "failed";
};
A successful test tenant also does not prove that every customer configuration will work. Keep the verification process repeatable for every onboarding session.
Recommended failure states
Return actionable states instead of a generic connection error.
| State | Meaning | Next action |
|---|---|---|
session_mismatch |
Browser result cannot be correlated | Restart onboarding |
token_invalid |
Credential validation failed | Repeat credential exchange |
waba_not_resolved |
Expected WABA was not found | Review business selection |
system_user_missing |
Required access is absent | Assign or repair system user |
phone_not_ready |
Phone path is incomplete | Follow path-specific recovery |
subscription_missing |
App is not subscribed | Subscribe and verify again |
webhook_not_received |
No delivery proof exists | Inspect webhook and routing |
routing_ambiguous |
Asset mapping matches multiple tenants | Stop and reconcile mappings |
These states make support, retry behavior, and audit logs much easier to reason about.
Final post-onboarding checklist
- [ ] Server-side onboarding session exists
- [ ] State is valid, single-use, and tenant-bound
- [ ] Browser result has been consumed exactly once
- [ ] Credentials remain server-side
- [ ] Token app, permissions, and expiry were validated
- [ ] Exact WABA was resolved
- [ ] WABA was not selected by array position
- [ ] System-user access was verified
- [ ] Standard and Coexistence paths are separated
- [ ] Phone-number ID is stored
- [ ] Phone status is ready for the selected path
- [ ] Expected app appears in
subscribed_apps - [ ] Billing checks match the commercial model
- [ ] One real webhook reached the receiver
- [ ] Webhook signature was verified
- [ ] WABA and phone number resolved to one tenant
- [ ] Duplicate delivery is safe
- [ ] Failure states are recoverable
- [ ] Only then is the tenant marked
active
The browser dialog finishing is a user-interface milestone.
Operational readiness requires a verified chain from the server-side onboarding session to the correct WABA, system user, phone number, app subscription, tenant mapping, and real webhook delivery.
Official references
Originally published on UnifyPort.
This article was prepared with AI assistance for language and structure, then technically reviewed and verified by the author.
Top comments (0)