> ## Documentation Index
> Fetch the complete documentation index at: https://documentacao.legitimuz.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Enviar a verificação por WhatsApp

> Entregue a jornada no celular do titular por mensagem, sem deixar a credencial passar pelo WhatsApp.

O titular não está na sua tela: está num call center, num cadastro por telefone, ou abandonou o
formulário ontem. O WhatsApp entrega a jornada no aparelho que tem a câmera boa.

|               |                                                                                     |
| ------------- | ----------------------------------------------------------------------------------- |
| **Stack**     | Node 20 · WhatsApp Cloud API (Meta)                                                 |
| **Você terá** | um link curto do seu domínio na mensagem, e a credencial nunca fora do seu servidor |

<Card title="Confira o exemplo" icon="brand-github" href="https://github.com/Legitimuz-Tech/legitimuz-examples/tree/master/pocs/whatsapp-handoff" horizontal>
  Os arquivos desta POC, com o destino de cada um.
</Card>

## A credencial não vai na mensagem

A `entry.url` carrega o acesso à jornada. Ela sai do seu backend para o titular daquela
verificação e para mais ninguém — e uma mensagem fica no histórico do aparelho, do WhatsApp Web
aberto no computador da loja, e do backup em nuvem.

Então o que viaja é um **link curto do seu domínio**, que só o seu servidor sabe trocar pela
`entry.url`:

<Steps>
  <Step title="Crie a verificação e guarde a entry">
    A `entry.url` fica no seu banco, junto com um token opaco e um prazo.
  </Step>

  <Step title="Mande o token, não a URL">
    A mensagem leva `https://seu-dominio/v/<token>`.
  </Step>

  <Step title="Troque no clique">
    A sua rota valida o token, marca como usado e redireciona para a `entry.url`.
  </Step>
</Steps>

Isso também é o que o template de botão do WhatsApp permite: a URL base é fixa e aprovada pela
Meta, e só o sufixo é variável. Você não conseguiria mandar a `entry.url` inteira nem se quisesse.

## Criar e despachar

```ts title="app/api/verifications/whatsapp/route.ts" theme={null}
import { randomBytes } from "node:crypto";

export async function POST(request: Request) {
  const operator = await authenticate(request);
  const { registrationId } = await request.json();

  const registration = await db.registrations.byId(registrationId);

  // O telefone vem do CADASTRO, nunca do corpo da requisição: quem escolhe o número escolhe
  // para quem a jornada vai.
  if (!registration.phone_verified) {
    return Response.json({ error: "phone_not_verified" }, { status: 409 });
  }

  const response = await fetch("https://api.legitimuz.com/public/verifications", {
    method: "POST",
    headers: {
      "X-API-Key": process.env.LEGITIMUZ_API_KEY!,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      schema_version: "1.0",
      ref_id: registration.id,
      document: { type: "cpf", number: registration.cpf },
      flow_public_id: process.env.LEGITIMUZ_FLOW_ID!,
    }),
  });

  const { verification, entry } = await response.json();

  if (entry.kind !== "web") {
    return Response.json({ error: "entry_not_web" }, { status: 409 });
  }

  // O token é o que vai para o WhatsApp. A `entry.url` fica aqui.
  const token = randomBytes(16).toString("base64url");

  await db.links.create({
    token,
    url: entry.url,
    registration_id: registration.id,
    expires_at: verification.expires_at,
    used_at: null,
  });

  await sendTemplate(registration.phone, registration.first_name, token);

  return Response.json({ sent: true, expiresAt: verification.expires_at });
}
```

## A mensagem

O WhatsApp exige um template aprovado para mensagem iniciada pela empresa. Registre um com botão
de URL dinâmica, cuja base é o seu domínio:

```text title="Template aprovado na Meta" theme={null}
Nome:   identity_verification
Corpo:  Olá, {{1}}! Para concluir seu cadastro, confirme sua identidade. O link vale por 24 horas
        e é só para você.
Botão:  URL dinâmica — https://seu-dominio/v/{{1}}
```

