Polling de eventos
A API de eventos lista o histórico de eventos da sua conta, com paginação. Use o polling no lugar do webhook ou para recuperar eventos perdidos durante uma indisponibilidade do seu endpoint.
Quando usar polling
O webhook é a forma principal de receber eventos em tempo real. O polling é recomendado em dois cenários:
- Sem webhook configurado: consulte eventos regularmente para processar mensagens recebidas, confirmações de envio e demais eventos.
- Recuperação após falha: após uma interrupção do seu endpoint de webhook, use o polling para buscar os eventos perdidos e reconciliar o estado do seu sistema.
Endpoint
GET /v1/eventsEscopo obrigatório: events:read
Parâmetros de consulta
| Parâmetro | Tipo | Descrição |
|---|---|---|
types[] | string[] | Filtrar por tipos de evento (OR), até 20 valores. Ex: types[]=message.received&types[]=message.sent |
type | string | Filtrar por um tipo de evento com suporte a wildcard. Ex: type=message.* |
request_id | string | Filtrar pelos eventos gerados por uma única requisição assíncrona (req_…). Útil para correlacionar o resultado de um POST (envio, resposta, edição ou exclusão) usando o X-Request-ID retornado no header da resposta. Reações não são correlacionáveis por aqui: o header devolvido no POST .../react não é um req_… válido; use o message_id da mensagem-alvo (veja Enviar reação). Ex: request_id=req_01993c5a1b24d3e9f01a2b3c4d5e6f70 |
number_alias | string | Filtrar pelos eventos de um único número ("main" ou o alias de um número extra). Ausente = todos os números da organização. Apenas os eventos sem número único (storage.* e media.unavailable) são excluídos quando este filtro é informado; message.quota.* é por número e é filtrável. |
number_id | string | Filtrar pelos eventos de um único número, pelo id (num_…). Combina com number_alias. Continua achando os eventos do número depois de uma renomeação, porque o filtro compara o id e não o alias. Um valor que começa com num_ mas não é um id válido responde 400 invalid_number_id. |
starting_after | string | Cursor para paginação progressiva. Passe o id do último item da página anterior. |
ending_before | string | Cursor para paginação retroativa. Passe o id do primeiro item da página atual. |
limit | integer | Número de eventos por página. Intervalo: 1 a 100. Padrão: 25. |
created[gte] | ISO-8601 | Retornar eventos criados a partir desta data e hora. |
created[lte] | ISO-8601 | Retornar eventos criados até esta data e hora. |
Formato de resposta
Os resultados são ordenados por created_at DESC (mais recentes primeiro).
{
"object": "list",
"data": [
{
"id": "evt_01993c5a1b24d3e9f01a2b3c4d5e6f70",
"type": "message.received",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-01T13:00:00Z",
"data": {
"id": "msg_01993c5a1b24d3e9f01a2b3c4d5e6f70",
"from": {
"name": "Carlos Lima",
"number": "5511977776666"
},
"to": {
"alias": "main",
"number": "5511973117056"
},
"preview": "Olá, gostaria de saber sobre o pedido",
"content_type": "text",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T13:00:00Z"
}
},
{
"id": "evt_c00a44c3fc774698b0f130d40bb5f104",
"type": "message.received",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-02T01:55:14Z",
"data": {
"id": "msg_e95500cc03c1492e81e46a77c1f0d420",
"from": {
"contact_id": "ct_45f2ece8a66a4eeaa661aeb9b8e58c77",
"name": "Ana Souza",
"number": "5511988887777"
},
"to": {
"group_id": "grp_e442553a56b14e2695f6acfd7d651b1a",
"name": "TNT"
},
"preview": "Bom dia pessoal",
"content_type": "text",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-02T01:55:14Z"
}
}
],
"has_more": true
} Eventos de grupo usam
to.group_id(formatogrp_{hex32}) em vez deto.number; o seu número que participa do grupo é identificado pelonumber_aliasdo envelope. Mensagens individuais (1:1) omitemto.group_id. Veja Webhooks para detalhes.
Envelope da lista
| Campo | Tipo | Descrição |
|---|---|---|
object | string | Sempre "list". |
data | array | Lista de eventos (no máximo limit itens). |
has_more | boolean | true quando existem mais eventos além da página atual. |
Envelope de cada evento
| Campo | Tipo | Descrição |
|---|---|---|
id | string | Identificador único (evt_…). Use como chave de idempotência. |
type | string | Tipo do evento (ex.: message.received). |
organization_id | string | Identificador público da sua organização (org_{hex32}). |
number_alias | string | Identificador do número Turbo Notify ao qual o evento pertence: "main" para o número principal ou o alias de um número extra. É a string do alias, nunca um UUID. Presente em eventos ligados a um número específico: message.*, number.*, group.* e todos os contact.* (inclusive contact.quota.*) e message.quota.* (a cota de mensagens é por número). Omitido em storage.* e em media.unavailable: os eventos que não pertencem a um número único. |
number_id | string | Identificador do número (num_…), logo depois do number_alias. Presente exatamente quando number_alias está presente. Nunca muda: é a chave para guardar. O number_alias é o alias que o número tinha quando o evento aconteceu. |
created_at | string | Momento de emissão do evento pelo Turbo Notify (quando o registro foi criado/publicado). ISO 8601 em UTC com sufixo Z e precisão de segundos (ex.: 2026-06-01T13:00:05Z). Distinto de data.at (o momento da ação no WhatsApp), quando presente. |
data | object | Conteúdo específico do evento, idêntico ao entregue via webhook. Quando há uma ação real no WhatsApp, data.at traz esse momento (valor distinto de created_at), inclusive para eventos de mensagens enviadas (message.sent/replied/edited/deleted) e os number.* de ação real, cujo at é o momento real no WhatsApp, em geral anterior ao created_at. Nos eventos de falha, data.at é o momento em que a falha foi detectada. Eventos que não correspondem a uma ação no WhatsApp não têm at. |
Nos eventos number.*, o data traz o id do número como primeiro campo (data.id), igual ao number_id do envelope.
Paginação por cursor
A paginação usa cursor, então as páginas não mudam quando chegam eventos novos. O cursor é o id de um evento.
Os eventos vêm do mais novo para o mais antigo, e é isso que decide qual cursor usar:
| Você quer | Use | Direção |
|---|---|---|
| Acompanhar o que acabou de acontecer | ending_before | Eventos mais novos que o cursor |
| Percorrer o histórico | starting_after | Eventos mais antigos que o cursor |
Acompanhar eventos novos: peça o que chegou depois do último evento processado e, se a página vier cheia, desça até alcançá-lo.
# Primeira consulta: sem cursor, traz os mais recentescurl "https://api.turbonotify.com/v1/events?limit=100" \ -H "Authorization: Bearer SUA_CHAVE"
# Consultas seguintes: só o que chegou depois do último que você processoucurl "https://api.turbonotify.com/v1/events?limit=100&ending_before=evt_01993c5a1b24d3e9f01a2b3c4d5e6f70" \ -H "Authorization: Bearer SUA_CHAVE"
# Se essa resposta veio com 100 eventos, ainda há mais: continue a partir# do ÚLTIMO evento dela até reencontrar o cursor que você já tinha.curl "https://api.turbonotify.com/v1/events?limit=100&starting_after=evt_01993c5a1b24d3e9f01a2b3c4d5e6f99" \ -H "Authorization: Bearer SUA_CHAVE"const LIMITE = 100;let ultimoVisto = null;
async function pagina(params) { const response = await fetch( `https://api.turbonotify.com/v1/events?${params}`, { headers: { 'Authorization': 'Bearer SUA_CHAVE' } } ); return (await response.json()).data;}
async function buscarNovidades() { const params = new URLSearchParams({ limit: String(LIMITE) }); if (ultimoVisto) params.set('ending_before', ultimoVisto);
let atual = await pagina(params); // Vazio: você está em dia. if (atual.length === 0) return [];
const lote = [...atual];
// Página cheia com um cursor conhecido: chegou mais do que coube, e o // que falta é mais antigo que o último desta página. Desça até // reencontrar o cursor anterior. while (ultimoVisto && atual.length === LIMITE) { atual = await pagina(new URLSearchParams({ limit: String(LIMITE), starting_after: lote[lote.length - 1].id, })); for (const evento of atual) { if (evento.id === ultimoVisto) { atual = []; break; } lote.push(evento); } }
// O primeiro do lote é o mais recente: é ele que vira o próximo cursor. ultimoVisto = lote[0].id; return lote;}import requests
LIMITE = 100ultimo_visto = None
def pagina(**params): response = requests.get( "https://api.turbonotify.com/v1/events", headers={"Authorization": "Bearer SUA_CHAVE"}, params={"limit": LIMITE, **params}, ) return response.json()["data"]
def buscar_novidades(): global ultimo_visto atual = pagina(ending_before=ultimo_visto) if ultimo_visto else pagina()
# Vazio: você está em dia. if not atual: return []
lote = list(atual)
# Página cheia com um cursor conhecido: chegou mais do que coube, e o # que falta é mais antigo que o último desta página. Desça até # reencontrar o cursor anterior. while ultimo_visto and len(atual) == LIMITE: atual = pagina(starting_after=lote[-1]["id"]) for evento in atual: if evento["id"] == ultimo_visto: atual = [] break lote.append(evento)
# O primeiro do lote é o mais recente: é ele que vira o próximo cursor. ultimo_visto = lote[0]["id"] return lotePercorrer o histórico: passe o id do último evento retornado em starting_after:
# Primeira páginacurl "https://api.turbonotify.com/v1/events?limit=20" \ -H "Authorization: Bearer SUA_CHAVE"
# Próxima página (starting_after = id do último evento retornado)curl "https://api.turbonotify.com/v1/events?limit=20&starting_after=evt_01993c5a1b24d3e9f01a2b3c4d5e6f70" \ -H "Authorization: Bearer SUA_CHAVE"async function buscarTodosOsEventos() { let startingAfter = null; const todos = [];
while (true) { const params = new URLSearchParams({ limit: '20' }); if (startingAfter) params.set('starting_after', startingAfter);
const response = await fetch( `https://api.turbonotify.com/v1/events?${params}`, { headers: { 'Authorization': 'Bearer SUA_CHAVE' } } ); const page = await response.json(); todos.push(...page.data);
if (!page.has_more) break; startingAfter = page.data[page.data.length - 1].id; }
return todos;}import requests
def buscar_todos_os_eventos(): url = "https://api.turbonotify.com/v1/events" headers = {"Authorization": "Bearer SUA_CHAVE"} todos = [] starting_after = None
while True: params = {"limit": 20} if starting_after: params["starting_after"] = starting_after
response = requests.get(url, headers=headers, params=params) page = response.json() todos.extend(page["data"])
if not page["has_more"]: break starting_after = page["data"][-1]["id"]
return todosFiltrar por tipo de evento
Filtre os eventos que seu sistema precisa processar usando o parâmetro types[]:
curl "https://api.turbonotify.com/v1/events?types[]=message.received&types[]=message.sent" \ -H "Authorization: Bearer SUA_CHAVE"const params = new URLSearchParams();params.append('types[]', 'message.received');params.append('types[]', 'message.sent');params.set('limit', '20');
const response = await fetch( `https://api.turbonotify.com/v1/events?${params}`, { headers: { 'Authorization': 'Bearer SUA_CHAVE' } });const page = await response.json();console.log(page);import requests
url = "https://api.turbonotify.com/v1/events"headers = {"Authorization": "Bearer SUA_CHAVE"}params = { "types[]": ["message.received", "message.sent"], "limit": 20}
response = requests.get(url, headers=headers, params=params)page = response.json()print(page)Filtrar por número
Como mensagens, contatos e grupos pertencem sempre a um número, você pode
restringir o polling aos eventos de um único número via number_alias. Se o
parâmetro estiver ausente, a resposta inclui os eventos de todos os números da
organização.
# Só os eventos do número principalcurl "https://api.turbonotify.com/v1/events?number_alias=main&limit=20" \ -H "Authorization: Bearer SUA_CHAVE"
# Só os eventos de um número extracurl "https://api.turbonotify.com/v1/events?number_alias=vendas-sp&limit=20" \ -H "Authorization: Bearer SUA_CHAVE"const params = new URLSearchParams({ number_alias: 'main', limit: '20' });
const response = await fetch( `https://api.turbonotify.com/v1/events?${params}`, { headers: { 'Authorization': 'Bearer SUA_CHAVE' } });const page = await response.json();console.log(page);import requests
url = "https://api.turbonotify.com/v1/events"headers = {"Authorization": "Bearer SUA_CHAVE"}params = {"number_alias": "main", "limit": 20}
response = requests.get(url, headers=headers, params=params)page = response.json()print(page)Intervalo recomendado
O polling usa o mesmo limite geral da API: 1000 requisições por segundo por organização. Acima disso a resposta é 429 Too Many Requests (rate_limit_exceeded). Consultar a cada poucos segundos fica muito abaixo desse teto.
Com volume alto, prefira webhooks para receber eventos em tempo real e reserve o polling para reconciliação.
Paridade com webhooks
O objeto data de cada evento retornado pela API é idêntico, byte a byte, ao data entregue via webhook para o mesmo evento. O envelope é o mesmo em ambos os canais:
| Campo | Webhook | Polling |
|---|---|---|
id | presente | presente |
type | presente | presente |
organization_id | presente | presente |
number_alias | presente (em eventos ligados a um número) | presente (em eventos ligados a um número) |
number_id | presente (em eventos ligados a um número) | presente (em eventos ligados a um número) |
created_at | presente | presente (mesmo valor) |
data | idêntico | idêntico |
Na prática:
- Sistemas que usam apenas polling processam os mesmos dados que sistemas baseados em webhook.
- Sistemas híbridos podem usar a API para reconciliar eventos perdidos reaproveitando o mesmo código de processamento.
- Catálogo completo de
typeedata: consulte a referência de eventos no documento de webhook. - As respostas dos convidados a um evento de calendário chegam como
message.calendar_responded, também comtype=message.*: veja Eventos de calendário. - Exemplos de payload por
content_type(texto, mídia, localização, calendário): Exemplos por content_type.
A paridade inclui o campo data.mentions:
quando uma mensagem menciona alguém, os eventos
message.received/message.replied/message.sent/message.edited retornados
pelo polling carregam exatamente a mesma lista mentions e o mesmo preview
(cada token de menção conta como 1 caractere) entregues via webhook,
byte a byte.
Recuperação após falha de webhook
Para recuperar eventos perdidos durante uma indisponibilidade do seu endpoint:
- Identifique a data e a hora aproximadas do início da falha.
- Consulte
GET /v1/events?created[gte]=<timestamp_da_falha>&limit=100e, enquantohas_morefortrue, continue comstarting_afterigual aoiddo último evento da página (veja Paginação por cursor). Uma única página traz só os eventos mais recentes do período. - Processe apenas os eventos não vistos usando o
idde cada evento como chave de idempotência.
Uma mensagem específica também dá para recuperar lendo-a: a listagem e a consulta de uma mensagem trazem os mesmos campos do evento message.* daquela mensagem (id, number_alias, number_id, from, to, direction, content_type, channel, origin, at). Se você perdeu um webhook, leia a mensagem por Consultar mensagem ou filtre Listar mensagens por grupo, contato ou direção e obtenha a mesma informação.
curl "https://api.turbonotify.com/v1/events?created[gte]=2026-06-01T08:00:00-03:00" \ -H "Authorization: Bearer SUA_CHAVE"const params = new URLSearchParams({ 'created[gte]': '2026-06-01T08:00:00-03:00', limit: '100'});
const response = await fetch( `https://api.turbonotify.com/v1/events?${params}`, { headers: { 'Authorization': 'Bearer SUA_CHAVE' } });const page = await response.json();console.log(page);import requests
url = "https://api.turbonotify.com/v1/events"headers = {"Authorization": "Bearer SUA_CHAVE"}params = { "created[gte]": "2026-06-01T08:00:00-03:00", "limit": 100}
response = requests.get(url, headers=headers, params=params)page = response.json()print(page)Retenção de eventos
GET /v1/events lista os eventos pelo horizonte do próprio registro de eventos, 90 dias, fixo na plataforma: o prazo de retenção de conteúdo do seu plano não esconde eventos. O que expira nesse prazo é o conteúdo dos eventos message.*: data.preview passa a trazer um marcador de conteúdo removido e data.transcription vira {"kind": "unavailable", "reason": "content_retention_expired"}, em até 15 minutos depois do prazo (veja Retenção das mensagens).
Erros possíveis
| Status | Código de erro | Descrição |
|---|---|---|
400 | invalid_event_id | O cursor fornecido em starting_after ou ending_before não é um id de evento válido (evt_ + 32 hex). Um cursor bem formado mas inexistente não gera erro. O filtro é ignorado. |
400 | invalid_request_id | O request_id informado não é um id de requisição válido (req_ + 32 hex). O X-Request-ID devolvido em respostas GET é um UUID de correlação, não um req_…. Só o header devolvido pelos POST assíncronos serve como filtro aqui. |
401 | not_authenticated | A chave de API está inválida ou ausente. |
403 | insufficient_scope | A chave de API não possui o escopo events:read (resposta inclui required_scope). |
422 | validation_error | Um parâmetro de consulta não passou na validação (por exemplo limit fora do intervalo de 1 a 100 ou created[gte]/created[lte] fora do formato ISO 8601). A resposta traz details.errors com os campos rejeitados. |
429 | rate_limit_exceeded | Limite de requisições ultrapassado. Aguarde antes de tentar novamente. |
500 | internal_error | Erro interno inesperado no servidor. |