Every WhatsApp API that is not Meta's starts the same way: someone opens Linked devices on a phone, and a QR code appears. That code is the whole handshake. This post is about where it comes from, why it keeps changing, what to do when the phone has no camera for it, and how to put the code in front of the person who owns the number without sending them a screenshot.
#What the QR code is
The QR code is not a login to your account. It is a device link, the same mechanism WhatsApp Web uses. Scanning it tells your phone to add a second device to your account, the way adding a laptop does. The linked device then talks to WhatsApp over its own connection, and your code talks to that device over REST.
That distinction matters for everything else in this post. A session is tied to the phone that linked it. It is not a token you can copy to a server and forget about.
#Where the code comes from
The phone never sends the code to you. Your code asks WhatsApp to start a linking session, WhatsApp returns a code, and you show it to the person with the phone. On wuapi that is one call, and the code arrives as a PNG data URL you can put straight into an img tag.
import { Wuapi } from "@wuapidev/sdk"
const wuapi = new Wuapi({ apiKey: process.env.WUAPI_API_KEY })
const account = await wuapi.accounts.create({
name: "Support line",
proxyLocation: { country: "VE", city: "caracas" },
})
const withCode = await wuapi.accounts.waitForQrCode(account.id)
console.log(withCode.status) // "qr_ready"
console.log(withCode.qrCodeUrl) // PNG data URL, e.g. <img src={qrCodeUrl} />The account comes back in initializing and moves to qr_ready when there is a code to show. The same QR code appears under Linked devices on the phone.
#Why the code rotates
A linking code has a short life. When it is close to expiring, the session asks for a new one and shows it instead. This is not a bug and it is not a sign that something is wrong.
{ "object": "account", "id": "k57a8m2x9d3f0q1wjh6ypc4n2d7s0vbr", "name": "Support line", "status": "qr_ready", "qrCodeUrl": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...", "pairingCode": null, "phone": null, "linkedAt": null}The practical consequence: if you render the code into a page, you have to keep it current. Show a stale code and the person scans something WhatsApp has already thrown away, and the failure looks like a bad camera.
const ready = await wuapi.accounts.waitUntilReady(account.id, {
onQrCode: (qrCodeUrl) => render(qrCodeUrl),
})
console.log(ready.phone) // the number, once the phone finishes linking#When there is no camera to scan it
Sometimes the person linking cannot scan: the server runs on the same phone they would scan with, or they are setting this up over a call. WhatsApp has a second path for that, and so does wuapi. You ask for a pairing code instead and read eight characters out loud.
On the phone: Settings, Linked devices, Link a device, Link with phone number instead. Then type what your code gives you.
const code = await wuapi.accounts.requestPairingCode(account.id, { phone: "+584241112233" })
console.log(code.code) // "WZYX-4K2Q"
console.log(code.expiresAt) // about 160 seconds outThe code lives about 160 seconds, which is WhatsApp's own lifetime for it. Ask for another one when it lapses. Asking on an account that is already linked answers 409 already_linked.
| what you want | what you pass | what comes back |
|---|---|---|
| a QR code to scan | nothing | qrCodeUrl, a PNG data URL |
| a code to read out | phone, in E.164 or digits | code, eight characters |
| the same thing at create time | pairingPhone on POST /v1/accounts | pairingCode on the account |
#The life of a session, from code to ready
An account is a small state machine, and the status tells you which step you are on. Knowing the states is how you avoid the common bug of sending before the number is actually usable.
From creating an account to a number you can send from
Two reasons do not reconnect on their own: logged_out, which means the device was removed from the phone, and temporary_ban. Retrying either one in a loop tends to make it worse. Call POST /v1/accounts/{accountId}/reconnect for a fresh code, and read disconnectReason before you do.
#The three ways to get a code into a room
A QR code only helps if the person with the phone can see it, and there are three ways to arrange that, in descending order of how much you can automate.
- Show it on a page, on the phone. The account's
qrCodeUrlis a PNG data URL, so a page in your app can render it. The person scans with the same phone. This is the standard path and it works everywhere except on the phone running the server. - Send a link to the page, from anywhere. A hosted linking page on your domain, branded with your product, which the person opens on their own phone. This is the path for a platform, and it is the only one that survives a customer who is on mobile data with a broken camera.
- Read a pairing code out loud. No camera involved at all. This is the path for a server on the same phone, and it is the one people forget exists.
The order matters because the failure modes differ. A page that renders a stale code fails visibly, since the person says so. A pairing code that expires fails silently, so ask for another one when the clock runs out.
| your situation | which path |
|---|---|
| the person has a phone and a camera | show the QR on a page |
| they are on mobile, or the page is on their own device | send a link to your linking page |
| the server runs on the phone that would scan it | ask for a pairing code |
| it is a customer, not you | send a link to a branded page |
#What happens if nobody scans it
An account that is not linked within 15 minutes stops its session and reports link_timeout. The number never appears, nothing is billed, and you start again with a new code. A failed link attempt is free, which means retrying costs nothing but the delay.
There is one more pause worth knowing about, and it is not about linking at all. An organization on the Free plan links its first number without a card and uses it for real, up to 2,000 messages and 0.5 GB of proxy traffic a month. When a limit is reached the session pauses, but the device stays linked, so when the month ends or the organization upgrades the account reconnects on its own rather than asking for a second scan.
That is enough to prove a number works end to end: link it, send from it, watch the receipts come back, and decide.
#The events, in the order they arrive
A link is not one event, it is four, and an integration that only listens for the first one will look broken while it is actually working.
The record exists, the session is starting
initializingA code is waiting to be shown
qr_readyOnly when you asked for a code instead
Linked. Sending starts and billing starts
readyThe four events a link produces
account.ready is the one to key your work on. Until it arrives the number is not billable and not sendable, and a send attempted too early answers account_not_ready.
#Sending the moment it is ready
The first send is the smallest useful piece of code in the whole integration, and it is worth writing it correctly the first time rather than retrofitting a key.
const message = await wuapi.messages.send(
{ accountId: ready.id, to: "+584241112233", text: "Your order has shipped." },
{ idempotencyKey: "order-4417-shipped" },
)
console.log(message.id, message.status) // "queued"The idempotency key is the part people skip. A send from a queue will be retried whenever the queue cannot tell a timeout from a failure, and without a key that retry sends a second "your order has shipped" to a customer who already got one. With the key, the second attempt returns the first response for 24 hours.
#A code you can hand to a customer
If you are a platform and each of your customers links their own number, the QR belongs on a page with your branding, not in a terminal. Create a project, create an invitation, and the person you invite scans a code on your domain.
const invitation = await wuapi.invitations.create({
projectId: "p3jk8n2m5r7t9v4x6z1c0b8d",
customerName: "Northwind",
externalId: "customer_8812",
email: "ops@northwind.example",
})
console.log(invitation.url) // your white-labelled linking pageThey scan, they are linked, and you never see a QR yourself. The project's keys and webhooks are what your code talks to afterwards.
#The shape of a link, end to end
time runs down the page · 7 steps
Who does what while a number is linked
The order of the last three steps is the one people get wrong. Your code picks the exit when the account is created, and the session connects through it. Choose the city the number's owner is actually in, not the one you are building from.
#Why the exit matters at link time
Every account connects through a residential exit in the country and city you pass, with a sticky address so the number keeps one network identity. If that exit is down, the account stays offline. It never falls back to a datacenter address, because a number that changes network identity looks exactly like a number that is being sold.
for await (const location of wuapi.proxyLocations.list({ q: "caracas" })) {
console.log(location.country, location.city, location.cityName)
}There are 1,580 cities across 134 countries to choose from, and the full list is browsable without a key at proxy locations. An unsupported pair answers 400 unsupported_proxy_location, and a missing one answers 400 invalid_request.
#Send as soon as it is ready
ready means you can send. The send returns 202 with the message queued, and everything after that arrives on your webhook.
const message = await wuapi.messages.send({
accountId: ready.id,
to: "+584241112233",
text: "Your order has shipped.",
})
console.log(message.status) // "queued"A number that is not ready yet answers account_not_ready. If the account dropped from ready less than three minutes ago, the send waits for it instead of failing, which is usually what you want during a reconnect.
#What the phone sees, and what your code sees
The person who owns the number has a Settings screen with their linked devices. Your code has an account with a status. Both are true at once and neither is a view of the other.
| on the phone | in your code | what it means |
|---|---|---|
| a device listed under Linked devices | status: "ready" | the session is live |
| the device removed from the phone | disconnectReason: "logged_out" | it will not come back on its own |
| no new device appears | status: "qr_ready" | the code was not scanned yet |
| a code that stops working | qr_ready, new qrCodeUrl | it rotated, show the new one |
| phone reconnected a device | status: "authenticating" | the handshake is running |
#Questions people ask
Why does the WhatsApp QR code keep changing?
A linking code is short lived. When it is close to expiring the session asks WhatsApp for a new one and the account reports a fresh `qrCodeUrl`. Render every new code your SDK hands you. A cached code is the most common reason a scan appears to fail.
Can I link a WhatsApp number without a QR code?
Yes. Ask for a pairing code with the number in E.164 or plain digits, and the account reports an eight character code that lives about 160 seconds. On the phone you type it under Settings, Linked devices, Link a device, Link with phone number instead.
How long does a WhatsApp QR code last?
A QR code lives until it is scanned or until it rotates, whichever comes first, and a pairing code lives about 160 seconds. The full linking attempt has its own window: an account that is not linked within 15 minutes stops its session and reports `link_timeout`, and you start again with a new code.
Does linking a number move my chats to your server?
No. A linked device syncs the same way a laptop does, and a number that has just linked may play back history so the new device has context. That replay is not delivered as events, because it would be thousands of events describing the past. New messages arrive from that point on.
What happens if the person removes the linked device?
The account reports `disconnected` with `disconnectReason: "logged_out"`. That is one of the reasons wuapi never reconnects on its own, because each attempt is another signal about the number. Call `POST /v1/accounts/{accountId}/reconnect` to get a fresh code and ask the owner to scan it again.
#Where to go next
The accounts and QR linking section of the docs has every field on POST /v1/accounts and every status value. If you are building this for customers rather than for yourself, choosing a WhatsApp API provider covers the questions to ask before you commit, and why WhatsApp bans numbers covers the habits that keep a linked number up.