Skip to main content

List and inspect numbers

List every WhatsApp number in your account, or fetch the full detail of one number — including health and, for Meta numbers, live data from the Meta Graph.
Use a tenant-scoped API key to manage numbers across your account. A number-scoped key can also call these endpoints. Authenticate with the x-api-key: ps_... header.

GET /v1/numbers — List all numbers

Lists all WhatsApp instances for the API key’s tenant as a JSON array.
  • Does not return session credentials.
  • Each number exposes its flat connection state and an isFullyConnected flag.
  • The list does not force a mass refresh of connection state — use GET /v1/numbers/{id}/status per number when you need a live check.
  • Each item’s apiKeys entries include only name, keyPrefix, and keyLast4 (no API key id).
  • Meta (Cloud API) items also carry a coexistence block — see Coexistence below.
  • Every item carries extraChargeUnpaidSince and extraChargeDisconnectAt — see Unpaid extra-number charge below.

GET /v1/numbers/ — Full detail of one number

Returns the flat state/isFullyConnected, a health block (see The health block), and — for Meta numbers — a nested camelCase meta block with phone, profile, and waba info pulled from the Meta Graph. For non-Meta numbers meta is null.
string
Add ?refresh=1 to force a live Meta Graph fetch and persist the snapshot. By default the response is cached (last meta snapshot).
Secrets (access tokens, app secret, provider config) are never returned. Returns 404 if the id is not in the caller’s tenant.
Response (Meta number):
For a non-Meta (unofficial / QR) number the same call returns "provider": "PILOT_STATUS", "meta": null and "coexistence": null.

The historyImport block

What happened to the device history of the current connection. Both fields are null when nothing did.
Both are final. Meta hands a number’s history over once, and deleting and reconnecting the number does not make it send it again. A new connection of the same number starts with both null.

The health block

health is the single place that answers can this number send right now, and if not, why. It is derived from Meta’s signals by one shared evaluator, so the list, the detail and the dashboard never disagree. One block on sending lives outside health: an unpaid extra-number charge. It is reported by extraChargeUnpaidSince, at the top level of the number — see Unpaid extra-number charge.
boolean
Our verdict, not Meta’s. state / isFullyConnected on a Meta number are derived from it (sendable → "OPEN", otherwise "DISCONNECTED").Read it as “does Pilot Status currently believe this number can send” — which is what the fields below add up to, and not the same question as “will Meta accept the next send”. Only Meta answers the second one, and it answers it per message, in the response to that send.
It gates OUR sending, so a wrong false is not cosmetic. When it goes false the number’s send queue is paused and jobs are deferred — your messages stop leaving, and Meta never sees them. Measured on 2026-09-15: a verdict derived from a Graph state field held a number sendable: false for close to four hours while Meta was accepting its sends perfectly. The rule behind it was withdrawn the same day.So: alert on sendable: false, by all means — it means your traffic has stopped. But treat it as “we have stopped sending, here is code”, and confirm against the error on an actual send before telling a customer the number is blocked at Meta.
string | null
Meta’s quality rating for the number, uppercased and passed through: GREEN, YELLOW, RED, UNKNOWN. null when Meta reported none. RED stops sending on our side (sendable: false).
string | null
The number’s own messaging limit (messaging_limit_tier), e.g. TIER_250, TIER_1K, TIER_2K, TIER_10K, TIER_100K, TIER_UNLIMITED. null when Meta reports none for it — which is itself information: a number with no tier of its own is not a fully registered Cloud API number, and calling cannot be enabled for it.
string | null
The Business-Portfolio limit (whatsapp_business_manager_messaging_limit), kept as its own field. Meta returns it even when the per-number tier is missing.It used to stand in for messagingLimitTier whenever Meta omitted the per-number one, and the substitution was invisible: the panel showed “2K/24h” for a number sitting at TIER_250, and the calling-enable gate opened for 17 numbers that had no tier at all. Which one governs a given send is Meta’s call, not ours — read both, and treat messagingLimitTier: null as “this number has no limit of its own”, never as “it has the portfolio’s”.
string | null
Meta’s phone-number status, uppercased and passed through verbatim — we do not enumerate it, because Meta adds values without notice. What we do fix is how each one is classified:A value outside both sets contributes nothing to the verdict; quality and the test-number rule below can still block.
string | null
Machine-readable cause, null when healthy — with one exception, meta_phone_not_registered, which rides on a HEALTHY, sendable: true row. Branch on this, not on reason.
string | null
The human-readable message for the same cause, in Portuguese. Diagnostic string, not a contract — the wording changes between releases. Show it; do not parse it.
The health transitions are also pushable. number.health_degraded, number.health_blocked, number.health_shadowban, number.recovered, number.inbound_stalled and number.inbound_recovered fire on transition and are in the event picker — subscribe to each by name, no "*" wildcard needed.⚠️ They arrive in the canonical { event, data } shape, not in Meta’s native envelope. If you subscribe to health and conversation on the same webhook, your endpoint receives two body shapes on one URL. See number health in the event reference.

Unpaid extra-number charge

