Convexa Bridge lets a Shopify store use Convexa with the WhatsApp number that already runs in its ERP, CRM or helpdesk. The number stays connected to your system; Convexa sends and receives through you. Your existing WhatsApp setup does not change.
For ERP, CRM and helpdesk providers and in-house developers. Building it takes about a day if you already use the WhatsApp Cloud API.
Every request from Convexa carries your API key as Authorization: Bearer <key>. Some calls may send it as ?access_token=<key> instead; accept both. Reply 401 for a wrong key. Your base address must be public HTTPS.
GET {base}/infoWhich number the bridge serves. Convexa calls it when connecting and to show connection status (10 second timeout). Several numbers (same WhatsApp Business Account): also list them all in phone_numbers; the store then chooses in Convexa which ones come into its inbox and which one sends automations.POST {base}/messagesRequired. The body is exactly what WhatsApp Cloud API expects for POST /{phone-number-id}/messages (text, template, image, document, interactive…). Pass it on unchanged, including audio.voice: true on recorded voice notes (otherwise the customer gets a file instead of a voice message). Send it with your token and return Meta's status code and JSON unchanged. If you listed several numbers, Convexa adds ?phone_number_id=<id>: send from that number (a reply goes out from the number the customer wrote to).GET {base}/message_templatesRequired for template messages (order updates, campaigns outside the 24-hour window). Same query and response as GET /{waba-id}/message_templates.POST {base}/message_templatesRecommended. Creates a template the merchant wrote in Convexa. Same body and response as POST /{waba-id}/message_templates. If your system keeps its own template list, add the template there too. Without it, merchants create templates in your system only (Convexa shows them automatically) and Convexa says so instead of failing.DELETE {base}/message_templatesRecommended, together with POST. Same query (name and language, or hsm_id) and response as DELETE /{waba-id}/message_templates. Editing a template in Convexa creates a new version (POST) and then deletes the old one.POST {base}/mediaOptional. Same as POST /{phone-number-id}/media. Without it, Convexa features that upload files to Meta are unavailable; messages that use a media link still work.GET {base}/media/{media-id}Recommended. Returns the file itself for a media id from an incoming message, with its real Content-Type (a voice note is audio/ogg). Fetch it from Meta with your token (GET /{media-id}, then the returned url). Without it, photos, voice notes and documents customers send do not open in the Convexa inbox.POST {base}/callsRequired for voice calls. Same body and response as POST /{phone-number-id}/calls: Convexa sends pre_accept and accept (with the SDP answer from the phone that took the call), reject or terminate. With several numbers Convexa adds ?phone_number_id=<id>. Answer within 5 seconds. Never answer a call yourself: Meta accepts a call only once.POST {base}/call_settingsRecommended for voice calls. Same body and response as POST /{phone-number-id}/settings. The store turns the call button on or off and sets call hours in Convexa; without it they do that in WhatsApp Manager.GET {your-base}/info
→ 200
{
"phone_number_id": "106540352242922",
"display_phone_number": "+90 212 555 01 44",
"waba_id": "102290129340398",
"verified_name": "Your Store",
"phone_numbers": [ // optional: every number you serve
{ "phone_number_id": "106540352242922", "display_phone_number": "+90 212 555 01 44", "verified_name": "Your Store" },
{ "phone_number_id": "106540352242977", "display_phone_number": "+90 216 555 02 55", "verified_name": "Your Store Support" }
]
}POST {your-base}/messages
Authorization: Bearer <CONVEXA_BRIDGE_API_KEY>
Content-Type: application/json
{
"messaging_product": "whatsapp",
"to": "905321112233",
"type": "text",
"text": { "body": "Your order #1048 has shipped." }
}200 OK (Meta's response, unchanged)
{
"messaging_product": "whatsapp",
"contacts": [{ "input": "905321112233", "wa_id": "905321112233" }],
"messages": [{ "id": "wamid.HBgMOTA1MzIx..." }]
}After connecting, Convexa shows a Convexa address and a signing secret. POST JSON there and sign the exact body bytes: X-Convexa-Signature: sha256=<hex HMAC-SHA256(body, secret)>.
POST <Convexa address>
X-Convexa-Signature: sha256=<hex>
Content-Type: application/json
{
"object": "convexa_bridge",
"event": "message.sent",
"message": {
"id": "wamid.HBgMOTA1MzIx...",
"phone_number_id": "106540352242922", // the number that sent it
"to": "905321112233",
"timestamp": 1792250000,
"type": "text",
"text": { "body": "Your order has shipped." }
}
}200Accepted.400Not JSON, or not a supported payload. Do not retry.401Signature does not match. Check the secret and that you sign the exact bytes you send.403The request came from an IP address the store did not allow, or the payload is for a phone number other than the bridged one. The message says which.404Unknown address: the store disconnected or reconnected. Ask for the new address.5xx / timeoutRetry with backoff (for example 2 s, 4 s, 8 s). Duplicates are safe: Convexa ignores a message ID it already has.Keys and secrets can leak. Limit both directions to known servers, so a leaked value alone cannot send or inject messages.
2.28.229.229Behind a load balancer or reverse proxy, check the client address your proxy reports (for example the last X-Forwarded-For entry it adds, or Express's req.ip with trust proxy), not the proxy's own address.
If these addresses ever change, this page shows the new ones; Convexa announces changes to connected stores in advance.
// Node.js 18+ / Express — a complete Convexa Bridge in one file.
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
const GRAPH = 'https://graph.facebook.com/v21.0';
const {
CONVEXA_BRIDGE_API_KEY, // you create it, the merchant enters it in Convexa
WA_PHONE_NUMBER_ID, // your WhatsApp phone number ID
WA_WABA_ID, // your WhatsApp Business Account ID
WA_ACCESS_TOKEN, // your Meta token (never leaves your server)
CONVEXA_INBOUND_URL, // shown in Convexa after connecting
CONVEXA_SECRET, // shown in Convexa after connecting
} = process.env;
// Accept bridge calls only from Convexa's servers (see "IP allow-lists").
// Behind a proxy use the address your proxy adds, e.g. app.set('trust proxy', 1).
const CONVEXA_IPS = (process.env.CONVEXA_IPS || '').split(',').map((ip) => ip.trim()).filter(Boolean);
function requireConvexaIp(req, res, next) {
const ip = (req.ip || '').replace(/^::ffff:/, '');
if (CONVEXA_IPS.length === 0 || CONVEXA_IPS.includes(ip)) return next();
res.status(403).json({ error: { message: 'IP address not allowed: ' + ip } });
}
function requireConvexaKey(req, res, next) {
const given = (req.get('authorization') || '').replace(/^Bearer\s+/i, '') || req.query.access_token || '';
const a = Buffer.from(given), b = Buffer.from(CONVEXA_BRIDGE_API_KEY);
if (a.length === b.length && crypto.timingSafeEqual(a, b)) return next();
res.status(401).json({ error: { message: 'Invalid API key' } });
}
// 1) Which number this bridge serves
app.get('/convexa/info', requireConvexaIp, requireConvexaKey, (req, res) => {
res.json({
phone_number_id: WA_PHONE_NUMBER_ID,
display_phone_number: '+90 212 555 01 44',
waba_id: WA_WABA_ID,
verified_name: 'Your Store',
// Optional, when this bridge serves several numbers of the same WABA:
// phone_numbers: [{ phone_number_id, display_phone_number, verified_name }, …],
});
});
// 2) Send: pass Convexa's Cloud API body to Meta, return Meta's answer as is.
// With several numbers Convexa adds ?phone_number_id=<id>: send from that one
// (only numbers you listed in /info; otherwise answer 400).
app.post('/convexa/messages', requireConvexaIp, requireConvexaKey, async (req, res) => {
const from = req.query.phone_number_id || WA_PHONE_NUMBER_ID;
const meta = await fetch(`${GRAPH}/${from}/messages`, {
method: 'POST',
headers: { Authorization: `Bearer ${WA_ACCESS_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ ...req.body, messaging_product: 'whatsapp' }),
});
res.status(meta.status).json(await meta.json());
// Optional: save the message in your own inbox here.
});
// 3) Templates: same query string, same response as Meta
app.get('/convexa/message_templates', requireConvexaIp, requireConvexaKey, async (req, res) => {
const qs = new URLSearchParams(req.query);
qs.delete('access_token');
const meta = await fetch(`${GRAPH}/${WA_WABA_ID}/message_templates?${qs}`, {
headers: { Authorization: `Bearer ${WA_ACCESS_TOKEN}` },
});
res.status(meta.status).json(await meta.json());
});
// 3b) Template management (recommended): create and delete, same body and answer as Meta.
// If your system keeps its own template list, save the template there too after
// Meta accepts it, so templates created in Convexa also show in your system.
app.post('/convexa/message_templates', requireConvexaIp, requireConvexaKey, async (req, res) => {
const meta = await fetch(`${GRAPH}/${WA_WABA_ID}/message_templates`, {
method: 'POST',
headers: { Authorization: `Bearer ${WA_ACCESS_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify(req.body),
});
res.status(meta.status).json(await meta.json());
});
app.delete('/convexa/message_templates', requireConvexaIp, requireConvexaKey, async (req, res) => {
const qs = new URLSearchParams(req.query); // name, language (or hsm_id)
qs.delete('access_token');
const meta = await fetch(`${GRAPH}/${WA_WABA_ID}/message_templates?${qs}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${WA_ACCESS_TOKEN}` },
});
res.status(meta.status).json(await meta.json());
});
// 3c) Voice calls (Convexa answers them in its mobile app; you only carry the commands).
// Same body and answer as Meta's POST /{phone-number-id}/calls and /settings.
app.post('/convexa/calls', requireConvexaIp, requireConvexaKey, async (req, res) => {
const from = req.query.phone_number_id || WA_PHONE_NUMBER_ID;
const meta = await fetch(`${GRAPH}/${from}/calls`, {
method: 'POST',
headers: { Authorization: `Bearer ${WA_ACCESS_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify(req.body), // pre_accept / accept (with Convexa's SDP answer), reject, terminate
});
res.status(meta.status).json(await meta.json());
});
app.post('/convexa/call_settings', requireConvexaIp, requireConvexaKey, async (req, res) => {
const from = req.query.phone_number_id || WA_PHONE_NUMBER_ID;
const meta = await fetch(`${GRAPH}/${from}/settings`, {
method: 'POST',
headers: { Authorization: `Bearer ${WA_ACCESS_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify(req.body), // { calling: { status, call_icon_visibility, call_hours } }
});
res.status(meta.status).json(await meta.json());
});
// 4) Forwarding to Convexa (signature over the exact bytes you send)
async function sendToConvexa(payload) {
const body = JSON.stringify(payload);
const signature = 'sha256=' + crypto.createHmac('sha256', CONVEXA_SECRET).update(body).digest('hex');
return fetch(CONVEXA_INBOUND_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Convexa-Signature': signature },
body,
});
}
// In your EXISTING Meta webhook handler, after answering Meta with 200:
// sendToConvexa(webhookBody).catch(() => {}); // only this phone number's entries
// "calls" changes (a customer calling) go the same way, at once: the phone rings for
// at most about 60 seconds. Do not pre_accept / accept calls yourself.
//
// After your system sends a WhatsApp message itself:
// sendToConvexa({
// object: 'convexa_bridge',
// event: 'message.sent',
// message: { id: wamid, phone_number_id: WA_PHONE_NUMBER_ID, to: '905321112233',
// timestamp: Math.floor(Date.now() / 1000),
// type: 'text', text: { body: 'Your order has shipped.' } },
// }).catch(() => {});
app.listen(3000);<?php
// Signing a forward to Convexa (PHP)
$body = json_encode($payload, JSON_UNESCAPED_UNICODE);
$signature = 'sha256=' . hash_hmac('sha256', $body, getenv('CONVEXA_SECRET'));
$ch = curl_init(getenv('CONVEXA_INBOUND_URL'));
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body, // send exactly the signed bytes
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "X-Convexa-Signature: $signature"],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
curl_exec($ch);// Signing a forward to Convexa (C# / .NET)
var body = JsonSerializer.Serialize(payload);
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var signature = "sha256=" + Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(body))).ToLowerInvariant();
var request = new HttpRequestMessage(HttpMethod.Post, inboundUrl)
{
Content = new StringContent(body, Encoding.UTF8, "application/json") // exactly the signed bytes
};
request.Headers.Add("X-Convexa-Signature", signature);
await httpClient.SendAsync(request);No. Your Meta token never leaves your server; Convexa only has the bridge key you create.
Your WhatsApp account, as today: every message is sent through your connection.
Convexa cannot send while your /messages endpoint is unreachable and reports the failure in its inbox. Incoming messages are not lost on your side; forward them when you are back.
With IP allow-lists on both sides, a leaked value is useless from any other server. Rotate it anyway: create a new API key and reconnect in Convexa, which also issues a new Convexa address and secret.
Each store connects its own number. Use a separate key per store, or route by the key on your side.
Convexa, in its mobile app: the conversation's assignee if they take calls, otherwise everyone on the team who takes calls on that number. Your system only forwards the call webhooks and carries the commands; the audio flows directly between the phone and Meta, never through your servers or Convexa's.
No. Templates you create in your system appear in Convexa through GET /message_templates. POST and DELETE only let merchants also create and remove them from Convexa; without them Convexa tells the merchant to use your system.