Skip to main content
What’s new on the Pilot Status platform — API additions, changes and notable fixes. Subscribe via the RSS icon above.
NoticesDashboardShipped

Notices: the button opens the dashboard with you already signed in

Payment and limit notices — extra numbers, charges, suspension, balance, phone lines — now carry a button that opens the dashboard already signed in, on the screen the notice is about. No password, no code.
  • For the workspace owner, on their own e-mail and WhatsApp. Other recipients of a notice keep the regular button, which asks them to sign in.
  • Before signing in, the page shows which account and which workspace you are about to enter. Only your click on Continue opens the session.
  • The link is valid for 24 hours and works once. After that, the same button leads to the normal sign-in and, once signed in, to the same screen.
  • Do not forward the notice: anyone holding the link within that window signs in to your account.
  • On e-mail, this applies to every notice. On WhatsApp, it applies to nearly all of them; the remaining ones start opening signed in as Meta approves the new message templates.
NumbersPaymentsAPIMCPDashboardShipped

Numbers: an extra number is charged when it first connects

Creating a number or handing out a pairing link no longer charges anything, on any plan. An extra number — a number past the capacity your workspace has paid for — is now charged when the number first connects, whichever way it was connected: the dashboard, the API, MCP, a remote pairing link or Meta Embedded Signup. A number that never connected does not count toward your capacity or your monthly bill. Creating a number is still refused with 402 (INSUFFICIENT_FUNDS or PLAN_NUMBER_LIMIT_REACHED, same body as before) when the next number would be an extra number and the workspace has neither the wallet balance to cover it nor a saved card. Extra numbers
  • If the charge is refused, the number stays connected but sending from it is blocked. POST /v1/messages/send answers 409 NUMBER_BLOCKED with reason: "extra_charge_unpaid", and a message that was already queued fails and shows NUMBER_BLOCKED in the logs. If the charge merely could not be concluded — the payment processor could not be reached — nothing is blocked, and it is retried automatically every 15 minutes.
  • What lifts the block: a wallet top-up or a saved card (both retry the charge automatically), the Pay now button on the dashboard’s Numbers page (workspace owner only), or the new POST /v1/numbers/{id}/pay.
  • After 7 days unpaid, a number connected by QR code is disconnected and stops counting. A reminder is sent 2 days before. Connecting it again is a new first connection, charged again. A Meta Cloud API number is never disconnected: it stays blocked for sending until it is paid.
  • New fields on GET /v1/numbers and GET /v1/numbers/{id}: extraChargeUnpaidSince and extraChargeDisconnectAt, ISO 8601 or null. They sit at the top level of the number, not inside health, and health.code never shows this block.
  • The Free plan never buys an extra number by itself. A Free workspace is not converted or charged by connecting a number: creating or pairing a number past the plan’s allowance answers 402 PLAN_NUMBER_LIMIT_REACHED, whatever the wallet balance, until you buy the extra number yourself (with confirmation) or move up a plan. Free plan
  • MCP: new tool numbers_pay.
MessagesMediaAPIBreakingShipped

Messages: voice together with templateId is rejected

Sending mediaType: "voice" together with templateId on POST /v1/messages/send is now rejected with 400, the same as audio. Previously the request was accepted and the media was silently ignored. To send a voice note, use a separate direct media send (media + mediaType: "voice", without templateId). Audio and voice notes
Phone LinesWebhooksAPIShipped

Phone lines: holder verification through the API, the call recording by API, and why a line was returned

  • New: account holder verification through the API — GET and POST /v1/phone-lines/verification, POST …/contact/{channel}/confirm, POST …/contact/{channel}/send and POST …/document. Submit the CNPJ or CPF, a contact e-mail, a WhatsApp number and the document as a base64 data URI (PDF, JPEG, PNG or WEBP, up to 10 MB); confirm the contact codes; read status (NONE, PENDING_CONTACT, IN_REVIEW, APPROVED, NEEDS_DOCUMENT or REJECTED) and canPurchase. The document number and the contacts come back masked. Owner only (phone_lines:purchase); the submit requires Idempotency-Key; at most 10 submissions per hour per workspace (429 RATE_LIMITED with Retry-After). Verification is no longer dashboard-only, and it is the same verification as the dashboard’s.
  • New: GET /v1/phone-lines/activations/{activationId}/audio downloads the call recording of a code request, with the same key and permission as polling it (phone_lines:read), sent with Cache-Control: private, no-store. A request with no recording (hasAudio: false), or of a returned or cancelled line, answers 404 ACTIVATION_NOT_FOUND. Listening to the recording is no longer dashboard-only.
  • Webhooks: audioUrl on phone_line.audio_received, phone_line.code_received and phone_line.code_failed — the absolute URL of that route, or null when the request has no recording. It holds no credential: fetch it with your tenant-scoped key.
  • Webhooks: phone_line.returned carries reason — unpaid, canceled_by_customer or verification_rejected.
  • A rejected verification takes back the lines bought during the review. 3 days after the rejection they are returned to the carrier (reason: "verification_rejected"), and every charge they paid — card portions included — is refunded to the wallet. Until then they keep working and are not renewed. Lines bought under an earlier, approved verification are not affected. See identity verification.
  • A line that leaves your workspace has its code history deleted — when it is returned, or when a cancelled line reaches CANCELED. GET /v1/phone-lines/{id}/activations still lists its requests, with code: null and hasAudio: false, and their recordings answer 404.
  • In the dashboard, Pay … now / Pay … and reactivate on a line’s page now charges that line only (Pay for all, on the list of lines, still charges every unpaid line).
MessagesMediaDocs

Docs correction: audio is an audio file on official numbers — use voice for a voice note

The docs said mediaType: "audio" is always delivered as a voice note (PTT). That is true only on unofficial (Pilot Status web) numbers. On official (Meta Cloud API) numbers, audio arrives as an audio file — a player, not a voice note. Nothing changed in the product today — this is the docs catching up.
  • To send a voice note on any number, use mediaType: "voice". Use audio only when you want an audio file on an official number. PLAYED is reported only on official numbers and only for voice notes: an audio send there does not reach it, and on unofficial numbers a voice note that was listened to shows as READ.
  • Audio is not always converted to Ogg/Opus. The 2026-07-29 entry below said both types are normalised to OGG/Opus mono. On official numbers the audio is converted to AAC in MP4 (audio/mp4) before it goes to Meta; on unofficial numbers it is converted to Ogg/Opus.
  • A caption on voice is not rejected. The docs said it returns 400. audio with a caption does; voice with a caption is accepted, but the caption is dropped on unofficial numbers and its rendering on official numbers is not guaranteed — don’t send one.
  • Official numbers do show an indicator before an audio send: “typing…”, when the conversation has a recent message from the contact. Unofficial numbers show “recording audio…”. The media guide said no indicator appeared on official numbers.
Audio and voice notes
Phone LinesWebhooksAPIShipped

Phone lines have a name

  • Name a line in the dashboard (Edit name on the line’s page), up to 60 characters, as you name a WhatsApp number. Left blank, the name is cleared and the line shows its number again. In the dashboard the name is the line’s title, and it also labels the line’s charges and receipts.
  • GET /v1/phone-lines and GET /v1/phone-lines/{id} return a new name field — the name you gave, or the formatted number ((11) 4863-7200, the same as display) when the line has none. It is never empty. The line returned by DELETE /v1/phone-lines/{id} carries it too.
  • Every phone_line.* webhook about a line carries name — every event except phone_line.verification_updated. phone_line.purchased always carries the formatted number, since a line is bought without a name.
  • Naming is dashboard-only: there is no API route to set it, and POST /v1/phone-lines still accepts only numbers.
Phone LinesWebhooksAPIShipped

Phone lines: cancel a wait, the code is read again from the recording, and every code request ends with an event

  • New: POST /v1/phone-lines/{id}/activations/{activationId}/cancel. Ends a code request that is still WAITING, so you can ask for a new code at once instead of getting 409 ACTIVATION_IN_PROGRESS until the 5-minute window closes. The request becomes TIMED_OUT with failureReason: "canceled_by_customer". A call that was already captured is never thrown away: the cancel then answers 409 ACTIVATION_NOT_WAITING. The dashboard has the same Cancel wait button.
  • A failed transcription is retried from the stored recording, up to 3 attempts in all, so a request can stay CAPTURED with failureReason: null for a few minutes; transcription_failed now means all 3 attempts failed.
  • A call in the window’s last seconds is no longer lost: the check right after pollDeadline reads the line one last time.
  • Webhooks: new events phone_line.audio_received (the call was captured and stored) and phone_line.code_failed (the request ended without a code, with reason). phone_line.code_received is now sent once for every accepted request — with the code, or with code: null plus a new failureReason field when it ended without one (no call in the window, a cancelled wait, a failed transcription, or a recording that could not be downloaded or stored). Until today it was sent only after a finished transcription. Webhooks subscribed to all events ("*") receive the new events; webhooks with an explicit list do not until you add them.
  • Line webhooks in the dashboard now have an optional name (up to 100 characters; blank uses the URL’s host) and can be edited — name, URL and events — without changing the signing secret. A URL that points to an internal or private network address is refused when you save it (WEBHOOK_URL_REFUSED), instead of being accepted and then failing every delivery.
