Skip to main content
Todo endpoint abaixo autentica com uma chave de API com escopo de número (x-api-key) cujo número seja Meta (API Oficial). A WABA é DERIVADA desse número — não existe parâmetro para nomear uma, de propósito.
Ler a chave do endpoint ou o endpoint do Flow exige flows:manage, não flows:read, mesmo os dois sendo GET. As respostas carregam endpointUrl / endpointUri / metaEndpointUri, e o token dentro dessas URLs é uma credencial: a Meta se autentica no endpoint DECIFRANDO, e a pública correspondente é pública por construção. Quem tiver as duas consegue forjar uma requisição que o Pilot Status decifra e repassa ao seu webhook com a nossa assinatura. Credencial só de leitura continua sendo credencial — e é também por isso que o valor nunca aparece no GET /v1/flows.
flows:read é um slug próprio e não é templates:read. A listagem de templates devolve texto que o seu workspace escreveu; a resposta de um Flow devolve o que um cliente digitou num formulário — um nome, um telefone, às vezes um documento. As duas coisas são gateadas separadamente de propósito.

Listar Flows

id é o id local e é o único id que você manda de volta para nós. metaFlowId é o da Meta e é somente leitura — vem null enquanto o Flow existe aqui e ainda não chegou à Meta.

Criar ou clonar

Devolve 201 com o Flow novo em DRAFT. Omita cloneFromFlowId para começar do zero.
cloneFromFlowId é o id local do Flow de origem — o campo id, não o metaFlowId. Clonar é a única forma de mudar um Flow que já foi publicado.

Renomear e recategorizar

Aceita apenas name e categories. Nunca publica e nunca deprecia — essas têm endpoint próprio, para um engano num formulário não alcançar uma operação irreversível.

Subir o Flow JSON

Um 200 significa que o documento compilou, e aí validationErrors vem sempre vazio:
Documento que a Meta recusa volta como 422 FLOW_JSON_INVALID, com a lista dela anexada:
Isto NÃO é como a API da Meta se comporta, de propósito — e a diferença é o ponto.A Meta responde 200 com os problemas listados dentro do payload. Ou seja: lá, sucesso aparente sobre documento inválido é o caso normal, e um cliente que olha só o status publica um Flow que nunca compilou.Esta rota lê a LISTA em vez do status da Graph, e responde 422 sempre que ela não está vazia. Um 200 nosso significa, então, o que um 200 deve significar. Leia validationErrors no 422 — é a resposta da Meta, verbatim.E uma recusa aqui é NOSSA, não da Meta — veja FLOW_JSON_DEAD_FORM_PREFIX abaixo. Aquele documento a Meta teria aceitado.flowJson é uma string, nunca um objeto aninhado: a Meta compila os bytes exatos, e re-serializar reordenaria em silêncio o que você escreveu.

A única recusa que é nossa: FLOW_JSON_DEAD_FORM_PREFIX

Um valor de formulário é referenciado pela palavra-chave literal form — ${form.quantidade}. O name do componente Form que envolve os campos nunca entra na referência: com um Form chamado form_produto, continua sendo ${form.quantidade}. A Meta ACEITA um documento com o prefixo errado, responde validation_errors: [], publica — e em execução manda "data": {} ao seu endpoint, onde o único sintoma é o telefone da pessoa dizendo “Could not complete this step. Please try again.” Desde 12 de setembro de 2026 esta rota recusa esse documento em vez de enviá-lo:
Cada entrada de deadRefs traz a substituição pronta, então o conserto é colar em vez de aplicar uma regra à mão.
Estreita de propósito, e um 200 aqui não significa “o documento é válido”. A checagem só dispara quando o root de uma referência coincide com o name de um Form declarado no MESMO documento — esse caso não resolve, nunca. Um root que não reconhecemos passa: a nossa ignorância não pode virar erro seu. Todo o resto continua sendo a Meta a julgar, e continua chegando como FLOW_JSON_INVALID.
Cada item de validationErrors é da Meta, verbatim — error, error_type, message e pointers nomeando os componentes recusados. Um que você vai encontrar se um Flow coleta arquivos é o COMPONENT_TYPE_MAX_NUMBER_PER_SCREEN: no máximo um PhotoPicker ou DocumentPicker por tela. Veja Flows com ele citado por inteiro. O SDK de Node lê o array com flowJsonValidationErrors(err).

