LunaDocsSign in

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 403 with error: "connection_not_released", and the faltando field 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

  1. In the console, open Números, Conectar número, and click Conectar WhatsApp.
  2. In Meta’s window, the number’s owner chooses the WhatsApp account and the number, and authorizes.
  3. 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())
Response (trimmed)
{
  "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_SENTRegistration is under way, and the platform drives it on its own. Do not open another connection: that would duplicate work already queued.
REGISTEREDThe number is registered on the Cloud API.
PIN_REQUIREDThe number requires the two-step verification PIN, and the platform does not know it. See the PIN.
BLOCKEDThe 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.

Look at `subscriptionConfirmedAt`. It records when the app’s subscription to the account was confirmed. While it is null, no event arrives, and the failure is silent: the number looks perfect and receives nothing.

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

  1. 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).
  2. In Meta’s window, choose the option to connect the app you already use, not the one to register a new number.
  3. 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/messages next 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 tetoDeVazaoPorSegundo field says which case yours is: with a value, it is the fixed ceiling; null, throughputLevel applies.

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
nullThere is no import for this number.
em_cursoIt is running. sincronizacaoPrazo says until when.
concluidaIt finished, and the older conversations already show up in GET /v1/messages.
recusadaThe 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. sincronizacaoProgresso is a label Meta assigns, and 100% does not mean success. What tells you the outcome is sincronizacaoDoHistorico, and nothing else.
  • Null in sincronizacaoProgresso means there is no import. Zero is an import that started and has not moved yet.
  • sincronizacaoPrazo is 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 pin has exactly six digits, and the id in 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 409 with error: "register_budget_reserved", and the tentativasRestantes and janelaVira fields 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 409 with error: "pin_not_applicable" and the current state.

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:

  1. POST /v1/onboarding/sessions opens the session and returns sessionId, configId, csrfState and expiresAt. Until the session is completed, no account exists on Luna’s side.
  2. The number’s owner authorizes in Meta’s window, which returns a code.
  3. POST /v1/onboarding/sessions/{id}/complete exchanges the code for permanent access. The csrfState comes back at completion and is compared in constant time.
  4. GET /v1/onboarding/sessions/{id} shows the state and the orientacao. POST /v1/onboarding/sessions/{id}/abandon records, 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, phoneNumberId and businessId are 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 200 even 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:write scope.
To see every field, open the Onboarding section in the API reference.