Conversas e Canais (Messaging)
Inventário portátil de conversas/canais e envio de resposta via REST (/api/v1), autenticados com API key permanente de Configurações → Desenvolvedor.
Caso de uso típico: o webhook channel.message.* entrega só metadados → seu backend busca o texto/histórico via REST → (opcional) responde com POST /messaging/reply → atualiza um dashboard externo. Sem OAuth, sem reconnect.
Não é a superfície completa de messaging. Agendamento, cancelamento, mutação de canal e URLs/tokens de gateway não entram no REST. Para LLM/tool-calling (ou schedule/cancel), use MCP com a mesma API key (
messaging.send_reply,messaging.send_approved_template,messaging.schedule_outbound, …) — ver “API key permanente no MCP”.
Autenticação
Seção intitulada “Autenticação”Mesma API key dos demais endpoints /api/v1:
# Recomendadocurl -H "x-api-key: sua_api_key" \ "https://app.garagem.ai/api/v1/messaging/conversations"
# Alternativacurl -H "Authorization: Bearer sua_api_key" \ "https://app.garagem.ai/api/v1/messaging/conversations"A chave age como o usuário que a criou (ou o referenceId user-scoped). A visibilidade é a mesma do inbox: só canais com grant de acesso para esse ator. Uma chave criada por alguém sem canais acessíveis retorna lista vazia (disabledReasons: ["no_accessible_channels"]), não 403 “org-wide”.
Rate limit: padrão Better Auth api-key — 1000 req/h. Prefira o padrão webhook → fetch; evite polling fixo.
Endpoints
Seção intitulada “Endpoints”GET /api/v1/messaging/conversations # Listar conversasGET /api/v1/messaging/conversations/detail # Detalhe + históricoPOST /api/v1/messaging/conversations/resync # Forçar ressincronização do históricoGET /api/v1/messaging/channels # Inventário de canaisGET /api/v1/messaging/labels # Etiquetas do canalGET /api/v1/messaging/labels/assignments # Mapa chatId → etiquetasPOST /api/v1/messaging/reply # Responder em conversa abertaPOST /api/v1/messaging/send # Enviar template HSM aprovadoLeituras (GET) passam pelas mesmas gates de domínio do MCP/UI: tenant_scope, channel_access_grant, chat_privacy_mode, mock_channel_exclusion. O resync usa exatamente essas gates de leitura: ele reconstrói o endereçamento do provedor para uma conversa, sem enviar nem alterar mensagens. Os demais POST reutilizam a folha portátil de outbound (GAR-878): channel_access_grant + chat_privacy_mode + outreach_safety (+ send_idempotency no send) + ack tipado (sem payload de provedor). Em canal com privacy whitelist, só chats whitelisted aceitam envio (pending → privacy_pending_approval; ocultos → privacy_denied).
Etiquetas WhatsApp e identidade. Atribuições aplicadas no aparelho muitas vezes chegam com chatId
@lid(Linked Identity). Quando o mapa LID→telefone é conhecido, a Foundation grava sob a forma@c.us(telefone). UseGET /messaging/labels/assignmentscom o mesmochatIdretornado porGET /messaging/conversations— normalmente@c.us. Mutações de etiqueta ficam fora do REST (UI/session + MCP).
Listar conversas
Seção intitulada “Listar conversas”curl -G "https://app.garagem.ai/api/v1/messaging/conversations" \ -H "x-api-key: sua_api_key" \ --data-urlencode "awaitingReplyOnly=true" \ --data-urlencode "limit=25" \ --data-urlencode "transports=whatsapp" \ --data-urlencode "transports=whatsapp_cloud"Query params
Seção intitulada “Query params”| Param | Tipo | Notas |
|---|---|---|
transports | enum[] | Repita a chave (?transports=a&transports=b). Valores: whatsapp, whatsapp_cloud, instagram, facebook_messenger, tiktok, email, web_chat, voip |
channelId | uuid | Restringe a um canal acessível |
search | string | Nome, handle mascarado, preview |
unreadOnly | boolean | true / false (string na query) |
awaitingReplyOnly | boolean | Última mensagem inbound sem resposta |
assignedToId | uuid | |
connectionStates | enum[] | connected, awaiting_auth, disabled, error, expired, draft |
sort | enum | last_activity_desc (padrão) ou unread_first |
limit | int 1–100 | |
cursor | string | Echo opaco de page.nextCursor |
Query estrita. Params desconhecidos (
?utm_source=…, typos comounread_only) retornam 400. Arrays separados por vírgula (?transports=a,b) também falham — use chaves repetidas.
Resposta (formato)
Seção intitulada “Resposta (formato)”{ "policy": { "principal": "tenant_member", "tenantScoped": true, "riskTier": "read", "mutates": false, "enforcedGates": [ "tenant_scope", "channel_access_grant", "chat_privacy_mode", "mock_channel_exclusion" ], "disabledReasons": [] }, "conversations": [ { "key": { "channelId": "aa0e8400-e29b-41d4-a716-446655440010", }, "channel": { "id": "aa0e8400-e29b-41d4-a716-446655440010", "name": "WhatsApp Vendas", "transport": "whatsapp_cloud", "connectionState": "connected" }, "participant": { "displayName": "Ana", "handleMasked": "+55•••••9999", "kind": "contact" }, "lastActivity": { "at": "2026-07-28T12:00:00.000Z", "preview": "Oi, ainda está disponível?", "fromMe": false }, "readState": { "unreadCount": 2, "isRead": false, "awaitingReply": true }, "privacy": { "state": "visible", "reason": null, "channelPrivacyEnabled": true }, "assignment": { "status": "open", "assignedToType": "user", "assignedToId": "…", "queueId": null }, "links": { "customerId": null, "primaryDealId": null }, "pinnedAt": null } ], "redaction": { "privacyFilteredCount": 0, "redactedCount": 0 }, "page": { "returned": 1, "hasMore": false, "nextCursor": null }, "scope": { "accessibleChannelCount": 3, "privacyEnabledChannelCount": 1 }}Detalhe + histórico
Seção intitulada “Detalhe + histórico”channelId e chatId vão na query (não no path). Chat ids de WhatsApp contêm @ e path segments percent-encoded não são decodificados pelo handler OpenAPI.
curl -G "https://app.garagem.ai/api/v1/messaging/conversations/detail" \ -H "x-api-key: sua_api_key" \ --data-urlencode "channelId=aa0e8400-e29b-41d4-a716-446655440010" \ --data-urlencode "limit=50" \ --data-urlencode "includeHistory=true"Query params
Seção intitulada “Query params”| Param | Tipo | Notas |
|---|---|---|
channelId | uuid | obrigatório |
chatId | string | obrigatório — echo de conversations[].key.chatId |
limit | int 1–100 | Tamanho da página de histórico |
before | ISO-8601 | Keyset: mensagens estritamente mais antigas |
includeHistory | boolean | false retorna só o estado da conversa |
Quando o ator não tem acesso (canal sem grant, chat hidden por privacy), a resposta traz conversation: null e um rejection tipado (not_found_or_inaccessible, channel_forbidden, privacy_restricted) — sem vazar identidade do chat.
Mídia vem como descritores (kind, mimeType, available); nunca URL de gateway, token ou bytes.
Envelope e histórico vêm de fontes diferentes
Seção intitulada “Envelope e histórico vêm de fontes diferentes”O estado da conversa (connectionState, lastActivity, unreadCount, privacy) é lido do registro do Foundation. O histórico é buscado ao vivo no canal, a cada request. São armazenamentos distintos, com modos de falha distintos — por isso uma conversa pode aparecer conectada e com atividade recente e ainda assim vir com history.messages: []. Isso não é contradição: o envelope continua correto.
O bloco history declara isso explicitamente:
| Campo | Notas |
|---|---|
source | provider_feed — de onde veio (ou deixou de vir) a página |
checkedAt | ISO-8601, quando a fonte foi consultada |
unavailableReason | null quando a página é confiável; caso contrário, o motivo |
retryable | Se reler mais tarde pode mudar a resposta |
retryAfterSeconds | Backoff sugerido; null quando não é retryable |
history.unavailableReason
Seção intitulada “history.unavailableReason”| Código | Significado | retryable |
|---|---|---|
feed_timeout | O canal não respondeu dentro do orçamento do request | sim |
feed_transport_unreachable | O canal respondeu com erro | sim |
feed_empty_pending_sync | Primeira página vazia numa conversa com atividade recente (menos de 48h) — a origem ainda não serve esse chat | sim |
feed_history_not_on_provider | Primeira página vazia, mas a última atividade tem mais de 48h — o provedor já não tem esse histórico | não |
feed_channel_not_connected | O canal está em error / expired / awaiting_auth — o envelope já diz isso; esperar o feed não resolve | não |
feed_not_supported_on_channel | O canal não tem feed de mensagens (ex.: canal só de comentários) — esperar nunca resolve | não |
conversation_feed_unavailable | Código legado, guarda-chuva dos anteriores. Não é mais emitido; mantido no enum para integrações que já o tratam | — |
Como o importador deve se comportar: enquanto houver unavailableReason, uma página vazia não é resposta — é ausência de resposta. Nunca grave “conversa sem mensagens” nesse caso. Se retryable for true, espere retryAfterSeconds e releia; se for false, pare de consultar o histórico desse canal. O Foundation já faz uma retentativa interna por leitura, então um motivo que chega até você não é um blip isolado.
Forçar ressincronização
Seção intitulada “Forçar ressincronização”Quando retryable é true e você não quer esperar o backoff, force o canal a reconstruir o estado de endereçamento da conversa:
curl -X POST "https://app.garagem.ai/api/v1/messaging/conversations/resync" \ -H "x-api-key: sua_api_key" \ -H "Content-Type: application/json" \A resposta traz outcome: recovered (voltou a ler), still_empty (reconstruído e ainda vazio), nothing_to_resync (canal sem estado desse tipo) ou unreachable (canal fora do ar). A chamada não envia nem altera mensagens, e passa pelos mesmos gates de acesso do detalhe — uma conversa que você não pode ler também não pode ser ressincronizada.
Paginação de histórico
Seção intitulada “Paginação de histórico”Siga history.nextBefore enquanto history.hasMore for true. As duas coisas andam juntas: hasMore: true nunca vem sem um nextBefore utilizável.
Inventário de canais
Seção intitulada “Inventário de canais”curl -G "https://app.garagem.ai/api/v1/messaging/channels" \ -H "x-api-key: sua_api_key" \ --data-urlencode "ownedOnly=false"| Param | Tipo | Notas |
|---|---|---|
transports | enum[] | Mesmos valores da lista de conversas |
connectionStates | enum[] | |
ownedOnly | boolean | Só canais que o ator é owner |
search | string |
Canais mock são excluídos. sessionName, tokens, QR e corpos de sharingRules nunca aparecem no payload.
Responder em uma conversa
Seção intitulada “Responder em uma conversa”curl -X POST "https://app.garagem.ai/api/v1/messaging/reply" \ -H "x-api-key: sua_api_key" \ -H "Content-Type: application/json" \ -d '{ "channelId": "11111111-1111-4111-8111-111111111111", "chatId": "[email protected]", "body": { "kind": "text", "text": "Olá! Recebemos sua mensagem." }, "idempotencyKey": "ops-dashboard-reply-2026-07-29-001" }'| Campo | Tipo | Notas |
|---|---|---|
channelId | uuid | Canal acessível pelo ator da API key |
chatId | string | Id da conversa (ex.: WhatsApp …@c.us) |
body | object | {"kind":"text","text":"…"} ou {"kind":"listing_cards","listingIds":["…"]} (1–10) |
idempotencyKey | string | Opcional (8–200 chars). Reenvio com a mesma chave não dispara de novo |
Resposta (ack)
Seção intitulada “Resposta (ack)”{ "policy": { "principal": "tenant_member", "tenantScoped": true, "mutates": true, "riskTier": "reply", "approval": "never", "idempotencyKeyed": true, "enforcedGates": [ "tenant_scope", "channel_access_grant", "chat_privacy_mode", "outreach_safety" ] }, "status": "sent", "messageId": "…", "handle": null, "reason": null, "idempotent": false}status tipado (sent / queued / blocked / forbidden / rejected / …). Em não-sucesso, reason explica o bloqueio (opt-out, privacy, sem acesso ao canal, etc.). Nunca retorna token, URL de gateway ou payload do provedor.
Enviar (proativo, channel-agnostic)
Seção intitulada “Enviar (proativo, channel-agnostic)”POST /messaging/send envia fora do fluxo de “reply em conversa aberta”. Informe exatamente um de:
text— texto livre (WAHA, Cloud, Instagram, …)templateId— HSM aprovado (somente WhatsApp Cloud / canais comsupportsTemplates)
idempotencyKey é obrigatório. Em WAHA, use text — templateId retorna channel_send_unsupported.
# Texto livre (WAHA / Cloud / …)curl -X POST "https://app.garagem.ai/api/v1/messaging/send" \ -H "x-api-key: sua_api_key" \ -H "Content-Type: application/json" \ -d '{ "channelId": "11111111-1111-4111-8111-111111111111", "chatId": "[email protected]", "text": "Olá! Segue o material que combinamos.", "idempotencyKey": "ops-text-send-2026-07-29-001" }'
# Template HSM (WhatsApp Cloud)curl -X POST "https://app.garagem.ai/api/v1/messaging/send" \ -H "x-api-key: sua_api_key" \ -H "Content-Type: application/json" \ -d '{ "channelId": "11111111-1111-4111-8111-111111111111", "chatId": "[email protected]", "templateId": "22222222-2222-4222-8222-222222222222", "idempotencyKey": "ops-template-send-2026-07-29-001" }'| Campo | Tipo | Notas |
|---|---|---|
channelId | uuid | Canal acessível pelo ator da API key |
chatId | string | Destinatário / conversa |
text | string | Texto livre (1–4096). Mutuamente exclusivo com templateId |
templateId | uuid | HSM aprovado no Foundation. Só Cloud. Mutuamente exclusivo com text |
dealId | uuid | Opcional — vincula a conversa a um negócio |
idempotencyKey | string | Obrigatório (8–200 chars). Retries seguros |
Mesmo shape de ack do reply; policy.riskTier vem como "proactive" e enforcedGates inclui chat_privacy_mode + send_idempotency.
Padrão webhook → REST
Seção intitulada “Padrão webhook → REST”- Assine
channel.message.received/channel.message.sentno webhook do tenant (Webhooks e Eventos). - No handler, leia
channelId+chatIddo envelope (metadados). - Chame
GET /messaging/conversations/detailcom a API key para obter texto/histórico. - (Opcional) responda com
POST /messaging/replyou envie comPOST /messaging/send(textoutemplateId). - Atualize o dashboard externo. Não faça polling periódico da lista.
Isso evita colocar PII de mensagem no envelope do webhook e respeita o rate limit da API key.
Privacidade e o que nunca cruza a borda
Seção intitulada “Privacidade e o que nunca cruza a borda”- Preview / body /
authorName/authorIdsão omitidos em chatsprivacy_pending_approval. authorIdé o usuário CRM que enviou a mensagem de saída (quando o envio passou pelo Garagem). Mensagens antigas, inbound e envios pelo app do celular ficamnull— não inventamos o dono do canal.authorNamepara outbound comauthorIdé o nome do diretório da organização; semauthorId, cai no push name do provider (grupos/inbound). Não useassignedToIdda conversa como proxy de quem digitou.- Chats hidden (blacklist / desconhecidos em canal com privacy) não aparecem na lista — só em
redaction.privacyFilteredCount. - Em canal whitelist,
POST /messaging/replyePOST /messaging/sendtambém respeitam privacy:pending→blocked/privacy_pending_approval; oculto →blocked/privacy_denied(nada é disparado). - Handles vêm mascarados (
+55•••••9999). - Artefatos de transporte proibidos:
sessionName, tokens, QR,access_token,phoneNumberId,wabaId,sharingRules,avatarUrl, etc.
Referência OpenAPI
Seção intitulada “Referência OpenAPI”Os cinco endpoints entram no spec publicado em:
https://app.garagem.ai/api/docs(também embutido em Referência da API neste site).