Publicar

Irreversível na Meta. Um Flow publicado nunca mais pode ser editado nem despublicado — só clonado num novo. "confirm": true é obrigatório; sem ele a requisição é recusada com 400.

Depreciar

Aposenta o Flow sem apagar, e sem apagar as respostas já enviadas. supersededByFlowId é opcional — mas, se você mandar, tem de nomear um Flow real seu: string vazia é recusada em vez de ignorada em silêncio, porque cadeia de versões rompida é invisível depois.

Apagar

Só um DRAFT pode ser apagado. Qualquer outro estado devolve 409.

Ler as respostas dos formulários

Devolve o que os clientes enviaram, ligado ao contato que respondeu, dentro da janela de retenção. Resposta vencida nunca é servida. Num Flow data_exchange as respostas chegam tela a tela, e é em submitted que elas ficam — uma entrada por troca, da mais antiga para a mais nova:
Num Flow data_exchange, o response é o lugar vazio — o que a pessoa digitou está aqui. O response é o nfm_reply da Meta, parseado, e num Flow com endpoint esse reply carrega só o flow_token — {"flow_token":"a1b2c3…"} — enquanto o seu próprio endpoint recebeu {"observacao":"Aobaaa"} na mesmíssima submissão. Quem lê o response ali encontra um token e conclui que a pessoa não enviou nada.Num Flow sem endpoint vale o contrário: não há trocas para capturar, submitted vem [], e os componentes de texto livre chegam no response exatamente como sempre chegaram.
Aqui o submitted é sempre um array — [] quando não há nada, nunca null. ⛔ No webhook flow.response_received o mesmo campo é o contrário: null, nunca []. Isso não é uma inconsistência à espera de ser arrumada, e “o mesmo campo se comporta igual nas duas superfícies” é justamente a suposição que mais custa caro. As duas resolvem problemas diferentes.Nesta superfície de leitura o valor vem de uma busca que responde nada para uma submissão sem trocas capturadas — e um campo posto a nada desaparece do JSON em vez de chegar vazio. O [] é o que mantém a chave presente, e o que mantém “nada foi capturado” separado de “não conseguimos olhar”.No evento o payload também é guardado, e o redator só apaga um campo que tenha algo dentro. Um array vazio ali voltaria como o marcador de “conteúdo indisponível” e seria lido como “havia respostas e nós escondemos” — então lá o valor honesto é null.Portanto ramifique por submitted.length aqui e por submitted !== null no evento. Nenhuma das duas grafias é prova de que a pessoa não digitou nada. O media logo abaixo segue a regra desta superfície também: sempre um array, [] quando nada foi anexado.
A lista é mais curta do que as trocas que o seu endpoint respondeu, e está certa assim. Só as trocas data_exchange são capturadas. O INIT carrega o estado que nós semeamos — os params mandados no envio, devolvidos de volta — e o BACK é navegação; nenhum dos dois é algo que uma pessoa digitou. Contar as entradas contra os seus próprios traços e reportar a diferença como perda é justamente o engano que este parágrafo existe para evitar.É a requisição que a Meta manda para nós, nunca o que o seu endpoint respondeu. O data que você devolve no SUCCESS, extension_message_response incluído, é cifrado de volta para a Meta e não é anotado em lugar nenhum do Pilot Status — nem aqui, nem em nenhuma outra superfície.É append-only, e não há deduplicação. Quem volta uma tela e envia de novo produz uma segunda entrada legítima em vez de substituir a primeira, e o at é o que separa duas passagens sob um mesmo flow_token.
Nada é truncado em silêncio — entrada sem conteúdo diz o porquê. Uma troca grande demais mantém a linha e o lugar dela na ordem e diz DATA_TOO_LARGE; uma que não deu para ler diz UNREADABLE. O teto de 50 trocas por formulário fecha a lista com uma entrada final dizendo LIMIT_REACHED, e nada depois dela foi guardado. O teto de tamanho — o que está por trás do DATA_TOO_LARGE — é medido em bytes, não em caracteres, então texto acentuado chega nele antes do que o comprimento sugere.
Um número em RELAY_ONLY não guarda nada aqui — nem entrada redigida, entrada nenhuma: não existe cópia guardada para redigir.Estas entradas são podadas em 30 dias + 1, e o dia a mais não é arredondamento. Uma troca é capturada enquanto a pessoa ainda está preenchendo o formulário, minutos antes da submissão que cria a resposta ao lado dela. Com 30 dias idênticos, as trocas venceriam primeiro, e uma resposta ainda sendo servida responderia submitted: [] — indistinguível de um formulário em que ninguém digitou nada.
Cada resposta traz um array media — os arquivos anexados dentro do formulário, re-hospedados por nós:
Aqui media é sempre um array — vazio quando não houve anexo — e isso deliberadamente não é o que o webhook faz. O flowMedia do flow.response_received é null em vez de [], porque o redator de armazenamento só troca um campo que tem algo dentro, e um array vazio seria lido como “houve anexos e nós escondemos”. Ela também traz sizeBytes, que o evento não traz.[] não é prova de que não havia arquivo. O join dos anexos é best-effort de propósito — um anexo que não conseguimos ler custa um campo, nunca a página — então um join que falha deixa todas as linhas daquela página com media: [], ou seja, “não conseguimos olhar” servido como “não tinha nada”. Reconcilie um Flow que coleta arquivos contra algo que você mesmo guardou.É aqui que o registro é definitivo — a URL já era definitiva no evento. A re-hospedagem é assíncrona: o evento é emitido assim que a submissão é gravada, e a transferência roda depois dele, então o evento normalmente chega antes de os bytes estarem no lugar. O url é um link para uma rota nossa e está presente durante tudo isso, também numa linha PENDING — buscá-lo espera pelo arquivo e então o entrega, e só responde 404 com o cabeçalho Retry-After se essa espera estourar. Ou seja: releia este endpoint para ter status, mimeType e sizeBytes, não para obter uma URL que o evento já te deu. O url só vem null quando a instalação não tem segredo de assinatura (aí ela cai na URL direta do objeto assim que o status for STORED), quando a Meta não mandou media_id, ou num número RELAY_ONLY.
O objeto guardado fica numa URL pública — quem tem o link lê o arquivo. É uma decisão explícita do dono, e a mesma propriedade que o mediaLink de entrada tem. Não existe cdn_url nem encryption_metadata nesta superfície nem em nenhuma outra: a Meta deriva uma chave AES por arquivo, e nem a chave nem a referência do CDN são guardadas ou publicadas.O tipo de conteúdo vem dos bytes e nunca é text/html nem image/svg+xml, com os bytes parecendo o que parecerem — os dois executariam script na origem que os serve.E a cópia não vence. Respostas, sessões e traços do endpoint são podados por uma janela de retenção; o arquivo não. flow_media_assets não tem coluna de expiração e nada na plataforma apaga a linha nem o objeto, então o link continua funcionando depois que a resposta a que ele pertencia já parou de ser servida aqui.
Esta é a cópia armazenada, não a ao vivo. Para receber as submissões conforme chegam, assine o evento de webhook flow.response_received — as respostas vêm nele, já parseadas. A mensagem de chat que carrega uma submissão traz só o rótulo da Meta (Enviado / Sent), então um webhook assinado apenas em messages não vê nada. Veja Receber as respostas de um Flow.
Uma página curta pode ser uma página truncada — e ela avisa. FlowResponse não tem coluna flowId — a única aresta do Flow para as respostas passa pelo token da sessão — então uma requisição correlaciona no máximo 10.000 envios daquele Flow. Passando desse teto, o payload traz "notice": "FLOW_RESPONSES_WINDOW_TRUNCATED" ao lado dos normais responses/total/page/pageSize/totalPages.É um 200, não um erro, e o campo some quando nada foi cortado. Leia: quem reconcilia contra uma página curta sem olhar o notice conclui que perdeu envios.

