Files
InfiniteAPI/docs/VIEW_ONCE.md
T
Renato Alcara 9c626801b0 docs: add hosted account restriction warning to VIEW_ONCE.md
Accounts registered as hosted (Meta Business Cloud API) have view-once
blocked by the WA server for all clients (Android, iOS, Web).
This is a server-side policy with no code workaround.

Added prominent warning block in section 1 and dedicated row in the
limitations table.

Co-Authored-By: Renato Alcará
2026-03-23 01:17:27 -03:00

16 KiB

Mensagens View-Once (Visualização Única) — Guia de Implementação

Data: 2026-03-23 Versão: 1.0


1. O que é View-Once

Mensagem de visualização única é um tipo especial de mídia (imagem, vídeo ou áudio) que o destinatário pode abrir apenas uma vez. Após abrir, o conteúdo some permanentemente.


⚠️ RESTRIÇÃO CRÍTICA — Contas Hosted (Meta Business Cloud API)

Contas registradas como "hosted" na Meta Business Cloud API têm view-once bloqueado pelo servidor do WhatsApp.

Essa restrição se aplica a todos os clientes (Android, iOS, WA Web) e não há como contornar por código — é uma política imposta diretamente pelo servidor WA para contas hosted.

Como identificar se sua conta é hosted:

  • A conta foi criada via Meta Business Cloud API (WABA)
  • Aparece como "Meta" ou "Business Cloud" nas configurações do WhatsApp Business Manager

Contas não-hosted (registradas diretamente via QR code ou pair code) funcionam normalmente com view-once.


Comportamento em cada dispositivo após envio via API (contas não-hosted):

Dispositivo O que aparece
Destinatário (Android/iOS) Recebe a mídia com ícone de olhinho — pode abrir uma vez
Android principal do remetente Mostra na conversa que a mensagem foi enviada (ícone de olhinho)
WA Web do remetente "Aguardando mensagem. Abra o WhatsApp no seu celular."

Nota: A mensagem "Aguardando mensagem..." no WA Web é o comportamento correto e esperado quando o envio vem de um companion (API). É uma limitação do protocolo WhatsApp — o servidor só permite que o celular principal notifique companions com o conteúdo da view-once.


2. Como Enviar via API

2.1 Imagem

curl -X POST https://sua-api.com/v1/messages/send_image \
  -H "Content-Type: application/json" \
  -H "x-api-key: SEU_API_KEY" \
  -d '{
    "instance": "nome-da-instancia",
    "to": "5511999999999",
    "caption": "Olha só isso!",
    "imageUrl": "https://exemplo.com/foto.jpg",
    "viewOnce": true
  }'

Resposta de sucesso:

{ "ok": true }

2.2 Vídeo

curl -X POST https://sua-api.com/v1/messages/send_video \
  -H "Content-Type: application/json" \
  -H "x-api-key: SEU_API_KEY" \
  -d '{
    "instance": "nome-da-instancia",
    "to": "5511999999999",
    "caption": "Assista uma vez",
    "videoUrl": "https://exemplo.com/video.mp4",
    "viewOnce": true
  }'

2.3 Áudio

curl -X POST https://sua-api.com/v1/messages/send_audio \
  -H "Content-Type: application/json" \
  -H "x-api-key: SEU_API_KEY" \
  -d '{
    "instance": "nome-da-instancia",
    "to": "5511999999999",
    "audioUrl": "https://exemplo.com/audio.ogg",
    "ptt": false,
    "viewOnce": true
  }'

2.4 Usando Base64 (sem URL pública)

curl -X POST https://sua-api.com/v1/messages/send_image \
  -H "Content-Type: application/json" \
  -H "x-api-key: SEU_API_KEY" \
  -d '{
    "instance": "nome-da-instancia",
    "to": "5511999999999",
    "imageBase64": "data:image/jpeg;base64,/9j/4AAQSkZJRgAB...",
    "viewOnce": true
  }'

3. Como Receber via Webhook

Quando um contato envia uma mensagem de visualização única para o número conectado na API, o webhook recebe o evento com key.isViewOnce = true.

3.1 Estrutura do evento recebido

