Enviar e receber
Conversas
Uma conversa é o par número seu × contato do outro lado (ou
número seu × grupo). O conversationId já está na sua mão: ele vem
em toda mensagem e em todo webhook. Aqui você lista as conversas por
última atividade — o que uma tela de inbox pede ao abrir — e busca a
foto de perfil do contato.
Listar conversas
GET /v1/conversations
Devolve as conversas dos seus números, da que teve atividade mais
recente para a mais antiga, cada uma já com a
última mensagem inteira — no mesmo formato de
GET /v1/messages, pelo mesmo
mapeamento. É a consulta que monta um inbox sem você precisar varrer a fila de
mensagens do tenant inteiro.
curl -H "x-api-key: $NUNTIS_API_KEY" \
'https://api.nuntis.com.br/v1/conversations?limit=50'
{
"data": [
{
"id": "conv_991",
"numberId": "num_7",
"contact": {
"phoneE164": "+5585999998888",
"jid": "5585999998888@s.whatsapp.net",
"pushname": "Ana"
},
"createdAt": "2026-08-20T13:02:11.000Z",
"createdAtUnix": 1787230931,
"lastActivityAt": "2026-09-02T09:41:03.000Z",
"lastActivityAtUnix": 1788342063,
"lastMessage": {
"id": "msg_18273",
"conversationId": "conv_991",
"numberId": "num_7",
"direction": "inbound",
"type": "text",
"status": "received",
"from": { "phoneE164": "+5585999998888", "pushname": "Ana" },
"text": { "body": "Bom dia, ainda tem vaga?" },
"timestamp": "2026-09-02T09:41:03.000Z",
"timestampUnix": 1788342063,
"isSystemMessage": false,
"mentionedIds": [],
"statusHistory": []
}
}
],
"cursor": { "next": "eyJjIjoiMjAyNi0wOS0wMlQwOTo0MTowMy4wMDBaIiwiayI6Ijk5MSJ9" }
}
{
"id": "conv_1042",
"numberId": "num_7",
"group": { "name": "Obra Aldeota" },
"createdAt": "2026-08-28T10:00:00.000Z",
"createdAtUnix": 1787911200,
"lastActivityAt": "2026-09-02T08:15:00.000Z",
"lastActivityAtUnix": 1788336900,
"lastMessage": { "id": "msg_18270", "group": { "name": "Obra Aldeota" }, "...": "..." }
}
Como distinguir os dois tipos: conversa individual traz
contact e não traz group; conversa de grupo traz
group e não traz contact. Não existe campo de
“tipo” — a presença do bloco é o significado, exatamente como
no recurso de mensagem.
O WhatsApp entrega eventos de protocolo pela mesma
tubulação das mensagens (rótulo de membro de grupo, handshake de
boas-vindas, sincronismo entre os aparelhos da própria conta). Desde
04/09/2026 eles são descartados na fronteira do canal:
não viram mensagem, não aparecem em lastMessage e não mexem em
lastActivityAt. Antes dessa data alguns chegaram como
type: "text" sem bloco text — o
histórico é preservado como foi gravado, nunca reescrito.
Edição de mensagem ainda não é um recurso do contrato:
enquanto não for, ela chega marcada com isSystemMessage: true.
Filtre por esse campo se não quiser exibi-la — e nunca por “sem conteúdo”
no atacado, que comeria eventos legítimos como
message.revoked.
phoneE164 não é erro — é ausência honesta
Quem chega até você só por LID (participante de grupo que
não expõe o telefone) aparece com contact.lid preenchido e
sem contact.phoneE164. O Nuntis não deriva
telefone a partir de LID em lugar nenhum: um número plausível e falso seria
pior que o vazio, porque você não teria como saber que ele é inventado.
Para abrir 1:1 com essa pessoa, copie o lid —
Abrir conversa com quem só tem lid.
Parâmetros
| Parâmetro | Efeito |
|---|---|
numberId | Restringe às conversas de um número seu (num_...). Número que não é seu responde 404, indistinguível de inexistente. |
type | individual ou group. Sem ele, vêm os dois. |
updatedAfter | ISO 8601 (UTC). Traz as conversas com lastActivityAt >= updatedAfter — inclusive. Veja os avisos abaixo. |
q | Busca pelo rótulo da outra ponta: telefone, ou parte do nome — do contato ou do grupo, sem diferenciar maiúsculas. Telefone casa por equivalência — digitar sem o nono dígito acha o mesmo contato. Sem acento-insensibilidade: Joao não acha João. Não busca conteúdo de mensagem. Ver Busca por nome. |
cursor | Cursor opaco devolvido em cursor.next. Repasse sem interpretar. Ausente = do topo. |
limit | Itens por página, de 1 a 100. Padrão 50. |
200 com lista vazia
Nunca 404. A coleção existe para todo cliente válido — um
404 aqui faria você concluir que a rota não existe e mudar o seu
código por causa de um estado passageiro do seu cadastro.
A listagem é uma foto, não um feed
A ordem é por última atividade, e atividade só sobe. Se uma conversa receber mensagem no meio da sua varredura, ela pula para cima do cursor e não aparece nas páginas seguintes daquela passada — ela aparece no topo da próxima. Nada se perde entre varreduras.
Em compensação, a lista nunca repete um item: o que você já
viu ficou acima do cursor e não volta. Para reconciliação
por mensagem, sem furo, continue usando
GET /v1/messages, que é
cronológica crescente.
Busca por nome: contato e grupo, no mesmo termo
q procura o rótulo que o operador vê na tela. Se
o termo for só dígitos, ele vira busca por telefone (casada por igualdade, com
equivalência do nono dígito); qualquer outra coisa vira busca por
trecho do nome — e esse trecho alcança tanto o nome do
contato de uma conversa individual quanto o
assunto do grupo de uma conversa group.
Não há parâmetro para escolher entre os dois: q=Aldeota devolve a
pessoa e o grupo que carregam esse nome, ordenados por última atividade como
qualquer página. Para separar, combine com type —
q=Aldeota&type=group devolve só a conversa de grupo. Os dois
filtros se somam.
O assunto do grupo é resolvido uma vez, quando o grupo entra
no Nuntis. Se aquela consulta não respondeu, group.name fica
null — e um grupo sem nome não tem por onde casar um termo de
texto. Ele continua na listagem e continua alcançável por
type=group; só não aparece na busca por nome.
Não há re-tentativa nesta versão, e renomear o grupo no aparelho também não atualiza o rótulo aqui.
Joao não acha João, e Aldeota não acha
Aldeóta. O casamento é por trecho literal, ignorando apenas
maiúsculas e minúsculas. Se a sua interface tem um campo de busca, vale
repassar o que o usuário digitou sem “corrigir” acento.
Atividade é mensagem — recibo não reordena
lastActivityAt é o instante da última mensagem da
conversa (ou a criação dela, quando ainda não houve mensagem — nesse caso
lastMessage vem null). Recibo de entrega, recibo de
leitura e revogação não movem a conversa: um
read de conversa inteira faria o seu inbox se mexer sem ninguém
ter dito nada. Essas transições continuam chegando por
message.status e
message.revoked.
O Nuntis não sabe o que você leu
unread, nem contador, nem “marcar como lida”
Quem sabe o que o seu usuário viu é você. E o recibo de leitura que o canal entrega é do aparelho do operador, não do seu CRM — publicá-lo como “lida” faria o Nuntis afirmar sobre você algo que não é dele.
O sinal que um inbox de fato usa sai de graça:
lastMessage.direction igual a inbound significa que
a última fala foi do lead — que é como se decide o que
destacar na tela.
Sincronismo incremental com updatedAfter
Guarde o maior lastActivityAt que você viu, reenvie-o em
updatedAfter e deduplique por id. O
filtro é inclusivo de propósito: com um corte exclusivo, a conversa cuja
atividade caiu no mesmo milissegundo da borda seria perdida.
updatedAfter não alcança o histórico importado — de propósito
O histórico entra com a data real
de cada mensagem. Uma conversa de março que só agora chegou ao Nuntis
não teve atividade ontem, e por isso
updatedAfter=ontem não a devolve — do contrário um único
pareamento devolveria dezenas de milhares de conversas “mudadas” de uma vez.
Para trazer histórico, faça a varredura completa (sem
updatedAfter) e deduplique por id. A receita está
em Histórico de conversas.
Erros desta rota
| Código | HTTP | Quando |
|---|---|---|
VALIDATION_FAILED | 400 | Parâmetro desconhecido, type fora do enum, updatedAfter fora do ISO 8601, ou cursor corrompido. Não fabrique cursor — repasse o que veio. |
UNAUTHORIZED | 401 | Credencial ausente ou inválida. |
NOT_FOUND | 404 | Só acontece com numberId: nenhum número acessível com esse identificador. Indistinguível de número de outro cliente da plataforma. |
Foto de perfil do contato
GET /v1/conversations/{conversationId}/avatar
Devolve a foto de perfil do contato daquela conversa como URL assinada e temporária, no mesmo formato que você já conhece da mídia de mensagem. Serve para a foto aparecer no card do lead na sua interface — e é só para isso que ela existe.
curl -H "x-api-key: $NUNTIS_API_KEY" \
'https://api.nuntis.com.br/v1/conversations/conv_9931/avatar'
{
"url": "https://bucket.devari.com.br/nuntis/avatars/9931.jpg?X-Amz-Signature=...",
"urlExpiresAt": "2026-08-31T20:00:00.000Z",
"fetchedAt": "2026-08-31T19:00:00.000Z"
}
{
"url": null,
"urlExpiresAt": null,
"fetchedAt": "2026-08-31T19:00:00.000Z"
}
| Campo | O que é |
|---|---|
url |
URL assinada e temporária da foto. null é resposta de sucesso, não erro — veja abaixo. |
urlExpiresAt |
Quando a url deixa de funcionar (ISO 8601). null quando url é null. Não há rota de renovação: peça o recurso de novo. |
fetchedAt |
Quando o Nuntis consultou o canal pela última vez — não quando esta resposta foi montada. É a idade da informação. null significa que o Nuntis não consultou — ver Conversa de grupo. |
Foto ausente é o caso comum, não a exceção
Numa amostra real de 14 contatos, medida durante a construção desta rota, 5 tinham foto visível. Os outros 9 não: 7 simplesmente não têm foto, e 2 têm privacidade fechada. É amostra pequena e não é promessa estatística — mas a ordem de grandeza é essa, e a conclusão de produto não muda: a maior parte dos seus leads vai cair no caso sem foto.
Uma interface que reserva espaço para a foto e não trata a ausência fica com
buraco na maioria das linhas. Trate url: null como o caminho
normal: renderize iniciais ou um placeholder, e faça a foto ser o
enfeite que ela é.
Dois motivos diferentes levam ao mesmo url: null, e para a sua
interface eles são a mesma coisa: o contato não tem foto, ou
a privacidade dele esconde a foto de quem não está na agenda
dele. Em nenhum dos dois o Nuntis falhou, e em nenhum dos dois adianta pedir de
novo em seguida.
Conversa de grupo: 200 com tudo nulo
Pedir a foto de uma conversa de grupo devolve 200
com url, urlExpiresAt e fetchedAt
todos null. Não é erro, e não há o que
re-tentar: foto de grupo não é entregue nesta versão do contrato.
{ "url": null, "urlExpiresAt": null, "fetchedAt": null }
Para a interface, o tratamento é o mesmo do contato sem foto: renderize o
placeholder. O que muda é o motivo, e ele viaja em
fetchedAt — sem nenhum campo novo no recurso:
url | fetchedAt | O que aconteceu |
|---|---|---|
| preenchida | preenchido | Há foto. Use a URL antes de urlExpiresAt. |
null | preenchido | Perguntamos ao canal, e ele respondeu que não há foto (o contato não tem, ou a privacidade dele a esconde). |
null | null | Não perguntamos. Hoje o único caso é a conversa de grupo. |
200, e não um erro
Porque 422 CHANNEL_CAPABILITY_UNSUPPORTED quer dizer “este
canal não sabe entregar foto de perfil” — e o canal sabe:
avatar vem true em
GET /v1/channels. Quem seguisse essa instrução leria
true, reservaria o espaço na tela e ainda assim tomaria erro. A
ausência aqui é uma decisão nossa sobre este tipo de conversa, e não
uma limitação do canal.
Como o Nuntis não consulta o canal neste caso, a resposta é a mesma com o
número desativado ou sem sessão conectada. Já a conversa de grupo de
outro cliente continua respondendo 404, igual a
um identificador inexistente — a verificação de posse vem antes.
Por que a rota é por conversa, e não por contato
Porque a visibilidade da foto é do par, não da pessoa. Um contato pode configurar “foto visível só para meus contatos” — e aí a foto aparece ou não dependendo de qual dos seus números está perguntando: se o telefone daquele operador estiver na agenda do lead, a foto aparece; se for outro número seu, que ele não tem salvo, não aparece.
O mesmo lead, portanto, pode ter foto para um número do seu produto e não ter para outro. A conversa carrega exatamente esse par (número, contato) — por isso ela é a âncora. Uma rota “por contato” teria de escolher um número por conta própria, e qualquer escolha implícita daria uma resposta que não corresponde ao que aquele operador enxerga.
Não faça cache da foto por contato do seu lado. Se você guardar “o lead X não tem foto” e exibir isso em todas as caixas, vai esconder a foto que outro número do seu produto enxerga perfeitamente. A chave é a conversa.
“Não tem foto” e “não consegui buscar agora” são respostas diferentes
Esta é a distinção que o contrato existe para preservar, e ela muda o que o seu código deve fazer:
| O canal respondeu que não há foto | Não deu para consultar agora | |
|---|---|---|
| Você recebe | 200 com url: null | Erro tipado — 503 CHANNEL_UNREACHABLE |
| É um fato? | Sim. Foi o canal que disse. | Não. É ignorância nossa, temporária. |
| Retry ajuda? | Não em seguida — a resposta não vai mudar. | Sim. retryable: true. |
| O que exibir | Iniciais / placeholder. | Mantenha o que já havia, ou o placeholder — e tente de novo depois. |
Uma falha temporária nunca é devolvida como url: null.
Se fosse, uma indisponibilidade de meio minuto apagaria da sua interface, por
horas, a foto de um lead que tem foto — sem erro em lugar nenhum para
denunciar. Por isso as duas respostas são de tipos diferentes: se você recebeu
200, a ausência é um fato do canal e você pode confiar nela.
A URL é temporária — gere na hora de exibir
url
Ela é assinada e expira (urlExpiresAt diz quando). Gravá-la no
seu banco é gravar uma mentira com hora marcada: em algum momento os cards do
seu inbox passam a mostrar imagem quebrada. Guarde o
conversationId e peça o recurso quando for renderizar. É a mesma
disciplina da mídia de mensagem.
Pedir de novo é barato: a resposta vem do estado que o Nuntis mantém, sem
consultar o canal a cada chamada. Em compensação não há promessa de
frescor — fetchedAt é a idade da informação, e a foto
pode ter mudado no canal depois daquele instante. Foto de perfil é enfeite de
interface; nenhuma decisão do seu produto deveria depender de ela estar
atualizada ao segundo.
Antes de reservar o espaço na interface
Nem todo canal entrega foto de perfil. Quem responde isso é o campo
avatar em
GET /v1/channels: onde ele é
false, esta rota responde
422 CHANNEL_CAPABILITY_UNSUPPORTED — e o certo é nem chamá-la,
escondendo o espaço da foto para números daquele canal.
avatar: true não promete que existe foto
Ele promete que o canal sabe responder a pergunta. A foto em si continua podendo não existir — e, como visto acima, na maioria das vezes é o que acontece. São duas condições diferentes: a capacidade decide se você pergunta; a resposta decide se você desenha.
Erros da foto de perfil
| Código | HTTP | Retentável | Quando |
|---|---|---|---|
UNAUTHORIZED |
401 | não | Credencial ausente ou inválida. |
NOT_FOUND |
404 | não | Nenhuma conversa acessível com esse identificador. Indistinguível de conversa que pertence a outro cliente da plataforma — de propósito. |
CHANNEL_CAPABILITY_UNSUPPORTED |
422 | não | O canal deste número não declara avatar. Consulte GET /v1/channels. |
NUMBER_DEACTIVATED |
422 | não | O número da conversa foi desativado. Reativar não é self-service. |
NUMBER_NOT_CONNECTED |
422 | não | O número não tem sessão conectada no canal, e sem sessão não há como perguntar nada sobre um contato. Retry não resolve — o remédio é parear o número e conferir GET /v1/numbers/{id}/status. |
CHANNEL_UNREACHABLE |
503 | sim | Não foi possível consultar o canal agora. Tente de novo com backoff — e não trate como “sem foto”. |
A forma do erro é a mesma do resto do contrato, e a regra também: decida o
retry por retryable, nunca por status HTTP nem por texto. Veja
Erros.
Participantes de uma conversa de grupo
GET /v1/conversations/{conversationId}/participants
Devolve quem está no grupo daquela conversa, segundo o canal — inclusive quem nunca falou — com o papel de cada um. É o que a tela de “Dados do grupo” mostra.
curl -H "x-api-key: $NUNTIS_API_KEY" \
'https://api.nuntis.com.br/v1/conversations/conv_9931/participants'
{
"data": [
{ "jid": "5585999998888@s.whatsapp.net", "lid": "123456789012345@lid",
"phoneE164": "+5585999998888", "pushname": "Ana", "role": "superadmin" },
{ "jid": "5585988887777@s.whatsapp.net", "lid": "998877665544332@lid",
"phoneE164": "+5585988887777", "pushname": null, "role": "member" },
{ "jid": null, "lid": "112233445566778@lid",
"phoneE164": null, "pushname": null, "role": "member" }
],
"fetchedAt": "2026-09-22T14:03:11.000Z"
}
| Campo | O que é |
|---|---|
jid |
Endereço de telefone do participante no canal (…@s.whatsapp.net). O mesmo significado de from.jid nas mensagens. null quando o canal escondeu o telefone — veja abaixo. |
lid |
Endereço anônimo (…@lid). O mesmo de from.lid: é por ele que você casa o participante com as mensagens que ele mandou. |
phoneE164 |
Telefone em E.164, derivado só de jid. Nunca é inventado a partir do lid. |
pushname |
O nome que a própria pessoa definiu: o último com que ela falou nesta conversa (o mesmo da bolha da mensagem) ou, se nunca falou aqui, como o número a conhece. Nunca o nome salvo na agenda do celular do operador. Pode ser null. |
role |
member, admin ou superadmin (criador/dono do grupo). Enum aditivo: trate valor desconhecido como member. |
fetchedAt |
Quando o Nuntis consultou o canal — a idade da lista, não o instante da resposta. |
A lista é consultada ao vivo — e o Nuntis não a guarda
A lista vem do canal, pela sessão do número daquela conversa, e fica em cache
do nosso lado por até 10 minutos (fetchedAt diz
a idade). Não há promessa de frescor: alguém pode ter entrado ou saído depois.
A ordem dos itens não é contratual, e a lista vem inteira, sem
paginação.
Ela nunca vai para banco, fila, log ou métrica, e some do cache sozinha. Se o seu produto precisa guardar quem está no grupo, guarde você — sob a sua relação com os titulares.
Participante sem telefone (jid: null)
Quando jid e phoneE164 vêm null e só o
lid vem preenchido, o canal escondeu o telefone
daquela pessoa. É raro — hoje, perto de 1 em cada 6.000 participantes — e é
limite do canal: renderize pelo pushname ou pelo
lid.
O nome vem nulo em boa parte dos participantes
Hoje cerca de 2/3 dos participantes vêm com
pushname: null: o nome só existe se a pessoa falou nesta conversa
ou se o número já a conhece. Renderize o telefone ou as iniciais. O nome da
agenda do celular do operador não é publicado, por decisão de
privacidade: é uma anotação de quem opera o número sobre outra pessoa.
Orçamento de consultas por número
Consultar grupos em sequência rápida é o que faz o WhatsApp limitar um
telefone. Por isso cada número tem um orçamento de consultas ao canal (hoje
5 por minuto e 30 por hora). Estourado, a rota responde
429 RATE_LIMITED com Retry-After e
X-RateLimit-Limit/-Remaining/-Reset
da janela do número. Resposta do cache não
consome orçamento: abrir a mesma tela de novo é de graça.
Com o lid ou o jid da lista você consegue
enviar 1:1 a um participante — e o anti-ban trata
esse envio como abordagem fria (espaçamento cheio e teto
frio do número). Usar a lista para disparo em massa é o caminho mais curto
para derrubar um número. A relação com o titular é sua.
Antes de mostrar “Dados do grupo”
Consulte gruposParticipantes em
GET /v1/channels: onde ele é
false, esta rota responde
422 CHANNEL_CAPABILITY_UNSUPPORTED. A foto do grupo continua
não sendo entregue nesta versão (veja
Conversa de grupo na foto de perfil).
Erros da lista de participantes
| Código | HTTP | Retentável | Quando |
|---|---|---|---|
UNAUTHORIZED | 401 | não | Credencial ausente ou inválida. |
NOT_FOUND | 404 | não | Nenhuma conversa acessível com esse identificador. Indistinguível de conversa de outro cliente — de propósito. |
CHANNEL_CAPABILITY_UNSUPPORTED | 422 | não | O canal do número não declara gruposParticipantes. |
CONVERSATION_NOT_GROUP | 422 | não | A conversa é 1:1. Só conversa de grupo tem lista de participantes. |
GROUP_MEMBERSHIP_ENDED | 422 | não | O número não é mais participante deste grupo. A conversa e o histórico continuam disponíveis. |
NUMBER_DEACTIVATED | 422 | não | O número da conversa foi desativado. |
NUMBER_NOT_CONNECTED | 422 | não | O número não tem sessão conectada. Pareie o número; retry não resolve. |
RATE_LIMITED | 429 | sim | Orçamento de consultas deste número esgotado. Respeite Retry-After. |
CHANNEL_UNREACHABLE | 503 | sim | Não foi possível consultar o canal agora. Nunca vem como lista vazia. |
Permissão
As três rotas desta página já estão cobertas por
messages:read — o mesmo escopo que a sua chave usa para listar
mensagens. Se você já lê mensagens, você já lista conversas, já lê a foto
de perfil e já lê a lista de participantes de um grupo: não é
preciso pedir uma chave nova nem uma permissão extra.
Escopos e rotação: Autenticação.