Enviar e receber
Enviar mensagens
Um único endpoint envia por qualquer canal. Quem determina o canal é o número remetente, não um parâmetro seu.
POST /v1/messages
O corpo
{
"numberId": "num_4711",
"to": { "phone": "+5511999990000" },
"type": "text",
"text": { "body": "Olá! Vi que você se inscreveu — posso ajudar?" }
}
| Campo | Obrigatório | Descrição |
|---|---|---|
numberId |
sim | Qual número seu envia. É ele que determina o canal. Veja Números. |
to |
sim* | Destinatário: exatamente um entre { "phone": "+5511999990000" }, { "jid": "…" } e { "lid": "…@lid" }. Veja Abrir conversa com quem só tem lid. |
conversationId |
sim* | Conversa que já existe. Alternativa a to. |
type |
sim | text, image, audio, video ou document. |
text |
quando type: text |
{ "body": "…" }, até 65536 caracteres. |
media |
quando type ≠ text |
Veja Mídia. |
replyTo |
não |
id da mensagem que este envio responde — o
msg_… do Nuntis. Veja
Citar uma mensagem.
|
* to e conversationId são alternativas —
mande um dos dois, nunca os dois.
Regras que valem saber antes de codar
-
phoneé E.164 estrito:+seguido de 7 a 15 dígitos, sem separadores.+5511999990000vale;(11) 99999-0000e5511999990000não. -
Campo desconhecido é recusado. Escrever
bodyTextem vez detextdevolve400em vez de ser descartado em silêncio — você precisa saber agora, e não descobrir pela mensagem vazia que chegou ao seu cliente. -
Confira a capacidade do canal antes de enviar mídia ou
usar
replyTo. Um canal que não declara a capacidade responde422 CHANNEL_CAPABILITY_UNSUPPORTED.
Citar uma mensagem
Todo identificador de mensagem que o Nuntis publica vive no
mesmo espaço: id, replyTo e
quotedMessageId são sempre msg_…. O identificador
interno do canal não atravessa a API — ele não serve em nenhuma rota nossa.
Isso fecha o ciclo sem tradução do seu lado: o
quotedMessageId que você recebeu numa mensagem pode ser copiado
direto para o replyTo do envio, e pode ser usado em
GET /v1/messages/{id} para buscar a mensagem citada.
-
A citada precisa estar na mesma conversa. Um
replyTode outra thread é ignorado — nunca é aplicado à conversa errada. -
Citação que não resolve não derruba o envio. A mensagem
sai sem a citação, com
202normal: perder o balão de citação é melhor que não entregar a mensagem. -
quotedMessageIdé omitido quando a mensagem citada nunca passou pelo Nuntis — é o caso de uma conversa anterior ao pareamento do número. Não há identificador nosso a publicar, e o do canal não é exposto no lugar dele. -
Continuamos aceitando o identificador do canal em
replyTo, para não quebrar integrações que já o usavam. Prefira omsg_…: é o formato documentado e o único que também funciona nas rotas de leitura.
Responder dentro de um grupo
Não há rota nem payload próprios: use o mesmo
POST /v1/messages, com o conversationId da conversa
de grupo que você recebeu. O que chega ao grupo é a mensagem do
seu número — em outbound de grupo, from é o seu
número, porque quem fala é o operador.
{
"numberId": "num_5",
"conversationId": "conv_9931",
"type": "text",
"text": { "body": "Fechado, pessoal. Mando o orçamento ainda hoje." }
}
gruposEnvio por canal — ele não é igual em todos
GET /v1/channels traz dois campos separados:
grupos (o canal entrega conversa de grupo) e
gruposEnvio (o canal aceita enviar). São eixos
diferentes. Onde gruposEnvio é false, a mesma
chamada responde 422 CHANNEL_CAPABILITY_UNSUPPORTED.
Uma mensagem enviada a um grupo alcança N pessoas de uma vez, e o fato de alguém ter acabado de falar lá não é consentimento da audiência da sua mensagem — quem escreveu foi um terceiro participante. Por isso o envio para grupo passa pelo espaçamento e pelo teto de sessão do número, e não pela isenção que uma resposta 1:1 recebe.
Abrir conversa com quem só tem lid
Em grupo, quem não expõe o telefone chega até você identificado apenas por
LID — você recebe from.lid e
from.phoneE164 ausente. Para falar com essa pessoa em 1:1,
copie o from.lid para to.lid:
{
"numberId": "num_5",
"to": { "lid": "999990000000000@lid" },
"type": "text",
"text": { "body": "Oi! Falo do time X, vi sua mensagem no grupo Y." }
}
A conversa 1:1 nasce neste envio — ela não existia antes, e o fato de a pessoa ter falado no grupo não conta como consentimento para o 1:1. Vale o espaçamento cheio e o teto frio do número. Se você disparar para muitos LIDs seguidos, a fila vai segurar — por desenho.
lid
De uma mensagem que você recebeu (from.lid) ou da
lista de participantes de um grupo.
Nos dois casos o envio 1:1 a quem nunca falou com o número é
abordagem fria. Puxar a lista de um grupo para disparo em
massa não é o caso de uso do Nuntis — e o anti-ban e o orçamento de consultas
por número existem justamente para frear isso.
-
O contato criado fica sem telefone, e isso é ausência
honesta: o Nuntis não deriva telefone a partir de LID em
lugar nenhum. Em
GET /v1/conversationsele aparece comcontact.lide semcontact.phoneE164. -
to: { "jid": "…@lid" }continua funcionando e produz exatamente a mesma conversa. O campolidapenas valida a forma (dígitos +@lid) e espelha ofrom.lidque você recebeu. -
Limite conhecido: se você já tem um contato dessa pessoa
por telefone e o número remetente ainda não aprendeu o LID dela, o envio por
lidcria uma segunda conversa. Unificar contatos duplicados é trabalho separado, ainda não disponível.
A resposta: 202, não 200
{
"messageId": "msg_18273",
"status": "queued",
"acceptedAt": "2026-08-13T18:12:03.000Z",
"acceptedAtUnix": 1786126323
}
A mensagem não saiu ainda. Ela entra numa fila de saída com
espaçamento para não queimar o número, e só depois vai para o canal. Um
contrato que devolvesse 200 aqui estaria mentindo em toda
mensagem.
Quem conta o final da história é o webhook de
status — ou, se você preferir puxar, GET /v1/messages/{id}.
Não faça polling em laço: a informação chega sozinha.
Exemplo completo
curl -X POST https://api.nuntis.com.br/v1/messages \
-H "x-api-key: $NUNTIS_API_KEY" \
-H "content-type: application/json" \
-H "idempotency-key: $(uuidgen)" \
-d '{
"numberId": "num_4711",
"to": { "phone": "+5511999990000" },
"type": "text",
"text": { "body": "Olá! Posso te ajudar?" }
}'
const { randomUUID } = require('crypto');
async function enviarTexto({ numberId, telefone, corpo, chaveDeEnvio }) {
const resposta = await fetch(`${process.env.NUNTIS_URL}/v1/messages`, {
method: 'POST',
headers: {
'x-api-key': process.env.NUNTIS_API_KEY,
'content-type': 'application/json',
// Gerada junto com a DECISÃO de enviar, não aqui. Veja abaixo.
'idempotency-key': chaveDeEnvio ?? randomUUID(),
},
body: JSON.stringify({
numberId,
to: { phone: telefone },
type: 'text',
text: { body: corpo },
}),
});
const corpoResposta = await resposta.json();
if (!resposta.ok) {
// { code, message, retryable, source } — decida o retry por `retryable`.
throw Object.assign(new Error(corpoResposta.message), corpoResposta);
}
// 202: aceita e agendada. O estado real chega pelo webhook.
return corpoResposta.messageId;
}
Idempotência
Mande o header Idempotency-Key com um identificador único
por intenção de envio (1 a 255 caracteres ASCII visíveis).
Idempotency-Key: 7f3c1a9e-2b40-4a1e-9c77-8de0f2a51b33
O header é opcional, e omiti-lo significa que um retry depois de um timeout de rede vai entregar a mensagem duas vezes ao destinatário. Isso é deliberado, não esquecimento.
Por que não deduplicamos por conteúdo
Numa conversa real, “ok” e “sim” se repetem o tempo todo, legitimamente. Suprimir a segunda ocorrência seria uma falha invisível — o seu sistema acharia que enviou, o destinatário nunca receberia, e ninguém veria erro nenhum. Duplicata incomoda; falso dedupe some. Por isso a deduplicação é opt-in e a chave é sua intenção, não o conteúdo.
Duas mensagens iguais com chaves diferentes são dois envios legítimos — e devem ser.
O que a chave garante
| Situação | Resultado |
|---|---|
| Mesma chave, mesmo payload, dentro de 24 h | Devolve a resposta original, sem reenviar, com o header Idempotency-Replayed: true. |
| Mesma chave, payload diferente | 422 IDEMPOTENCY_KEY_REUSED. Use uma chave nova ou reenvie o payload original. |
| Mesma chave em voo agora | 409 IDEMPOTENCY_IN_PROGRESS, com retryable: true. Tente de novo com backoff — a chave não queimou. |
Payload inválido (400) |
A chave não é consumida. Corrija o payload e reenvie com a mesma chave. |
Não a gere na hora da chamada HTTP: se a chamada falhar por timeout e você gerar uma chave nova no retry, a proteção não existe. Crie a chave quando o seu sistema decide enviar, persista-a junto com o seu registro, e reuse-a em toda tentativa daquele envio.
O escopo da chave é a sua API key, e a janela contratual é de 24 horas.
Estados de entrega
A escada é monotônica — só sobe, nunca desce:
queued → sent → delivered → read → played
| Estado | Significa |
|---|---|
queued | Aceita e agendada na fila de saída. Ainda não saiu. |
sent | Saiu do Nuntis para o canal. |
delivered | Chegou ao aparelho do destinatário. |
read | O destinatário abriu. |
played | Áudio ouvido. |
failed | Terminal, fora da escada. Vem acompanhado de um bloco failure. |
received | Estado terminal de mensagem recebida. Inbound não percorre a escada de saída — nasce entregue. |
queued que não anda: o número não pode enviar
A causa mais comum de uma mensagem presa em queued é o envio
daquele número estar desligado. O envio não falha — fica retido, e sai
todo de uma vez quando o envio voltar.
Confira sendingEnabled em
GET /v1/numbers
antes de enfileirar, e bloqueie a ação na sua interface enquanto ele for
false.
Num canal com deliveryReceipts: "none" — o Telegram, hoje —
sent é o estado final de sucesso. Nunca
chegará delivered nem read, e um laço esperando
por eles nunca termina. Confira GET /v1/channels antes de
modelar leitura na sua interface.
Quando falha
{
"id": "msg_18273",
"status": "failed",
"failure": {
"code": "CHANNEL_DISCONNECTED",
"retryable": true,
"at": "2026-08-13T18:12:04.000Z"
}
}
Decida o retry por failure.retryable, nunca por interpretação do
texto. Códigos novos entram de forma aditiva — trate um
code desconhecido como falha genérica.
Significa que o Nuntis não obteve confirmação do canal e, por isso, não tentou de novo — a mensagem pode ter sido entregue. Reenviar nesse caso pode duplicar no aparelho do destinatário.
Se ela tiver saído, ela reaparece sozinha na conversa pelo eco do canal
(evento message.echo) — mas só onde o canal tem esse
eco. Em canal que declara echoDeSaida: false junto com
idDeMensagemReservavel: false não há reconciliação nenhuma: ali
o DISPATCH_AMBIGUOUS é terminal, e
retryable: false é a palavra final. Leia os dois campos em
GET /v1/channels antes de
escrever o tratamento.
Consultar e reconciliar
| Rota | Para quê |
|---|---|
GET /v1/messages/{id} |
Estado canônico de uma mensagem, com o histórico completo de transições. |
GET /v1/messages |
Lista cronológica ascendente, paginada por cursor. É o caminho de reconciliação. |
# primeira página
curl -H "x-api-key: $NUNTIS_API_KEY" \
'https://api.nuntis.com.br/v1/messages?limit=50'
# próxima página — repasse `cursor.next` sem interpretar
curl -H "x-api-key: $NUNTIS_API_KEY" \
'https://api.nuntis.com.br/v1/messages?since=eyJjIjoi...&limit=50'
Ele é opaco e codifica posição mais desempate; o formato pode mudar sem aviso. Fabricar um cursor a partir de um timestamp faz você perder mensagens gravadas no mesmo milissegundo.
Guarde o último cursor.next não-nulo e volte com ele.
next: null significa “nada novo agora”, não “acabou para
sempre”.
O statusHistory vem preenchido em
GET /v1/messages/{id} e vazio na listagem, de
propósito: montar o histórico de cada item multiplicaria as consultas na rota
mais chamada do contrato.
Retomar de um ponto no tempo: updatedAfter
Se você não quer varrer a coleção inteira a cada rodada, mande
updatedAfter com um instante ISO 8601: a resposta traz as
mensagens com timestamp >= updatedAfter. A receita é a mesma da
listagem de conversas:
guarde o maior timestamp que você viu, reenvie-o e
deduplique por id.
# abre a varredura a partir de um instante
curl -H "x-api-key: $NUNTIS_API_KEY" \
'https://api.nuntis.com.br/v1/messages?updatedAfter=2026-09-01T00:00:00.000Z'
# continua paginando — mantenha os DOIS parâmetros
curl -H "x-api-key: $NUNTIS_API_KEY" \
'https://api.nuntis.com.br/v1/messages?updatedAfter=2026-09-01T00:00:00.000Z&since=eyJjIjoi...'
O corte é inclusivo de propósito: com um corte exclusivo, a
mensagem que caiu no mesmo milissegundo da borda que você guardou seria
perdida. É por isso que a deduplicação por id não é opcional —
você vai receber de volta o que estava exatamente na borda.
updatedAfter e since se somam. Não
remova o filtro ao virar de página: abra a varredura com o instante e continue
mandando os dois.
updatedAfter filtra pela existência da
mensagem, não pelo estado dela. delivered, read,
failed e a revogação não alteram o
timestamp: uma mensagem de ontem que foi lida agora
não volta com updatedAfter=hoje.
O nome sugere “tudo que mudou”, e essa é justamente a leitura que quebra: um
sincronizador de status construído em cima deste filtro ficaria
eternamente vazio, sem erro em lugar nenhum. Para acompanhar estado, use o
webhook message.status ou
GET /v1/messages/{id}.
Uma conversa antiga que só agora chegou ao Nuntis entra com a
data real de cada mensagem — ela não aconteceu ontem, então
updatedAfter=ontem não a devolve. Para trazer histórico, faça a
varredura completa (sem o filtro) e deduplique por
id. A receita está em
Histórico de conversas.