MessagesMediaAPIShipped

Media URLs and the Evolution layers: internal addresses are refused

  • POST /v1/messages/send with a media URL that points to an internal or private network address — or that redirects to one — now fails with MEDIA_DOWNLOAD_FAILED before any request is made, on every number type, with errorMessage starting Mídia recusada:. It is not retried. Public URLs are unaffected.
  • The Evolution V2 and Evolution GO layers now refuse, with 400, a request whose media, picture, previewed link, webhook or integration URL points to an internal or private network address. Nothing is forwarded.
NumbersMessagesAPIShipped

Numbers: a Meta number reconnected after deletion keeps working — and one that is not registered says so

Reconnecting the same Meta number no longer leaves it unable to send. Registering a number on the Cloud API sets a two-step verification PIN on it at Meta, and until today that PIN lived only with the number record. Permanently deleting the number and connecting it again made the new registration use a different PIN; Meta refused it (133005), the number looked connected, and every send failed with 133010 “Account does not exist in Cloud API”. The PIN is now kept per Meta phone number and reused on reconnection.Sends refused for that reason now carry errorCode: META_PHONE_NOT_REGISTERED instead of the generic META_SEND_FAILED, with a message telling you to register the number again in the dashboard.health.code: meta_phone_not_registered marks a Meta number whose phone status is DISCONNECTED, on GET /v1/numbers and GET /v1/numbers/{id}/status, and the dashboard shows a “Not registered on the Cloud API” badge. The row stays sendable: true on purpose: the queue is not paused, and each send returns Meta’s explicit error. See health.code.In the dashboard:
  • Register on the Cloud API asks for the PIN when Meta says it does not match, instead of ending in an error with nowhere to type it.
  • Delete permanently now shows what will be lost — how many conversations and messages, plus the other data tied to the number — before you type the number to confirm.
  • Reconnecting a Meta number that is still in the trash now goes through Restore — the same 30-day window and the same charge as Restore — and brings the number back with its conversations, keys and settings. It is matched by the Meta phone number, in the same workspace.
NumbersMessagesAPIShipped

Unofficial numbers: reconnecting the same number works again

  • Logging out and reconnecting the SAME unofficial number could go unrecorded. When that happened, number.connected was not sent, the dashboard kept showing the number as disconnected with sending paused, and POST /v1/messages/send answered 409 NUMBER_BLOCKED until someone clicked “Resume sending”. Fixed: the reconnection is now recorded, number.connected is sent, and sending is released as soon as the connection is back, with no click needed.
  • 409 NUMBER_BLOCKED with reason: "all_connections_down" now says what it means: the number has been disconnected for more than 60 minutes and must be reconnected before sending. It used to say that WhatsApp was refusing sends. code, reason and the HTTP status are unchanged. Error codes
  • POST /v1/messages/{id}/resend now answers the same 409 NUMBER_BLOCKED as POST /v1/messages/send when the number cannot send. It used to answer a generic 500.
  • Messages that expire while the number was disconnected for more than 60 minutes now say so in error. The errorCode is still EXPIRED_WHILE_NUMBER_BLOCKED.
MessagesMediaDocs

Docs correction: base64 media works on unofficial numbers

The docs said unofficial (Pilot Status web) numbers do not accept base64 media on POST /v1/messages/send. They do, since 2026-08-24. Nothing changed in the product today — this is the docs catching up. On unofficial numbers, Pilot Status uploads the decoded file to its own storage and sends the provider a URL; if that upload fails, the message fails with MEDIA_REHOST_FAILED. The base64 caps are unchanged (50 MiB for document, 16 MiB for every other type). For stickers on unofficial numbers, use a public http(s) URL. Media messages
MessagesMediaAPIShipped

Messages: a base64 document can be up to 50 MiB

POST /v1/messages/send now accepts a base64 media of up to 50 MiB (52,428,800 bytes, decoded) when mediaType is document. Until now every base64 payload was capped at 16 MiB whatever its type, so a 20 MB document sent inline was refused, although Meta takes documents of up to 100 MB. Every other type — image, video, audio, voice, sticker — and a media sent without mediaType keep the 16 MiB (16,777,216 bytes) cap.The refusal keeps its shape. Above the cap the request answers 400 Validation error before anything is queued, and details.fieldErrors.media names the limit that applied:
For every other type the text is unchanged: media base64 payload exceeds the 16777216 byte limit.Media sent by URL is unaffected. A public http(s) URL is not held to these caps; on Meta Cloud API numbers Meta’s own ceilings apply (document 100 MB, video and audio 16 MB, image 5 MB), and a file above them fails after the 202 with META_MEDIA_TOO_LARGE. For a document above 50 MiB, send a URL. Send message
NumbersAPIMCPSDKShipped

Numbers: deleting is reversible for 30 days — and the permanent delete is now documented

DELETE /v1/numbers/{id} is a 30-day soft delete, and has been since August: the device is logged out right away, and the response carries restorableUntil. Two endpoints complete it, and the documentation did not mention either: POST /v1/numbers/{id}/restore brings the number back within the window, and DELETE /v1/numbers/{id}/purge deletes it permanently, right away. Purge before creating the same phone again — while the number is in the window, POST /v1/numbers answers 409 NUMBER_SOFT_DELETED.Billing: on Pay As You Go, a deleted number is not charged on the next cycle, purged or not. On the other plans, deleting does not reduce paid capacity — schedule a reduction for that. Restoring pays like creating: when your paid capacity is already full, a restore buys one extra number, prorated; if that cannot be paid it answers 422 INSUFFICIENT_FUNDS and the number stays deleted.MCP: new tool numbers_purge (OWNER only), next to numbers_restore. Python SDK 1.8.0: numbers.purge() and numbers.restore(), and numbers.delete() now types its real response (ok, restorableUntil).
ChatwootMessagesShipped

Chatwoot (API mirror): media from unofficial numbers arrives with its attachment — and with a file name

Images, audio, video and documents received on unofficial (web/QR) numbers reach Chatwoot with the attachment again. On the API mirror (Model A), since late August, these could arrive as an empty bubble: the mirror posted the message before its media had been stored. It now waits for the media and posts the bubble with it, usually within a few seconds. If the media is still not available after 20 seconds, the bubble says [mídia] with a link that opens the file as soon as it is stored. Media received before this change stays as it is in Chatwoot. What agents seeAn attachment that arrives without a file name now gets one. Photos, voice notes, videos and stickers usually come without a name and reached Chatwoot as attachment, with no extension. They are now named after their kind and the message id — imagem-3EB03F18.jpg, audio-3EB0FFE7.ogg, figurinha-3EB0A1B2.webp — so the download opens in the right app. A file that carries its own name keeps it.
ChatwootMessagesAPIShipped

Chatwoot: refused replies show why, API messages follow the open conversation, no text lost behind “unsupported”

A Chatwoot reply that Pilot Status refuses is now marked failed in Chatwoot, with the reason on the bubble and a private note in the conversation — before, the bubble looked sent. This covers every refusal of an agent reply: WhatsApp refusing sends from the number, billing suspended, the 24h window closed, /template not found, an attachment without a public URL, an empty message, no destination. What agents seeA new code, NUMBER_BLOCKED, names a Chatwoot reply refused because WhatsApp is refusing sends from the number (banned, restricted). It used to be retried four times and recorded as CHATWOOT_SEND_EXCEPTION on each attempt; now it is recorded once and not retried. It is the same code POST /v1/messages/send answers with 409 in that state. Log Error CodesNative Cloud inbox (Model B): messages sent through the Pilot Status API land in the contact’s open conversation. Chatwoot opens a new conversation each time the previous one is resolved, and API messages kept going to the old, resolved one. With no open conversation, Pilot Status now opens one, and the customer’s reply joins it.Native Cloud inbox (Model B): the real message is no longer lost behind “This message is unsupported”. On coexistence numbers, WhatsApp sometimes delivers a placeholder (131060) before the real message, with the same id, and Chatwoot kept the placeholder and discarded the text. Pilot Status now holds the placeholder a few seconds and drops it when the real message arrives. When WhatsApp never delivers the content to the API, the placeholder still appears — the message is only in the WhatsApp app.A send to a number without its country code no longer splits the conversation in the inbox and in GET /v1/conversations: on official numbers the conversation follows the number WhatsApp confirms for the send. Keep sending in E.164 (with the country code).
MessagesAPIShipped

errorCode on the message detail

