@msgly/whatsapp
v1.11.0
Published
WhatsApp Cloud API adapter for Msgly
Maintainers
Readme
@msgly/whatsapp
WhatsApp Cloud API adapter for Msgly. Send and receive WhatsApp messages through the unified hub — text, all media types, interactive buttons, quick replies, reactions, and pre-approved templates. Zero classes, runs in Node, Next.js, and Edge runtimes.
📖 Docs & channel reference: https://ayushjain070401.github.io/msgly/
Install
npm install @msgly/core @msgly/whatsappQuick start
import express from 'express';
import { createHub } from '@msgly/core';
import { createWhatsAppAdapter } from '@msgly/whatsapp';
const hub = createHub();
hub.register(
createWhatsAppAdapter({
phoneNumberId: process.env.WHATSAPP_PHONE_NUMBER_ID!,
accessToken: process.env.WHATSAPP_ACCESS_TOKEN!,
appSecret: process.env.META_APP_SECRET!,
verifyToken: process.env.META_VERIFY_TOKEN!,
}),
);
await hub.connect({ throwOnFailure: true });
hub.on('message', async (msg) => {
if (msg.content.type === 'text') {
await hub.send({
channel: 'whatsapp',
account: msg.account,
contact: msg.contact,
content: { type: 'text', text: `You said: ${msg.content.text}` },
});
}
});
const app = express();
app.use(express.json({ verify: (req, _r, buf) => ((req as any).rawBody = new Uint8Array(buf)) }));
const handlers = hub.createWebhookHandler();
app.get('/webhook/:channel', handlers.get);
app.post('/webhook/:channel', handlers.post);
app.listen(3000);Config
interface WhatsAppConfig {
phoneNumberId: string; // long numeric id from API Setup
accessToken: string; // temporary (24h) or System User token
appSecret: string; // from App Settings → Basic
verifyToken: string; // your chosen string for webhook handshake
/** WABA ID — required for template management, phone number list, and webhook subscription. */
wabaId?: string;
/** App ID — required for profile picture upload and token introspection. */
appId?: string;
/** Number also runs the WhatsApp Business app — caps throughput at 20 mps. */
coexistence?: boolean;
apiBase?: string; // defaults to https://graph.facebook.com
apiVersion?: string; // defaults to v23.0
}Upgrading from 1.5.0 or earlier? The default Graph version moved from
v20.0tov23.0, because Meta retires v20.0 on 24 September 2026. Upgrading the package is the whole fix — unless you setapiVersion: 'v20.0'yourself, in which case an explicit value still wins and you must raise it. See the migration guide.
Setup (20 minutes)
- Create a Meta App. Go to developers.facebook.com → My Apps → Create App → Business type.
- Add the WhatsApp product to your app → Set up.
- Copy test credentials from the API Setup tab:
- Phone number ID (long numeric, NOT the human phone) →
WHATSAPP_PHONE_NUMBER_ID - Temporary access token (24h) →
WHATSAPP_ACCESS_TOKEN
- Phone number ID (long numeric, NOT the human phone) →
- Get the App Secret. Settings → Basic → Show next to App Secret →
META_APP_SECRET. - Pick a verify token. Any random string →
META_VERIFY_TOKEN. - Add a test recipient. API Setup → "To" dropdown → Manage phone number list → add your personal WhatsApp number (max 5 in test mode).
- Subscribe the webhook. WhatsApp → Configuration tab:
- Callback URL:
<PUBLIC_URL>/webhook/whatsapp - Verify token: same as
META_VERIFY_TOKEN - Click Verify and Save (your server must be running)
- Webhook fields → Subscribe to
messages
- Callback URL:
- Test. Message the test number from your personal WhatsApp.
Production tokens. The 24-hour token works for testing only. For production, create a System User token: Business Settings → Users → System Users → create one → Generate Token with scopes
whatsapp_business_messagingandwhatsapp_business_management. System User tokens don't expire.
Capabilities
| Feature | Supported |
| ------------- | --------- |
| text | ✓ |
| image | ✓ |
| video | ✓ |
| audio | ✓ |
| file | ✓ |
| location | ✓ |
| buttons | ✓ (max 3, 20-char labels) |
| quick replies | ✓ |
| list picker | ✓ (type: 'list', max 10 rows) |
| CTA URL button| ✓ (type: 'cta_url') |
| templates | ✓ |
| reactions | ✓ (sendReaction) |
| typing | ✓ (sendTypingIndicator) |
| threaded reply| ✓ (replyTo) |
| coexistence | ✓ (Business app + Cloud API on one number) |
The adapter silently truncates button counts and label lengths to fit Meta's limits.
The 24-hour window
WhatsApp's policy: free-form replies (text, media, interactive) only work within 24 hours of an inbound user message. Outside that window you must send a pre-approved template.
Templates are created and approved in Meta dashboard → WhatsApp → Message Templates. Approval usually takes minutes for transactional templates.
await hub.send({
channel: 'whatsapp',
account: { channel: 'whatsapp', channelAccountId: process.env.WHATSAPP_PHONE_NUMBER_ID! },
contact: { channel: 'whatsapp', channelUserId: '919999999999' },
content: {
type: 'template',
templateName: 'order_confirmation',
language: 'en',
variables: { '1': 'Udesh', '2': 'ORDER-12345' },
},
});Variable keys are positional — '1' maps to {{1}} in the template body.
For templates with an image/video header, URL buttons with a dynamic suffix, or quick-reply button payloads, pass the raw Meta components array instead of variables:
await hub.send({
channel: 'whatsapp',
account, contact,
content: {
type: 'template',
templateName: 'promo_with_image',
language: 'en_US',
// components wins over variables when both are present
components: [
{
type: 'header',
parameters: [{ type: 'image', image: { link: 'https://cdn.example.com/promo.jpg' } }],
},
{
type: 'body',
parameters: [{ type: 'text', text: 'Ayush' }, { type: 'text', text: '30%' }],
},
{
type: 'button',
sub_type: 'url',
index: 0,
parameters: [{ type: 'text', text: 'PROMO30' }],
},
],
},
});Sending examples
Image
await hub.send({
channel: 'whatsapp',
account, contact,
content: {
type: 'image',
mediaRef: { kind: 'url', value: 'https://example.com/cat.png' },
caption: 'meow',
},
});WhatsApp requires the URL to be publicly accessible HTTPS, or you can upload first:
const adapter = hub.getAdapter('whatsapp');
const ref = await adapter.uploadMedia({
data: new Uint8Array(/* image bytes */),
mimeType: 'image/png',
});
await hub.send({
channel: 'whatsapp',
account, contact,
content: { type: 'image', mediaRef: ref, caption: 'meow' },
});MediaFile.data accepts Uint8Array | Blob | ReadableStream<Uint8Array> — pass whichever your environment naturally produces.
Documents
Set mediaRef.filename to control the name the recipient sees. Without it
WhatsApp names the file after the URL it fetched, which is rarely readable:
content: {
type: 'file',
mediaRef: {
kind: 'url',
value: 'https://example.com/d/9f2c',
filename: 'invoice-0042.pdf', // what the recipient sees
},
}uploadMedia carries filename onto the ref it returns, so an uploaded
document keeps its name too. Inbound documents arrive with the sender's
filename on mediaRef.filename.
Interactive buttons
await hub.send({
channel: 'whatsapp',
account, contact,
content: {
type: 'interactive',
text: 'Confirm your order?',
buttons: [
{ id: 'confirm', label: 'Confirm' },
{ id: 'cancel', label: 'Cancel' },
],
},
});User taps a button → you receive an inbound message where content.text is the button's visible label and interaction.data is the button's stable id — use interaction.data for CSAT / postback matching since labels can be localised.
List picker
When there are more choices than three buttons can hold. Up to 10 rows total across all sections; sections are filled in order and any beyond the tenth row are dropped. Over-long labels — button, section title, row title, row description, header, footer — are truncated to Meta's limits rather than rejected by the API.
Body text and row/button ids are never touched: truncating a body would lose
your message, and truncating an id would silently break postback matching.
await hub.send({
channel: 'whatsapp',
account, contact,
content: {
type: 'list',
text: 'Which slot suits you?',
buttonLabel: 'View times', // max 20 chars
header: 'Booking', // optional
footer: 'Tap to choose', // optional
sections: [
{
title: 'Morning',
rows: [
{ id: '0900', title: '09:00', description: 'with Dr. Rao' },
{ id: '1030', title: '10:30' },
],
},
],
},
});The reply arrives as an inbound message whose interaction.data is the row id.
CTA URL button
A tappable button that opens a link, so the raw URL never appears in the body.
content: {
type: 'cta_url',
text: 'Your receipt is ready.',
buttonLabel: 'View receipt',
url: 'https://example.com/r/1',
}Replying in-thread
Quote the message you are answering by passing its platform id:
await hub.send({
channel: 'whatsapp',
account,
contact,
content: { type: 'text', text: 'Yes — shipping Tuesday.' },
replyTo: inbound.externalId, // NOT inbound.id
});Reacting to a message
await adapter.sendReaction(contact, inbound.externalId, '👍');
await adapter.sendReaction(contact, inbound.externalId, ''); // removeBusiness profile
import type { WhatsAppAdapter } from '@msgly/whatsapp';
const adapter = hub.getAdapter('whatsapp') as WhatsAppAdapter;
// Read current profile
const profile = await adapter.getBusinessProfile();
// { about, address, description, email, profilePictureUrl, websites, vertical }
// Update fields (pass only what you want to change)
await adapter.updateBusinessProfile({
about: 'Fast shipping • Easy returns',
email: '[email protected]',
websites: ['https://example.com'],
vertical: 'RETAIL',
});
// Upload a new profile picture (requires config.appId)
import { readFileSync } from 'fs';
await adapter.uploadProfilePicture({
data: readFileSync('./logo.jpg'),
mimeType: 'image/jpeg',
filename: 'logo.jpg',
});Display name
// Read the current verified name and its review status
const { displayName, nameStatus } = await adapter.getDisplayName();
// nameStatus → "APPROVED" | "AVAILABLE_WITHOUT_REVIEW" | "PENDING_REVIEW" | "DECLINED" | "NONE"
// Request a display name change (goes through WhatsApp review)
const result = await adapter.requestDisplayName('Acme Support');
// result.decision → "APPROVED" | "PENDING" | "DECLINED"Two-step verification PIN
// Set or rotate the 6-digit PIN for the registered phone number
await adapter.setTwoStepPin('123456');
// Disable two-step verification entirely
await adapter.removeTwoStepPin();Message templates
Requires config.wabaId.
// List all templates (paginated)
const { templates, nextCursor } = await adapter.listTemplates({ limit: 20 });
// templates[0] → { id, name, status, category, language, components }
// Paginate
const page2 = await adapter.listTemplates({ limit: 20, after: nextCursor });
// Create a new template
const { id, status } = await adapter.createTemplate({
name: 'order_shipped',
category: 'UTILITY',
language: 'en_US',
components: [
{
type: 'BODY',
text: 'Your order {{1}} has shipped! Track it at {{2}}.',
},
],
});
// Edit an existing template's components
await adapter.editTemplate(id, {
components: [{ type: 'BODY', text: 'Updated text {{1}}.' }],
});
// Delete (all language variants)
await adapter.deleteTemplate('order_shipped');
// Delete a specific language variant
await adapter.deleteTemplate('order_shipped', templateId);Phone number management
Requires config.wabaId for listing.
// List all phone numbers in the WABA
const numbers = await adapter.listPhoneNumbers();
// [{ id, displayPhoneNumber, verifiedName, qualityRating, nameStatus }]
// Get info for the configured phone number (or pass a specific id)
const info = await adapter.getPhoneNumberInfo();Phone number registration flow
Use this to add a new number to your WABA. Requires config.wabaId.
// 1. Add the number to the WABA (returns a phone_number_id)
const { id } = await adapter.createPhoneNumber({
cc: '44', // country calling code
phoneNumber: '7911123456',
verifiedName: 'Acme Support',
});
// 2. Request OTP (use the new id or set phoneNumberId in config)
await adapter.requestVerificationCode({ codeMethod: 'SMS', language: 'en_US' });
// 3. Verify OTP received by SMS
await adapter.verifyCode('123456');
// 4. Activate with a two-step PIN
await adapter.registerPhoneNumber('123456');WABA operations
Requires config.wabaId.
// Get WABA metadata
const waba = await adapter.getWabaInfo();
// { id, name, currency, messageTemplateNamespace, timezoneId }
// Check which apps are subscribed to webhook events
const apps = await adapter.getSubscribedApps();
// Subscribe this app to WABA-level events (run once during deployment)
await adapter.subscribeToWebhook();
// Per-WABA routing: override the callback URL for this specific WABA.
// Use this when one Meta App serves multiple tenants with different webhook URLs.
await adapter.subscribeToWebhook({
overrideCallbackUri: 'https://tenant-a.example.com/webhook/whatsapp',
verifyToken: process.env.META_VERIFY_TOKEN!,
});
// Unsubscribe when a WhatsApp channel is disconnected (stops webhook delivery)
await adapter.unsubscribeFromWebhook();App-level webhook fields
Run this once during initial app setup to choose which event types the Meta App receives. Requires config.appId and config.appSecret.
await adapter.setAppWebhookFields([
'messages',
'message_template_status_update',
'account_alerts',
'phone_number_name_update',
'phone_number_quality_update',
]);This is equivalent to ticking fields in the Meta Dashboard → App → Webhooks but can be done programmatically during deployment.
Facebook Embedded Signup
When using Meta's Embedded Signup widget to let users connect their own WhatsApp numbers, the frontend returns a short-lived auth code that your backend must exchange for a permanent token. Requires config.appId and config.appSecret.
// Exchange the code returned by the Embedded Signup JS SDK
const { accessToken, tokenType, expiresIn } = await adapter.exchangeCodeForToken({
code: req.query.code as string,
redirectUri: 'https://app.example.com/connect/whatsapp/callback', // must match your app settings
});
// The resulting token is a user token — use debugToken to discover its WABA ID
const tokenInfo = await adapter.debugToken(accessToken);
const wabaId = tokenInfo.granularScopes
?.find(s => s.scope === 'whatsapp_business_management')
?.targetIds?.[0];Coexistence (Business app + Cloud API on one number)
Coexistence lets a business keep using the WhatsApp Business app on their phone while the same number is also connected to the Cloud API. Staff answer from the handset, your server automates the rest, and neither side loses the chat history.
Eligibility — read before you build
Coexistence is gated on both sides, and "the customer has a WhatsApp Business account" is not sufficient:
| Requirement | Applies to | | --- | --- | | Meta Solution Partner or Tech Provider status | you | | Embedded Signup v4, with session logging enabled | you | | The WhatsApp Business app (not just a WABA), v2.24.17+ | the customer | | Account tenure and messaging-quality checks | the customer | | Region availability | the customer |
Note the terminology trap: a WhatsApp Business Account (WABA) is the API-side container and every Cloud API user has one. Coexistence specifically requires the WhatsApp Business app — the SMB phone app — to already be running that number.
Coexistence numbers also cannot get a blue-badge Official Business Account, and Standard Business Verification is unavailable (Partner-Led Business Verification or Meta Verified are the alternatives). Group chats, disappearing and view-once messages, live location and broadcast lists are disabled on the handset once onboarded, and throughput is fixed at 20 messages/second.
Subscribe to the extra webhooks
Coexistence adds three webhook fields. Without them you get no visibility into what staff type on the phone:
import { COEXISTENCE_WEBHOOK_FIELDS } from '@msgly/whatsapp';
await wa.setAppWebhookFields([...COEXISTENCE_WEBHOOK_FIELDS]);
// ['messages', 'history', 'smb_app_state_sync', 'smb_message_echoes']Route each payload to the right parser
handleWebhook returns only genuine inbound customer messages. History and
echoes are deliberately excluded: replaying a back-fill would flood your bot
with months of old traffic, and treating an echo as inbound would make the bot
reply to its own operator. Each has its own parser, and every parser returns
[] for payloads that are not its own, so calling all four is safe:
app.post('/webhook', async (req, res) => {
if (!(await wa.verifySignature(toWebhookRequest(req)))) return res.sendStatus(403);
res.sendStatus(200); // ack fast, then process
const body = req.body;
// Real customers writing in — the only thing a bot should answer.
for (const msg of await wa.handleWebhook(toWebhookRequest(req))) {
await bot.handle(msg);
}
// Staff replying from the handset. Log it, stop any pending auto-reply,
// but never treat it as customer input.
for (const echo of wa.parseMessageEchoes(body)) {
await store.recordOutbound(echo.contact.channelUserId, echo.content);
await bot.cancelPendingReply(echo.contact.channelUserId);
}
// Back-fill, delivered in chunks over the minutes after onboarding.
for (const chunk of wa.parseHistory(body)) {
for (const thread of chunk.threads) {
await store.importThread(thread.contactId, thread.messages);
}
if (chunk.progress === 100) await store.markBackfillComplete();
}
// The business's own phone address book.
for (const contact of wa.parseContactSync(body)) {
await store.upsertContact(contact.phoneNumber, contact.fullName);
}
});parseHistory marks each message inbound or outbound by comparing the
sender against the business number, comparing digits only — WhatsApp writes the
business number as +1 555-0100 in history metadata but 15550100 on the
message itself.
Trigger the one-time sync
After onboarding succeeds, ask WhatsApp to push contacts and history. Results arrive asynchronously on the webhooks above; this call only requests them.
await wa.requestSmbAppData(); // both
await wa.requestSmbAppData({ history: false }); // contacts onlyThis runs once per onboarding, and the whole sync must complete within 24 hours of onboarding or the customer has to start over. History covers roughly 180 days, excludes group conversations, and media is generally only retrievable for the last ~14 days.
Check whether a number is coexisting
const status = await wa.getCoexistenceStatus();
// { isOnBusinessApp: true, platformType: 'CLOUD_API' }Pace to the real throughput
Set coexistence: true in config so sendBulk paces to the 20 mps ceiling
Meta applies to dual-platform numbers rather than the higher API-only default:
const wa = createWhatsAppAdapter({ ...credentials, coexistence: true });
wa.rateLimit; // { perSecond: 20 }Token introspection
Requires config.appId and config.appSecret.
// Inspect the current access token
const info = await adapter.debugToken();
// { isValid, type, appId, expiresAt, scopes, userId, granularScopes }
// Inspect a different token
const info2 = await adapter.debugToken(someOtherToken);
if (!info.isValid) {
console.error('Token is expired or invalid — rotate it');
}
// granularScopes: scope → WABA/page IDs the token covers
// Useful to auto-discover the WABA ID after Embedded Signup:
const wabaId = info.granularScopes
?.find(s => s.scope === 'whatsapp_business_management')
?.targetIds?.[0];Typing indicator
WhatsApp Cloud API added native typing bubbles in 2024. The adapter exposes two methods:
// Show a typing bubble to the contact. Requires the externalId of their last inbound message.
await adapter.sendTypingIndicator(msg.contact, msg.externalId!);
// ... do AI work ...
await hub.send({ channel: 'whatsapp', account, contact, content: { type: 'text', text: reply } });Under the hood this calls POST /{phoneNumberId}/messages with status: "read" + typing_indicator: { type: "text" }. The bubble disappears after ~25 seconds or when you send a message.
For generic cross-channel code that calls sendTyping?.(contact) without a message ID, adapter.sendTyping is a safe no-op — it won't throw.
Mark as read (without typing)
To show the blue double-tick without a typing bubble:
await adapter.markAsRead(msg.externalId!);Inbound message types
The adapter maps WhatsApp message types to the unified content model:
| WhatsApp type | Unified content | Notes |
|---------------|-----------------|-------|
| text | TextContent | |
| image | MediaContent (image) | |
| video | MediaContent (video) | |
| audio | MediaContent (audio) | |
| document | MediaContent (file) | |
| sticker | MediaContent (image, image/webp) | |
| location | LocationContent | |
| contacts | TextContent | Formatted contact names |
| reaction | TextContent (emoji) | msg.metadata.reactedToMessageId + msg.metadata.reactionEmoji |
| order | TextContent | Catalog + order text summary |
| button | TextContent (label) | msg.interaction.data = button payload |
| interactive (button_reply) | TextContent (label) | msg.interaction.data = button ID |
| interactive (list_reply) | TextContent (label) | msg.interaction.data = option ID |
| interactive (nfm_reply) | TextContent | Flow response_json content |
| system / unsupported | — | Dropped (not user-initiated) |
For reactions, check msg.metadata:
hub.on('message', (msg) => {
if (msg.metadata?.reactedToMessageId) {
const emoji = msg.metadata.reactionEmoji as string;
const reactedId = msg.metadata.reactedToMessageId as string;
// handle reaction
}
});Delivery receipts
WhatsApp delivers status updates (delivered/read/failed) as separate webhook events. The hub's standard webhook handler ignores these for hub.on('message') purposes — if you need granular delivery tracking, use adapter.parseStatuses(rawBody):
const adapter = hub.getAdapter('whatsapp') as WhatsAppAdapter;
const receipts = adapter.parseStatuses(req.body);
// [{
// status: 'delivered',
// messageId: 'wamid.xxx',
// recipientId: '919999999999', // which contact the status is for
// timestamp: '...',
// error?: { code: 'wa_131026', message: '...', permanent: true, retryable: false }
// }]Error codes
error.code is Meta's numeric code with a wa_ prefix ("wa_131026"). A send
response and a later status webhook report the same failure with the same code,
so one check covers both paths.
Two flags come with it, and they answer different questions:
| Flag | Question | Set when |
|---|---|---|
| permanent | is this recipient dead? | only for 131021/131026 — the number cannot receive WhatsApp messages. A suppression store acts on this. |
| retryable | could the same request ever succeed? | false for bad tokens, bad parameters, unknown or paused templates, a closed 24-hour window; true for throttles and Meta-side outages. |
They are deliberately separate. 132001 (template does not exist) is never
worth retrying, but it says nothing about the recipient — marking it permanent
would suppress every contact in a campaign over one typo in a template name.
Codes the adapter does not recognise leave both flags undefined, which the
core reads as "retry, never suppress" — the cautious default.
Meta's message often reads (#100) Invalid parameter; when error_data.details
carries the real reason, it is appended after a colon.
Signature verification debugging
verifySignature(req) returns a boolean. When you need to know why a webhook is failing:
const result = await adapter.verifySignatureVerbose(req);
// { ok: false, reason: 'mismatch' | 'no_signature' | 'bad_format' | 'no_secret' }
if (!result.ok) {
if (result.reason === 'no_secret') console.error('appSecret not configured');
if (result.reason === 'no_signature') console.error('X-Hub-Signature-256 header missing');
if (result.reason === 'bad_format') console.error('header does not start with sha256=');
if (result.reason === 'mismatch') console.error('HMAC does not match — wrong appSecret or raw body lost');
}Common pitfalls
(#131047)re-engagement message: you're outside the 24-hour window. Use a template.(#131030)recipient phone not in allowed list: in test mode, recipients must be added under Manage phone number list. Business verification removes the limit.InvalidSignature: wrongappSecret, OR your Express setup isn't capturing the raw body. Theverifycallback inexpress.json()is essential.- Verify handshake fails:
META_VERIFY_TOKENmust match byte-for-byte between code and the form in the Meta dashboard. Server must be reachable when you click Verify. - Template send fails with
(#132001): template name or language code doesn't match an approved template. Templates are case-sensitive. - Token expired after 24h: replace the temporary access token with a System User token.
Documentation
Full setup walkthrough and multi-channel usage: https://github.com/AyushJain070401/msgly
License
MIT