Chave do endpoint data_exchange

Só é necessária para Flows cujas telas chamam o seu backend — veja Conecte a sua API a um Flow para o quadro completo. {numberId} é o número ao qual a sua chave está amarrada. A chave privada nunca sai do Pilot Status e não é devolvida por endpoint nenhum; endpointUrl é a base para registrar no seu Flow, com o id do Flow acrescentado como segmento final.A chave é por número; a URL de encaminhamento é por Flow. As duas têm superfície aqui — a chave logo abaixo, a URL em Endpoint do Flow — e nenhuma das duas aparece no GET /v1/flows, de propósito: uma chave de API pode ser uma aplicação de terceiros, e o endereço do seu webhook interno não é algo que uma integração somente-leitura tenha direito de receber.
As duas rotas agem sobre o número da própria chave, e o {numberId} do caminho tem de bater com ele. Uma chave escopada por número já nomeia o seu; uma chave de conta precisa estreitar com x-whatsapp-number-id. Um id de caminho que não bate responde 404 FLOW_NUMBER_NOT_FOUND — nunca 403, e o mesmo corpo quer o id seja de outro tenant, quer seja de outro número seu, para que um palpite errado não aprenda nada.uploadedAt é null quando um par foi gerado aqui mas a Meta nunca aceitou. Isso não é o mesmo que não ter chave, e é o estado que um POST repetido RETOMA em vez de substituir.

