Getting startedDeep dive

The WhatsApp QR code, and what it is actually for

Where the QR code in Linked devices comes from, why it rotates, what a pairing code is for, and how to link a number to your own code.

wuapiGetting started7 min read
A QR code beside the linking steps: POST /v1/accounts, status qr_ready, then account.connected with a live indicator.LINKED DEVICES / LINK A DEVICEPOST /v1/accountsstatus: qr_readyaccount.connected[GETTING STARTED]THE QR CODE ROTATES WHILE IT WAITS

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 out

The 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 wantwhat you passwhat comes back
a QR code to scannothingqrCodeUrl, a PNG data URL
a code to read outphone, in E.164 or digitscode, eight characters
the same thing at create timepairingPhone on POST /v1/accountspairingCode 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.

initializingThe session is starting
qr_readyA code is waiting to be scanned or read
authenticatingThe phone accepted the link
readyLinked, and you can send
disconnectedThe connection dropped
failedStopped, and will not recover on its own
initializingqr_readycode issued
qr_readyauthenticatingscanned
authenticatingreadyhandshake done
readydisconnectednetwork drop
disconnectedqr_readyreconnect: new code
disconnectedfailedlogged out or banned

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.

  1. Show it on a page, on the phone. The account's qrCodeUrl is 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.
  2. 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.
  3. 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 situationwhich path
the person has a phone and a camerashow the QR on a page
they are on mobile, or the page is on their own devicesend a link to your linking page
the server runs on the phone that would scan itask for a pairing code
it is a customer, not yousend 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.

account.created

The record exists, the session is starting

initializing
account.qr_code_issued

A code is waiting to be shown

qr_ready
pairing_code_issued

Only when you asked for a code instead

account.ready

Linked. Sending starts and billing starts

ready

The 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 page

They scan, they are linked, and you never see a QR yourself. The project's keys and webhooks are what your code talks to afterwards.

Owner
Phone
API
Proxy
Asks to link their numberPOST /v1/accounts
Starts a session, returns qr_ready
Shows the code in Linked devices
Scans it, or types the pairing code
Picks a residential exitproxyLocation
Holds the connection opensticky
Reports readyaccount.ready

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.

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 phonein your codewhat it means
a device listed under Linked devicesstatus: "ready"the session is live
the device removed from the phonedisconnectReason: "logged_out"it will not come back on its own
no new device appearsstatus: "qr_ready"the code was not scanned yet
a code that stops workingqr_ready, new qrCodeUrlit rotated, show the new one
phone reconnected a devicestatus: "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.

wuapi is an independent service. It is not affiliated with, endorsed or sponsored by WhatsApp. WhatsApp is a trademark of its respective owner.

03/What to read next

Every post

Link a number and send your first message.

One REST call, a typed SDK, and webhooks signed with HMAC-SHA256 over the raw body. No per-message fees.

The whole API is in the docs, and the docs are in one file if you are handing the work to a coding agent.

All posts · openapi.json