Connect a number
There are three situations: a new number for the Cloud API, a number that keeps working on the WhatsApp Business app on the phone, and a number that is already on another provider’s Cloud API. In all of them, the number and the WhatsApp Business account stay with the end business.
How the connection works
The connection happens in the flow Meta calls Embedded Signup. In Meta’s window, the number’s owner chooses their WhatsApp account and number, and authorizes access on the consent screen. Luna exchanges the code from that authorization for permanent access, subscribes the app to the account and registers the number.
- There is no QR Code. Each end business needs its own WhatsApp Business account at Meta, with its own payment method. See the model difference if you come from an unofficial library.
- Luna serves only numbers with the Brazil country code (+55). A number from another country does not become active.
- A new account must have its e-mail confirmed to open the connection. Without it the API answers
403witherror: "connection_not_released", and thefaltandofield says what is missing. - The connection window is opened from the Luna console, in the Números (Numbers) menu, under Conectar número (Connect number). The addresses authorized in Meta’s app for that window are fixed, and today they are the console’s.
A new number
- In the console, open Números, Conectar número, and click Conectar WhatsApp.
- In Meta’s window, the number’s owner chooses the WhatsApp account and the number, and authorizes.
- Follow along in the console or through the API. The connection code is valid for 30 seconds, and the console exchanges it the moment it arrives.
curl -X GET "https://api.lunahia.com.br/v1/numbers/YOUR_NUMBER_ID" \
-H "Authorization: Bearer $LUNA_API_KEY"const res = await fetch('https://api.lunahia.com.br/v1/numbers/YOUR_NUMBER_ID', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
},
});
console.log(res.status, await res.json());import os
import requests
res = requests.get(
"https://api.lunahia.com.br/v1/numbers/YOUR_NUMBER_ID",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
timeout=10,
)
print(res.status_code, res.json()){
"id": "ed404df1-b420-4ed6-8743-ba1609892e0a",
"metaPhoneNumberId": "123456789012345",
"displayNumber": "+55 11 99999-0000",
"state": "REGISTERED",
"podeRetentar": false,
"qualityRating": "GREEN",
"orientacao": "Número conectado e operando. Mensagens já entram e saem por ele.",
"tetoDeVazaoPorSegundo": null,
"sincronizacaoDoHistorico": null,
"subscriptionConfirmedAt": "2026-10-06T11:27:47.141Z",
"credentialStatus": "active",
"podeReconectar": false
}A number’s states
| `state` | What it means |
|---|---|
PENDING, REGISTER_SENT | Registration is under way, and the platform drives it on its own. Do not open another connection: that would duplicate work already queued. |
REGISTERED | The number is registered on the Cloud API. |
PIN_REQUIRED | The number requires the two-step verification PIN, and the platform does not know it. See the PIN. |
BLOCKED | The number used up the registration attempts Meta grants in a 72-hour window. All that is left is waiting for the window to roll. |
The orientacao field says, in Portuguese, what is happening and what to do. It is written for you to pass on to your customer without rewriting. The podeRetentar field says whether the state allows a new connection attempt: the decision is the platform’s, because each registration attempt spends a quota Meta counts per number.
If Meta refuses sending because the end business has no payment method, the number stays registered and sends nothing. It is not a platform error. The orientacao says what is missing and where to fix it.
A number that stays on the phone app (coexistence)
Meta calls coexistence the case of a number that today exists only in the WhatsApp Business app on the phone. It has never been through any API. Connected this way, the number keeps working in the app, and Luna operates it as well.
What you need
- The WhatsApp Business app (not regular WhatsApp) at version 2.24.17 or newer.
- The end business’s account enabled for it on Meta’s side. The option only shows up in the window when it is.
- Real prior use of the app. Meta refuses, with an insufficient-activity warning, a number that was just installed or just left regular WhatsApp. The console’s connection screen recommends using the app for a few days, about a week, and trying again.
How to connect
- In the console, open Números, Conectar número, and click Conectar número que já uso no WhatsApp Business (Connect a number I already use on WhatsApp Business).
- In Meta’s window, choose the option to connect the app you already use, not the one to register a new number.
- The confirmation code arrives as a message in WhatsApp itself, in the phone app.
What changes once connected
- The device’s earlier conversations are imported into the platform, and show up in
GET /v1/messagesnext to the new ones. - What the business owner answers from the phone app arrives too, in the recipient’s conversation.
- Sending has a fixed ceiling of 20 messages per second per number, and it does not rise over time. A number outside coexistence follows a progression Meta raises on its own as quality builds. The
tetoDeVazaoPorSegundofield says which case yours is: with a value, it is the fixed ceiling; null,throughputLevelapplies.
The history import has a deadline
Once the deadline passes without finishing, Meta’s platform disconnects the end business, and it has to redo the connection on the device. What tells you where the import stands is GET /v1/numbers/{id}, in the sincronizacaoDoHistorico and sincronizacaoPrazo fields:
| `sincronizacaoDoHistorico` | What it means |
|---|---|
| null | There is no import for this number. |
em_curso | It is running. sincronizacaoPrazo says until when. |
concluida | It finished, and the older conversations already show up in GET /v1/messages. |
recusada | The business owner declined to share on the phone. It will not finish, and asking again changes nothing. |
- Do not read the percentage on its own.
sincronizacaoProgressois a label Meta assigns, and 100% does not mean success. What tells you the outcome issincronizacaoDoHistorico, and nothing else. - Null in
sincronizacaoProgressomeans there is no import. Zero is an import that started and has not moved yet. sincronizacaoPrazois the platform’s estimate: 24 hours from when it saw the import begin. Meta’s own clock starts slightly earlier, so treat the instant as a limit and act with margin.
If the import is still running as the deadline approaches, Luna sends your webhook the history_sync.deadline_approaching event. The notice goes out before the deadline, never after, and there is one per number. It can repeat, always with the same event_id. The current state is at GET /v1/numbers/{id}/health.
{
"version": "2026-08-25",
"event_id": "d42ca56e44ef023d52b0a0739dcf3afb0d5054a8b84321c41e432ed6707b043f",
"event_type": "history_sync.deadline_approaching",
"occurred_at": "2026-10-07T03:00:00.000Z",
"phone_number_id": "123456789012345",
"history_sync": {
"prazo": "2026-10-07T09:00:00.000Z"
}
}curl -X GET "https://api.lunahia.com.br/v1/numbers/YOUR_NUMBER_ID/health" \
-H "Authorization: Bearer $LUNA_API_KEY"const res = await fetch('https://api.lunahia.com.br/v1/numbers/YOUR_NUMBER_ID/health', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
},
});
console.log(res.status, await res.json());import os
import requests
res = requests.get(
"https://api.lunahia.com.br/v1/numbers/YOUR_NUMBER_ID/health",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
timeout=10,
)
print(res.status_code, res.json())A number coming from another provider
Migrating a number that is already on another provider’s Cloud API is a supported path. It needs no new number and loses no existing one. If you come from an unofficial library, read the Evolution API migration guide first: it explains what changes in the model.
The prerequisites are on the end business’s side
- Own the WhatsApp Business account.
- Have the number released at the previous provider.
- Know the two-step verification PIN, or turn that verification off in its WhatsApp Manager.
The PIN
A number already registered on another provider’s Cloud API almost always has two-step verification configured there, and Meta requires the existing PIN to register it again. There is no endpoint to read or to turn that PIN off, so it has to come from the end business. When the number is in PIN_REQUIRED, give the PIN:
curl -X POST "https://api.lunahia.com.br/v1/numbers/YOUR_NUMBER_ID/pin" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"pin": "123456"
}'const res = await fetch('https://api.lunahia.com.br/v1/numbers/YOUR_NUMBER_ID/pin', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"pin": "123456"
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/numbers/YOUR_NUMBER_ID/pin",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"pin": "123456",
},
timeout=10,
)
print(res.status_code, res.json())- The
pinhas exactly six digits, and theidin the URL is Luna’s, not Meta’s. - The PIN is stored encrypted and never comes back through any route.
- A wrong PIN is refused by Meta and spends one of the ten registration attempts it grants per number in a 72-hour window. Using them all locks the number until the window rolls.
- That is why the platform keeps the last ones. Near the end, the route answers
409witherror: "register_budget_reserved", and thetentativasRestantesandjanelaVirafields say how many are left and when the window turns over. Confirm the PIN with the end business before trying again. - A number that is not waiting for the PIN answers
409witherror: "pin_not_applicable"and the currentstate.
When the authorization expires
credentialStatus says whether the customer’s authorization is still valid. When it dies, the number has to be reconnected. If credentialStatus is reauth_required and podeReconectar is true, open a reconnection session:
curl -X POST "https://api.lunahia.com.br/v1/onboarding/reconnect" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"numberId": "00000000-0000-4000-8000-000000000000"
}'const res = await fetch('https://api.lunahia.com.br/v1/onboarding/reconnect', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"numberId": "00000000-0000-4000-8000-000000000000"
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/onboarding/reconnect",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"numberId": "00000000-0000-4000-8000-000000000000",
},
timeout=10,
)
print(res.status_code, res.json())The returned session is used exactly like a new connection’s, and completing it replaces that account’s authorization instead of creating a second one. A number whose authorization is still valid answers 409 with error: "reconnect_not_applicable" and the current credentialStatus. A permanently revoked authorization also answers 409, and the path is a new connection.
The onboarding routes
The console is a client of this same API, with no privileged route. If you need to understand what it does, the cycle of a connection is this:
POST /v1/onboarding/sessionsopens the session and returnssessionId,configId,csrfStateandexpiresAt. Until the session is completed, no account exists on Luna’s side.- The number’s owner authorizes in Meta’s window, which returns a code.
POST /v1/onboarding/sessions/{id}/completeexchanges the code for permanent access. ThecsrfStatecomes back at completion and is compared in constant time.GET /v1/onboarding/sessions/{id}shows the state and theorientacao.POST /v1/onboarding/sessions/{id}/abandonrecords, as telemetry, which screen the flow stopped on.
curl -X POST "https://api.lunahia.com.br/v1/onboarding/sessions" \
-H "Authorization: Bearer $LUNA_API_KEY"const res = await fetch('https://api.lunahia.com.br/v1/onboarding/sessions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
},
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/onboarding/sessions",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
timeout=10,
)
print(res.status_code, res.json())curl -X POST "https://api.lunahia.com.br/v1/onboarding/sessions/YOUR_SESSION_ID/complete" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"code": "YOUR_CODE_FROM_META",
"csrfState": "YOUR_CSRF_STATE"
}'const res = await fetch('https://api.lunahia.com.br/v1/onboarding/sessions/YOUR_SESSION_ID/complete', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"code": "YOUR_CODE_FROM_META",
"csrfState": "YOUR_CSRF_STATE"
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/onboarding/sessions/YOUR_SESSION_ID/complete",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"code": "YOUR_CODE_FROM_META",
"csrfState": "YOUR_CSRF_STATE",
},
timeout=10,
)
print(res.status_code, res.json())- The code is valid for 30 seconds. Call the completion the moment it arrives, with no queue, no intermediate work and no cold start on the way.
wabaId,phoneNumberIdandbusinessIdare optional. When the browser does not deliver them, Luna finds them through the Graph API from the token itself. Send what you have.- The completion answers
200even when a step fails. The body carries the real state (state,terminal,motivo,podeRetentar,proximoPasso), because a connection that stops halfway is business state, not a request error. - The onboarding routes require the
onboarding:writescope.