Ler o estado atual

UNKNOWN não é NOT_SET, e juntar os dois sai caro. Uma chamada que falhou não diz nada sobre o que a Meta tem. Tratá-la como “sem chave” leva a rotacionar — e rotacionar substitui uma chave que provavelmente estava boa.

Gerar, rotacionar ou importar

Um POST, dois verbos. A presença de privateKey decide qual, e eles não são intercambiáveis: gerar registra uma chave pública nova na Meta e substitui o que estava lá; importar não registra nada. Só confirm, privateKey e passphrase são aceitos; qualquer outro campo é recusado, nunca ignorado. A resposta é a forma de leitura acima mais "replaced", que diz se uma chave que estava viva acabou de ser deslocada — um 200 sozinho não diz.
Substituir uma chave VIVA exige {"confirm": true}, nos dois verbos.Gerar é destrutivo na Meta: ela guarda exatamente uma pública por número, então registrar outra substitui a anterior — e a privada antiga é sobrescrita aqui no mesmo instante. Não dá para restaurar, e toda troca data_exchange em voo naquele número para de decifrar.Importar é destrutivo aqui: nada muda na Meta, mas a metade privada guardada é substituída, e se a Meta não conseguir confirmar a nova o número fica com uma chave que pode não decifrar nada. Por isso os dois são recusados com códigos diferentes — FLOW_ENDPOINT_KEY_REQUIRES_CONFIRMATION e FLOW_ENDPOINT_KEY_IMPORT_REQUIRES_CONFIRMATION — porque quem lesse “isto substitui o seu registro na Meta” depois de uma importação iria conferir na Meta uma mudança que nunca aconteceu.⚠️ A primeira configuração NÃO pede confirmação, e retentar uma chave que a Meta nunca aceitou (uploadedAt: null) também não — não há o que destruir em nenhum dos dois casos. Confirmação exigida onde nada se perde treina o reflexo que a desarma no único caso em que ela protege.
Uma importação é verificada antes de ser guardada. Derivamos a metade pública da chave enviada e comparamos com a da Meta: coincidiu, é guardada e carimbada (metaStatus: "VALID"); divergiu, é recusada de saída com FLOW_ENDPOINT_KEY_IMPORT_MISMATCH e nada é gravado; se não conseguimos fazer a chamada ao Graph, a chave fica guardada com uploadedAt: null e metaStatus UNKNOWN/NOT_SET — que é o estado “não confirmada”, não uma falha.
passphrase é usada uma vez e não é guardada. Ela abre o PEM; a chave passa a ser mantida normalizada e cifrada em repouso, e depois disso a passphrase não tem mais leitor. Guarde a sua própria cópia — não conseguimos devolvê-la. Envie só quando a chave tiver uma (string vazia é uma passphrase, não a ausência de uma), e nunca sozinha: passphrase sem privateKey é recusada com FLOW_ENDPOINT_KEY_PASSPHRASE_ORPHAN, porque senão essa mesma requisição geraria um par e substituiria o seu registro na Meta.