```ts title="lib/whatsapp.ts" theme={null}
const GRAPH = "https://graph.facebook.com/v21.0";

export async function sendTemplate(phone: string, name: string, token: string) {
  const response = await fetch(`${GRAPH}/${process.env.WHATSAPP_PHONE_ID}/messages`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.WHATSAPP_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      messaging_product: "whatsapp",
      to: phone,
      type: "template",
      template: {
        name: "identity_verification",
        language: { code: "pt_BR" },
        components: [
          { type: "body", parameters: [{ type: "text", text: name }] },
          // O sufixo do botão: a base da URL é fixa no template aprovado.
          {
            type: "button",
            sub_type: "url",
            index: "0",
            parameters: [{ type: "text", text: token }],
          },
        ],
      },
    }),
  });

  if (!response.ok) {
    // Não registre o corpo da resposta cru em log: ele repete o telefone do titular.
    throw new Error(`whatsapp_${response.status}`);
  }
}
```

## A rota que troca o token

```ts title="app/v/[token]/route.ts" theme={null}
export async function GET(_: Request, { params }: { params: { token: string } }) {
  const link = await db.links.consume(params.token);

  // `consume` é um UPDATE condicional, não um SELECT seguido de UPDATE: dois cliques simultâneos
  // no mesmo link não podem resolver os dois.
  if (!link) return new Response("Link inválido ou já usado", { status: 410 });
  if (new Date(link.expires_at) < new Date()) {
    return new Response("Link expirado", { status: 410 });
  }

  // 302 e `no-store`: a URL de destino não pode ficar em cache de proxy nem no histórico do CDN.
  return new Response(null, {
    status: 302,
    headers: { Location: link.url, "Cache-Control": "no-store" },
  });
}
```

<Warning>
  Não faça a rota devolver a `entry.url` em JSON para o front redirecionar. Isso coloca a
  credencial no histórico de rede do navegador e em qualquer extensão instalada. O redirecionamento
  é do servidor.
</Warning>

## Antes de enviar para gente de verdade

| Regra                                           | Por quê                                                                           |
| ----------------------------------------------- | --------------------------------------------------------------------------------- |
| Só para número já verificado no seu cadastro    | quem escolhe o número escolhe o destinatário da jornada                           |
| Só com opt-in registrado                        | mensagem iniciada pela empresa exige consentimento, e a Meta bloqueia quem ignora |
| Nunca em grupo ou lista de transmissão          | a jornada é de uma pessoa; grupo entrega a credencial a todas                     |
| Um envio por verificação, com limite de reenvio | reenvio sem teto vira vetor de assédio e custo                                    |

<Info>
  Fora da janela de 24 horas de atendimento, a Meta só entrega template aprovado. Dentro dela, a
  mensagem livre funciona — mas o link curto continua sendo o que você manda.
</Info>

## Antes de rodar

```bash title=".env" theme={null}
LEGITIMUZ_API_KEY=<SUA_CHAVE>
LEGITIMUZ_WEBHOOK_SECRET=<SEGREDO_DO_ENDPOINT>
LEGITIMUZ_FLOW_ID=<FLOW_PUBLIC_ID>
```

| Variável                   | Onde achar                                                                       |
| -------------------------- | -------------------------------------------------------------------------------- |
| `LEGITIMUZ_API_KEY`        | Integrações → Segurança → [Chaves de API](/platform/tokens). Aparece uma vez     |
| `LEGITIMUZ_WEBHOOK_SECRET` | Integrações → Segurança → [Webhooks](/platform/webhooks), na criação do endpoint |
| `LEGITIMUZ_FLOW_ID`        | Solução KYC → Fluxos, no menu da linha, em **Copiar ID do fluxo**                |

Use uma integração **sandbox**. Nenhum dos três valores vai para o browser ou para o app.

Esta POC precisa também das credenciais da Meta:

```bash title=".env" theme={null}
WHATSAPP_PHONE_ID=<ID_DO_NUMERO>
WHATSAPP_TOKEN=<TOKEN_DA_APP>
```

## O que esta POC não faz

<Warning>
  POC é código para entender o fluxo, não para copiar em produção. Em todas elas, `authenticate()` é
  um stub, `db` é um objeto de mentira e não há migration, observabilidade nem retentativa própria.
</Warning>

* Sem webhook de status do WhatsApp: você não sabe se a mensagem foi lida.
* Sem fallback para SMS quando o número não tem WhatsApp.
* Sem rate limit por cadastro no reenvio.
* Sem migration para a tabela `links`; ela precisa de índice único em `token`.

## Próximo passo

<Columns cols={2}>
  <Card title="QR code" icon="qrcode" href="/guides/pocs/qr-handoff">
    A mesma entrega, quando o titular está na sua tela.
  </Card>

  <Card title="Retomada e expiração" icon="clock" href="/guides/pocs/resume-expiry">
    O que fazer quando o link expira antes do clique.
  </Card>
</Columns>