{
  "event": "messages.upsert",
  "instance": "nome-da-instancia",
  "data": {
    "messages": [
      {
        "key": {
          "remoteJid": "5511999999999@s.whatsapp.net",
          "fromMe": false,
          "id": "3EB0ABCDEF123456",
          "isViewOnce": true
        },
        "message": {
          "viewOnceMessageV2": {
            "message": {
              "imageMessage": {
                "url": "https://mmg.whatsapp.net/...",
                "mimetype": "image/jpeg",
                "mediaKey": "base64...",
                "fileEncSha256": "base64...",
                "directPath": "/v/...",
                "viewOnce": true
              }
            }
          }
        },
        "messageTimestamp": 1742700000,
        "pushName": "João Silva"
      }
    ],
    "type": "notify"
  }
}

3.2 Como identificar que é view-once

O campo principal para identificar é key.isViewOnce = true. Existem dois caminhos para confirmar:

function isViewOnce(msg) {
  // Caminho 1: flag direta (mais simples)
  if (msg.key?.isViewOnce) return true

  // Caminho 2: verificar o wrapper da mensagem
  const inner =
    msg.message?.viewOnceMessageV2?.message ||
    msg.message?.viewOnceMessage?.message ||
    msg.message?.viewOnceMessageV2Extension?.message

  return !!(
    inner?.imageMessage?.viewOnce ||
    inner?.videoMessage?.viewOnce ||
    inner?.audioMessage?.viewOnce
  )
}

3.3 Como acessar a mídia

function getViewOnceMedia(msg) {
  const inner =
    msg.message?.viewOnceMessageV2?.message ||
    msg.message?.viewOnceMessage?.message ||
    msg.message?.viewOnceMessageV2Extension?.message

  if (!inner) return null

  if (inner.imageMessage) return { type: 'image', media: inner.imageMessage }
  if (inner.videoMessage) return { type: 'video', media: inner.videoMessage }
  if (inner.audioMessage) return { type: 'audio', media: inner.audioMessage }

  return null
}

// Uso:
const media = getViewOnceMedia(msg)
if (media) {
  console.log(`View-once ${media.type}`)
  console.log('URL:', media.media.url)
  console.log('DirectPath:', media.media.directPath)
  console.log('MediaKey:', media.media.mediaKey) // necessário para download
}

4. Como Exibir numa Ferramenta (Frontend/CRM)

4.1 Lógica de detecção e exibição

function renderMessage(msg) {
  if (!isViewOnce(msg)) {
    // Renderizar mensagem normal
    return renderNormalMessage(msg)
  }

  const media = getViewOnceMedia(msg)
  if (!media) return

  // Verificar se já foi aberta (controle local da ferramenta)
  const alreadyOpened = localStorage.getItem(`vo_${msg.key.id}`)

  if (alreadyOpened) {
    // Mostrar placeholder de "já visualizado"
    return renderViewOncePlaceholder(media.type, msg.key.fromMe)
  }

  if (msg.key.fromMe) {
    // Mensagem enviada por mim — nunca mostrar conteúdo
    return renderSentViewOnce(media.type)
  }

  // Mensagem recebida — mostrar botão para "abrir uma vez"
  return renderViewOnceButton(media.type, msg)
}

4.2 Componente de exibição — mensagem recebida

function renderViewOnceButton(type, msg) {
  const icons = { image: '📷', video: '🎥', audio: '🎵' }
  const labels = { image: 'Foto', video: 'Vídeo', áudio: 'Áudio' }

  return `
    <div class="view-once-bubble received">
      <span class="view-once-icon">${icons[type]}</span>
      <span class="view-once-label">${labels[type]} de visualização única</span>
      <button onclick="openViewOnce('${msg.key.id}', '${type}')">
        Abrir
      </button>
    </div>
  `
}

async function openViewOnce(msgId, type) {
  // 1. Marcar como aberta ANTES de mostrar (só uma chance)
  localStorage.setItem(`vo_${msgId}`, '1')

  // 2. Baixar e mostrar a mídia
  const mediaUrl = await downloadViewOnceMedia(msgId)
  showMedia(type, mediaUrl)

  // 3. Após fechar/visualizar, substituir pelo placeholder
  onMediaClosed(() => {
    replaceWithPlaceholder(msgId, type)
  })
}

4.3 Componente de exibição — mensagem enviada