GET /v1/messages/{messageId} now returns errorCode — the same value GET /v1/messages returns for the same message, from the same derivation. Until now the detail carried only errorMessage, whose text is not a contract. Branch on errorCode; it is null when there is no error. On the detail this is additive: no existing field changes.Log Error Codes now lists every value errorCode can take — including the web-number rejections and the full Meta Cloud API set — with the Meta numeric code behind each META_* code, so you can join it to statuses[].errors[].code on the native webhook.On both endpoints and in Logs, two new codes, BILLING_SUSPENDED and NUMBER_DELETED_BEFORE_SEND, name failures that used to arrive as SEND_FAILED, and a web shadowban (error 463) now shows WEB_SHADOWBAN instead of SEND_FAILED. PLAYED joins DELIVERED, READ and CANCELED as a status whose errorCode is always null.21 more codes now name failures of their own: reconciler expiries on a blocked or disconnected number, Chatwoot agent replies, template list/carousel/Flow composition refusals, web-number media and payment-order failures, and platform refusals on official numbers before the send reaches Meta — full list. They used to arrive as SEND_FAILED (META_SEND_FAILED for the official-number refusals), and a few as a code guessed from the underlying error text, such as DELIVERY_TIMEOUT. If you branch on SEND_FAILED, expect these more specific codes instead, including on older messages: the code is computed when the message is read, not when it failed.On official numbers, a send Meta refuses at request time now gets the same code the status webhook gives for that Meta number. 131048 is META_RATE_LIMITED (it said META_NO_CONVERSATION), 131052 and 131053 are META_MEDIA_DOWNLOAD_ERROR and META_MEDIA_UPLOAD_ERROR, 4 and 80007 are META_RATE_LIMITED, 100 is META_INVALID_PARAM, 131000 is META_GENERIC_ERROR, and 190 is META_TOKEN_INVALID on both paths. Three send-time codes become public: META_TEMPLATE_INVALID_BUTTON_URL, META_NUMBER_INFO_NEEDED and META_CONTENT_FLAGGED. This applies only to messages that fail after the release — older ones keep the code they got, except the four legacy codes below. The page also maps each message.failed webhook code to the API errorCode for the same message.Four codes the send-time path no longer writes are now read with today’s code on older messages too, because each came from exactly one Meta number: META_SPAM_RATE_LIMITED → META_RATE_LIMITED, META_TEMPLATE_BAD_PARAM → META_MEDIA_DOWNLOAD_ERROR, META_BAD_REQUEST → META_INVALID_PARAM, META_UPLOAD_SESSION_INVALID → META_GENERIC_ERROR. An older META_NO_CONVERSATION or META_TEMPLATE_NOT_APPROVED stays as stored, because it is ambiguous.Eleven more codes name failures the worker used to record only as free text, which read SEND_FAILED: connection and credentials, media download and conversion, message composition, and PROVIDER_UNREACHABLE for a provider that could not be reached on any attempt — full list. A few failures also move from DELIVERY_TIMEOUT or SEND_FAILED to a more precise code. These change what is recorded, so only messages that fail after the release get them.A new code, META_DELIVERY_UNKNOWN, marks a send on an official number that may have reached Meta but got no message id back: delivery is unknown, so do not resend blindly — the code’s row says how to check. A Meta media upload that returned no media id now reads META_MEDIA_UPLOAD_ERROR. Both apply only to messages that fail after the release.
TemplatesMessagesAPIChatwootShipped

Dynamic URL buttons of templates created in Meta’s console can be sent

A template created in Meta’s WhatsApp Manager with positional placeholders numbers its dynamic URL button’s variable on its own: in https://ctlnk.io/{{1}}, that {{1}} is not the body’s {{1}}. Until now there was no way to supply it — the send was accepted with 202 and then failed with Meta 131008 (“Button at index 0 of type Url requires a parameter”).Send it as button_<N> in variables, where N is the button’s position in the template’s button list, starting at 1 — every button counts, static ones included. Only the variable part goes in, not the full URL:
GET /v1/templates and GET /v1/templates/{id} list the key in variables — ["1", "2", "3", "button_1"] for the template above.A missing value is refused on the spot. A send without button_<N>, or with an empty value, now answers 400 MISSING_TEMPLATE_VARIABLES (or INVALID_TEMPLATE_VARIABLE_VALUE) naming the key, instead of 202 and a FAILED message later.Named placeholders are unchanged: a button created in Pilot Status as https://shop.com/promo/{{link}} is still filled by variables.link. DetailsCopy-code buttons on official numbers take the code on every send. For Meta, the code typed on a copy-code button at creation is only an example; the code the customer copies is a required parameter of each send. Until now Pilot Status sent none for a button without a {{variable}}, so Meta refused the send. On a template approved by Meta (source: "META"), such a button now takes its code as button_<N> too — "button_2": "BLACKFRIDAY" for buttons [QUICK_REPLY, COPY_CODE] — the GET lists the key, and a send without it is refused with 400. On unofficial numbers the code is per send too, through the button’s {{variable}}. DetailsChatwoot: /template fills button variables. The composer command accepts button_<N>=value among its arguments without consuming a positional number — /template link_pagamento_pix Maria, R$ 10, 21/09/2026, button_1=SdjLn — and templates sent with the structured parameters of Chatwoot’s WhatsApp template picker (processed_params with body and buttons) now reach the send, body parameters as variables and button parameters by position as button_<N>. DetailsTemplates with named variables created in Meta’s console now pick up later edits. A template created in Meta’s WhatsApp Manager with named parameters ({{first_name}}) kept the copy Pilot Status first imported: an edit made afterwards in Meta’s console — a new button, a changed text — never reached it, and the send could then be refused by Meta. The template sync now brings those edits in. Templates created in Pilot Status keep their variable names, as before. The button_<N> keys in variables are now also listed in button order.
WebhooksBreakingShipped

Official numbers no longer receive canonical message.* events

On a number connected through Meta’s official Cloud API, the canonical message.* family is no longer dispatched to your webhooks — not message.sent, not message.failed, none of it. Those numbers receive Meta’s native envelope, which is where a send’s outcome has always been: value.statuses[], carrying status and, on a failure, errors[].This is a change, not a clarification. Until today message.sent and message.failed did reach webhooks on official numbers that had subscribed to "*" — 3,310 sent deliveries to 19 webhooks in the 7 days before the change. If your receiver keys off those two events on an official number, it stopped hearing them today.What to use instead, in order of preference: read value.statuses[] from the native envelope you already receive; or poll GET /v1/messages?status=FAILED, which works on both connection types because the message row reaches FAILED either way — including for a failure Meta itself reported.Unofficial numbers are unaffected: message.* is dispatched there exactly as before. And on official numbers the number.*, call.* and flow.response_received families continue to arrive. Details
MessagesAPIShipped

Resend a failed message without keeping its payload

POST /v1/messages/{id}/resend sends the stored request of a FAILED or CANCELED message again. You no longer need to hold the original body on your side to recover from a failure.It creates a new message, with its own id and correlationId, and leaves the original row untouched — status, timestamps and provider message id all stay readable, so the two attempts remain distinguishable. The response is the same shape POST /v1/messages/send returns, plus originalMessageId. On a template with a Flow button it carries a new flowToken, so the answer that comes back is attributable to this attempt and not to the first one.The identifier is the same one GET /v1/messages/{id} accepts: internal id, correlationId, or the provider’s wamid.It counts against your quota, like any send, and it needs messages:resend (ADMIN/OWNER) — deliberately higher than messages:send, because a resend spends quota on a send already paid for once and can deliver twice.Before you automate a retry: a message that reads FAILED has already been retried by the platform — 10 attempts 30s apart, a reconciler, and a delivery check at 30 and 60 minutes. FAILED means we gave up, so an immediate resend usually fails for the same reason. Read errorMessage first and resend once the cause is fixed. SENT and QUEUED are refused with 409 MESSAGE_NOT_RESENDABLE for the same reason: those are still in flight, and resending gives one message two senders. Details
DashboardNumbersHistoryShipped

The number card tells you when WhatsApp history was lost

Live in production as of today.A “History not imported” warning on the Numbers page. When a Meta number’s device history arrived while history import was off, or Meta refused to send it again, the number’s card shows an amber badge. Open the card for the date, the number of events and what it means. It is the same information as historyImport on GET /v1/numbers/{id}. DetailsThe “Sync history” button is back. It had not been shown for coexistence numbers since June 30. If Meta already sent the number’s history, it refuses the new request, and the warning now appears on the card right away instead of on the next page load.Also fixed: some texts showed a doubled apostrophe (“I”m”), and the Pix help in template forms appeared in English for Portuguese and Spanish users.
NumbersHistoryAPIShipped

Remote pairing takes settings for Meta, and the number tells you what happened to its history