An extra number — a number past the capacity your workspace has paid for — is charged when it first connects (Extra numbers). If that charge is refused, the number stays connected but sending from it is blocked until it is paid. Two top-level fields report it, on every item of GET /v1/numbers (unofficial and Meta) and on GET /v1/numbers/{id}:
string | null
ISO 8601 instant at which the charge was first refused. Sending from the number is blocked since then: POST /v1/messages/send answers 409 NUMBER_BLOCKED with reason: "extra_charge_unpaid". null when the number is not blocked.
string | null
ISO 8601 instant at which the number is disconnected if it is still unpaid — 7 days after extraChargeUnpaidSince. null when the number is not blocked, and always null on a Meta Cloud API number: it is never disconnected, it stays blocked for sending until paid.
These fields are not part of health. The block is about billing, not about the connection: state, isFullyConnected and health.sendable keep describing the connection alone, and health.code never shows extra_charge_unpaid. A number can read state: "OPEN" and health.sendable: true and still refuse every send. Check extraChargeUnpaidSince too.
To lift the block, call POST /v1/numbers/{id}/pay, top the wallet up or save a card. Once paid, both fields go back to null.

Refreshing from the Meta Graph

?refresh=1 is the only thing that reads Meta. Nothing refreshes meta on a schedule for you: without it, meta.fetchedAt stays pinned to whenever the snapshot was last written — typically the moment the number was paired — no matter how much traffic the number has handled since. What one refresh costs: four sequential Graph reads (the phone-number node, the business profile, the WABA, and the calling settings), each independently try/caught, then one write to persist the snapshot. There is no rate limit of ours on the call; the ceiling you will meet is Meta’s own Graph throttling, shared with every other call made with that number’s token. Because the refresh persists, the freshness is not a property of the endpoint: a later GET /v1/numbers reads the same snapshot the refresh just wrote. The list is not permanently “weaker” than the detail — it is the same cache.
Why coexistence.evidence can differ between two calls at the same instant. Both routes run the same predicate, and it prefers the strongest evidence present in the snapshot:
  1. meta.phone.isOnBizApp → meta_is_on_biz_app
  2. else metaIsCoexistence (recorded from what Meta answered at onboarding) → history_sync
  3. else the fingerprint → phone_fields (only ever LIKELY)
  4. else UNKNOWN
is_on_biz_app is an undocumented Graph field, so it rides the rich field list; if that request fails we fall back to a minimal field set that does not include it. A snapshot without it therefore answers from history_sync — same status, weaker evidence. A ?refresh=1 that succeeds puts isOnBizApp back in the snapshot, and from then on both routes report meta_is_on_biz_app.
UNKNOWN is not a timing problem, so waiting does not fix it. It means we hold no evidence at all: no isOnBizApp in the snapshot, no onboarding answer, and no fingerprint match. Call ?refresh=1 immediately — that is what fetches is_on_biz_app, and it normally resolves to CONFIRMED or NO on that one call.If it is still UNKNOWN after a successful refresh, the rich phone read is failing. Look at meta.errors / meta.graphErrors in the same response for the reason (an expired token and a removed app both show up there) — retrying on a timer will not help.

Coexistence — and why platformType does not answer it

Coexistence means the number is still live in the WhatsApp Business app on a phone and connected to the Cloud API at the same time.
Do not read meta.phone.platformType to answer this. A coexistence number reports CLOUD_API — because it is on the Cloud API. That is what coexistence means. Meta’s value set for that field has no COEXISTENCE member, so a number in coexistence and a “pure” Cloud API number are indistinguishable there.
Read the coexistence block instead. It is returned on Meta numbers by both GET /v1/numbers and GET /v1/numbers/{id}, and is null for unofficial (QR-paired) numbers.
string
CONFIRMED — the number is in coexistence. NO — it is not; this is a pure Cloud API number (unavailable in the phone app). LIKELY — inferred, not answered: Meta leaves a fingerprint on a coexistence number (it refuses /register, so the number stays NOT_VERIFIED, with NON_EXISTS name status and no PIN). UNKNOWN — we cannot answer with what we have.
string
Where the answer came from, so you can tell an answer from a guess: meta_is_on_biz_app (Meta’s own field, also mirrored at meta.phone.isOnBizApp), history_sync (what Meta replied when we asked for the on-device history), or phone_fields (the fingerprint above — only ever appears with LIKELY). null when the status is UNKNOWN.
UNKNOWN does not mean “no”. The fingerprint behind LIKELY is a one-way signal: when it matches it is worth trusting, but its absence proves nothing. Treat UNKNOWN as “ask again later” — calling ?refresh=1 on the number pulls Meta’s own answer and usually upgrades it to CONFIRMED or NO.

What coexistence changes in practice

  • Meta refuses /register for the number (“not available for SMB businesses”), so it never becomes a fully registered Cloud API number.
  • Calling cannot be enabled while the number stays in coexistence.
  • Messaging works normally — sending and receiving are unaffected.
  • To use calling, the number has to be migrated off the WhatsApp Business app. That is the number owner’s decision.