function renderSentViewOnce(type) {
  const labels = { image: 'Foto', video: 'Vídeo', audio: 'Áudio' }
  return `
    <div class="view-once-bubble sent">
      <span class="view-once-icon">👁️</span>
      <span>${labels[type]} de visualização única</span>
      <span class="view-once-status">Enviada</span>
    </div>
  `
}

4.4 Placeholder após visualização

function renderViewOncePlaceholder(type, fromMe) {
  const labels = { image: 'foto', video: 'vídeo', audio: 'áudio' }
  const text = fromMe
    ? `Você enviou uma ${labels[type]} de visualização única`
    : `${labels[type]} de visualização única aberta`

  return `
    <div class="view-once-bubble placeholder">
      <span class="view-once-icon">🚫</span>
      <span>${text}</span>
    </div>
  `
}

4.5 CSS sugerido

.view-once-bubble {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px 14px;
  border-radius: 12px;
  max-width: 280px;
}

.view-once-bubble.received {
  background: #f0f0f0;
  color: #333;
}

.view-once-bubble.sent {
  background: #dcf8c6;
  color: #333;
  align-self: flex-end;
}

.view-once-bubble.placeholder {
  background: #e8e8e8;
  color: #999;
  font-style: italic;
}

.view-once-bubble button {
  background: #25d366;
  color: white;
  border: none;
  border-radius: 8px;
  padding: 6px 12px;
  cursor: pointer;
  font-size: 13px;
}

5. Código Implementado na API

5.1 Geração da mensagem — src/Utils/messages.ts

Quando viewOnce: true é passado, a mídia é encapsulada no wrapper moderno viewOnceMessageV2 (campo 55 do proto) e o flag viewOnce=true é setado na mídia interna:

if (hasOptionalProperty(message, 'viewOnce') && !!message.viewOnce) {
    // Seta viewOnce=true na mensagem de mídia interna
    if (m.imageMessage) m.imageMessage.viewOnce = true
    else if (m.videoMessage) m.videoMessage.viewOnce = true
    else if (m.audioMessage) m.audioMessage.viewOnce = true
    // Usa viewOnceMessageV2 (campo 55) — formato atual do WA
    m = { viewOnceMessageV2: { message: m } }
}

5.2 Envio — src/Socket/messages-send.ts

Detecção de view-once real (não confunde com botões/listas que também usam o wrapper viewOnceMessage):

// Detecta view-once real pela flag viewOnce na mídia interna.
// viewOnceMessage* wrappers são também usados por botões/listas/carrosséis
// (que carregam interactiveMessage dentro) — esses NÃO têm viewOnce=true na mídia.
const viewOnceInner =
    message.viewOnceMessageV2?.message ||
    message.viewOnceMessage?.message ||
    message.viewOnceMessageV2Extension?.message
const isViewOnceMsg = !!(
    viewOnceInner?.imageMessage?.viewOnce ||
    viewOnceInner?.videoMessage?.viewOnce ||
    viewOnceInner?.audioMessage?.viewOnce
)

// Para view-once: DSM enviado apenas para o celular principal (device=0).
// Companions (device>0) são omitidos — o servidor WA gera
// <unavailable type="view_once"/> automaticamente para eles.
const viewOnceMeRecipients = isViewOnceMsg
    ? meRecipients.filter(jid => !jidDecode(jid)?.device)
    : meRecipients

// assertSessions apenas para quem vai realmente receber
await assertSessions([...viewOnceMeRecipients, ...otherRecipients])

const [meNodes, otherNodes] = await Promise.all([
    createParticipantNodes(viewOnceMeRecipients, meMsg || message, extraAttrs),
    createParticipantNodes(otherRecipients, message, extraAttrs)
])
participants.push(...meNodes)
participants.push(...otherNodes)

// phash recalculado com os participantes reais
const phashRecipients = isViewOnceMsg
    ? [...viewOnceMeRecipients, ...otherRecipients]
    : [...meRecipients, ...otherRecipients]
if (phashRecipients.length > 0) {
    extraAttrs['phash'] = generateParticipantHashV2(phashRecipients)
}

getMediaType() — garante que vídeo e áudio view-once sejam entregues:

