Most WhatsApp integration guides stop at “send your first message.” That’s the easy part. The real challenge begins when you try to run it in production, wherein tokens expire, webhooks retry, assets change, and external systems evolve without warning. What looks like a simple API integration is actually a distributed system built on OAuth, event delivery, and long-lived credentials.
This guide breaks down what it actually takes to build a production-grade WhatsApp integration with Meta. Not just how the flow works, but how to make it reliable, secure, and resilient under real-world conditions.
System Architecture (What You Are Actually Building)
This integration is not “just an API call”.
It is a distributed, event-driven system:
Browser
→ Meta OAuth
→ Backend Auth Server
→ Meta Graph API
→ Meta Webhooks
→ Your Webhook Server
→ Business Logic / Bot Engine
→ Meta Messages API
→ End User
Key properties:
- OAuth 2.0 with PKCE
- At-least-once event delivery
- Long-lived credentials
- Asynchronous message handling
This implies:
- Token lifecycle management
- Idempotency
- Webhook signature verification
- Duplicate message handling
- Async processing
Meta Developer Setup
Create App
- Go to developers.facebook.com.
- Create a business-type app.
- Add the WhatsApp product to your app.
Credentials
You need the following credentials from App Settings > Basic: * App ID (Client ID): Identifies your app. * App Secret (Client Secret): Used to secure token exchanges.
onfiguration ID
Go to WhatsApp → Quickstart (or Configuration), where you’ll find the Embedded Signup Builder settings and create a Configuration that groups the required permissions. This gives you a Configuration ID, which simplifies login by letting you pass a single ID to the Facebook SDK instead of requesting permissions one-by-one.
The SDK then runs a preset flow that asks only for what’s needed, specifically, whatsapp_business_management (to read and update business details like address and profile info) and whatsapp_business_messaging (to send and receive messages on behalf of the business).
OAuth Flow (Embedded Signup)
Meta uses OAuth 2.0 Authorization Code Grant with PKCE.
This is not classic Facebook Login.
Flow:
- Frontend triggers login
- Meta returns short-lived code
- Backend exchanges code
- Backend stores long-lived token
Frontend Login
Initializing the SDK
First, we load the SDK and initialize it with our App ID (Client ID). This typically happens in a useEffect on the frontend.
// Frontend: Load and Initialize SDK
useEffect(() => {
window.fbAsyncInit = function() {
FB.init({
appId: process.env.NEXT_PUBLIC_FB_APP_ID, // Your Meta App ID
autoLogAppEvents: true,
xfbml: true,
version: 'v22.0'
});
};
// Load sdk.js asynchronously
(function(d, s, id){
var js, fjs = d.getElementsByTagName(s)[0];
if (d.getElementById(id)) {return;}
js = d.createElement(s); js.id = id;
js.src = "https://connect.facebook.net/en_US/sdk.js";
fjs.parentNode.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));
}, []);
Triggering Login (FB.login)
When the user clicks the button, we trigger the login pop-up. This is where we pass the Configuration ID.
function startWhatsAppOnboarding() {
FB.login(
response => {
if (response.authResponse?.code) {
exchangeAuthCode(response.authResponse.code);
}
},
{
config_id: process.env.NEXT_PUBLIC_META_CONFIG_ID, // The ID from Step 2.3
response_type: 'code', // Crucial: We want a code, not a client-side token
override_default_response_type: true
}
);
}
Receiving WABA ID and Phone Number ID (Embedded Signup Callback)
After the user completes the Embedded Signup flow, Meta sends the result back to the browser using the postMessage API.
This is how your frontend automatically receives: – waba_id → WhatsApp Business Account ID
– phone_number_id → The actual phone number connected to the API
Without these two values, you cannot: – Register the phone number – Send messages – Subscribe to webhooks – Update business profile
They are the primary identifiers for all future WhatsApp API calls.
How Meta Sends the Data
When the embedded signup finishes, Meta executes:
window.postMessage(...)
from within the Facebook iframe.
Your app listens for this message:
useEffect(() => {
const handleFacebookMessage = (event: MessageEvent) => {
if (
event.origin !== "https://www.facebook.com" &&
event.origin !== "https://web.facebook.com"
) {
return;
}
try {
const data = JSON.parse(event.data);
if (data.type === "WA_EMBEDDED_SIGNUP" && data.event === "FINISH") {
const { phone_number_id, waba_id } = data.data;
// save these into the db for future uses
saveToTheDB(
projectId,
phone_number_id,
waba_id
);
}
} catch {
console.log("Non-JSON message received");
}
};
window.addEventListener("message", handleFacebookMessage);
return () => window.removeEventListener("message", handleFacebookMessage);
}, []);
Backend Token Exchange
Controller:
@Post('exchange-token')
async exchangeToken(@Body('code') code: string, @Body('projectId') projectId: string) {
return await this.service.exchangeCodeForToken(code, projectId);
}
Service:
async exchangeCodeForToken(authCode: string, projectId: string) {
const url = 'https://graph.facebook.com/v21.0/oauth/access_token';
// Call Meta OAuth Endpoint
const response = await axios.post(url, {
client_id: process.env.FB_APP_ID, // App ID
client_secret: process.env.FB_APP_SECRET, // App Secret
grant_type: 'authorization_code',
code: authCode,
});
const accessToken = response.data.access_token;
// Storing the token in our Database
// (Pseudo-code: Replace with your actual DB call)
await this.db.saveToken({
projectId: projectId,
token: accessToken
});
return response.data;
}
Token Management (Critical)
Meta has three token types:
You must convert to a System User token for real use.
Store: – accessToken – expiresAt – tokenType – encrypted at rest
Register Phone Number
Meta requires you to explicitly “register” the phone number to enable messaging capabilities via the API. This is also where you would handle the PIN if the user has Two-Step verification enabled.
async function registerPhoneNumber(phoneNumberId: string, token: string, pin?: string) {
await axios.post(
`https://graph.facebook.com/v21.0/${phoneNumberId}/register`,
{
messaging_product: 'whatsapp',
pin
},
{ headers: { Authorization: `Bearer ${token}` } }
);
}
Register Webhook
Why? Even if logged in, your app won’t receive messages until you explicitly tell Meta, “I want to subscribe to updates for this WABA”.
async registerToWebhook(projectId: string, wabaId: string) {
// 1. Retrieve the saved token
const token = await this.getTokenFromDb(projectId);
// 2. Subscribe API
await axios.post(
`https://graph.facebook.com/v21.0/${wabaId}/subscribed_apps`,
{},
{
headers: { Authorization: `Bearer ${token}` }
}
);
}
Webhook Setup
Webhook Handling
Webhooks are how Exei receives real-time messages.
Setup in Developer Portal
Before your code works, you must tell Meta where your server is:
- Go to developers.facebook.com > Your App > Whatsapp(in the left sidebar) > Configuration.
- Find the Webhook section.
- Click Edit and enter your Back-end URL (e.g., https://api.exei.ai/webhook).
- Enter a Verify Token (a random string you create, e.g., my_secret_verify_token).
- Click Verify and Save. Meta will hit your API immediately to check it.
Note: If WhatsApp is not present in the left sidebar, then add it by clicking on Add Product.
Handling the Verification Request (GET)
This endpoint answers Meta’s “Are you really the owner?” check.
Verification Endpoint
function verifyWebhook(req, res) {
const mode = req.query['hub.mode'];
const token = req.query['hub.verify_token'];
const challenge = req.query['hub.challenge'];
if (mode === 'subscribe' && token === process.env.WEBHOOK_VERIFY_TOKEN) {
return res.status(200).send(challenge);
}
return res.sendStatus(403);
}
If this fails: reject the request.
Handling Incoming Messages
Webhooks are: - At least once - Out of order - Possibly duplicated
So you must: - Store message IDs - Ignore duplicates
async function handleIncomingMessage(payload) {
const message = payload.entry?.[0]?.changes?.[0]?.value?.messages?.[0];
if (!message) return;
if (await isDuplicate(message.id)) return;
await storeMessageId(message.id);
if (message.type === 'text') {
await sendTextMessage({
phoneNumberId: payload.entry[0].changes[0].value.metadata.phone_number_id,
to: message.from,
text: `Received: ${message.text.body}`
});
}
}
Sending Messages
async function sendTextMessage({ phoneNumberId, to, text }) {
const token = await getSystemToken(phoneNumberId);
await axios.post(
`https://graph.facebook.com/v21.0/${phoneNumberId}/messages`,
{
messaging_product: 'whatsapp',
to,
type: 'text',
text: { body: text }
},
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
);
}
Profile Management
Fetch Profile
Before updating, we usually fetch the current data to populate the form.
async function getBusinessProfile(phoneId, token) {
const result = await axios.get(
`https://graph.facebook.com/v21.0/${phoneId}/whatsapp_business_profile?fields=about,address,description,email,profile_picture_url,websites,vertical`,
{
headers: { Authorization: `Bearer ${authToken}` },
},
);
return result.data.data[0];
}
Update Profile
Updates to text fields are straightforward.
async function updateBusinessProfile(phoneId, token, payload) {
await axios.post(
`https://graph.facebook.com/v21.0/${phoneId}/whatsapp_business_profile`,
{
messaging_product: "WhatsApp",
...payload
},
{ headers: { Authorization: `Bearer ${token}` } }
);
}
Profile Picture Upload
This only works if: – App is business verified – Using system user token
Updating the profile picture is unique. It requires a Resumable Upload flow handled in two distinct steps.
Constraints: * Dimensions: Image must be >= 192×192 px and <= 640×640 px. * Formats: JPG, PNG.
Step 1: Start Upload Session (Frontend)
We send the file metadata to Meta to get an Upload Handle (h:…).
async function startMediaUpload(file, token) {
const { data } = await axios.post(
`https://graph.facebook.com/v21.0/app/uploads?file_length=${file.size}&file_type=${file.type}`,
null,
{ headers: { Authorization: `Bearer ${token}` } }
);
return data.id;
}
Step 2: Upload Bytes (Backend)
We feed the imageCode from Step 1 and the actual file binary into the second request.
// imageCode comes from Step 1 result
async uploadWhatsAppMediaStep2(fileBuffer: Buffer, imageCode: string, token: string) {
const url = `https://graph.facebook.com/v21.0/${imageCode}`;
const response = await axios.post(url, fileBuffer, {
headers: {
Authorization: `Bearer ${token}`,
'file_offset': '0', // Required for resumable upload
'Content-Type': 'application/octet-stream'
}
});
// RETURNS: A handle to the uploaded image
// You can now use this handle in the 'updateProfile' call
// as 'profile_picture_handle'
return response.data.h;
}
Then:
updateBusinessProfile(phoneId, token, {
profile_picture_handle: imageHandle
});
Conclusion
WhatsApp integration is not a feature. It is an infrastructure decision. What looks like “just connect WhatsApp” is, in reality:
- An OAuth identity flow
- A third-party asset provisioning system
- A long-lived credential store
- An event-driven messaging pipeline
- A security-sensitive webhook interface
You are not building a chat widget. But you are operating inside Meta’s platform as a delegated system.
The Embedded Signup flow hides most of the complexity, but it does not remove it. It simply moves critical responsibilities to your backend:
- Managing system user tokens
- Storing WABA and phone number IDs
- Verifying webhook signatures
- Handling retries, duplicates, and async events
- Securing business data you do not own
Once connected, your application becomes part of a distributed system controlled by external infrastructure, external policies, and external failure modes.
If you treat it like a normal REST API, it will fail silently. However, if you treat it like a production platform integration, it will scale cleanly. The real success metric isn’t “messages are sent.” It’s resilience:
- Do tokens expire without breaking things?
- Can webhooks be safely replayed?
- Can assets be rotated smoothly?
- Will the system survive changes from Meta tomorrow?
If the answer is yes to these, it’s truly working. And that is the difference between a demo integration and a real system.
At Exei, the Agentic AI platform for customer service automation and engagement. Our AI agents integrate seamlessly with WhatsApp, enabling real-time messaging and intelligent interactions. For more in-depth, practical guides on how real-world AI systems are built, stay connected.
