Pular para o conteúdo

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”.


Mesma API key dos demais endpoints /api/v1:

Janela do terminal
# Recomendado
curl -H "x-api-key: sua_api_key" \
"https://app.garagem.ai/api/v1/messaging/conversations"
# Alternativa
curl -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.


GET /api/v1/messaging/conversations # Listar conversas
GET /api/v1/messaging/conversations/detail # Detalhe + histórico
POST /api/v1/messaging/conversations/resync # Forçar ressincronização do histórico
GET /api/v1/messaging/channels # Inventário de canais
GET /api/v1/messaging/labels # Etiquetas do canal
GET /api/v1/messaging/labels/assignments # Mapa chatId → etiquetas
POST /api/v1/messaging/reply # Responder em conversa aberta
POST /api/v1/messaging/send # Enviar template HSM aprovado

Leituras (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). Use GET /messaging/labels/assignments com o mesmo chatId retornado por GET /messaging/conversations — normalmente @c.us. Mutações de etiqueta ficam fora do REST (UI/session + MCP).


Janela do terminal
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"
ParamTipoNotas
transportsenum[]Repita a chave (?transports=a&transports=b). Valores: whatsapp, whatsapp_cloud, instagram, facebook_messenger, tiktok, email, web_chat, voip
channelIduuidRestringe a um canal acessível
searchstringNome, handle mascarado, preview
unreadOnlybooleantrue / false (string na query)
awaitingReplyOnlybooleanÚltima mensagem inbound sem resposta
assignedToIduuid
connectionStatesenum[]connected, awaiting_auth, disabled, error, expired, draft
sortenumlast_activity_desc (padrão) ou unread_first
limitint 1–100
cursorstringEcho opaco de page.nextCursor

Query estrita. Params desconhecidos (?utm_source=…, typos como unread_only) retornam 400. Arrays separados por vírgula (?transports=a,b) também falham — use chaves repetidas.

{
"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",
"chatId": "[email protected]"
},
"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
}
}

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.

Janela do terminal
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 "[email protected]" \
--data-urlencode "limit=50" \
--data-urlencode "includeHistory=true"
ParamTipoNotas
channelIduuidobrigatório
chatIdstringobrigatório — echo de conversations[].key.chatId
limitint 1–100Tamanho da página de histórico
beforeISO-8601Keyset: mensagens estritamente mais antigas
includeHistorybooleanfalse 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.

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:

CampoNotas
sourceprovider_feed — de onde veio (ou deixou de vir) a página
checkedAtISO-8601, quando a fonte foi consultada
unavailableReasonnull quando a página é confiável; caso contrário, o motivo
retryableSe reler mais tarde pode mudar a resposta
retryAfterSecondsBackoff sugerido; null quando não é retryable
CódigoSignificadoretryable
feed_timeoutO canal não respondeu dentro do orçamento do requestsim
feed_transport_unreachableO canal respondeu com errosim
feed_empty_pending_syncPrimeira página vazia numa conversa com atividade recente (menos de 48h) — a origem ainda não serve esse chatsim
feed_history_not_on_providerPrimeira página vazia, mas a última atividade tem mais de 48h — o provedor já não tem esse históriconão
feed_channel_not_connectedO canal está em error / expired / awaiting_auth — o envelope já diz isso; esperar o feed não resolvenão
feed_not_supported_on_channelO canal não tem feed de mensagens (ex.: canal só de comentários) — esperar nunca resolvenão
conversation_feed_unavailableCó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.

Quando retryable é true e você não quer esperar o backoff, force o canal a reconstruir o estado de endereçamento da conversa:

Janela do terminal
curl -X POST "https://app.garagem.ai/api/v1/messaging/conversations/resync" \
-H "x-api-key: sua_api_key" \
-H "Content-Type: application/json" \
-d '{"channelId":"aa0e8400-e29b-41d4-a716-446655440010","chatId":"[email protected]"}'

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.

Siga history.nextBefore enquanto history.hasMore for true. As duas coisas andam juntas: hasMore: true nunca vem sem um nextBefore utilizável.


Janela do terminal
curl -G "https://app.garagem.ai/api/v1/messaging/channels" \
-H "x-api-key: sua_api_key" \
--data-urlencode "ownedOnly=false"
ParamTipoNotas
transportsenum[]Mesmos valores da lista de conversas
connectionStatesenum[]
ownedOnlybooleanSó canais que o ator é owner
searchstring

Canais mock são excluídos. sessionName, tokens, QR e corpos de sharingRules nunca aparecem no payload.


Janela do terminal
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"
}'
CampoTipoNotas
channelIduuidCanal acessível pelo ator da API key
chatIdstringId da conversa (ex.: WhatsApp …@c.us)
bodyobject{"kind":"text","text":"…"} ou {"kind":"listing_cards","listingIds":["…"]} (1–10)
idempotencyKeystringOpcional (8–200 chars). Reenvio com a mesma chave não dispara de novo
{
"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.


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 com supportsTemplates)

idempotencyKey é obrigatório. Em WAHA, use text — templateId retorna channel_send_unsupported.

Janela do terminal
# 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"
}'
CampoTipoNotas
channelIduuidCanal acessível pelo ator da API key
chatIdstringDestinatário / conversa
textstringTexto livre (1–4096). Mutuamente exclusivo com templateId
templateIduuidHSM aprovado no Foundation. Só Cloud. Mutuamente exclusivo com text
dealIduuidOpcional — vincula a conversa a um negócio
idempotencyKeystringObrigató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.


  1. Assine channel.message.received / channel.message.sent no webhook do tenant (Webhooks e Eventos).
  2. No handler, leia channelId + chatId do envelope (metadados).
  3. Chame GET /messaging/conversations/detail com a API key para obter texto/histórico.
  4. (Opcional) responda com POST /messaging/reply ou envie com POST /messaging/send (text ou templateId).
  5. 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.


  • Preview / body / authorName / authorId são omitidos em chats privacy_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 ficam null — não inventamos o dono do canal.
  • authorName para outbound com authorId é o nome do diretório da organização; sem authorId, cai no push name do provider (grupos/inbound). Não use assignedToId da 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/reply e POST /messaging/send també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.

Os cinco endpoints entram no spec publicado em:

https://app.garagem.ai/api/docs

(também embutido em Referência da API neste site).