# LinkZap — API e webhooks (documentação completa) URL base: `https://linkzap.orion.moe/api/v1` · OpenAPI: https://linkzap.orion.moe/openapi.json · Índice: https://linkzap.orion.moe/llms.txt São 14 páginas, na ordem em que a documentação as apresenta. --- > Fonte: https://linkzap.orion.moe/developers/start.md # A API do LinkZap Tudo o que a sua equipe faz na caixa — receber, responder, etiquetar, iniciar um fluxo — o seu sistema também pode fazer. E tudo o que acontece aqui pode chegar ao seu sistema no instante em que acontece. ## São dois sentidos, e você vai usar os dois **A API** é o que _você_ chama: mandar uma mensagem quando um pedido é aprovado, cadastrar o lead que veio do formulário, ler a conversa para mostrar no seu CRM. **Os webhooks** são o que _nós_ chamamos: cada mensagem recebida, cada conversa resolvida, cada opt-out vira um `POST` assinado no endereço que você registrar. É o que evita ficar perguntando “tem novidade?” de minuto em minuto. Existe ainda um terceiro caminho, o mais simples de todos: o [webhook de entrada](https://linkzap.orion.moe/developers/inbound-webhook.md), uma URL por número que transforma um formulário de site, um Zapier ou um CRM em contato e conversa sem você escrever uma linha de código. ## Três minutos até a primeira mensagem ### 1. Crie uma chave No painel, **Configurações → Integrações → Novo token**. A chave começa com `lz_` e aparece **uma única vez** — guarde-a onde você guarda as outras senhas do seu sistema. Se a perder, revogue e crie outra. Se o que você quer é uma chave que só fala por UM número, use **Canais → Copiar chave de API**. Ela é mais segura para integrações de terceiros: não alcança os outros números da conta. ### 2. Confira que ela funciona A resposta diz de qual organização é a chave e qual é o escopo dela — é como você descobre, sem adivinhar, se colou a chave da conta ou a de um canal. Comando — Conferir a chave: ```bash curl https://linkzap.orion.moe/api/v1/me \ -H "Authorization: Bearer lz_SUA_CHAVE" ``` ### 3. Descubra o seu channelId Quase toda escrita pede o número pelo qual falar. A lista traz também se ele está pronto para enviar — e, se não estiver, o motivo em palavras. Comando — Listar os números conectados: ```bash curl https://linkzap.orion.moe/api/v1/channels \ -H "Authorization: Bearer lz_SUA_CHAVE" ``` ### 4. Mande a mensagem Não precisa criar o contato nem a conversa antes: se o telefone é novo, os dois nascem aqui. A mensagem aparece na caixa da sua equipe creditada como **API**, como qualquer outra. Comando — Enviar uma mensagem: ```bash curl -X POST https://linkzap.orion.moe/api/v1/messages \ -H "Authorization: Bearer lz_SUA_CHAVE" \ -H "Content-Type: application/json" \ -d '{ "channelId": "SEU_CHANNEL_ID", "phone": "5592999990000", "text": "Seu pedido #1234 saiu para entrega." }' ``` ### 5. Receba o que acontece Em **Configurações → Integrações → Novo webhook**, aponte para uma URL pública sua e escolha os eventos. A partir daí cada resposta do contato chega no seu servidor, assinada. Como verificar a assinatura está em [Webhooks de saída](https://linkzap.orion.moe/developers/webhooks.md). ## Antes de começar, três coisas > [!WARNING] > **O número precisa estar conectado** > > A API não conecta números — ela fala pelos que já estão. Se `GET /channels` devolver `ready: false`, o motivo está no campo `reason`, e é o mesmo motivo que o envio recusaria com 409. > [!WARNING] > **Janela de 24 horas na conta oficial** > > Nos números conectados pela API oficial da Meta (`provider: "cloud"`), texto livre só sai se o contato falou com você nas últimas 24 horas. Fora disso, só um template aprovado — que hoje se envia pelo painel, pelo fluxo, pela sequência ou pela campanha. Números conectados por QR (`provider: "web"`) não têm essa janela. > [!CAUTION] > **Opt-in não é burocracia** > > Mandar mensagem para quem não pediu é o caminho mais rápido para o número ser bloqueado — e no modo QR o número é seu. Registre o opt-in (`optIn: true` ao criar o contato) e respeite o opt-out no seu sistema também. --- > Fonte: https://linkzap.orion.moe/developers/authentication.md # Autenticação Uma chave, dois escopos, nenhum OAuth. Toda chamada leva a chave num cabeçalho. ## Como enviar a chave O jeito preferido é `Authorization: Bearer`. Para ferramentas que não deixam mexer nesse cabeçalho (é o caso de vários construtores de automação), `x-api-key` vale o mesmo. Comando — As duas formas são equivalentes: ```bash curl https://linkzap.orion.moe/api/v1/me -H "Authorization: Bearer lz_SUA_CHAVE" curl https://linkzap.orion.moe/api/v1/me -H "x-api-key: lz_SUA_CHAVE" ``` A chave tem sempre o formato `lz_` seguido de 40 caracteres. Qualquer outra coisa — chave truncada, com espaço, revogada ou de outra instalação — responde `401` com o motivo. ## Os dois escopos - **Chave da conta** (Configurações → Integrações): alcança todos os números, todos os contatos e todas as conversas da organização. É a que você usa no seu próprio backend. - **Chave de canal** (Canais → Copiar chave de API): só fala por aquele número. Pedir outro `channelId` responde `403`, e `GET /channels`, `GET /conversations` e `GET /messages` devolvem só o que é daquele canal. É a que você entrega a um integrador de fora. > [!WARNING] > **Contato é da conta, não do canal** > > A única exceção ao parágrafo acima, e ela é deliberada: `GET /contacts` e `POST /contacts` **não** filtram por canal. Um contato é a pessoa, e a mesma pessoa pode falar por mais de um número seu — uma base de contatos por canal criaria duplicata a cada número novo. Na prática: uma chave de canal lê e escreve **todos** os contatos da organização. Se isso não serve ao seu caso, não entregue uma chave de canal a terceiros esperando que ela isole a base. Com uma chave de canal você pode **omitir** o `channelId` nas rotas de escrita: o escopo preenche sozinho. `GET /api/v1/me` diz qual dos dois você tem em mãos. ## Criar, rotacionar, revogar A chave aparece **uma vez**. Guardamos apenas um hash dela — nem nós conseguimos lê-la de volta, o que também significa que ninguém com acesso ao banco consegue. A lista mostra só o prefixo (`lz_ab12cd34`), o nome que você deu e quando foi usada pela última vez. - **Rotacionar** é criar a nova, trocar no seu sistema e só então revogar a antiga — nessa ordem, a integração não pisca. - **Revogar** vale na hora, na chamada seguinte. Não há prazo de carência. - Em **Canais → Copiar chave de API**, “copiar” _gera_ uma chave nova e revoga a anterior do mesmo canal — pela mesma razão: o valor antigo não existe mais em lugar nenhum para ser copiado de novo. > [!CAUTION] > **Se a chave vazou** > > Revogue primeiro, investigue depois. Em Configurações → Integrações, o campo “último uso” de cada chave mostra se ela andou sendo usada — e por onde continuar procurando. ## Onde a chave não deve estar - **No navegador, nunca.** A API não responde a pedidos de outra origem (não há CORS), justamente para que ninguém seja tentado a chamá-la do JavaScript de uma página — ali a chave estaria à vista de qualquer visitante. Chame sempre do seu servidor. - No repositório: variável de ambiente ou cofre de segredos, como as outras credenciais. - Em log de erro e em rastro de exceção — é o vazamento mais comum, e o mais silencioso. --- > Fonte: https://linkzap.orion.moe/developers/conventions.md # Convenções e erros As regras que valem para todas as rotas — leia uma vez e não precise reler em cada página. ## URL base e formato Toda rota mora sob `https://linkzap.orion.moe/api/v1`. Pedido e resposta são JSON em UTF-8; as rotas que recebem corpo esperam `Content-Type: application/json`. O que volta fica sempre dentro de `data`. É uma casca aparentemente inútil, e ela existe para que acrescentar um campo ao lado (`page`, `total`, `created`) nunca quebre quem já lê o que está dentro. ## Paginação **Três listas são paginadas** — `GET /contacts`, `GET /conversations` e `GET /messages`. Elas aceitam `page` (a partir de 1) e `limit` (1 a 200, padrão 50), e devolvem `total` no corpo: o número de itens que casam com o filtro, não o da página. **Duas não são**, porque não crescem: `GET /channels` e `GET /flows` devolvem tudo de uma vez, num `{ data: [...] }` seco, sem `page`, `limit` nem `total`. Uma conta tem alguns números e algumas dezenas de fluxos — paginar ali seria cerimônia sem motivo. Exemplo — Segunda página, de 20 em 20: ```bash curl "https://linkzap.orion.moe/api/v1/contacts?page=2&limit=20" \ -H "Authorization: Bearer lz_SUA_CHAVE" ``` ## Datas e telefones - **Datas** saem em ISO-8601 com fuso UTC (`2026-09-21T14:03:11.000Z`). Converta para o fuso da sua operação na hora de mostrar — o painel faz isso com o fuso que está em Configurações → Geral. - **Telefones** entram como DDI + DDD + número, só dígitos: `5592999990000`. Aceitamos com `+`, espaço, parêntese e traço e normalizamos; o que não vira um número brasileiro válido responde 422. Nas respostas o telefone volta sempre normalizado. - **Identificadores** são strings opacas. Não tente derivar nada delas nem presuma tamanho. ## Erros Todo erro é um JSON com `error` — uma frase em português, escrita para aparecer no seu log ou para a sua equipe ler. Quando o problema é de validação, vem junto `issues`, apontando campo por campo. Exemplo — 422 com o campo apontado: ```json { "error": "Dados inválidos.", "issues": [ { "path": "phone", "message": "String must contain at least 8 character(s)" } ] } ``` **Os status que você vai encontrar** | Status | Significado | Quando acontece | | --- | --- | --- | | `200` | Deu certo | O corpo traz `data`, e nas listas também `page`, `limit` e `total`. | | `201` | Criou | Algo passou a existir: um contato novo, uma mensagem, um fluxo iniciado. | | `400` | O corpo não é JSON | Só em `POST`: o texto enviado não foi lido como JSON. Confira a vírgula sobrando e o `Content-Type`. Campo faltando ou fora do formato **não** é 400, é 422. | | `401` | Chave ausente, inválida ou revogada | Não tente de novo com a mesma chave — ela não vai passar a valer. | | `403` | Fora do escopo | Uma chave de canal pedindo outro canal. É 403 e não 404 de propósito: não dizemos se o outro canal existe. | | `404` | Não existe | Canal, conversa, contato, fluxo ou arquivo que não é desta organização. | | `409` | Existe, mas não dá agora | O caso comum é o número não estar pronto para enviar; o motivo vem em palavras. | | `422` | Os dados não passam | Telefone fora do formato, campo faltando, valor longo demais. Vem com `issues[]` apontando o campo. | | `429` | Rápido demais | Espere o que o `Retry-After` disser. | | `500` | Foi culpa nossa | Tente de novo com recuo. Se persistir, fale com o suporte com o horário da chamada. | ## Limite de chamadas Cada chave pode fazer **120 chamadas por minuto**, e a organização inteira, **600** somando todas as chaves. Passando disso, a resposta é `429` com `Retry-After` em segundos — espere o que ele disser em vez de tentar em laço. > [!NOTE] > **Precisa de mais?** > > Os limites são generosos para integração de verdade e apertados para laço acidental. Se a sua operação passa disso de forma legítima, fale com o suporte antes de espalhar chaves para contornar — o teto da organização soma todas elas. ## Repetir sem estragar `POST /contacts` é idempotente pelo telefone: chamar duas vezes atualiza, não duplica (o `created` da resposta diz qual dos dois aconteceu). `POST /messages` **não é** — cada chamada manda uma mensagem. Se o seu código tem retentativa automática, guarde o `messageId` que voltou antes de tentar de novo. --- > Fonte: https://linkzap.orion.moe/developers/channels.md # Canais Os números de WhatsApp conectados a esta conta — e o channelId que quase toda escrita pede. ## Quem sou eu ### `GET /api/v1/me` Confere a chave e diz de qual organização e de qual escopo ela é. É a primeira chamada a fazer com uma chave nova. Pedido: ```bash curl https://linkzap.orion.moe/api/v1/me \ -H "Authorization: Bearer lz_SUA_CHAVE" ``` Resposta: ```json { "data": { "org": { "id": "org_...", "name": "Clínica VidaVet" }, "scope": "account", "channel": null, "token": { "id": "tok_...", "name": "CRM", "prefix": "lz_ab12cd34", "createdAt": "2026-09-20T12:00:00.000Z", "lastUsedAt": "2026-09-21T14:02:59.000Z" } } } ``` Com uma chave de canal, `scope` vem `"channel"` e `channel` traz o número ao qual ela está presa. ## Os números conectados ### `GET /api/v1/channels` Lista os números da conta, em ordem alfabética pelo rótulo, com o estado de cada um. Devolve todos de uma vez — esta lista não é paginada. Uma chave de canal vê só o próprio. Pedido: ```bash curl https://linkzap.orion.moe/api/v1/channels \ -H "Authorization: Bearer lz_SUA_CHAVE" ``` Resposta: ```json { "data": [ { "id": "chn_9f2a...", "label": "Recepção", "provider": "web", "phone": "5592999990000", "enabled": true, "ready": true, "reason": null, "createdAt": "2026-09-02T18:11:04.000Z" }, { "id": "chn_71bd...", "label": "Comercial", "provider": "cloud", "phone": "5592988880000", "enabled": true, "ready": false, "reason": "O número está desconectado.", "createdAt": "2026-09-14T09:02:41.000Z" } ] } ``` - `provider: "web"` — número conectado pelo QR do celular. Sem janela de 24 horas; o número é seu e o risco de bloqueio também. - `provider: "cloud"` — conta oficial da Meta. Tem janela de 24 horas para texto livre, e fora dela só template aprovado. - `ready` e `reason` vêm da **mesma** verificação que o envio faz. Se `ready` é `false`, um `POST /messages` por esse canal responderia 409 com exatamente esse `reason` — dá para checar antes em vez de tentar e tratar o erro. > [!NOTE] > **Guarde o id, não o rótulo** > > O `label` é o nome que a equipe deu ao número e pode mudar a qualquer momento na tela de Canais. O `id` não muda enquanto o canal existir. --- > Fonte: https://linkzap.orion.moe/developers/contacts.md # Contatos A base de quem fala com você. O telefone é a identidade: criar duas vezes o mesmo número atualiza, não duplica. ## Listar e buscar ### `GET /api/v1/contacts` Lista os contatos da organização, do mais recentemente atualizado para o mais antigo. Paginada. **Uma chave de canal vê todos os contatos da conta** — contato não pertence a canal. **Parâmetros da URL** | Campo | Tipo | Descrição | | --- | --- | --- | | `query` | texto | Busca por nome, telefone ou e-mail. | | `phone` | texto | Acha o contato exato. Aceita qualquer formatação; 422 se não for um telefone válido. | | `page` | inteiro | A partir de 1. Padrão 1. | | `limit` | inteiro | De 1 a 200. Padrão 50. | Pedido: ```bash curl "https://linkzap.orion.moe/api/v1/contacts?phone=5592999990000" \ -H "Authorization: Bearer lz_SUA_CHAVE" ``` Resposta: ```json { "data": [ { "id": "ctt_3c81...", "phone": "5592999990000", "name": "Maria Souza", "email": null, "fields": { "cpf": "000.000.000-00", "plano": "ouro" }, "tags": ["cliente", "vip"], "optIn": true, "optInAt": "2026-09-20T11:02:00.000Z", "optOutAt": null, "source": "api", "createdAt": "2026-09-20T11:02:00.000Z", "updatedAt": "2026-09-21T09:40:12.000Z" } ], "page": 1, "limit": 50, "total": 1 } ``` `optIn` é derivado: só é `true` se existe um opt-in registrado e **nenhum** opt-out depois dele. ## Criar ou atualizar ### `POST /api/v1/contacts` Cria pelo telefone (201) ou atualiza o que já existe (200). O campo `created` da resposta diz qual dos dois aconteceu. **Corpo (JSON)** | Campo | Tipo | Obrigatório | Descrição | | --- | --- | --- | --- | | `phone` | texto | obrigatório | DDI + DDD + número. | | `name` | texto | — | Até 120 caracteres. | | `email` | texto | — | Precisa ser um e-mail válido, ou string vazia para limpar. | | `fields` | objeto | — | Campos próprios seus, texto de até 500 caracteres cada. Mesclam com os que já existem — mandar um campo não apaga os outros. Campo que ainda não existe na conta é criado como texto. Se o campo tem **tipo** (Configurações › Campos do contato), o valor é convertido para a forma do tipo — número `"1234.5"`, data `"2026-12-31"`, sim/não `"true"`/`"false"`, lista com o texto da opção — e a chamada devolve **422** quando ele não cabe (texto num campo de número, opção que não existe); nesse caso nada da chamada é gravado. O nome do campo não diferencia maiúsculas de minúsculas. | | `tags` | lista | — | Até 20 nomes de etiqueta. As que não existem são criadas. Aplicar uma etiqueta pode **disparar um fluxo**, se houver um esperando por ela. | | `optIn` | booleano | — | `true` registra o opt-in, `false` registra o opt-out. Ausente não mexe no que já estava. | Pedido: ```bash curl -X POST https://linkzap.orion.moe/api/v1/contacts \ -H "Authorization: Bearer lz_SUA_CHAVE" \ -H "Content-Type: application/json" \ -d '{ "phone": "5592999990000", "name": "Maria Souza", "tags": ["lead-site"], "fields": { "origem": "formulario-contato" }, "optIn": true }' ``` Resposta: ```json { "data": { "id": "ctt_3c81...", "phone": "5592999990000", "name": "Maria Souza", "email": null, "fields": { "origem": "formulario-contato" }, "tags": ["lead-site"], "optIn": true, "optInAt": "2026-09-21T09:40:12.000Z", "optOutAt": null, "source": "api", "createdAt": "2026-09-21T09:40:12.000Z", "updatedAt": "2026-09-21T09:40:12.000Z" }, "created": true } ``` > [!WARNING] > **Opt-in tem consequência** > > É ele que libera o contato para sequências e campanhas. Marque `optIn: true` apenas quando a pessoa de fato autorizou — a caixa de seleção que ela marcou no seu formulário, o aceite no seu cadastro. Um opt-in inventado vira mensagem indesejada, e mensagem indesejada vira número bloqueado. Criar um contato dispara o webhook `contact.created`; mudar o consentimento dispara `contact.opt_in` ou `contact.opt_out`; mudar nome, e-mail, campo ou etiqueta dispara `contact.updated`, com o que mudou em `changed`. Se o seu sistema também escuta esses eventos, lembre que a sua própria chamada os provoca. --- > Fonte: https://linkzap.orion.moe/developers/conversations.md # Conversas Uma conversa é um contato falando por um canal. É a unidade que a sua equipe atende e a que os webhooks referenciam. ## Listar conversas ### `GET /api/v1/conversations` Lista as conversas, da que teve mensagem mais recente para a mais antiga. **Parâmetros da URL** | Campo | Tipo | Descrição | | --- | --- | --- | | `status` | texto | Um de `open`, `in_flow` ou `resolved`. Qualquer outro valor responde 422. | | `channelId` | texto | Filtra por número. Uma chave de canal que pedir outro recebe 403. | | `contactId` | texto | Todas as conversas de um contato (uma por canal por onde ele falou). | | `page` | inteiro | A partir de 1. | | `limit` | inteiro | De 1 a 200. Padrão 50. | Pedido: ```bash curl "https://linkzap.orion.moe/api/v1/conversations?status=open&limit=20" \ -H "Authorization: Bearer lz_SUA_CHAVE" ``` Resposta: ```json { "data": [ { "id": "cnv_5ad2...", "status": "open", "channel": { "id": "chn_9f2a...", "label": "Recepção", "provider": "web" }, "contact": { "id": "ctt_3c81...", "phone": "5592999990000", "name": "Maria Souza" }, "assignedTo": "usr_44e1...", "participants": ["usr_7b20..."], "unread": 2, "lastMessageAt": "2026-09-21T14:02:59.000Z", "lastMessagePreview": "Pode ser quinta de manhã?", "automationPausedUntil": null, "createdAt": "2026-09-19T08:30:00.000Z" } ], "page": 1, "limit": 20, "total": 37 } ``` ## Os três estados - `open` — está na fila humana, esperando alguém (ou já com responsável). - `in_flow` — um fluxo está conduzindo a conversa. Ela **não** aparece na aba “Abertos” do painel, porque não está esperando ninguém. - `resolved` — o atendimento foi encerrado. Uma mensagem nova do contato reabre. `automationPausedUntil` diz até quando a automação está calada nessa conversa — é o que acontece automaticamente depois que uma pessoa responde, para a Aura não falar por cima. `unread` é a contagem que a equipe vê no painel, não um estado do WhatsApp. ## O que esta rota ainda não faz Ela lê. Resolver, reabrir, atribuir a alguém, encaminhar para um setor ou pausar a automação continuam sendo ações do painel — não existem por API hoje, e preferimos dizer isso a documentar um endpoint que não há. Se a sua integração precisa de alguma delas, fale com o suporte: saber qual é a que falta é o que decide a ordem em que elas entram. --- > Fonte: https://linkzap.orion.moe/developers/messages.md # Mensagens Enviar pelo número conectado e ler o histórico de uma conversa. ## Enviar ### `POST /api/v1/messages` Manda texto ou mídia. Se o telefone ainda não é contato, o contato e a conversa nascem aqui. **Corpo (JSON)** | Campo | Tipo | Obrigatório | Descrição | | --- | --- | --- | --- | | `channelId` | texto | com chave de conta | Por qual número falar. Com uma **chave de canal** pode ser omitido — o escopo preenche. Com a chave da conta é obrigatório: faltando, a resposta é 422 `Informe channelId.` | | `phone` | texto | — | Para quem. Informe `phone` **ou** `conversationId`. | | `conversationId` | texto | — | Responder numa conversa que já existe. Precisa ser do mesmo canal, senão 422. | | `text` | texto | — | Até 4096 caracteres. Sem mídia, é a mensagem; com mídia, vira a legenda se não houver `caption`. | | `mediaUrl` | URL | — | Um arquivo numa URL pública, que nós baixamos e enviamos. O endereço precisa ser público de verdade: se ele aponta para rede interna (ou redireciona para lá), ou se redireciona mais de três vezes, a resposta é 422 e nada é enviado. | | `mediaId` | texto | — | Um arquivo do Drive do LinkZap — mais confiável que URL para o que você reenvia sempre. | | `caption` | texto | — | Legenda da mídia, até 1024 caracteres. | Pedido: ```bash curl -X POST https://linkzap.orion.moe/api/v1/messages \ -H "Authorization: Bearer lz_SUA_CHAVE" \ -H "Content-Type: application/json" \ -d '{ "channelId": "chn_9f2a...", "phone": "5592999990000", "text": "Sua consulta está confirmada para quinta, 9h." }' ``` Resposta: ```json { "data": { "messageId": "msg_7bd0...", "conversationId": "cnv_5ad2...", "channelId": "chn_9f2a..." } } ``` É preciso um de `text`, `mediaUrl` ou `mediaId`, e um de `phone` ou `conversationId` — faltando, a resposta é 422 dizendo qual. > [!WARNING] > **409 é a recusa honesta** > > Se o número estiver desconectado, desligado ou impedido de enviar, a resposta é `409` com o motivo real em palavras, e nada foi enviado. Isso é diferente de um erro: dá para mostrar o motivo para a sua equipe e tentar de novo depois. `GET /channels` antecipa o mesmo motivo. A mensagem aparece na caixa creditada como **API**, e **não** pausa a Aura — quem pausa a automação é uma pessoa respondendo pelo painel ou pelo celular. Se você está montando um chatbot próprio, leia a [receita correspondente](https://linkzap.orion.moe/developers/recipes.md#4-um-chatbot-seu-respondendo) antes. ## Ler o histórico ### `GET /api/v1/messages` As mensagens de uma conversa, da mais recente para a mais antiga. **Parâmetros da URL** | Campo | Tipo | Obrigatório | Descrição | | --- | --- | --- | --- | | `conversationId` | texto | obrigatório | Obrigatório. Ache o id em `GET /conversations`. | | `page` | inteiro | — | A partir de 1. | | `limit` | inteiro | — | De 1 a 200. Padrão 50. | | `includeNotes` | booleano | — | Por padrão as notas internas da equipe **não** vêm. `true` as inclui. | Pedido: ```bash curl "https://linkzap.orion.moe/api/v1/messages?conversationId=cnv_5ad2...&limit=20" \ -H "Authorization: Bearer lz_SUA_CHAVE" ``` Resposta: ```json { "conversationId": "cnv_5ad2...", "data": [ { "id": "msg_7bd0...", "direction": "out", "kind": "text", "text": "Sua consulta está confirmada para quinta, 9h.", "media": null, "author": { "kind": "automation", "label": "Fluxo · Confirmação" }, "status": "read", "receipts": { "sent": "2026-09-21T14:02:59.000Z", "delivered": "2026-09-21T14:03:04.000Z", "read": "2026-09-21T14:05:22.000Z" }, "externalId": "3EB0...", "replyToId": null, "transcript": null, "createdAt": "2026-09-21T14:02:59.000Z" } ], "page": 1, "limit": 20, "total": 48 } ``` - `direction` é `in` (o contato), `out` (saiu daqui) ou `note` (nota interna, só com `includeNotes=true`). - `author` diz quem escreveu: `contact`, `team` (com o nome da pessoa) ou `automation` (com a origem — `Aura`, `Fluxo · X`, `Campanha`…). Mensagens antigas, de antes de existir esse carimbo, vêm como `Automação`. - `media.url` só existe depois que o arquivo foi baixado para cá; enquanto `media.pending` for `true`, a URL é `null` — preferimos isso a entregar um link que não abre. - `transcript` traz a transcrição de uma nota de voz, quando a Aura já transcreveu. > [!CAUTION] > **Notas internas são internas** > > Elas são o que a equipe escreve para a equipe, e foram escritas com essa expectativa. Se você espelha a conversa num portal que o cliente final enxerga, não peça `includeNotes=true`. --- > Fonte: https://linkzap.orion.moe/developers/flows.md # Fluxos Um fluxo é a conversa automática que a sua equipe montou no builder. Pelo API você pode iniciá-lo para um telefone — o resto acontece sozinho. ## Listar fluxos ### `GET /api/v1/flows` Os fluxos da organização, em ordem alfabética pelo nome. Devolve todos de uma vez — esta lista não é paginada. **Parâmetros da URL** | Campo | Tipo | Descrição | | --- | --- | --- | | `enabled` | booleano | `true` traz só os ligados — que são os únicos que dá para iniciar. | Pedido: ```bash curl "https://linkzap.orion.moe/api/v1/flows?enabled=true" \ -H "Authorization: Bearer lz_SUA_CHAVE" ``` Resposta: ```json { "data": [ { "id": "flw_2c77...", "name": "Triagem", "enabled": true, "channelId": null, "isMain": true, "updatedAt": "2026-09-18T20:14:00.000Z" } ] } ``` - `channelId: null` significa que o fluxo atende **qualquer** número — não que ele esteja sem canal. Por isso uma chave de canal enxerga tanto os fluxos daquele número quanto os sem número. - `isMain` marca o fluxo principal: o que responde quando nenhum outro gatilho combina. ## Iniciar um fluxo ### `POST /api/v1/flows/{id}/start` Coloca a conversa daquele telefone dentro do fluxo. Cria o contato e a conversa se não existirem. **Corpo (JSON)** | Campo | Tipo | Obrigatório | Descrição | | --- | --- | --- | --- | | `channelId` | texto | com chave de conta | Por qual número. Com uma **chave de canal** pode ser omitido — o escopo preenche. Com a chave da conta é obrigatório: faltando, a resposta é 422 `Informe channelId.` | | `phone` | texto | obrigatório | DDI + DDD + número. | Pedido: ```bash curl -X POST https://linkzap.orion.moe/api/v1/flows/flw_2c77.../start \ -H "Authorization: Bearer lz_SUA_CHAVE" \ -H "Content-Type: application/json" \ -d '{ "channelId": "chn_9f2a...", "phone": "5592999990000" }' ``` Resposta: ```json { "data": { "conversationId": "cnv_5ad2...", "flowId": "flw_2c77...", "result": "started" } } ``` `result` tem dois valores: **`started`**, que vem com `201`, e **`skipped`**, que vem com `409` — a conversa não aceita automação agora (está pausada, bloqueada ou já dentro de outro fluxo). > [!NOTE] > **Este 409 é a única exceção da API** > > Quando `result` é `skipped`, o `409` vem com **corpo de sucesso** (`{ "data": … }`), e não com `{ "error": … }` como todos os outros erros. Nada deu errado: o pedido foi entendido e recusado por estado, e o `result` diz qual. Se o seu código trata todo não-2xx lendo `error`, trate este caso antes. Antes disso, os `409` normais (com `error`): o fluxo está desligado, não atende aquele canal, ou o número não está pronto para enviar. `404` é fluxo ou canal que não é desta organização. > [!NOTE] > **Por que isso costuma ser melhor que mandar texto** > > Iniciar um fluxo entrega a conversa a algo que sabe esperar resposta, ramificar e passar para um humano na hora certa. Mandar uma mensagem solta deixa a resposta do contato caindo na fila sem contexto. Quando existir um fluxo para o caso, prefira iniciá-lo. > [!WARNING] > **Um fluxo em andamento não é interrompido** > > Se a conversa já está dentro de outro fluxo, esta chamada não atropela o que estava acontecendo — e devolve o motivo. Mandar de novo em laço não muda isso. --- > Fonte: https://linkzap.orion.moe/developers/deals.md # Negócios O funil do CRM. Um negócio pertence a um contato, mora numa etapa de um funil e está em aberto, ganho ou perdido. O valor é sempre em centavos de real. ## Funis e etapas ### `GET /api/v1/pipelines` Lista os funis da conta, cada um com as etapas na ordem do quadro. Não é paginada. **Comece por aqui:** é daqui que sai o `stageId` que criar e mover negócio pedem. Pedido: ```bash curl https://linkzap.orion.moe/api/v1/pipelines \ -H "Authorization: Bearer lz_SUA_CHAVE" ``` Resposta: ```json { "data": [ { "id": "ppl_10aa...", "name": "Vendas", "isDefault": true, "stages": [ { "id": "stg_1a01...", "name": "Novo", "position": 0 }, { "id": "stg_77b0...", "name": "Proposta", "position": 1 } ] } ] } ``` `isDefault` marca o funil que recebe o negócio criado sem `stageId`. Os nomes das etapas são da equipe e mudam; guarde o `id`. ## Listar negócios ### `GET /api/v1/deals` Lista os negócios, do mexido mais recentemente para o mais antigo. Paginada. **Uma chave de canal vê todos os negócios da conta** — negócio, como contato, não pertence a canal. **Parâmetros da URL** | Campo | Tipo | Descrição | | --- | --- | --- | | `status` | texto | Um de `open`, `won` ou `lost`. Qualquer outro valor responde 422. | | `pipelineId` | texto | Os negócios de um funil. | | `stageId` | texto | Os negócios de uma etapa. | | `contactId` | texto | Os negócios de um contato. | | `page` | inteiro | A partir de 1. | | `limit` | inteiro | De 1 a 200. Padrão 50. | Pedido: ```bash curl "https://linkzap.orion.moe/api/v1/deals?status=open&limit=20" \ -H "Authorization: Bearer lz_SUA_CHAVE" ``` Resposta: ```json { "data": [ { "id": "dea_81c2...", "title": "Plano anual", "valueCents": 299000, "status": "open", "pipelineId": "ppl_10aa...", "stageId": "stg_77b0...", "contact": { "id": "ctt_3c81...", "phone": "5592999990000", "name": "Maria Souza" }, "ownerId": "usr_44e1...", "expectedCloseOn": "2026-10-31", "lostReason": null, "conversationId": null, "closedAt": null, "createdAt": "2026-10-06T13:00:00.000Z", "updatedAt": "2026-10-06T13:00:00.000Z" } ], "page": 1, "limit": 20, "total": 1 } ``` `valueCents: 299000` é R$ 2.990,00. Zero quer dizer que o negócio não tem valor informado. ## Criar um negócio ### `POST /api/v1/deals` Cria um negócio para um contato **que já existe** (crie o contato antes, com `POST /contacts`). Responde 201 com o negócio. **Corpo (JSON)** | Campo | Tipo | Obrigatório | Descrição | | --- | --- | --- | --- | | `contactId` | texto | — | O contato do negócio. Use este **ou** `phone` — um dos dois, nunca os dois. | | `phone` | texto | — | O telefone do contato. 404 se não houver contato com ele. | | `title` | texto | obrigatório | O que está sendo negociado. Até 120 caracteres; acima disso, 422. | | `valueCents` | inteiro | — | O valor em **centavos**: R$ 1.250,00 é `125000`. | | `stageId` | texto | — | A etapa de entrada. Sem ela, o negócio entra na primeira etapa do funil padrão. Etapa que não existe responde 404. | | `ownerId` | texto | — | O id de uma pessoa da equipe, como responsável. 422 se não for da equipe. | | `expectedCloseOn` | data | — | A previsão de fechamento, `AAAA-MM-DD`. Dia que não existe no calendário (30 de fevereiro) responde 422. | | `conversationId` | texto | — | A conversa de onde o negócio nasceu. Só é guardada se for daquele contato; de outro, é ignorada. | Pedido: ```bash curl -X POST https://linkzap.orion.moe/api/v1/deals \ -H "Authorization: Bearer lz_SUA_CHAVE" \ -H "Content-Type: application/json" \ -d '{ "phone": "5592999990000", "title": "Plano anual", "valueCents": 299000, "expectedCloseOn": "2026-10-31" }' ``` Resposta: ```json { "data": { "id": "dea_81c2...", "title": "Plano anual", "valueCents": 299000, "status": "open", "pipelineId": "ppl_10aa...", "stageId": "stg_77b0...", "contact": { "id": "ctt_3c81...", "phone": "5592999990000", "name": "Maria Souza" }, "ownerId": "usr_44e1...", "expectedCloseOn": "2026-10-31", "lostReason": null, "conversationId": null, "closedAt": null, "createdAt": "2026-10-06T13:00:00.000Z", "updatedAt": "2026-10-06T13:00:00.000Z" } } ``` > [!WARNING] > **Não é idempotente** > > Cada chamada cria um negócio. Se o seu sistema pode repetir o pedido (uma fila que reentrega, um formulário enviado duas vezes), confira antes com `GET /deals?contactId=…&status=open`. ## Mover, ganhar, perder e editar ### `POST /api/v1/deals/{id}` Muda um negócio. Mande só o que quer mudar; **tudo ou nada** — se um dos campos é recusado, nenhum é gravado. Responde 200 com o negócio como ficou. **Corpo (JSON)** | Campo | Tipo | Descrição | | --- | --- | --- | | `stageId` | texto | Move para esta etapa (o negócio entra no topo dela). Só com o negócio em aberto: mover um fechado responde 409. A etapa em que ele já está não é um movimento: o negócio fica onde estava. | | `status` | texto | `won` ou `lost` fecha o negócio; `open` reabre um fechado. Fechar o que já está fechado responde 409. | | `lostReason` | texto | Por que se perdeu, até 80 caracteres. **Obrigatório** com `status: lost` — é o dado que diz por que a conta perde venda. Sem `status: lost` no mesmo pedido, responde 422. | | `title` | texto | Novo título. | | `valueCents` | inteiro | Novo valor, em centavos. | | `ownerId` | texto ou nulo | Novo responsável; `null` tira. | | `expectedCloseOn` | data ou nulo | Nova previsão; `null` tira. | Pedido: ```bash curl -X POST https://linkzap.orion.moe/api/v1/deals/dea_81c2... \ -H "Authorization: Bearer lz_SUA_CHAVE" \ -H "Content-Type: application/json" \ -d '{ "status": "won" }' ``` Resposta: ```json { "data": { "id": "dea_81c2...", "title": "Plano anual", "valueCents": 299000, "status": "won", "pipelineId": "ppl_10aa...", "stageId": "stg_77b0...", "contact": { "id": "ctt_3c81...", "phone": "5592999990000", "name": "Maria Souza" }, "ownerId": "usr_44e1...", "expectedCloseOn": "2026-10-31", "lostReason": null, "conversationId": null, "closedAt": "2026-10-07T14:10:00.000Z", "createdAt": "2026-10-06T13:00:00.000Z", "updatedAt": "2026-10-07T14:10:00.000Z" } } ``` Criar dispara o webhook `deal.created`; mudar de etapa, `deal.stage_changed`; fechar, `deal.won` ou `deal.lost`. Os mesmos eventos saem quando a mudança é feita por uma pessoa no painel, por um fluxo ou pela Aura — então, se o seu sistema escuta e escreve, lembre que a sua própria chamada os provoca. --- > Fonte: https://linkzap.orion.moe/developers/tasks.md # Tarefas O que a equipe tem de fazer: ligar de volta, mandar a proposta, cobrar o retorno. Uma tarefa pode ser de um contato, de um negócio ou de ninguém, e tem responsável e prazo. ## Listar tarefas ### `GET /api/v1/tasks` Lista as tarefas, da criada mais recentemente para a mais antiga. Paginada. A chave de API vê as tarefas de **toda a equipe**. **Parâmetros da URL** | Campo | Tipo | Descrição | | --- | --- | --- | | `status` | texto | `open` (em aberto) ou `done` (concluídas). Qualquer outro valor responde 422. | | `contactId` | texto | As tarefas de um contato. | | `dealId` | texto | As tarefas de um negócio. | | `assigneeId` | texto | As tarefas de uma pessoa da equipe. | | `page` | inteiro | A partir de 1. | | `limit` | inteiro | De 1 a 200. Padrão 50. | Pedido: ```bash curl "https://linkzap.orion.moe/api/v1/tasks?status=open" \ -H "Authorization: Bearer lz_SUA_CHAVE" ``` Resposta: ```json { "data": [ { "id": "tsk_5d90...", "title": "Ligar de volta", "notes": null, "contactId": "ctt_3c81...", "dealId": null, "assigneeId": "usr_44e1...", "dueAt": "2026-10-08T02:59:59.000Z", "dueHasTime": false, "done": false, "doneAt": null, "createdAt": "2026-10-06T13:00:00.000Z", "updatedAt": "2026-10-06T13:00:00.000Z" } ], "page": 1, "limit": 50, "total": 1 } ``` `dueAt` vem em UTC. Com `dueHasTime: false` o prazo é o dia inteiro, e o horário gravado é o **fim daquele dia no fuso da organização** — por isso `02:59:59Z` para um dia em Brasília. ## Criar uma tarefa ### `POST /api/v1/tasks` Cria uma tarefa. Responde 201. Quem recebe a tarefa (`assigneeId`) é lembrado por e-mail: na hora marcada ou, sem hora, às 9h do dia. **Corpo (JSON)** | Campo | Tipo | Obrigatório | Descrição | | --- | --- | --- | --- | | `title` | texto | obrigatório | O que tem de ser feito. Até 160 caracteres. | | `notes` | texto | — | Detalhes, para quem vai fazer. Até 2.000 caracteres. | | `contactId` | texto | — | O contato da tarefa. Opcional. | | `phone` | texto | — | Em vez de `contactId`: o telefone de um contato que já existe. 404 se não houver. Não mande os dois. | | `dealId` | texto | — | O negócio da tarefa. Com ele, o contato é o do negócio. | | `assigneeId` | texto | — | O id de uma pessoa da equipe. 422 se não for da equipe. | | `dueOn` | data | — | O dia do prazo, `AAAA-MM-DD`, **no fuso da organização**. | | `dueTime` | hora | — | `HH:MM`, opcional. Só vale junto de `dueOn`. | Pedido: ```bash curl -X POST https://linkzap.orion.moe/api/v1/tasks \ -H "Authorization: Bearer lz_SUA_CHAVE" \ -H "Content-Type: application/json" \ -d '{ "phone": "5592999990000", "title": "Ligar de volta", "assigneeId": "usr_44e1...", "dueOn": "2026-10-07" }' ``` Resposta: ```json { "data": { "id": "tsk_5d90...", "title": "Ligar de volta", "notes": null, "contactId": "ctt_3c81...", "dealId": null, "assigneeId": "usr_44e1...", "dueAt": "2026-10-08T02:59:59.000Z", "dueHasTime": false, "done": false, "doneAt": null, "createdAt": "2026-10-06T13:00:00.000Z", "updatedAt": "2026-10-06T13:00:00.000Z" } } ``` ## Concluir, reabrir e editar ### `POST /api/v1/tasks/{id}` Muda uma tarefa. Mande só o que quer mudar. Concluir a que já está concluída não muda nada e responde 200. **Corpo (JSON)** | Campo | Tipo | Descrição | | --- | --- | --- | | `done` | booleano | `true` conclui; `false` reabre. | | `title` | texto | Novo título. | | `notes` | texto ou nulo | Novos detalhes; `null` apaga. | | `assigneeId` | texto ou nulo | Novo responsável; `null` tira. | | `dueOn` | data ou nulo | Novo dia do prazo; `null` tira o prazo inteiro. Mudar só o dia mantém a hora que a tarefa já tinha. | | `dueTime` | hora ou nulo | Nova hora; `null` volta a valer o dia inteiro. Pode vir sem `dueOn` quando a tarefa já tem dia (a hora entra nele); numa tarefa sem dia, responde 422. | Pedido: ```bash curl -X POST https://linkzap.orion.moe/api/v1/tasks/tsk_5d90... \ -H "Authorization: Bearer lz_SUA_CHAVE" \ -H "Content-Type: application/json" \ -d '{ "done": true }' ``` Resposta: ```json { "data": { "id": "tsk_5d90...", "title": "Ligar de volta", "notes": null, "contactId": "ctt_3c81...", "dealId": null, "assigneeId": "usr_44e1...", "dueAt": "2026-10-08T02:59:59.000Z", "dueHasTime": false, "done": true, "doneAt": "2026-10-07T14:10:00.000Z", "createdAt": "2026-10-06T13:00:00.000Z", "updatedAt": "2026-10-07T14:10:00.000Z" } } ``` Criar dispara o webhook `task.created` e concluir dispara `task.completed` — também quando a tarefa nasce de um fluxo ou da Aura. Reabrir não dispara evento. --- > Fonte: https://linkzap.orion.moe/developers/inbound-webhook.md # Webhook de entrada Uma URL por número: qualquer sistema que saiba fazer um POST — formulário de site, CRM, Zapier, Make, n8n — vira contato, mensagem e fluxo aqui dentro, sem você escrever código. ## Onde pegar a URL e a chave No painel, **Canais**: cada número tem a própria URL de webhook e o próprio botão **Copiar chave de API**. Os dois andam juntos — a chave do canal é a que abre aquela URL. URL — O formato da URL: ``` https://linkzap.orion.moe/api/webhooks/channel/SEU_CHANNEL_ID ``` Abrir essa URL no navegador (um `GET`) devolve o contrato em JSON — útil para conferir que copiou a URL certa. ## O que mandar ### `POST /api/webhooks/channel/{id}` Cria ou atualiza o contato pelo telefone e, se pedido, manda a mensagem ou inicia o fluxo — tudo num POST. **Corpo (JSON)** | Campo | Tipo | Obrigatório | Descrição | | --- | --- | --- | --- | | `phone` | texto | obrigatório | DDI + DDD + número. | | `name` | texto | — | Nome do contato. | | `email` | texto | — | E-mail válido. | | `message` | texto | — | Um texto para enviar ao contato pelo canal. Use **ou** isto **ou** `flowId`, nunca os dois. | | `flowId` | texto | — | Em vez de texto: inicia este fluxo na conversa do contato. | | `tags` | lista | — | Até 20 etiquetas; as que não existem são criadas. Podem disparar um fluxo por si sós. | | `fields` | objeto | — | Campos próprios seus (origem, número do pedido, o que fizer sentido). | | `optIn` | booleano | — | Registra o consentimento — marque só se a pessoa de fato autorizou. | Pedido: ```bash curl -X POST https://linkzap.orion.moe/api/webhooks/channel/SEU_CHANNEL_ID \ -H "x-api-key: lz_CHAVE_DO_CANAL" \ -H "Content-Type: application/json" \ -d '{ "phone": "5592999990000", "name": "Maria Souza", "message": "Oi, Maria! Recebemos o seu pedido de contato. Em que posso ajudar?", "tags": ["lead-site"], "fields": { "origem": "formulario-contato" }, "optIn": true }' ``` Resposta: ```json { "data": { "contactId": "ctt_3c81...", "created": true, "channelId": "chn_9f2a...", "conversationId": "cnv_5ad2...", "messageId": "msg_7bd0..." } } ``` ## O que volta - `201` — criou o contato, ou enviou a mensagem, ou iniciou o fluxo. - `200` — **só num caso**: o contato já existia e você não pediu mensagem nem fluxo. - `403` — a chave é de outro canal. - `409` — o número não está pronto para enviar. **O contato foi salvo mesmo assim**; só a mensagem não saiu. - `503` — este servidor está sem a conexão de sistema; o webhook fica indisponível até alguém configurá-la. Os campos de `data` dependem do que você pediu: sempre `contactId`, `created` e `channelId`; mais `conversationId` e `messageId` quando houve mensagem; mais `conversationId` e `flow: "started"` quando houve fluxo. ## Quando usar este, e quando usar a API Este webhook é um atalho: faz num POST o que seriam duas ou três chamadas da API (criar contato, garantir conversa, enviar ou iniciar fluxo). Use-o quando quem chama é uma ferramenta sem código — um formulário, um automatizador, um gatilho do seu CRM. Quando quem chama é o _seu_ backend, prefira a API: ela devolve mais informação, separa as responsabilidades e não obriga a mandar contato e mensagem no mesmo pacote. > [!WARNING] > **Não é o webhook que te avisa** > > Este é o que _você_ chama. Para receber aqui o que acontece lá — mensagem do contato, conversa resolvida —, o que você quer é a outra página: [Webhooks de saída](https://linkzap.orion.moe/developers/webhooks.md). --- > Fonte: https://linkzap.orion.moe/developers/webhooks.md # Webhooks de saída Em vez de você perguntar “tem novidade?” de minuto em minuto, nós avisamos. Cada evento vira um POST assinado no endereço que você registrar. ## Registrar No painel, **Configurações → Integrações → Novo webhook**: uma URL `https` pública, uma descrição e os eventos que interessam. O **segredo** aparece ali — é com ele que você confere a assinatura. Se precisar dele depois, o menu do webhook tem “Revelar segredo”; e “Gerar novo segredo” troca o anterior na hora. São até 10 webhooks por organização, e a URL precisa ser pública de verdade: endereço de rede interna é recusado no cadastro **e** na hora da entrega. > [!NOTE] > **Comece pelo ping** > > O botão de avião ao lado de cada webhook manda um evento `ping` na hora e mostra o status HTTP que voltou. É a forma mais rápida de saber se a sua URL está de pé antes de esperar um evento de verdade. ## O que chega Todo envio é um POST com estes cabeçalhos: - `X-LinkZap-Event` — o nome do evento, para rotear sem abrir o corpo. - `X-LinkZap-Delivery` — o id desta entrega. **É a sua chave de idempotência**: a mesma entrega pode chegar duas vezes se a sua resposta se perder no caminho. - `X-LinkZap-Signature` — `sha256=` seguido do HMAC-SHA256 do corpo com o seu segredo. - `User-Agent: LinkZap-Webhooks/1`. Exemplo — O corpo, sempre com esta forma: ```json { "id": "whd_8e31...", "event": "message.received", "data": { "conversationId": "cnv_5ad2...", "contactId": "ctt_3c81...", "channelId": "chn_9f2a...", "messageId": "msg_7bd0...", "phone": "5592999990000", "text": "Oi, queria marcar uma consulta", "kind": "text", "event": "message.received", "occurredAt": "2026-09-21T14:03:11.000Z" } } ``` ## Verificar a assinatura > [!CAUTION] > **Sem verificar, qualquer um pode fingir ser nós** > > A sua URL é pública. O que distingue um evento nosso de um POST inventado por terceiros é a assinatura, e ela só vale se for conferida contra o **corpo cru**. Se o seu framework já converteu o JSON em objeto e você reserializa para conferir, a assinatura não vai bater — e a tentação de “simplificar” desligando a verificação é justamente o buraco. Trecho — Node.js (Express): ```js // Express. O corpo CRU é obrigatório: JSON re-serializado não bate com a assinatura. import express from "express"; import crypto from "node:crypto"; const app = express(); const SECRET = process.env.LINKZAP_WEBHOOK_SECRET; app.post("/linkzap", express.raw({ type: "application/json" }), (req, res) => { const assinatura = req.header("x-linkzap-signature") ?? ""; const esperada = "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex"); // timingSafeEqual exige o mesmo tamanho — compare os buffers com cuidado. const a = Buffer.from(assinatura); const b = Buffer.from(esperada); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401); const { id, event, data } = JSON.parse(req.body.toString("utf8")); // Responda AGORA e processe depois: o prazo é de 10 segundos. res.sendStatus(200); enfileirar({ id, event, data }); }); ``` Trecho — PHP: ```php [!WARNING] > **Webhook desligado descarta** > > Enquanto a chave do webhook estiver desligada, as entregas que venceriam são marcadas como desistidas em vez de ficarem acumulando. Desligar não é pausar. ## Os eventos ### `message.received` — Mensagem recebida O contato mandou uma mensagem — o gatilho mais usado. ```json { "id": "whd_...", "event": "message.received", "data": { "conversationId": "cnv_...", "contactId": "ctt_...", "channelId": "chn_...", "messageId": "msg_...", "phone": "5592999990000", "text": "Oi, queria marcar uma consulta", "kind": "text", "event": "message.received", "occurredAt": "2026-09-21T14:03:11.000Z" } } ``` ### `message.sent` — Mensagem enviada Saiu uma mensagem daqui — por uma pessoa da equipe, pela Aura, por um fluxo, por campanha ou pela própria API. ```json { "id": "whd_...", "event": "message.sent", "data": { "conversationId": "cnv_...", "contactId": "ctt_...", "channelId": "chn_...", "messageId": "msg_...", "phone": "5592999990000", "text": "Claro! Que dia fica bom?", "author": "automation", "event": "message.sent", "occurredAt": "2026-09-21T14:03:11.000Z" } } ``` ### `conversation.opened` — Conversa aberta Uma conversa resolvida voltou para a fila. ```json { "id": "whd_...", "event": "conversation.opened", "data": { "conversationId": "cnv_...", "contactId": "ctt_...", "channelId": "chn_...", "status": "open", "event": "conversation.opened", "occurredAt": "2026-09-21T14:03:11.000Z" } } ``` ### `conversation.resolved` — Conversa resolvida O atendimento foi encerrado. ```json { "id": "whd_...", "event": "conversation.resolved", "data": { "conversationId": "cnv_...", "contactId": "ctt_...", "channelId": "chn_...", "status": "resolved", "event": "conversation.resolved", "occurredAt": "2026-09-21T14:03:11.000Z" } } ``` ### `conversation.assigned` — Conversa atribuída A conversa passou a ter (ou deixou de ter) responsável. ```json { "id": "whd_...", "event": "conversation.assigned", "data": { "conversationId": "cnv_...", "assignedTo": "usr_...", "by": "usr_...", "event": "conversation.assigned", "occurredAt": "2026-09-21T14:03:11.000Z" } } ``` ### `conversation.department_changed` — Conversa mudou de setor O setor PRINCIPAL da conversa mudou: ela foi encaminhada para outro time. ```json { "id": "whd_...", "event": "conversation.department_changed", "data": { "conversationId": "cnv_...", "departmentId": "dpt_...", "by": "usr_...", "event": "conversation.department_changed", "occurredAt": "2026-09-21T14:03:11.000Z" } } ``` ### `conversation.participants_changed` — Participantes da conversa mudaram Alguém entrou ou saiu da conversa além do responsável. Vem a lista inteira que ficou, e o responsável do momento. ```json { "id": "whd_...", "event": "conversation.participants_changed", "data": { "conversationId": "cnv_...", "assignedTo": "usr_...", "participants": [ "usr_...", "usr_..." ], "by": "usr_...", "event": "conversation.participants_changed", "occurredAt": "2026-09-21T14:03:11.000Z" } } ``` ### `conversation.departments_changed` — Setores adicionais da conversa mudaram Um setor foi adicionado ou tirado além do principal. Vem a lista inteira que ficou, e o setor principal do momento. ```json { "id": "whd_...", "event": "conversation.departments_changed", "data": { "conversationId": "cnv_...", "departmentId": "dpt_...", "departmentIds": [ "dpt_..." ], "by": "usr_...", "event": "conversation.departments_changed", "occurredAt": "2026-09-21T14:03:11.000Z" } } ``` ### `contact.created` — Contato criado Um telefone novo entrou na base — por mensagem recebida, pela API, por importação ou pelo webhook de entrada. ```json { "id": "whd_...", "event": "contact.created", "data": { "contactId": "ctt_...", "phone": "5592999990000", "name": "Maria", "source": "whatsapp", "event": "contact.created", "occurredAt": "2026-09-21T14:03:11.000Z" } } ``` ### `contact.opt_in` — Contato deu opt-in O contato autorizou receber mensagens — é o que libera campanha e sequência. ```json { "id": "whd_...", "event": "contact.opt_in", "data": { "contactId": "ctt_...", "phone": "5592999990000", "source": "api", "event": "contact.opt_in", "occurredAt": "2026-09-21T14:03:11.000Z" } } ``` ### `contact.opt_out` — Contato pediu opt-out O contato pediu para não receber mais. Respeite no seu sistema também. ```json { "id": "whd_...", "event": "contact.opt_out", "data": { "contactId": "ctt_...", "phone": "5592999990000", "source": "api", "event": "contact.opt_out", "occurredAt": "2026-09-21T14:03:11.000Z" } } ``` ### `contact.updated` — Contato alterado Mudou o nome, o telefone, o e-mail, um campo, uma etiqueta ou o responsável de um contato. `changed` diz o quê; releia o contato com `GET /contacts?phone=`. Uma mesma chamada pode gerar mais de um (um por tipo de mudança: nome, campos, etiquetas), e o de um contato recém-criado sai depois do `contact.created` dele. Opt-in e opt-out têm evento próprio. ```json { "id": "whd_...", "event": "contact.updated", "data": { "contactId": "ctt_...", "phone": "5592999990000", "name": "Maria Souza", "changed": [ "fields" ], "event": "contact.updated", "occurredAt": "2026-09-21T14:03:11.000Z" } } ``` ### `deal.created` — Negócio criado Um negócio entrou no funil — por uma pessoa, por um fluxo, pela Aura ou pela API. ```json { "id": "whd_...", "event": "deal.created", "data": { "dealId": "dea_...", "contactId": "ctt_...", "phone": "5592999990000", "title": "Plano anual", "valueCents": 299000, "status": "open", "pipelineId": "ppl_...", "stageId": "stg_...", "stage": "Novo", "ownerId": "usr_...", "lostReason": null, "conversationId": "cnv_...", "event": "deal.created", "occurredAt": "2026-09-21T14:03:11.000Z" } } ``` ### `deal.stage_changed` — Negócio mudou de etapa O negócio foi para outra etapa do funil. Vem a etapa nova e, em `fromStageId`/`fromStage`, a anterior. Reordenar dentro da mesma etapa não dispara. ```json { "id": "whd_...", "event": "deal.stage_changed", "data": { "dealId": "dea_...", "contactId": "ctt_...", "phone": "5592999990000", "title": "Plano anual", "valueCents": 299000, "status": "open", "pipelineId": "ppl_...", "stageId": "stg_...", "stage": "Proposta", "ownerId": "usr_...", "lostReason": null, "conversationId": "cnv_...", "fromStageId": "stg_...", "fromStage": "Em contato", "event": "deal.stage_changed", "occurredAt": "2026-09-21T14:03:11.000Z" } } ``` ### `deal.won` — Negócio ganho O negócio foi fechado como ganho. ```json { "id": "whd_...", "event": "deal.won", "data": { "dealId": "dea_...", "contactId": "ctt_...", "phone": "5592999990000", "title": "Plano anual", "valueCents": 299000, "status": "won", "pipelineId": "ppl_...", "stageId": "stg_...", "stage": "Negociação", "ownerId": "usr_...", "lostReason": null, "conversationId": "cnv_...", "event": "deal.won", "occurredAt": "2026-09-21T14:03:11.000Z" } } ``` ### `deal.lost` — Negócio perdido O negócio foi fechado como perdido. `lostReason` traz o motivo. ```json { "id": "whd_...", "event": "deal.lost", "data": { "dealId": "dea_...", "contactId": "ctt_...", "phone": "5592999990000", "title": "Plano anual", "valueCents": 299000, "status": "lost", "pipelineId": "ppl_...", "stageId": "stg_...", "stage": "Proposta", "ownerId": "usr_...", "lostReason": "Preço", "conversationId": "cnv_...", "event": "deal.lost", "occurredAt": "2026-09-21T14:03:11.000Z" } } ``` ### `task.created` — Tarefa criada Uma tarefa foi criada para a equipe. `contactId` e `phone` vêm nulos em tarefa que não é de um contato. ```json { "id": "whd_...", "event": "task.created", "data": { "taskId": "tsk_...", "title": "Ligar de volta", "contactId": "ctt_...", "phone": "5592999990000", "dealId": null, "assigneeId": "usr_...", "dueAt": "2026-10-08T02:59:59.000Z", "doneAt": null, "event": "task.created", "occurredAt": "2026-09-21T14:03:11.000Z" } } ``` ### `task.completed` — Tarefa concluída Alguém concluiu a tarefa. Reabrir não dispara evento. ```json { "id": "whd_...", "event": "task.completed", "data": { "taskId": "tsk_...", "title": "Ligar de volta", "contactId": "ctt_...", "phone": "5592999990000", "dealId": null, "assigneeId": "usr_...", "dueAt": "2026-10-08T02:59:59.000Z", "doneAt": "2026-10-07T14:10:00.000Z", "event": "task.completed", "occurredAt": "2026-09-21T14:03:11.000Z" } } ``` ### `ping` — Teste de entrega Você apertou o botão de avião na tela de Integrações. Não corresponde a nada que tenha acontecido na conta — serve para conferir que a sua URL responde e que a assinatura confere. ```json { "id": "whd_...", "event": "ping", "data": { "by": "voce@exemplo.com", "event": "ping", "occurredAt": "2026-09-21T14:03:11.000Z" } } ``` --- > Fonte: https://linkzap.orion.moe/developers/recipes.md # Receitas Quatro integrações inteiras, do jeito que a gente faria. Copie e troque os ids. ## 1. O lead do site vira conversa O visitante preenche o formulário e, em vez de entrar numa planilha para alguém ligar depois, já recebe a primeira mensagem — no minuto em que o interesse existe. Trecho — No backend do seu site, depois de validar o formulário: ```js const r = await fetch("https://linkzap.orion.moe/api/webhooks/channel/" + process.env.LINKZAP_CHANNEL_ID, { method: "POST", headers: { "x-api-key": process.env.LINKZAP_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ phone: form.telefone, name: form.nome, email: form.email, message: `Oi, ${form.nome.split(" ")[0]}! Recebemos o seu contato sobre ${form.assunto}. Já te respondo por aqui.`, tags: ["lead-site"], fields: { origem: form.utm_source ?? "direto", assunto: form.assunto }, optIn: true, // a pessoa marcou a caixa de consentimento no formulário }), }); if (!r.ok) { // 409 = o número não está pronto. O CONTATO foi salvo mesmo assim: // registre para a equipe retomar, não perca o lead. console.error("linkzap:", r.status, await r.text()); } ``` > [!NOTE] > **Uma variação que costuma render mais** > > Troque `message` por `flowId`. Em vez de uma frase solta cuja resposta cai na fila sem contexto, o contato entra num fluxo que já pergunta o que precisa saber e só chama um humano quando vale a pena. ## 2. O seu sistema avisa o cliente Pedido aprovado, consulta confirmada, boleto vencendo. O caso mais comum de todos. Trecho — Uma função para reusar: ```js async function avisar(telefone, texto) { const r = await fetch("https://linkzap.orion.moe/api/v1/messages", { method: "POST", headers: { Authorization: `Bearer ${process.env.LINKZAP_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ channelId: process.env.LINKZAP_CHANNEL_ID, phone: telefone, text: texto, }), }); if (r.status === 409) { // O número não está pronto para enviar; o motivo vem em palavras. const { error } = await r.json(); throw new Error("canal indisponível: " + error); } if (r.status === 429) { // Respeite o Retry-After em vez de tentar em laço. throw new Error("freio: tente em " + r.headers.get("retry-after") + "s"); } if (!r.ok) throw new Error(await r.text()); const { data } = await r.json(); return data.messageId; // guarde: reenviar sem checar manda duas vezes } ``` > [!WARNING] > **Na conta oficial, cuidado com a janela** > > Se o número é `provider: "cloud"`, texto livre só sai se o contato falou com você nas últimas 24 horas. Para avisos que partem de você, o caminho é um template aprovado — que hoje se dispara pelo painel, por um fluxo, por uma sequência ou por uma campanha. Nos números por QR não há essa restrição. ## 3. Espelhar as conversas no seu banco Para o seu time comercial ver o histórico de WhatsApp dentro do seu próprio sistema, sem trocar de tela. Escute os eventos e escreva no seu banco — nada de varrer a API de tempos em tempos. Trecho — O receptor, com assinatura e idempotência: ```js app.post("/linkzap", express.raw({ type: "application/json" }), async (req, res) => { if (!assinaturaConfere(req)) return res.sendStatus(401); // ver "Webhooks de saída" const entrega = req.header("x-linkzap-delivery"); const { event, data } = JSON.parse(req.body.toString("utf8")); // Responda ANTES de processar: o prazo é de 10 segundos. res.sendStatus(200); // A mesma entrega pode chegar duas vezes — o id é a chave. if (await jaProcessei(entrega)) return; await marcarProcessado(entrega); switch (event) { case "message.received": case "message.sent": await db.mensagens.upsert({ externo: data.messageId, conversa: data.conversationId, telefone: data.phone, texto: data.text, entrada: event === "message.received", quando: data.occurredAt, }); break; case "contact.opt_out": // Respeite no SEU sistema também: nada de incluí-lo no próximo disparo. await db.contatos.marcarOptOut(data.phone); break; } }); ``` Para trazer o que aconteceu antes de você ligar o webhook, percorra `GET /api/v1/conversations` e, para cada uma, `GET /api/v1/messages?conversationId=…`. Faça isso uma vez, na carga inicial — o resto vem pelos eventos. ## 4. Um chatbot seu respondendo Você já tem a sua própria automação e quer que ela atenda por aqui. Funciona, com um cuidado que economiza um bug difícil. Trecho — Receber, pensar, responder: ```js // No receptor de webhook, depois de conferir a assinatura: if (event === "message.received") { const resposta = await meuBot(data.text, data.conversationId); await fetch("https://linkzap.orion.moe/api/v1/messages", { method: "POST", headers: { Authorization: `Bearer ${process.env.LINKZAP_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ channelId: data.channelId, conversationId: data.conversationId, // responda NA conversa text: resposta, }), }); } ``` > [!CAUTION] > **Desligue a Aura nesses canais** > > Uma mensagem enviada pela API não silencia a automação daqui — quem faz isso é uma pessoa respondendo pelo painel ou pelo celular. Se a Aura ou um fluxo também estiverem atendendo esse número, o contato recebe duas respostas para a mesma pergunta. Escolha um: ou a automação do LinkZap, ou a sua. Repare também que a sua própria resposta gera um `message.sent` no seu receptor. Se o seu código reage a esse evento, garanta que ele não se responda em laço. ## Continue por aqui Os detalhes de cada rota estão na [referência](https://linkzap.orion.moe/developers/channels.md), e a verificação da assinatura em três linguagens está em [Webhooks de saída](https://linkzap.orion.moe/developers/webhooks.md). Ficou faltando alguma coisa? O suporte quer saber qual — é assim que a gente decide o que entra primeiro. --- > Fonte: https://linkzap.orion.moe/developers/whatsapp-own-app.md # API oficial com o seu app da Meta Enquanto a conexão pela janela da Meta não está aberta para todos, você pode ligar um número oficial do WhatsApp usando um app que é SEU na Meta. Leva uns 20 minutos, uma vez só. Você gera as chaves, cola no LinkZap e nós conferimos e ligamos o resto. ## Antes de começar - Um **portfólio empresarial** na Meta (o antigo Gerenciador de Negócios), em [business.facebook.com](https://business.facebook.com), com você como administrador. - Um **número que não esteja em uso** no app do WhatsApp (nem no WhatsApp Business do celular). Ao entrar na API oficial, ele deixa de funcionar no aplicativo. - Um **cartão** para a Meta cobrar as conversas. A conta de mensagens é sua, cobrada pela Meta; o LinkZap cobra só a assinatura. > [!WARNING] > **O número sai do celular** > > Um número registrado na API oficial não abre mais no app do WhatsApp. Se o número que você quer usar está hoje no celular, use um chip novo ou fale com o suporte antes. ## Passo a passo ### 1. Crie o app Em [developers.facebook.com/apps](https://developers.facebook.com/apps), clique em **Criar app**. Escolha o caso de uso do **WhatsApp** (ou **Outro → Empresa** e, depois, adicione o produto **WhatsApp**) e ligue o app ao seu portfólio empresarial. ### 2. Adicione o número No app, abra **WhatsApp → Configuração da API** e clique em **Adicionar número de telefone**. Preencha o nome de exibição e a categoria do negócio e confirme o código por SMS ou ligação. Na mesma tela ficam os dois números que o LinkZap pede: o **ID do número de telefone** e o **ID da conta do WhatsApp Business**. Anote os dois. Eles são sequências de dígitos, não o telefone em si. ### 3. Cadastre a forma de pagamento No [WhatsApp Manager](https://business.facebook.com/wa/manage/home), em **Configurações da conta → Forma de pagamento**, cadastre o cartão. Sem ele a Meta não deixa iniciar conversas. ### 4. Publique o app e copie as chaves dele Em **Configurações do app → Básico**, preencha a **URL da Política de Privacidade** (pode ser a da sua empresa, ou `https://linkzap.orion.moe/privacy`) e salve. Depois, no topo do painel, mude o app para **Ao vivo** (publicado). Na mesma tela, copie o **ID do app** e a **Chave secreta do app** (clique em **Mostrar**). A chave tem 32 letras e números. ### 5. Crie o usuário do sistema e gere o token Em [business.facebook.com/settings](https://business.facebook.com/settings), abra **Usuários → Usuários do sistema → Adicionar**. Dê um nome (por exemplo, `LinkZap`) e a função **Administrador**. Com o usuário selecionado, clique em **Atribuir ativos** e dê **controle total** a dois ativos: o **app** que você criou e a **conta do WhatsApp**. Clique em **Gerar novo token**, escolha o app, marque a validade **Nunca** e as permissões `whatsapp_business_management` e `whatsapp_business_messaging`. Copie o token: ele aparece uma vez só. > [!WARNING] > **Não use o token da tela “Configuração da API”** > > Aquele token é temporário e vence em 24 horas. O LinkZap recusa token com prazo, para o seu número não parar de falar amanhã. ### 6. Cole no LinkZap Em **Canais → Adicionar canal → WhatsApp Business Oficial com o seu app**, cole os cinco dados: ID do app, chave secreta, token, ID da conta e ID do número. O LinkZap confere cada um e diz exatamente qual passo refazer se algo não bater. **Você não precisa configurar webhook**: nós gravamos o endereço no seu app. Se o número ainda não estiver registrado na API oficial, a tela pede a sua confirmação antes de registrar. ## Depois de conectado - O número atende pela caixa como qualquer outro, com templates, janela de 24 horas e a Aura. - **Não redefina a chave secreta do app** nem apague o usuário do sistema. Se fizer isso, as mensagens param de chegar; na tela do canal, use **Atualizar credenciais** com os dados novos. - Quando a conexão pela janela da Meta abrir para todos, avisaremos. O número pode continuar como está.