Convexa
Developer guide

Convexa Bridge: connect WhatsApp through your system

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.

How it works

  1. Customer messages arrive at your system from Meta as today. You forward them to Convexa.
  2. When a Convexa user replies, Convexa calls your /messages endpoint with a Cloud API message body. You send it with your own WhatsApp connection and return Meta's response.
  3. When your own users send a message, you report it to Convexa, so both inboxes show the whole conversation.

Steps for the store owner

  1. Ask your ERP, CRM or helpdesk provider for the Convexa Bridge address and API key (or have your developers build it with this guide).
  2. In Convexa → Onboarding step 2 (or Settings → WhatsApp), choose "Connect through my system (API)" and enter the address and key.
  3. Convexa shows a Convexa address and a signing secret. Enter both in your system so it can forward messages to Convexa.
  4. Send a test message from Convexa and reply from your phone. Both should appear in Convexa and in your system.

What your system implements

Authentication

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..." }]
}

Forwarding to Convexa

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)>.

Incoming messages and statusesForward Meta's webhook payload as you received it (object: whatsapp_business_account). Include only entries for the numbers in your /info; a payload for another number is rejected with 403. Messages for numbers the store left out of its inbox are accepted and ignored. Do it after you answer Meta, in the background.
Template approvalsForward message_template_status_update changes for the bridged WhatsApp Business Account (entry.id is its WABA id) the same way, so approvals and rejections show in Convexa right away. Updates for another account are rejected with 403.
Voice callsForward calls changes (field: calls, event connect / terminate) the same way and at once, before anything else: the customer's phone rings for at most about 60 seconds. Subscribe your Meta app to the calls webhook field. Convexa rings the store's team in its mobile app and answers through POST /calls; do not answer the call in your system.
Messages your system sendsSend a message.sent event (below) after each successful send, so Convexa shows it in the conversation. Include phone_number_id (the number that sent it): Convexa ignores sends from numbers the store left out of its inbox. Messages Convexa sent through you are already known; do not echo those back.
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." }
  }
}

Convexa's responses

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.

IP allow-lists (recommended)

Keys and secrets can leak. Limit both directions to known servers, so a leaked value alone cannot send or inject messages.

Convexa → your systemAccept every bridge endpoint (/info, /messages, /message_templates, /media, /calls, /call_settings) only from Convexa's addresses below; answer 403 to anything else. Even with your API key, nobody else can send WhatsApp messages through you.
Your system → ConvexaIn Convexa (onboarding step 2 or Settings → WhatsApp), the store owner enters your servers' outgoing IP addresses. Convexa then rejects forwards from any other address with 403, even if the signature is valid.

Convexa's outgoing IP addresses

2.28.229.229

Behind 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.

Code samples

Node.js

// 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

<?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);

C#

// 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);

Test checklist

  • GET /info with the key returns your number; without the key it returns 401.
  • Convexa connects and Settings → WhatsApp shows "Connected via Bridge".
  • A reply sent from the Convexa inbox reaches the customer and appears in your system.
  • A customer message appears in the Convexa inbox within a few seconds.
  • A message sent from your system appears in the Convexa conversation.
  • A template message (order update) sends outside the 24-hour window.
  • A template created in Convexa appears in Meta (and in your template list); after Meta approves it, Convexa shows it as approved.
  • A voice note recorded in the Convexa app reaches the customer as a voice message (not a file), and a customer's voice note plays in Convexa.
  • With the call button on, a customer's call rings in the Convexa mobile app; answering connects the audio, and the call shows in the conversation when it ends.

Questions

Does Convexa need access to our Meta account?

No. Your Meta token never leaves your server; Convexa only has the bridge key you create.

Who pays Meta's charges?

Your WhatsApp account, as today: every message is sent through your connection.

What if our system is down?

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.

What if our API key or the signing secret leaks?

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.

Can one bridge serve several stores?

Each store connects its own number. Use a separate key per store, or route by the key on your side.

Who answers voice calls?

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.

Our system already manages templates. Do we need the template endpoints?

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.

Questions about the integration: support@getconvexa.com