Live in production as of today.POST /v1/numbers/remote-pairing honours settings with provider=META. It used to ignore the block without a word, so a Meta link could not decide history on creation. It is now written on the placeholder the connection lands on, and the 201 returns the placeholder’s numberId together with the settings it was created with. If you were taking the id from inside the link’s token, read it from the body instead. DetailshistoryImport on GET /v1/numbers/{id}. discarded says the device history arrived while historyImportEnabled was false and was dropped; syncRefused says Meta refused to send it again. Both are final — Meta sends a number’s history once, and deleting and reconnecting does not repeat it. DetailsMedia from the history is imported. Meta sends it in separate deliveries after the conversations; those deliveries used to be ignored, so media messages from the history showed up empty. Media Meta never sent stays as a placeholder.Also: a history request Meta refuses because it was already made now answers 422 HISTORY_SYNC_LIMIT_REACHED on the dashboard’s sync button instead of a 500.
WebhooksAPIShipped

Webhook deliveries are signed, and number health is subscribable

Live in production as of today. Both of these change answers this page gave earlier the same day.Signing works now. Creating a webhook mints a secret and returns it in clear on that 201 — the only response in the life of a secret that carries it. Deliveries then include x-pilot-status-signature, the hex HMAC-SHA256 of the raw body.Webhooks created before today have no secret and stay unsigned: switching signature verification on underneath a live integration is its own outage. POST /v1/webhooks/{id}/rotate-secret turns it on for one of them, and is also the only way to recover a lost secret. Read hasSecret to tell the two apart before deciding whether your receiver may reject unsigned deliveries. Detailsnumber.health_* no longer needs "*". The family — number.health_degraded, number.health_blocked, number.health_shadowban, number.recovered, number.inbound_stalled, number.inbound_recovered — is in the event picker. It was always dispatched, but only to a webhook that had opted into everything, so subscribing precisely meant receiving no health signal at all. On a Meta number that is the whole story, since number.disconnected never fires there. DetailsIdempotency-Key on Meta numbers. The native envelope now carries the header, keyed <field>:<wamid>:<type> — deliberately not the wamid alone, because Meta delivers the same wamid twice with different content. The envelope body itself is unchanged: the key rides the header only, so what you receive stays byte-for-byte what Meta sent.Also shipped: origin and metaPricing on GET /v1/messages/{id} (Meta’s own billed category, no longer inferred from the template), and sendable + health on GET /v1/numbers/{id}/status for Meta numbers — additive, state there still reads OPEN by design.
WebhooksNumbersDocs

Four things the docs said that the platform does not do

Corrections, all of them load-bearing for an integration being built right now. Nothing changed in the product — these are the docs catching up to it.1 — A signing secret cannot be set. Anywhere. The webhook docs said it was configured on the dashboard’s Webhooks page. It is not: that form has name, url and events, and so does the API. The secret column exists and the delivery path honours it, but no surface writes to it — so x-pilot-status-signature is not sent today. Do not build a receiver that rejects unsigned deliveries; it would reject everything. Details2 — Idempotency-Key is not on every delivery. It is derived from a message id in the payload, so a body that has none gets no header and no idempotencyKey field. That includes every Meta Cloud API native envelope ({ object, entry }) and the number.* families — i.e. all of a Meta number’s conversation traffic. Details3 — On a Meta number, GET /v1/numbers/{id}/status is not a health check. Once the number has a Meta phone id, state there is always OPEN — it never consults health. A RESTRICTED number, or one whose quality fell to RED, answers OPEN there while GET /v1/numbers and GET /v1/numbers/{id} answer DISCONNECTED for the same number at the same instant. Read health.sendable for “can it send”. Details4 — Two pairing links, two different TTLs. The META Embedded Signup session lasts 30 minutes; the PILOT_STATUS QR token lasts 24 hours or until it connects. The docs quoted the 24h figure for both. Read expiresAt off the 201 instead of hardcoding either. DetailsDocumented alongside them, from the same pass: the full health block (portfolioMessagingLimitTier, code, reason and every value each takes), where the billed category actually lives (value.statuses[].pricing, not the message detail), the CTWA referral block and the duplicate-wamid trap, the number-health events that only reach "*" subscribers, the two array keys an echo can arrive under, and the real coexistence history window — 30 days, not six months.
NumbersAPI

platformType never says COEXISTENCE — and that is not a bug

A number in coexistence — still live in the WhatsApp Business app on a phone and connected to the Cloud API — reports "platformType": "CLOUD_API". Because it is on the Cloud API. That is what coexistence means, and Meta’s value set for that field has no COEXISTENCE member, so reading it to answer “is this number in coexistence?” always answers no.GET /v1/numbers and GET /v1/numbers/{id} now return a block that does answer it:
status is CONFIRMED, NO, LIKELY or UNKNOWN. evidence tells you where the answer came from, so you can separate an answer from a guess — Meta’s own is_on_biz_app field (also mirrored at meta.phone.isOnBizApp), what Meta replied when we asked for the on-device history, or the fingerprint Meta leaves on a number it refuses to register.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. Call ?refresh=1 on the number to pull Meta’s own answer.What coexistence changes in practice: Meta refuses /register for the number, so calling can never be enabled while it stays that way. Messaging is unaffected.
FlowsAPI

A Flow whose payload named the Form was published, opened, and then sent your endpoint nothing

If your Flow’s data_exchange payload referenced values as ${form_product.quantity} — the Form component’s own name as the prefix — Meta compiled the document, published it, reported validation_errors: [], and every entity came back AVAILABLE. Nothing anywhere said a word.Then, at runtime, your endpoint received this:
And the person on the other end saw only:

The prefix is a keyword, not a name

A form value is referenced by the literal keyword form. The name you gave the enclosing Form never appears in the reference:
For a field filled on an earlier screen it is ${screen.<SCREEN_ID>.form.<input name>} — the literal form is still in there.

What changed

POST /v1/flows/{id}/json now refuses such a document before it reaches Meta, and hands back the replacement already written out:
One entry per reference, deduplicated. The status is 422, the same as FLOW_JSON_INVALID — branch on code, not on the status.
Narrow on purpose, and a 200 is still not a verdict on your document. The check fires only when a reference’s root matches the name of a Form declared in the same document, which is the one case that provably cannot resolve on any version. A root we do not recognise is left alone — our ignorance must not become your error. Everything else is still Meta’s to judge.
A Flow already published elsewhere is not covered. The check runs on upload, so a document that reached Meta by another route keeps the defect. Published Flows are immutable at Meta: clone it, fix the references, publish the clone — and remember the endpoint URL is configured per Flow, so the clone starts with none.

While you are there: answer 200 on a business error

Any non-200 from your endpoint becomes that same generic sentence on the phone, whatever the reason. An error the person needs to read belongs inside the reply:
FlowsAPI

A data_exchange Flow could not be sent at all — the entry screen we added for you was the reason

If you sent a Flow with "action": "data_exchange", the message never arrived. Meta rejected it:

The screen was ours, not yours

Nothing in those requests was wrong. The body carried flowId, cta and action — no screen. We resolved the Flow’s first screen and added it for you, which is the right thing to do on a navigate send and the one thing Meta forbids on a data_exchange one: for that action the entry-screen payload is not optional, it is rejected.Measured in production on 10 September 2026: three sends of one account, none delivered, the 24-hour window open the whole time.

What changed

We no longer resolve or attach an entry screen when the action is data_exchange, and the field is dropped at the last step before the wire even if something upstream still carries it. A data_exchange Flow now sends, delivers and opens — verified end to end on 11 September 2026, INIT included.screen and data were always navigate-only; now the API says so instead of letting Meta say it:
Refused, not dropped. Silently discarding data would let you believe values reached the Flow that never left our process. The initial state of a data_exchange Flow reaches it through the INIT request Meta makes to your endpoint.
The flow block is also now in the OpenAPI spec and the Playground, where only the prose had it before.
FlowsWebhooksAPI

In a data_exchange Flow the answers were never in response — submitted carries them

We sent you to data.response for what a customer typed. On a data_exchange Flow that field is the empty place, and it always was.

What response actually holds there

response is Meta’s nfm_reply, parsed. On a Flow whose screens are answered by your API, the submission that closes the form carries the token and nothing else. Measured three times in production on 2026-09-06, this is the whole of it:
In that same submission your own endpoint received {"observacao": "Aobaaa", "foto": [...]}. The answers existed — they reached you screen by screen, inside the encrypted exchanges — and nothing here kept them, so every surface that offered to show you a data_exchange submission showed you a token instead.
This is the data_exchange case, not every Flow. A plain Flow — no endpoint, NAVIGATE between screens — delivers its free-text components in response, and always did. It is when your API answers the screens that the nfm_reply empties out, because the answers went to your API instead of into it.

New: submitted, on the event and on the read surface

