Running Two WhatsApp Providers on One Webhook: Meta Cloud API and Twilio
Senquel started with Twilio for WhatsApp and later added Meta Cloud API as the primary provider. Rather than maintain two separate webhook URLs, we kept one and used HTTP semantics to route between providers. Here's exactly how that works, and the gotchas we hit.
One webhook URL, two providers
1. Content-Type as a provider discriminator
Twilio sends application/x-www-form-urlencoded. Meta sends application/json. A single POST handler reads the Content-Type header and delegates:
export async function POST(req: NextRequest): Promise {
const ct = req.headers.get('content-type') ?? '';
if (ct.includes('application/json')) return handleMetaWebhook(req);
return handleTwilioWebhook(req);
}
HTTP requests carry metadata in headers — key-value pairs sent alongside the body. Content-Type tells the receiver how the body is encoded.
application/x-www-form-urlencoded is the oldest web format: key=value&key2=value2 pairs, like a classic HTML form submission. Twilio uses this.
application/json is a JSON object ({"key": "value"}). Meta uses this.
Reading the Content-Type header is free — it does not require parsing the body — making it the ideal free routing signal before doing any real work.
One URL registered in Twilio's dashboard and in Meta's webhook settings. When we update shared logic (the command parser, the project summary formatter), it applies to both providers without touching routes. The alternative — separate URLs — would mean two dashboards to update, two sets of env vars to verify, and two endpoints diverging over time.
2. HTTP GET for Meta's challenge verification
Before Meta delivers any POST events, it sends a GET request to your webhook URL with three query params: hub.mode, hub.verify_token, and hub.challenge. Your endpoint must echo back the challenge value with HTTP 200. If it doesn't, Meta never sends POSTs — and the dashboard just shows "callback URL couldn't be validated."
Twilio doesn't use GET at all, so the same URL can safely serve Meta's verification on GET and both providers' events on POST:
export async function GET(req: NextRequest): Promise {
const { searchParams } = new URL(req.url);
const mode = searchParams.get('hub.mode');
const token = searchParams.get('hub.verify_token');
const challenge = searchParams.get('hub.challenge');
if (mode === 'subscribe' && token === process.env.META_WEBHOOK_VERIFY_TOKEN) {
return new NextResponse(challenge ?? '', { status: 200 });
}
return new NextResponse('Forbidden', { status: 403 });
}
Two things that cost us time here: the webhook must be deployed before you try to verify it in the Meta dashboard (obvious in hindsight), and the META_WEBHOOK_VERIFY_TOKEN must be set in Vercel before deployment — not after. Meta starts a retry loop on the first failed verification and doesn't deliver POST events while it's retrying. The symptom looks like a broken subscription, not a missing env var.
facebookexternalua. If you see them, the verification token is wrong or missing — fix it and redeploy. The POST queue will resume once verification passes.
3. Sync TwiML vs. async REST — different response shapes per provider
Twilio and Meta have opposite webhook response models. Twilio expects a synchronous TwiML XML body in the HTTP response — the response itself contains the message to send back. Meta expects an immediate HTTP 200, then a separate REST call to the Graph API to send the reply.
Twilio vs Meta: response model comparison
This changes how overflow chunks work for long messages. Twilio gets the first chunk via TwiML (free, no extra API call) and overflow chunks via REST. Meta gets all chunks via REST:
// Twilio: first chunk in TwiML body, overflow via REST
async function buildTwimlReply(to: string, text: string): Promise {
const [first, ...rest] = chunkMessage(text);
for (const chunk of rest) {
await twilioClient.messages.create({ from, to: 'whatsapp:' + to, body: chunk });
}
return new NextResponse(
'' + escapeXml(first) + ' ',
{ headers: { 'Content-Type': 'text/xml' } },
);
}
// Meta: all chunks via REST, return 200 immediately
export async function sendText(to: string, text: string): Promise {
for (const chunk of chunkMessage(text)) {
await fetch('https://graph.facebook.com/v19.0/' + phoneId + '/messages', {
method: 'POST', headers: { Authorization: 'Bearer ' + token },
body: JSON.stringify({ messaging_product: 'whatsapp', to, type: 'text',
text: { body: chunk, preview_url: false } }),
});
}
}
The chunking logic itself — splitting at line boundaries, hard-splitting lines longer than the limit — is identical. Only the delivery mechanism differs. We keep it in a shared chunkMessage() helper with a WA_MAX constant that differs per provider (1500 for Twilio, 4096 for Meta).
An OAuth access token is a string that proves you have permission to act on behalf of a resource. When Meta's API receives a request, it reads the Authorization: Bearer <token> header to decide whether to allow it.
Tokens can be scoped (limited to specific permissions), and can expire. A session token might last 60 minutes — it is meant for a logged-in user browsing the developer console. A long-lived or non-expiring token is what server-side applications need.
The dangerous thing: both types look identical — a long alphanumeric string. The only difference is an expiry timestamp that is easy to overlook.
4. App Dashboard tokens expire; System User tokens don't
Meta's developer console shows an "access token" on the App Dashboard. It looks like a production credential — long alphanumeric string, copy button, ready to paste into an env var. It expires in roughly 60 minutes. After that, every outbound message fails with "Access token is missing" or "Invalid OAuth access token" and the webhook goes silent.
The permanent credential is a System User token, generated in Meta Business Manager (a separate console from the developer dashboard):
- Business Manager → System Users → Create system user with Admin role
- Assign your WhatsApp app to the system user
- Generate New Token → select the app → grant
whatsapp_business_messaging - Copy — this token does not expire
The two tokens look identical in format. The only way to tell them apart is to check whether the dashboard shows an expiry time next to the token. If it does, it's the wrong one. Any credential going into a production env var should have no expiry timestamp — look for the permanent alternative before using a session token.