const getMediaType = (message: proto.IMessage) => {
    // Desencapsula wrapper view-once antes de verificar o tipo de mídia.
    // Sem isso, message.videoMessage e message.audioMessage são undefined
    // (estão dentro do wrapper) e o atributo mediatype ficaria ausente no
    // enc node — o servidor WA descarta silenciosamente vídeo/áudio view-once.
    const inner =
        message.viewOnceMessage?.message ||
        message.viewOnceMessageV2?.message ||
        message.viewOnceMessageV2Extension?.message
    if (inner) {
        return getMediaType(inner)
    }

    if (message.imageMessage) return 'image'
    else if (message.videoMessage) return 'video'
    // ...
}

5.3 Recepção — src/Utils/decode-wa-message.ts

Quando a API recebe uma view-once como companion (linked device), o servidor envia dois stanzas:

  • Stanza 1 (enc): conteúdo completo com metadados da mídia
  • Stanza 2 (unavailable type="view_once"): sinal de sync

O stanza 1 é detectado e marcado corretamente:

// Detecta view-once na stanza 1 recebida por linked device.
// O wrapper viewOnceMessage também é usado por mensagens interativas
// (interactiveMessage, listMessage) — esses NÃO têm viewOnce=true na mídia.
const viewOnceInner =
    msg.viewOnceMessage?.message ||
    msg.viewOnceMessageV2?.message ||
    msg.viewOnceMessageV2Extension?.message
if (
    viewOnceInner?.imageMessage?.viewOnce ||
    viewOnceInner?.videoMessage?.viewOnce ||
    viewOnceInner?.audioMessage?.viewOnce
) {
    fullMessage.key.isViewOnce = true
}

6. Fluxo Completo

API envia view-once para destinatário
│
├── Protobuf gerado:
│   viewOnceMessageV2 {
│     message {
│       imageMessage { viewOnce: true, url, mediaKey, ... }
│     }
│   }
│
├── Stanza enviado ao servidor WA:
│   <message to="destinatario@s.whatsapp.net" type="media">
│     <enc type="pkmsg">...viewOnceMessageV2 cifrado...</enc>   ← para o destinatário
│     <participants>
│       <to jid="meu-numero:0@lid">                             ← celular principal
│         <enc type="msg">...DSM{viewOnceMessageV2}...</enc>
│       </to>
│       <!-- device>0 omitidos — servidor gera unavailable -->
│     </participants>
│   </message>
│
└── Distribuição pelo servidor:
    ├── Destinatário      → recebe enc → abre uma vez ✅
    ├── Celular principal → recebe DSM → mostra "você enviou" ✅
    └── WA Web            → recebe <unavailable> → "Aguardando..." ✅


Destinatário envia view-once de volta para a API
│
├── Stanza recebido pela API (stanza 1 — enc com conteúdo):
│   <message from="destinatario@s.whatsapp.net" type="media">
│     <enc type="msg">...viewOnceMessageV2 cifrado...</enc>
│   </message>
│
├── decode-wa-message.ts:
│   └── Descriptografa → detecta viewOnce=true → seta key.isViewOnce=true
│
├── Stanza recebido (stanza 2 — unavailable, ignorado):
│   <message from="...">
│     <unavailable type="view_once"/>
│   </message>
│
└── Webhook emitido:
    messages.upsert → msg.key.isViewOnce = true ✅

7. Limitações

Situação Comportamento Motivo
Conta hosted (Meta Business Cloud API) View-once bloqueado para todos os clientes Política do servidor WA para contas hosted — sem workaround
WA Web do remetente "Aguardando mensagem. Abra no celular." Servidor WA só aceita <unavailable> vindo do celular principal, não de companions
View-once de documento/sticker Não suportado Limitação do protocolo WA — só imagem, vídeo e áudio
error_479 nos logs Normal, não é erro TcToken é atualizado após o envio de view-once
Companion não consegue abrir Por design O conteúdo não é entregue para linked devices

8. Checklist de Validação

Envio:

  • Destinatário recebe com ícone de olhinho
  • Destinatário consegue abrir a mídia
  • Após abrir, a mídia some
  • Celular principal do remetente mostra "você enviou" com ícone de olhinho
  • WA Web do remetente mostra "Aguardando mensagem..."
  • Funciona para imagem, vídeo e áudio
  • Logs mostram 📤 Message sent (error_479 é normal)

Recepção:

  • Webhook recebe key.isViewOnce = true
  • viewOnceMessageV2.message.imageMessage (ou video/audio) está presente
  • API não crasha ao receber view-once de outro dispositivo
  • Mensagem interativa (botão/lista) enviada após view-once não é afetada