flow.response_received (under data) and GET /v1/flows/{id}/responses now both carry submitted — the exchanges Meta sent us while the person was still filling the form, in order:
screen is a string or null, at is ISO-8601, and data is the parsed object or null.Empty is spelled differently on the two surfaces, and that is deliberate. On GET /v1/flows/{id}/responses submitted is [] and never null: the value comes from a lookup that answers nothing when there are no exchanges, and a field set to nothing would vanish from the JSON instead of arriving empty. On flow.response_received it is null and never [] — the rule flowMedia already follows there, for the reason that payload is stored: the redactor blanks a field only when it holds something, so an empty array would come back reading as “there were answers and we hid them”. Neither spelling is proof the person typed nothing.

Nothing is ever silently truncated

omitted is null or exactly one of three values. An entry either has its data with omitted: null, or data: null and the reason it is missing. There is no third shape, so a null data always has its answer beside it.Over-sized is measured in bytes, not characters. Accents and emoji cost more than one byte each, so DATA_TOO_LARGE can land on an answer that looks short.

The list is shorter than the endpoint traces, and that is the feature

Only data_exchange exchanges are captured — INIT and BACK are not. INIT hands back the state we seeded, the params sent at send time returned to us, and BACK is navigation. Neither is anything a person typed. So the Flow’s trace screen lists more calls than submitted lists entries. That difference is INIT and BACK, and the shorter list is the correct one.
submitted is the request Meta sends us, never what your endpoint replied. The screens and payloads your API returns are not captured here, and are not captured anywhere: extension_message_response still appears on no surface in Pilot Status. This field did not change that.
It is append-only, with no de-duplication. Somebody who steps back a screen and submits it again produces a second, genuine entry — both are real, and at is what separates two runs of the same form under one token.A number in RELAY_ONLY keeps nothing here. Not a blanked row: no row. The exchange is still decrypted and forwarded to your endpoint, as everything is in that mode; what is not kept is the copy.

Kept for 30 days + 1

Responses are pruned at 30 days, the exchanges behind them at 31, and the extra day is not a rounding. An exchange is captured while the customer is still filling the form, minutes before the submission that creates the response row. On an identical 30 days the exchanges would expire first, and a response in its last minutes of life would publish an empty submitted beside answers that had been there the whole time.See Receive Flow responses for the field on both surfaces, and Data Retention & PII Modes for the windows.
FlowsWebhooksAPI

endpoint_uri had no writer anywhere in the product, and a file attached inside a form reached no surface at all

Two holes on the same feature, both of the kind that reports nothing.

Everything was configurable except the one field Meta requires

The previous entry made the data_exchange proxy configurable: a key per number, a forwarding URL per Flow. Both said, out loud, that saving the URL registers nothing at Meta — and nothing else registered it either. There was no writer for endpoint_uri on any surface: not the dashboard, which showed FLOW_ENDPOINT_META_URI_UNKNOWN and offered no way to act on it, and not the API. A data_exchange Flow could be wired end to end and still not be published, because Meta refuses publish with “Publishing without specifying ‘endpoint_uri’ is forbidden” (Meta’s wording, not ours).New: POST /v1/flows/{id}/endpoint — the same path as the save, a different act. GET reads that state and PUT / PATCH store your destination; none of the three talks to Meta. POST writes our proxy URL into the Flow’s endpoint_uri at Meta.
The body is optional — an empty one is legal here, unlike on PUT — and confirmOverwrite is the only field it accepts. Anything else is 400 FLOW_UNKNOWN_FIELDS; whatsappNumberId in the body is 400 FLOW_NUMBER_FROM_KEY, because the number comes from the credential as everywhere in /v1; a truncated body is 400 FLOW_BODY_INVALID and is never read as an empty one. The 200 is the same endpoint state GET and PUT already returned, with no new field and no secret — POST never mints one.It is idempotent, and the check reads Meta live. A Flow already pointing at us answers 200 with no write to the Graph. It deliberately does not consult the hourly-synced metaEndpointUri column, because that column is silent about a URI registered in the last hour — exactly the window in which two people wire the same Flow.A Flow pointing somewhere else is refused, and the refusal names the other endpoint:
409, and one of the two bodies on this surface that carry a fourth key — FLOW_JSON_INVALID already adds validationErrors the same way. Ours is deliberately not echoed — our URL embeds the number’s endpoint token, which is the credential of the proxy. Re-sending with {"confirmOverwrite": true} takes the Flow over, and taking it over removes every exchange of that Flow from whoever answers it today: Meta’s write is a plain overwrite, with no merge, no undo and no notice to the endpoint that stops being called.confirmOverwrite is never coerced. A non-boolean is 400 FLOW_ENDPOINT_CONFIRM_INVALID rather than a truthy read — "false" is a truthy string, and reading it as consent would take over a live endpoint on a request whose author believed they had declined.
FLOW_ENDPOINT_NUMBER_HAS_NO_KEY now appears in two roles. It was only ever a value inside the warnings array of a 200; it is now also a 422 code with a body of its own, on the registration. Saving a destination on a key-less number still succeeds with a warning; registering at Meta cannot succeed at all, because there is no URL to register. A client that tested warnings.includes(...) can now receive the same spelling as a failure.

A file attached inside a form expired on Meta’s CDN and was never ours

A PhotoPicker or a DocumentPicker puts a file in the submission, and it never arrives in response. The bytes go to Meta, and the reference that reaches them — with the AES key Meta derives per file beside it — travels inside the encrypted data_exchange request that only the proxy decrypts, never in the nfm_reply that becomes response. Meta then deletes the file after at most 20 days, and nothing here decrypted it, copied it or listed it: there was no decrypter in the codebase at all, so the attachment was simply lost. It is now decrypted, re-hosted, and published on both surfaces and in the SDK types.flow.response_received now carries flowMedia:
And GET /v1/flows/{id}/responses now carries media on every response — the same files, settled, plus sizeBytes:
url is there on the first delivery, and it is a link that waits. The event is emitted as soon as the submission is stored and the re-host runs on a queue after it, so the submission is ordinarily announced while the transfer is still in flight. Rather than publish nothing across that window, url points at a route of ours: fetching it before the bytes land holds the request while the re-host finishes and then serves the file, every fetch after that is a redirect, and a wait that runs out answers 404 with a Retry-After header. The value does not change when the file lands, so it is safe to store on arrival — and status (PENDING / STORED / FAILED) is still how you know whether the bytes are ours. Treat the link as opaque: fetch it, never construct one.
url is still null in three cases, and “not transferred yet” is not one of them. A deployment with no signing secret configured falls back to the older behaviour — the direct object URL, once status is STORED; Meta sent no media_id to sign an identity over; or the number is RELAY_ONLY, where no attachment is stored at all.
mimeType is sniffed from the decrypted bytes, never from the file name, and is never text/html or image/svg+xml — both would execute script in the origin that serves them.
null is not proof that no file was sent, and neither is []. flowMedia is also null when Meta echoed no flow_token with the submission, when the attachment lookup failed — best-effort, costing a field rather than the event — and before the re-host job runs, since the row that would say PENDING is only written once the transfer actually starts, by the backend that performs it, and not at the moment the file is queued. The read surface has the mirror: its attachment join is best-effort too, so a join that fails leaves every row on the page with media: []. A Flow that collects files is reconciled against something you kept yourself.
fileName is never null. A photo taken in the camera reports no name, and the field’s own name is used in its place — so a fileName equal to field may be the device’s or ours, and the two are indistinguishable.Neither surface carries cdn_url or encryption_metadata. Meta derives one AES key per file; forwarding either would put the key that opens the customer’s document into your webhook and into your logs.
The re-hosted file sits at a public URL — whoever holds the link reads it — and the copy never expires. Responses, sessions and endpoint traces are pruned on a retention window; the file is not. Nothing in the platform deletes the row or the object, so the link outlives the response it belonged to: the answers stop being served, the link to the document keeps working. Decide that before a Flow of yours asks someone for an identity document.
Node SDK. FlowResponse.media and FlowResponseReceivedEventData.flowMedia are typed, and the webhook parser now declares flowMedia — that line is the whole point: z.object strips keys it was not told about, so a field on the wire and absent from the schema does not fail, it vanishes, and you see a submission with no attachments instead of an error saying the SDK is behind. It is .nullable().optional() on purpose: null is the emitter saying nothing is listed, absent is a backend older than the field, and rejecting the second would reject every event from an older deployment.

Four dashboard Flow error bodies were half an envelope, and one of them now reaches /v1

