Appearance
Provisioning apps from your product
This guide walks through creating WhatsApp apps by API, so your own product can give each customer a linked WhatsApp number without anyone touching the gateway dashboard. For the endpoint tables, see the Provisioning API reference.
Let an AI agent build it
Want a coding agent to implement this in your product? Use the ready-made agent prompt.
The model
customer clicks "Connect WhatsApp"
│
your backend ──POST /api/provision/apps──▶ gateway (creates the app, starts pairing)
│
your backend ──GET .../qr (poll)─────────▶ gateway (returns a QR image)
│
customer scans the QR in WhatsApp ────────────────▶ status becomes "ready"
│
your backend ──POST /api/messages (app apiKey)──▶ gateway ──▶ WhatsApp
your backend ◀──signed webhook (message.received)── gatewayTwo kinds of credentials are involved:
| Credential | Held by | Used for |
|---|---|---|
Provisioning key (x-provisioning-key) | Your backend, one per product | Creating and managing apps |
App apiKey (x-api-key) | Your backend, one per customer | Sending messages through the Tenant API |
Keep keys on your server
Never send a provisioning key, an app apiKey or a webhookSecret to a browser or mobile app. Your frontend talks to your backend, which talks to the gateway.
1. Get a provisioning key
An operator with admin access creates it once per product. It is shown only once.
bash
node scripts/create-provisioner.js "Acme CRM" 200 # name, optional app limitOr with the Admin API:
bash
curl -X POST https://gateway.example.com/api/provisioners \
-H "x-admin-key: $ADMIN_API_KEY" -H "Content-Type: application/json" \
-d '{"name":"Acme CRM","maxApps":200}'Store the key as a secret in your backend, for example WA_PROVISIONING_KEY.
2. Create an app for a customer
Call this when a customer first connects WhatsApp. Pass your own customer id as externalRef so you can find the app again.
bash
curl -X POST https://gateway.example.com/api/provision/apps \
-H "x-provisioning-key: $WA_PROVISIONING_KEY" -H "Content-Type: application/json" \
-d '{
"name": "Acme Dental",
"externalRef": "cust_1042",
"webhookUrl": "https://api.acme.example/wa/hook"
}'json
{
"id": "app_9f2c...",
"name": "Acme Dental",
"externalRef": "cust_1042",
"webhookUrl": "https://api.acme.example/wa/hook",
"status": "starting",
"apiKey": "wa_...",
"webhookSecret": "..."
}201 is returned and pairing starts at once. apiKey and webhookSecret appear only in this response. Save them (encrypted) against the customer record before you do anything else.
| Field | Rules |
|---|---|
name | Required, 1 to 100 characters |
externalRef | Optional, up to 200 characters |
webhookUrl | Optional, must be an http or https URL. Can be set later with PATCH. |
Make it safe to retry
Creating twice makes two apps. Look up first:
js
async function getOrCreateApp(customerId, name) {
const list = await gw(`/api/provision/apps?externalRef=${encodeURIComponent(customerId)}`);
if (list.apps.length) return { app: list.apps[0], created: false };
const app = await gw('/api/provision/apps', {
method: 'POST',
body: { name, externalRef: customerId, webhookUrl: process.env.WA_HOOK_URL },
});
await saveCredentials(customerId, app.id, app.apiKey, app.webhookSecret); // only chance
return { app, created: true };
}where gw is a small helper:
js
const BASE = process.env.WA_GATEWAY_URL;
async function gw(path, { method = 'GET', body } = {}) {
const res = await fetch(BASE + path, {
method,
headers: { 'x-provisioning-key': process.env.WA_PROVISIONING_KEY, 'content-type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json();
if (!res.ok) throw Object.assign(new Error(data.error), { status: res.status, code: data.code, retryAfter: res.headers.get('retry-after') });
return data;
}3. Show the QR code to the customer
Your backend exposes a small endpoint for your frontend. It proxies the gateway so the provisioning key stays private.
js
// GET /customers/:id/whatsapp/qr (your own route, behind your own auth)
app.get('/customers/:id/whatsapp/qr', async (req, res) => {
const appId = await appIdFor(req.params.id);
res.json(await gw(`/api/provision/apps/${appId}/qr`));
});The gateway answers one of:
| Response | Show the customer |
|---|---|
200 { "status": "qr", "qr": "data:image/png;base64,..." } | The image. Put it straight into an <img src>. |
202 { "status": "starting", "qr": null } | A spinner. Poll again in 2 to 3 seconds. |
200 { "status": "ready", "qr": null, "info": {...} } | "Connected". Stop polling. |
Poll every 2 to 3 seconds. WhatsApp rotates the QR code about every 20 seconds, so always render the latest one. Tell the customer to open WhatsApp, Linked devices, Link a device and scan.
No camera? Use a pairing code
If the customer is on the same phone that holds the number, a QR cannot be scanned. Request a code instead:
bash
curl -X POST https://gateway.example.com/api/provision/apps/$APP_ID/pairing-code \
-H "x-provisioning-key: $WA_PROVISIONING_KEY" -H "Content-Type: application/json" \
-d '{"phoneNumber":"15551234567"}'
# {"pairingCode":"ABCDEFGH","expiresIn":180}Show the code. The customer enters it under Linked devices, Link with phone number.
4. Know when it is connected
Either keep polling the QR endpoint until status is ready, or read the app:
bash
curl https://gateway.example.com/api/provision/apps/$APP_ID \
-H "x-provisioning-key: $WA_PROVISIONING_KEY"The response includes live status and, when ready, info.phone. Mark the customer as connected in your database at that point.
5. Send and receive
From here the customer's app behaves like any other. Use that app's own apiKey with the Tenant API:
bash
curl -X POST https://gateway.example.com/api/messages \
-H "x-api-key: $CUSTOMER_APP_KEY" -H "Content-Type: application/json" \
-d '{"to":"15557654321","text":"Your appointment is tomorrow at 10:00."}'Incoming messages and delivery receipts arrive at the app's webhookUrl. Route them to the right customer using the x-wa-gateway-app header or appId in the body, and verify each one with that customer's webhookSecret. See Webhooks.
One webhook URL for all customers
Point every app at the same URL on your backend. Look up the secret by appId, verify the signature, then dispatch.
6. Manage the lifecycle
| Need | Call |
|---|---|
| Change name or webhook URL | PATCH /api/provision/apps/:id with { "name", "webhookUrl" } |
| Rotate the webhook secret | POST /api/provision/apps/:id/webhook-secret. The new secret is returned only here and the old one stops working immediately. |
| Customer wants a different number | POST /api/provision/apps/:id/unlink, then show the new QR |
| Customer leaves | DELETE /api/provision/apps/:id. Stops the client and deletes the app, session and messages. |
| Reconcile your records | GET /api/provision/apps (add ?externalRef= to filter). Returns { "apps": [...] }. |
A key sees only the apps it created. Any other app id answers 404, so one product can never read another's customers.
If an app stays offline for SESSION_INACTIVE_DAYS (default 7), its stored WhatsApp login is cleaned up and the customer must scan again. The app, key and webhook are kept. Show a "Reconnect" button that calls the QR endpoint.
7. Handle limits and errors
| Status | Code | Cause | What to do |
|---|---|---|---|
| 400 | BAD_REQUEST | Invalid name, externalRef or webhookUrl | Fix the input |
| 401 | Missing or revoked provisioning key | Check the key | |
| 404 | NOT_FOUND | Unknown app, or one this key did not create | Check the id |
| 409 | APP_LIMIT | The key already holds maxApps apps | Delete unused apps or ask an admin to raise the limit |
| 429 | RATE_LIMITED | Too many apps created this hour | Wait for the Retry-After header (seconds) |
The per-key defaults are PROVISION_MAX_APPS (100) and PROVISION_CREATES_PER_HOUR (30). Each app runs its own browser, so size the gateway for the apps you expect. See Production checklist.
Checklist
- [ ] Provisioning key stored as a backend secret
- [ ]
externalRefset to your customer id, with a lookup before create - [ ]
apiKeyandwebhookSecretsaved immediately after create - [ ] QR proxied through your backend, polled every 2 to 3 seconds
- [ ] Webhook handler verifies the signature and de-duplicates on
x-wa-gateway-delivery - [ ] Handling for
409 APP_LIMIT,429and a reconnect flow - [ ] Delete the gateway app when the customer deletes their account