Pairing extra numbers with the API
Pairing links the number’s WhatsApp to Turbo Notify as a linked device. After you add the extra number, whoever has its phone at hand finishes the pairing in WhatsApp, with a pairing code or by scanning a QR code. Through the API, you choose the method on each request. The dashboard offers the same two methods, see Pairing in the dashboard. The main number is paired in the dashboard.
If you offer WhatsApp numbers to your own customers, the person using the code or scanning the QR code is your customer, on your screen. This page shows how to build that screen so they can finish on their own, even when something goes wrong.
How it works
- You request the pairing with
POST /v1/numbers/{ref}/pair, with the methodpairing_code(the default) orqr. The number moves tostatus: "pairing". - Within a few seconds the code, or the QR code image, is available in the
pairingblock ofGET /v1/numbers/{ref}. - On the number’s phone, the person opens WhatsApp. For the code, they go to Linked devices > Link a device > Link with phone number instead and type the code. For the QR code, they go to Linked devices > Link a device and point the camera at the image.
- The number moves to
status: "connected"(or"failed", with the reason inreason).
Once paired, the number shows up under Linked devices on the phone with the name of your organization, so the person recognizes the connection and does not remove it by mistake. On WhatsApp Business for Android, the app asks the person to type a name for the device and shows that name instead.
Request the code or the QR code
The request body is optional: {"method": "pairing_code"} or {"method": "qr"}. Without a body, the method is pairing_code. Any other value answers 422 validation_error.
curl -X POST \ https://api.turbonotify.com/v1/numbers/vendas_sp/pair \ -H "Authorization: Bearer YOUR_KEY"const response = await fetch('https://api.turbonotify.com/v1/numbers/vendas_sp/pair', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_KEY' }});
const result = await response.json();console.log(result);import requests
url = "https://api.turbonotify.com/v1/numbers/vendas_sp/pair"headers = {"Authorization": "Bearer YOUR_KEY"}
response = requests.post(url, headers=headers)print(response.json())package main
import ( "fmt" "io" "net/http")
func main() { req, _ := http.NewRequest("POST", "https://api.turbonotify.com/v1/numbers/vendas_sp/pair", nil) req.Header.Set("Authorization", "Bearer YOUR_KEY") resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) fmt.Println(string(body))}<?php$url = 'https://api.turbonotify.com/v1/numbers/vendas_sp/pair';
$options = [ 'http' => [ 'header' => ['Authorization: Bearer YOUR_KEY'], 'method' => 'POST' ]];
$context = stream_context_create($options);$result = file_get_contents($url, false, $context);var_dump(json_decode($result, true));?>var client = new HttpClient();client.DefaultRequestHeaders.Add("Authorization", "Bearer YOUR_KEY");
var response = await client.PostAsync( "https://api.turbonotify.com/v1/numbers/vendas_sp/pair", null);Console.WriteLine(await response.Content.ReadAsStringAsync());import java.net.http.*;import java.net.URI;
public class TurboNotify { public static void main(String[] args) throws Exception { var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder() .uri(URI.create("https://api.turbonotify.com/v1/numbers/vendas_sp/pair")) .header("Authorization", "Bearer YOUR_KEY") .POST(HttpRequest.BodyPublishers.noBody()) .build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); }}The example above requests the code (the default). To request the QR code, send method: "qr":
curl -X POST \ https://api.turbonotify.com/v1/numbers/vendas_sp/pair \ -H "Authorization: Bearer YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"method": "qr"}'const response = await fetch('https://api.turbonotify.com/v1/numbers/vendas_sp/pair', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ method: 'qr' })});
const result = await response.json();console.log(result);import requests
url = "https://api.turbonotify.com/v1/numbers/vendas_sp/pair"headers = {"Authorization": "Bearer YOUR_KEY"}
response = requests.post(url, headers=headers, json={"method": "qr"})print(response.json())Required scope: numbers:write
{
"alias": "vendas_sp",
"status": "pairing"
} The response confirms the request. The code or the QR code is generated by WhatsApp and arrives asynchronously.
Show the code
Poll GET /v1/numbers/{ref} while status is "pairing". When the code
is ready, the pairing block is filled in. Always read pairing.method
before showing pairing.data:
{
"id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"contact_id": null,
"name": null,
"type": "extra",
"alias": "vendas_sp",
"number": "5511988887777",
"status": "pairing",
"at": "2026-09-28T14:00:00Z",
"description": null,
"scheduled_deletion_at": null,
"reason": null,
"pairing": {
"method": "pairing_code",
"data": "Z5X7-LTYL",
"expires_at": "2026-09-28T14:02:40Z"
}
} pairing.methodis"pairing_code".pairing.datais the code, 8 characters, shown with a hyphen. Show it as text.pairing.expires_atis when the whole attempt stops being usable: use it for the countdown.- While the code is still being generated,
pairingisnull.
When the countdown reaches zero without use, offer a button to ask for a new
code or QR code. If nobody finishes, the number becomes failed with
reason: "pairing_timeout" shortly after. The lookup requires the
numbers:read scope.
If pairing.method is "qr", see Show the QR code.
Show the QR code
With method: "qr", the pairing block carries the QR code image:
{
"id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"contact_id": null,
"name": null,
"type": "extra",
"alias": "vendas_sp",
"number": "5511988887777",
"status": "pairing",
"at": "2026-09-28T14:00:00Z",
"description": null,
"scheduled_deletion_at": null,
"reason": null,
"pairing": {
"method": "qr",
"data": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...",
"expires_at": "2026-09-28T14:02:40Z"
}
} pairing.methodis"qr".pairing.datais a PNG image as a data URI (data:image/png;base64,...). Use it as thesrcof an<img>, never as text.pairing.expires_atis when the whole attempt stops being usable, as with the code: use it for the countdown.- WhatsApp renews the QR code on its own while the attempt lasts, and Turbo
Notify keeps
pairing.datacurrent. ReadGET /v1/numbers/{ref}again and replace the image on every read. Do not keep the first one. - While the QR code is still being generated,
pairingisnull.
<img id="qr" alt="QR code to link WhatsApp" /><script> // result: what poll(alias) returns, every 2 seconds if (result.screen === 'qr') document.getElementById('qr').src = result.image;</script>Mistyped code
If the code is mistyped on the phone, WhatsApp invalidates that code right away: not even the correct code is accepted afterwards. WhatsApp does not report this error to anyone, so your screen must offer a new code at any time.
To generate a new code, call POST /v1/numbers/{ref}/pair again while the
number has status: "pairing". The previous code is discarded, the pairing
block goes back to null and then carries the new code. Always show the
latest code. The same applies when the code expires.
Replace the attempt in progress
Calling POST /v1/numbers/{ref}/pair while the number has
status: "pairing" replaces the attempt in progress: a new code or QR code
comes out, and you can switch the method (from code to QR code, or the other
way around). It also works on an attempt started in the dashboard: the
number’s sheet then follows the new attempt. The previous code or QR code
stops working.
The WhatsApp of the registered number
Pairing must be completed in the WhatsApp of the registered number. If
another WhatsApp uses the code or scans the QR code, the pairing is refused,
the device is unlinked from that other WhatsApp and the number ends up with
status: "failed" and reason: "paired_phone_mismatch". To try again,
request a new code or QR code and complete the pairing on the phone of the
registered number.
Pairing result
| Outcome | Through status | Through webhook |
|---|---|---|
| Succeeded | status: "connected" | number.connected |
| Failed | status: "failed" and the reason in reason | number.connect.failed, with the same reason |
The code and the QR code are only delivered by the status lookup: since they are short-lived credentials, they are not sent in webhooks. Show them only to the person pairing the number.
Failure reasons
reason | What to tell your customer |
|---|---|
pairing_timeout | The code or the QR code was generated but not used in time. Get a new code or QR code. |
paired_phone_mismatch | The code or the QR code was used on another WhatsApp. Use the phone of the registered number. |
phone_already_bound | This number is already in use in the account. |
main_number_not_connected | The account’s main number must be connected first. Reconnect the main number and try again. |
code_generation_failed | The code or the QR code could not be obtained from WhatsApp right now. Try again shortly. |
connection_lost | The number lost its connection to WhatsApp. Pair it again. |
pairing_failed | The pairing could not be completed. Try again. |
New reasons may be added. Treat a value you do not recognize as
pairing_failed.
Recommended pairing experience
A pairing screen that handles the common cases on its own:
- Let the person choose the method and request the pairing with
POST /v1/numbers/{ref}/pair: QR code when the customer sees the screen on a computer and points their phone at it, code when they open the screen on their own phone and type the code into WhatsApp. - Poll the status every 2 seconds while
statusis"pairing". Withpairingfilled in, checkpairing.method. If it ispairing_code, showpairing.dataas text. If it isqr, showpairing.dataas an image (<img src>) and replace the image on every read, because the QR code renews. In both cases, show the countdown topairing.expires_at. Withpairingnull, show “generating”. - Keep “Get a new code” or “Get a new QR code” always visible, not only
when the attempt expires: it is the way out of a mistyped code. The button
calls
POST /v1/numbers/{ref}/pairagain, and can switch the method. - Finish on the result:
inactiveshows “finishing” for a moment;connectedshows success;failedshows the message for thereasonand offers to try again. - Respect the attempt limit: on a
429, show how long is left from theRetry-Afterheader instead of repeating the call.
const API = 'https://api.turbonotify.com/v1/numbers';const headers = { 'Authorization': 'Bearer YOUR_KEY', 'Content-Type': 'application/json',};
// Called when the screen opens and by the "Get a new code" or "Get a new QR code" button.// method: 'pairing_code' or 'qr'.async function requestPairing(alias, method) { const res = await fetch(`${API}/${alias}/pair`, { method: 'POST', headers, body: JSON.stringify({ method }), }); if (res.status === 429) { return { waitSeconds: Number(res.headers.get('Retry-After')) }; } return { waitSeconds: 0 };}
// Called every 2 seconds while the screen is open.async function poll(alias) { const res = await fetch(`${API}/${alias}`, { headers }); const number = await res.json(); if (number.status === 'connected') return { screen: 'success' }; if (number.status === 'failed') return { screen: 'failure', reason: number.reason }; if (number.status === 'inactive') return { screen: 'finishing' }; if (number.pairing) { const { method, data, expires_at } = number.pairing; // On the 'qr' screen, replace the <img> src on every read: the QR code renews. if (method === 'qr') return { screen: 'qr', image: data, expiresAt: expires_at }; return { screen: 'code', code: data, expiresAt: expires_at }; } return { screen: 'generating' };}import requests
API = "https://api.turbonotify.com/v1/numbers"HEADERS = {"Authorization": "Bearer YOUR_KEY"}
def request_pairing(alias: str, method: str) -> int: """Called when the screen opens and by the "Get a new code" or "Get a new QR code" button.
method: "pairing_code" or "qr". Returns how many seconds to wait before allowing another request. """ res = requests.post(f"{API}/{alias}/pair", headers=HEADERS, json={"method": method}) if res.status_code == 429: return int(res.headers["Retry-After"]) return 0
def poll(alias: str) -> dict: """Called every 2 seconds while the screen is open.""" number = requests.get(f"{API}/{alias}", headers=HEADERS).json() if number["status"] == "connected": return {"screen": "success"} if number["status"] == "failed": return {"screen": "failure", "reason": number["reason"]} if number["status"] == "inactive": return {"screen": "finishing"} if number["pairing"]: pairing = number["pairing"] # On the "qr" screen, replace the <img> src on every read: the QR code renews. if pairing["method"] == "qr": return {"screen": "qr", "image": pairing["data"], "expires_at": pairing["expires_at"]} return {"screen": "code", "code": pairing["data"], "expires_at": pairing["expires_at"]} return {"screen": "generating"}Attempt limit
Each POST /v1/numbers/{ref}/pair asks WhatsApp for a new code, which triggers a
notification on the number’s phone, or for a new QR code. Too many requests in a row
make WhatsApp itself block new codes for that number for a while. To protect
the number, pairing accepts up to 10 requests per minute per number and
30 per minute per account. Above that the response is
429 pairing_rate_limited, with the Retry-After header in seconds:
- Retry-After: 42
{
"error": "pairing_rate_limited"
} Cancel pairing
While the number has status: "pairing", you can cancel the request in
progress and move the number back to "pending":
curl -X POST \ https://api.turbonotify.com/v1/numbers/vendas_sp/cancel-pairing \ -H "Authorization: Bearer YOUR_KEY"const response = await fetch('https://api.turbonotify.com/v1/numbers/vendas_sp/cancel-pairing', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_KEY' }});
const result = await response.json();console.log(result);import requests
url = "https://api.turbonotify.com/v1/numbers/vendas_sp/cancel-pairing"headers = {"Authorization": "Bearer YOUR_KEY"}
response = requests.post(url, headers=headers)result = response.json()print(result)package main
import ( "fmt" "io" "net/http")
func main() { req, _ := http.NewRequest("POST", "https://api.turbonotify.com/v1/numbers/vendas_sp/cancel-pairing", nil) req.Header.Set("Authorization", "Bearer YOUR_KEY") resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) fmt.Println(string(body))}<?php$url = 'https://api.turbonotify.com/v1/numbers/vendas_sp/cancel-pairing';
$options = [ 'http' => [ 'header' => ['Authorization: Bearer YOUR_KEY'], 'method' => 'POST' ]];
$context = stream_context_create($options);$result = file_get_contents($url, false, $context);$response = json_decode($result, true);var_dump($response);?>var client = new HttpClient();client.DefaultRequestHeaders.Add("Authorization", "Bearer YOUR_KEY");
var response = await client.PostAsync( "https://api.turbonotify.com/v1/numbers/vendas_sp/cancel-pairing", null);var result = await response.Content.ReadAsStringAsync();Console.WriteLine(result);import java.net.http.*;import java.net.URI;
public class TurboNotify { public static void main(String[] args) throws Exception { var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder() .uri(URI.create("https://api.turbonotify.com/v1/numbers/vendas_sp/cancel-pairing")) .header("Authorization", "Bearer YOUR_KEY") .POST(HttpRequest.BodyPublishers.noBody()) .build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); }}{
"alias": "vendas_sp",
"status": "pending"
} Canceling only works while the number has status: "pairing". To pair later,
request a new code or QR code.
Possible errors
Request the code or the QR code (POST /v1/numbers/{ref}/pair)
| Status | Error code | Description |
|---|---|---|
402 | subscription_inactive | The organization’s subscription is not active (the response includes details.status). |
402 | feature_not_available | The current plan does not include extra numbers. |
403 | main_number_pair_not_allowed | The main number is paired in the dashboard. |
404 | number_not_found | The alias or id does not exist in the account. |
409 | already_paired_or_connected | The number is already paired or connected. |
422 | validation_error | method is neither pairing_code nor qr (or the body carries another field). |
429 | pairing_rate_limited | Attempt limit reached: wait Retry-After seconds. |
Cancel (POST /v1/numbers/{ref}/cancel-pairing)
| Status | Error code | Description |
|---|---|---|
402 | feature_not_available | The current plan does not include extra numbers. |
404 | number_not_found | The alias or id does not exist in the account. |
409 | not_pairing | The number does not have status: "pairing". |
Both routes also return 401 not_authenticated (invalid or missing key) and
403 insufficient_scope (missing the numbers:write scope).