/v1/flows, /v1/flows/{id} and /v1/flows/{id}/json each translate their own write failures and have answered { error, errorEN, code } since their first line — no body and no string of theirs moved here. The table that was short of the envelope is the translator behind the dashboard’s Flow API (/api/flows/*), whose bodies were { error, code }: Portuguese only. Four now carry errorEN — FLOW_NOT_FOUND (404), FLOW_NOT_DRAFT (409), FLOW_REQUIRES_META_NUMBER (422), and the arm that relays a Graph refusal, the failure most likely to need a translated remedy (“the token expired”, “the 24-hour window closed”) and the only one of the four whose English half already existed and was being dropped on the floor.That last arm is the one an integrator can see. The registration is the only /v1 route that chains this translator, and the only one that talks to the Graph, so a Meta refusal on POST /v1/flows/{id}/endpoint arrives with both halves. The other three do not reach an integrator: the registration answers /v1’s own FLOW_NOT_FOUND and FLOW_REQUIRES_META_NUMBER bodies, which were already bilingual and did not move, and it never raises FLOW_NOT_DRAFT at all — registering an endpoint_uri does not require a draft.Two Portuguese messages also changed, and gained the remedy they were missing. They are the dashboard’s; codes and HTTP statuses are untouched:
FLOW_REQUIRES_META_NUMBER gained ”— select a Meta number to continue” the same way, and that remedy is deliberately not the one /v1 gives: there the number comes from the API key, so the sentence stays “use a key bound to a Meta number”. No /v1 message string moved. If you drive /api/flows/* directly and match on the message rather than on code, this breaks you; code is the contract and did not move.

A tenant-scoped key could send from a number the caller never asked for

Fixed. An account-wide (scope: "tenant") key names the number per request, with x-whatsapp-number-id. When such a key nevertheless carried a number of its own on its row — an operational mitigation, a migration, a hand-written UPDATE, all three of which happened — the worker re-resolved the send by that number and discarded the header in silence. The message left from the wrong WhatsApp number, reached the right person, and nothing anywhere reported it.A tenant-scoped key now honours the header it was given. A number-scoped key still wins over the header, and that is a rule rather than an omission: it is the same one the edge already enforces, because otherwise the header would let a key for number A act on number B.
WebhooksAPI

A failed delivery now says why: response carries the cause chain

When a delivery attempt never reached your endpoint, GET /v1/webhooks/{id}/logs used to store the same three words for every possible reason:
A hostname that did not resolve, a refused connection, an expired certificate and a timeout were indistinguishable in the log. The reason existed at the moment of failure — it lives in the error’s cause — and was thrown away before the row was written.response now carries the whole chain, outermost cause first, joined by <- and tagged with the system error code:
Nothing else changed: same endpoint, same fields, same types. response is still a nullable string, and a FAILED row with a real statusCode still carries your endpoint’s body.
response is a diagnostic string, not a contract — its wording comes from the runtime and can change. Branch on status and statusCode; read response to find out why.
See Reading delivery logs for the codes you are most likely to see and what each one means.
FlowsWebhooksChatwoot

Flow answers reach you: flow.response_received, and a data_exchange endpoint anyone can actually turn on

Two halves of the same hole, closed together.

The answers were stored and nowhere else

A submitted Flow arrives from Meta as interactive.nfm_reply, and the only human-readable thing in it is a label — Sent / Enviado. That label is what lands in content, in GET /v1/messages and in the Chatwoot mirror. The answers were written to storage and readable at GET /v1/flows/{id}/responses, but no live surface carried them: a webhook looked, for all practical purposes, empty.New event: flow.response_received, carrying the answers already parsed.
You have to subscribe to it. A webhook whose events list contains only messages does not receive it, and nothing reports an error — which is exactly what the original report looked like.
response holds the parsed answers; responseRaw holds the raw bytes and is populated only when the parse failed. A submission is never dropped for being unparseable.Chatwoot. Chatwoot’s own WhatsApp parser does not know nfm_reply and renders a submission with content: null. That is Chatwoot’s limitation, not the number’s. On conversations mirrored by Pilot Status the answers are now posted as a private note — visible to agents, invisible to the customer — and the public bubble is replaced with a short fixed line instead of Meta’s label. On Chatwoot’s native Cloud channel we are not in the path at all, so the event is the only way to read them there.

data_exchange: the proxy is now reachable

Pilot Status has been the encrypted endpoint Meta calls for data_exchange Flows — we decrypt, forward plain JSON to your API, and encrypt your reply. Until today there was no way to turn it on: the forwarding URL had no writer, and no number had a key.Both are configurable now — on the Flows screen, and on the public API: POST /v1/numbers/{id}/flow-endpoint-key for the key, PUT /v1/flows/{id}/endpoint for the URL, both gated at flows:manage. They sit at different levels, and the surfaces say so: the URL is per Flow (one number serves several forms), the key is per number (Meta stores exactly one public key per phone number).You can generate a key or import your own.
  • Generate — we create an RSA-2048 pair, store the private half encrypted, and register the public half with Meta. ⚠️ This replaces the key Meta holds for that number: if you already run your own data_exchange endpoint there, it stops decrypting. Rotating over a live key needs an explicit confirmation; a first-time setup does not, because there is nothing to destroy.
  • Import — hand us the key you already use. Nothing changes at Meta; the only Graph call on that path is a read. We derive the public half from what you sent and compare it with Meta’s: a match is stored and stamped; a mismatch is refused outright (FLOW_ENDPOINT_KEY_IMPORT_MISMATCH) with nothing written, because storing it would leave a number that reports configured: true and decrypts nothing; and a Graph call we could not make leaves the key saved with uploadedAt: null — marked unverified rather than lost. ⚠️ A passphrase-protected PEM is accepted, and the passphrase is used once to open it and is not stored — keep your own copy.
The endpoint contract, unchanged and now documented. We POST {version, action, screen, data, flow_token, flow_id, number_id} with an x-pilot-status-signature header — HMAC-SHA256 over the raw body, the same scheme as our outbound webhooks. You answer 2xx, valid JSON, containing screen; data is optional; do not send version (we echo Meta’s). Meta’s ping is answered by us and never forwarded.A 200 without screen is refused on purpose. Passed through, Meta renders it as nothing at all — the customer sits on a form that never advances and never errs. It is treated as a failed call instead: a retryable error over the screen they are already on.Saving the URL registers nothing at Meta — pointing Meta’s endpoint_uri at us stays a separate, deliberate act, so a rename cannot hijack a Flow already served by someone else’s endpoint. The endpoint resource reports metaEndpointUri, drift and a warnings list (FLOW_ENDPOINT_NUMBER_HAS_NO_KEY is the one that matters) so a save that changed nothing at Meta cannot look like one that did.New guides: Receive Flow responses and Connect your API to a Flow.
APINumbersBreaking

Default changed: a new number no longer imports the device’s history

settings.historyImportEnabled now defaults to false. A number you create from today on starts existing at the moment it connects — the up-to-30-days of conversation the device hands over on connection is discarded instead of stored.Numbers created before today keep the value they had. Nothing was rewritten, and a number that is already connected is unaffected.If you want the history, ask for it when you CREATE the number:
POST /v1/numbers and POST /v1/numbers/remote-pairing accept the whole settings block now, and the 201 echoes back what the number was created with.Creating and then PATCHing does not work, and that is why the block exists. WhatsApp hands the history over in a single burst right after the connection; the flag is read per message as those arrive; nothing can request them again. A PATCH sent after POST races that burst — on the pairing-link flow it has no chance at all, because whoever opens the link connects on the spot.In the dashboard the question is now part of the connect wizard, next to the phone number — the only moment where the answer still changes anything.Why the default flipped. Storing a month of conversations nobody asked for is the expensive default: it fills /chat with history the tenant already read on the phone, and every reconnect replays it. Off is the reversible choice for a new number; on is not, once the burst is gone.
APINumbersWebhooks

Turn WhatsApp Channels off on a number: ignoreNewsletters

Documented. settings.ignoreNewsletters on PATCH /v1/numbers/{id} drops WhatsApp Channel (@newsletter) posts on arrival: no conversation, no stored message, no message.newsletter webhook. It defaults to false, so nothing changes for a number that receives channels today.
The behaviour is not new — the field is reachable now. The API has accepted it for a week, but it was missing from the OpenAPI spec, so the playground had no box for it, and missing from the SDKs, the MCP server and the n8n node. The same shape as the caption fix in the 18 August entry: accepted by the API, invisible everywhere you would look for it.It reads like ignoreGroups and is not one of the advanced fields. The unofficial provider applies ignoreGroups itself, and has no @newsletter gate at all — a channel post always reaches the platform, so the only place it can be refused is here. The field takes a plain true / false, never the null that resets an advanced field to the provider default: for channels there is no provider default to return to.Python SDK: numbers.update() copies settings key by key from an allowlist that did not include this one, so the field was dropped in silence — the call returned 200 and the channels kept arriving. Fixed, shipping in the next release; curl and the playground are unaffected.
APIMessagesMedia

Video: .mov is converted for you, and the 16 MB ceiling is now explicit

Fixed. Sending a .mov video (QuickTime) to a Meta Cloud API number failed. The Cloud API accepts exactly two video types on upload — video/mp4 and video/3gpp — and .mov, which is what iPhone and most Android cameras record, is not one of them. The send failed with Received file of type 'video/quicktime'.You do not have to convert anything: .mov and .webm are now converted to MP4 automatically before the send. When the codecs already fit (H.264 + AAC, which is what phone cameras write) it is a container rewrite with no re-encode, so there is no quality loss.Video is capped at 16 MB. That is WhatsApp’s limit, not ours, and there is no way around it. Above it the send fails with the new META_MEDIA_TOO_LARGE code, which now carries the measured size instead of relaying a raw provider error. The other ceilings, for reference: image 5 MB, audio 16 MB, document 100 MB, sticker 100 KB static / 500 KB animated.Sending a video as mediaType: "document" no longer returns 202. It never worked — the document class has no video type in it, so a video sent that way was always refused downstream — and it is a common attempt at trading the 16 MB video ceiling for the 100 MB document one. On Meta Cloud API numbers the request is now rejected up front:
422, before the message is queued, instead of a 202 followed by an opaque failure. Unofficial (Pilot Status web) numbers are unaffected — they deliver a video-as-document fine, and the restriction is Meta’s alone.The conversion is decided by the file, not by the URL. The container is identified from the file’s own bytes, so a .mov is converted even when the URL carries no extension at all (a .bin object key, a signed URL) or carries a misleading one. This matters because a mislabelled video is the worst case there is: Meta accepts the upload, returns a message id, and then never delivers — with no failure surfaced. See the media guide.
APIMessages

Send stickers: mediaType: "sticker"

New. The direct-media mode of POST /v1/messages/send accepts mediaType: "sticker". It works on Meta Cloud API numbers and on unofficial (Pilot Status web) numbers alike — each one has its own sticker route underneath, and the API picks it for you.
A sticker carries no text. Sending caption alongside it is rejected with 400 rather than delivered without the text — no provider has a caption field on a sticker, so accepting it would mean the send “succeeds” minus what you wrote. mediaFilename is rejected for the same reason, and a sticker cannot be used as template media: a template header is TEXT, IMAGE, VIDEO or DOCUMENT, and there is no sticker header.The file must be image/webp and exactly 512×512 pixels — static up to 100 KB, animated up to 500 KB. The dimensions are not a guideline: a 361×363 sticker is rejected outright. These rules are WhatsApp’s, not ours, and WhatsApp is what enforces them: a wrong-sized, wrong-dimension or wrong-format file is still accepted here with 202 and fails later in the message.failed webhook, as META_MEDIA_UPLOAD_ERROR (Meta code 131053) with the exact reason in error_data.details. Convert before sending rather than relying on the API to catch it. See the media guide.If you were sending a .webp as mediaType: "image" to approximate a sticker, that delivered an ordinary image — switch it to "sticker" to get a real one.Also in this release: caption is now documented in the API playground. The field has always been accepted for image, video and document sends, but it was missing from the OpenAPI spec, so the playground had no box for it.
APIAds

Which ad each conversation came from — and the campaign’s name

New: GET /v1/referrals and GET /v1/referrals/summary. When someone taps a Click-to-WhatsApp ad and writes to your official-API number, WhatsApp tells us which ad they clicked. That was being received and discarded; it is now stored and readable. The first endpoint lists the inbound messages that came from an ad, the second groups them per ad with a conversation count.Meta attaches the origin to the first message of a conversation only and never repeats it, so one row is one conversation started by that ad. conversations counts distinct conversations and messages counts messages — two messages in one thread are 1 and 2, so conversations is the lead count.The names. Beside the raw sourceId, the referral object now carries adName, adsetId, adsetName, campaignId, campaignName and namesResolvedAt — the same labels you use in Ads Manager, rather than an opaque 120249828053880703.
Those six come back null until the advertiser shares the ad account with us, and null is a supported state, not an error. Reading an ad’s name needs ads_read on the advertiser’s own ad account, granted through Meta partner sharing: they add the Pilot Status business portfolio as a partner, with “view performance”. Until then the conversation is still counted and sourceId is still returned — only the names are null.
No endpoint resolves a name on demand. Resolution runs asynchronously, off the request path, so querying never triggers a Marketing API call and a null can become a name minutes later with nothing done on your side. namesResolvedAt is what makes that observable: null means “not resolved yet”, while a timestamp with null names means “we asked and Meta returned nothing” — a deleted ad, or sharing still missing.Attribution starts at this release: there is no backfill, so conversations that arrived before today carry no ad origin.Docs: Click-to-WhatsApp attribution.
ConnectEmbed

Styling the Connect button now renders the button

Behaviour change. A remote-pairing link minted with branding.button now renders only the button — no header, no card, transparent background. Until today that took a second step the response never mentioned: the host page also had to append ?mode=button to the iframe URL. Sending branding.button and opening the returned remotePairingUrl gave you the full connect page, which read as “my styling was dropped”.One object, both effects: style a button, get a button. Paste the returned remotePairingUrl into a browser and you see exactly what your customer will see.If you want the full page for a styled link, append ?mode=page (or pass mode: "page" to the SDK). That is the new explicit opt-out, and it keeps the token’s styling on the button inside the card. Nothing else moved: an unbranded link still renders the page, and an explicit ?mode=button still renders the button. metaFlow: "credentials" and QR links are unaffected — a credentials form and a QR timeline do not fit in a button, so they always render the page.@pilot-status/embed 0.2.0. ConnectOptions.mode now defaults to auto instead of "page": omit it and the SDK resolves the mode from the token, then sizes the iframe to match (48 px for a button instead of a 520 px minimum). Pass an explicit "page" or "button" to decide it yourself.Fixed: the served SDK bundle was stale. The embed.js bundle shipped to host pages had not been rebuilt since 1 July, so it predated button mode entirely — PilotStatus.connect.mount(el, { mode: "button" }) silently rendered the full page for every SDK caller. Rebuilt; the raw-iframe path was never affected.Fixed: the docs told you to load the SDK from a host that does not exist. Four pages — both Embed Connect pages and both Embed Chat pages — carried <script src="https://embed.pilotstatus.com.br/embed.js">. That hostname has no DNS record, so the script tag failed to resolve and PilotStatus was never defined: nothing on the page worked, and the browser console blamed DNS rather than us. The SDK has always been served from https://pilotstatus.com.br/embed.js (which is what the Embed Sessions page already used). All six references now point there. If you copied the old snippet, change that one URL.Docs: Embedded Signup and Button mode.
WebhooksDocs

Every webhook event, in one place — including Meta’s

Webhook events now opens with a map of which vocabulary each number speaks, and documents the Meta Cloud API side that was missing: all 23 subscribable field names (messages, message_echoes, the five template fields, phone_number_quality_update, the account family, calls, flows, the four group fields, payment_configuration_update, user_preferences), what each one reports, and which need a paid plan.Also written down for the first time: Meta numbers deliver Meta’s native envelope and never message.received (events: ["*"] does not convert it); each change is delivered on its own, so entry and entry[0].changes always hold exactly one element; and nine of Meta’s fields are never forwarded to a customer webhook, not even to a "*" subscription — account_alerts, automatic_events, history, messaging_handovers, partner_solutions, security, smb_app_state_sync, standby, tracking_events.
APIWebhooks

message.stories, and group/channel events are no longer paid-only

New event: message.stories. WhatsApp Status (stories) posted by your contacts are now delivered as a webhook event, with the re-hosted media in mediaLink. Subscribe to it — or to the "*" wildcard — and it starts arriving.It is demand-driven, like groups: the provider does not send Status at all until some webhook asks for it, and stops again when the last subscription goes away. That is deliberate. Before this change a connected number received the Status of every contact it could see and the platform discarded all of them at the end of the pipeline — measured at 1,869 events a day, 17% of everything entering ingestion, with no consumer.A Status is not persisted: it creates no conversation and no message, and does not appear in GET /v1/messages. WhatsApp expires stories after 24h; the mediaLink we re-host is durable, the story is not. There is no to field either — a Status is broadcast to the poster’s contact list, not addressed to your number.message.group, message.newsletter and message.stories are no longer paid-only. They were hidden from the event picker on non-paid plans; that restriction was legacy and has been removed. All three are selectable on any plan.
APIWebhooksDashboard

Number settings, and history no longer floods your webhook

Behaviour change. When a number connects, WhatsApp hands the provider the device’s message history. Until now those old messages were forwarded to your webhook as message.received / message.group / message.newsletter — meaning every reconnect re-delivered up to 30 days of conversation, with nothing in the payload to tell it apart from traffic that had just arrived. They are still imported into the chat; they are no longer delivered. If you want the old behaviour on a number, turn on settings.webhookHistoricalMessages.createdAt now means what it says. On message webhooks it carries when the message actually happened, per the provider, instead of when we processed it. Same field, same type, same ISO 8601 format.New: PATCH /v1/numbers/{id}. Per-number configuration, partial — retention policy plus a settings block covering history behaviour and, for unofficial (QR-paired) numbers, the provider’s advanced settings: rejectCall, msgRejectCall, alwaysOnline, readMessages, ignoreGroups, ignoreStatus. See the reference. If you were calling POST /v1/numbers/{id}/settings, that route never existed and was silently 404-ing.Unknown /v1 paths now return JSON. A wrong path used to render an HTML 404 page, which broke typed HTTP clients without telling you why. It now answers { "error": "Not found", "code": "ROUTE_NOT_FOUND" } like every other error on the API.Available in the Node (0.5.0), Python (1.4.0) and n8n (1.3.0) SDKs, and in the MCP server.
API

Logout WhatsApp instance endpoint

New endpoint POST /v1/numbers/{id}/logout to disconnect a WhatsApp instance without removing it from the database. This is useful when you need to temporarily disconnect a number for troubleshooting or reconnection to a different device, without losing the instance configuration. See the POST /v1/numbers/{id}/logout reference.
API

Voice notes with waveform (voice mediaType)

A new "voice" mediaType gives you control over how audio messages render. "audio" sends a voice note (PTT) in the default player; "voice" sends it with the waveform (“ondinha”) visualisation. On Meta Cloud API numbers the waveform is enabled when the server sends voice: true in the audio payload. Both types are normalised server-side to OGG/Opus mono (stripped metadata, zero start-time), so every audio file that reaches the recipient is compatible even when the original upload was a browser WebM or an iOS recording. See the POST /v1/messages/send reference.
APIDashboard
Building a carousel in the template editor is now faithful end to end. The live preview renders the card strip again (it previously showed nothing), the editor enforces a carousel’s exclusivity — a carousel can’t carry a header, template buttons, a list button or an order card, so those controls hide or disable while a carousel is present — and {{variables}} inside a card (its body or a button) are now recognised and substituted in the preview instead of being left as raw tokens.On the API side, the carousel field is now in the OpenAPI spec for POST /v1/messages/send, so it shows up in the interactive playground and in generated clients. We also corrected the docs: a URL button in a session carousel is impossible only on Meta numbers — on unofficial (Pilot Status web) numbers a URL button sends ad-hoc.
APIDashboard
Send a carousel — a horizontally scrollable set of media cards — in a single message. A new carousel field on POST /v1/messages/send carries 2–10 cards, each with its own image, a short body line and up to two buttons, and the template editor gains a carousel builder with a live, draggable preview so you can reorder cards as you compose them. A session carousel is free-form and needs no approval, but its buttons are quick-reply only and it only delivers inside the 24-hour service window — the same request works on official and unofficial numbers. A template carousel is an approved Meta template instead, so it delivers at any time and a card may mix a quick-reply button with a URL button.
API

Template deletion reports the truth

DELETE /v1/templates/{id} no longer reports success when Meta refuses to remove a template. When Meta rejects the deletion — for example when the access token lacks permission on the WhatsApp Business account — the endpoint now answers 409 with a bilingual message naming the template and the reason, and the template is kept locally because it still exists on Meta. Only a genuine removal on both sides returns { "deleted": true }.
DashboardChat

Delivered buttons, carousels and order cards render in chat

Messages that arrive carrying buttons, a carousel or an order card now render as their real bubble in the conversation and the message logs, instead of collapsing to plain text. Template replies show their buttons, carousels scroll through their cards, and order cards list their items — so the thread reads the way the recipient actually saw it.
Dashboard

A revoked Meta number is detected

When a number connected through the official Meta API loses access — the app was removed from the WhatsApp Business account, or the WABA itself was deleted — the dashboard now detects it and badges the number “Conexão perdida”. Previously the number simply went quiet with no sign that Meta had cut access; the badge now tells you to reconnect.
Fix
URL-button examples no longer double the base URL when the example value already includes it, so the previewed and sent link is the real one. And a template send now passes the body’s own variables, so a template that uses a {{variable}} in both its body and a button no longer fails with a parameter-count error.
Connect

Button-only embed for Embedded Signup

The hosted Embedded Signup gains a button-only embed mode: place just the Facebook connect button on your own page instead of the full flow. The button’s styling travels in the embed token, and a completed connection reports back paired with the connected number.
API

The tenant API key can now act on any number

A tenant-scoped key could manage numbers but not use them — sending required a separate per-number key. It can now call any per-number endpoint of any number in the tenant, including sending, by naming the number in the x-whatsapp-number-id header (it accepts the id from GET /v1/numbers). Without the header, per-number endpoints still answer 403 TENANT_SCOPE_NOT_ALLOWED; an id from another tenant answers 404. See Authentication.
DashboardWebhooks

Webhook deliveries are scoped to the active number

The Webhooks screen lists deliveries for the number you have selected. Delivery rows recorded without a number — message-status events — used to appear under every number at once; they are now attributed to the number that produced them.
DashboardTemplates

Build lists and payments as template buttons

Botão de Lista in the template editor builds an interactive list — button label, sections and rows — with live counters and a live preview that opens the options as a bottom drawer, the way the WhatsApp client does. A template carrying a list is sent as an interactive message rather than an approved template, so on official numbers it is not submitted to Meta and only delivers inside the 24-hour service window; the editor says so while you build.Botão de Pagamento now holds both payment shapes in one place: the PIX copy-code button and the itemized order card, which used to live in a separate section below the editor. Templates saved before this keep working unchanged.
Dashboard

Numbers that stop receiving are detected

A WhatsApp session can wedge one way: the number keeps sending, the connection reports itself healthy, and no incoming message arrives for hours. The dashboard now watches each number’s own inbound rhythm and flags it when messages stop arriving while sending still works, so the badge stops claiming health it has not verified.
Dashboard

Web shadowban flagged on the first refusal

When WhatsApp refuses sends over an unofficial connection, the number is now flagged immediately instead of after three refusals in the same window — a tenant that sends through the API a few times a week could previously stay blocked for weeks behind a green badge. The flag clears on its own as soon as a send succeeds again.
Dashboard

Compose lists from the chat

The chat composer gains a Lista action: build the button label, sections and rows with live validation and a live preview, then send — on official and unofficial numbers alike. Sent lists now render as a real list bubble in the conversation and in the message logs, and replying to a message while sending a list keeps the quote.
API

List messages

New list field on POST /v1/messages/send — send an interactive single-select list: text becomes the message body and buttonText opens a menu of up to 10 rows grouped in up to 10 sections. Works on official and unofficial numbers with the same request, and the recipient’s selection arrives as a normal inbound reply.
DashboardTemplates

Template insights

Every template already submitted to Meta gains an insights button on the templates list: sent, delivered and read counts with read rate, clicks per button, quality and status badges, and a 7/30/90-day period selector — powered by Meta’s template analytics (enabled automatically on your account the first time you open it).
Dashboard

Business profile preview on official numbers

The block of a number connected via the official Meta API now shows the WhatsApp business profile preview exactly as customers see it — photo, name, phone, description, category and website — with a Compartilhar button that copies the number’s wa.me link.
APITemplatesPayments

Service window API, provider-aware template preview and PIX payments

New endpoint GET /v1/service-window — check whether the 24-hour service window is open or closed for a contact before sending: pass destinationNumber, get { open, windowType, lastInboundAt, expiresAt }. Meta numbers report the real 24h window; unofficial numbers always report open.Template editor preview now renders exactly what the recipient sees for the connected number: the Meta template card on official numbers, and the delivered text + buttons form on unofficial numbers — with live {{variable}} substitution using your example values, and the preview column following the page scroll.PIX payment button (PAYMENT_REQUEST) now works on every number type: official numbers with WhatsApp Pay render the native payment card, and unofficial numbers deliver a “Copiar código Pix” copy button carrying the full BR Code (no length limit).Itemized order card: templates can carry an order block (items, quantities, total and payment method). Unofficial EVO_V2 numbers deliver it as a real order card — accompanied by the template’s text and buttons in a first message when present. Numeric fields (price, quantity, totals) accept a {{variable}} resolved at send time.Also: the “send via API” modal now shows the template name as templateId, and ORDER_DETAILS template submission to Meta is attempted for payments-enabled accounts instead of being refused locally.
API

Message pricing API

New endpoint GET /v1/meta/pricing — the published Meta / WhatsApp per-message rate for a market + currency + category (e.g. ?market=BR&currency=BRL&category=marketing → { "pricePerMessage": 0.3217 }). Call it with no params to list the available markets, currencies and categories, and add ?tiers=1 for the volume tiers. Also available as the meta_pricing_get MCP tool and in the interactive playground.
API

List your message logs over the API

New endpoint GET /v1/messages — page through the messages sent and received by your number without opening the dashboard. Filter by direction, status, date range and phone number, and each row carries the same messageId you already use with GET /v1/messages/{id}, so a list call and a status call line up. Also available as the messages_list MCP tool.
Dashboard

Logs filters, and a badge for the 24-hour window

The Logs screen gained the filter set it was missing: search a phone number in any format, search by template name, and narrow by status, direction, origin and whether the message errored. A new “Janela 24h” badge marks the messages that were sent inside the 24-hour service window — the ones that did not need an approved template — and doubles as a filter, so you can separate free-form traffic from template traffic at a glance. See Logs & Analytics.