Endpoint do Flow

Para onde as trocas já decifradas de um Flow data_exchange são encaminhadas. Por Flow, flows:manage em todos os verbos inclusive no GET, e {flowId} é o id local do GET /v1/flows. Um caminho, dois atos diferentes. O GET lê esse estado e o PUT / PATCH guardam o seu destino; nenhum dos três fala com a Meta. O POST registra o nosso proxy como endpoint_uri do Flow na Meta.
Campos do corpo: url (obrigatório, https:// ou um null explícito para limpar — nunca omitido) e rotateSecret (booleano). PUT e PATCH são a mesma operação. O número vem da credencial e é recusado no corpo (FLOW_NUMBER_FROM_KEY).Salvar a URL não registra nada na Meta — isso é um ato separado sobre um sistema separado, para que um rename não sequestre em silêncio um Flow já apontado para o endpoint de outra pessoa. metaEndpointUri, drift e warnings são como você enxerga a diferença.

Registrar o endpoint_uri na Meta

Um POST no mesmo caminho escreve endpointUri no endpoint_uri do Flow na Meta. O corpo é opcional; confirmOverwrite (booleano) é o único campo aceito, e o número vem da credencial como em todo o resto. A resposta é o mesmo estado acima sem secret — o POST nunca cria nenhum.
Um Flow data_exchange não pode ser publicado enquanto isso não for feito. A Meta recusa o publish com “Publishing without specifying ‘endpoint_uri’ is forbidden”.
É idempotente. Um Flow que já aponta para nós responde 200 sem escrever nada na Meta. A verificação lê a Meta ao vivo em vez da coluna metaEndpointUri sincronizada de hora em hora, porque essa coluna é muda sobre uma URI registrada na última hora — exatamente a janela em que duas pessoas configuram o mesmo Flow.
O 409 FLOW_ENDPOINT_URI_WOULD_OVERWRITE nomeia o endpoint que você está prestes a deslocar, em currentEndpointUri. O nosso não é ecoado — ele carrega o token de endpoint do número, que é a credencial do proxy.Reenviar com {"confirmOverwrite": true} assume o Flow, e isso tira todas as trocas dele de quem responde hoje. A escrita da Meta é uma sobrescrita simples: sem mesclagem, sem desfazer e sem aviso ao endpoint deslocado.
secret é mostrado exatamente uma vez, na requisição que o criou. Ele é guardado cifrado e não há caminho de leitura de volta. secret: null numa gravação posterior quer dizer que já existia um e foi mantido — quem diz se existe algum é hasSecret. {"rotateSecret": true} cria um novo, e é a única saída para um segredo que vazou.
Passo a passo completo, incluindo o contrato que o seu endpoint precisa cumprir: Conecte a sua API a um Flow.

Erros

Toda falha desta superfície responde o mesmo envelope — existe exatamente uma forma para ler, e uma grafia:
FLOW_JSON_INVALID acrescenta um array validationErrors; FLOW_JSON_DEAD_FORM_PREFIX acrescenta deadRefs; FLOW_UNKNOWN_FIELDS acrescenta as chaves recusadas; FLOW_ENDPOINT_URI_WOULD_OVERWRITE acrescenta currentEndpointUri. Nunca assuma que o conjunto de códigos é fechado — trate os que você conhece e caia no code para o resto.
Os dois últimos não têm prefixo FLOW_ — são compartilhados por toda a API pública e mantêm a grafia global. Um cliente que filtre por code.startsWith("FLOW_") perderia os dois.

Relacionado