Envio em lote
Fila persistente, múltiplas instâncias, controle assíncrono, recovery e progresso.
Recurso Pro: todas as rotas
/message/batches...são classificadas comercialmente como CodeChat API Go Pro. O runtime atual não aplica entitlement de plano nem retorna402; a autenticação executável é feita por JWT de usuário.
O Envio em lote persiste no PostgreSQL uma mensagem, os destinatários individuais, as instâncias permitidas e cada tentativa de envio. A requisição HTTP apenas cria ou altera o estado do lote; o MessageBatchWorker consome os itens de forma assíncrona, com concorrência 1 por lote. A fila no banco é a fonte da verdade.
Autenticação
Todas as rotas abaixo usam JWT de usuário:
Authorization: Bearer <jwt-do-usuario>O token precisa ser assinado com HS256, conter userId em formato UUID e conter exp. A expiração sempre é validada, mesmo quando AUTHENTICATION_JWT_EXPIRES_IN=0. apikey, x-api-key e apiKey não autenticam esse fluxo.
Na criação, userId é persistido como "ownerUserId" e retornado como ownerUserId. Consultas, itens, tentativas e ações (start, pause, stop) só acessam lotes desse mesmo usuário. O mesmo userId isola eventos de WebSocket em /ws/global/events.
Arquitetura e persistência
"MessageBatch": definição, estado, lease e contadores derivados."MessageBatchInstance": IDs internos permitidos, snapshot do nome e contadores por instância."MessageBatchItem": destinatários normalizados, ordem, estado e resultado."MessageBatchAttempt": uma linha por tentativa, inclusive indisponibilidade da instância."MessageBatchOutbox": eventos globais entregues após o commit."Message"."messageBatchId"e"Message"."messageBatchItemId": vínculo opcional com a mensagem persistida já existente.
Contadores são recalculados dos itens na mesma transação que conclui uma tentativa. O progresso é floor(processed * 100 / total), em que processed = success + failed + skipped + unknown.
Criar um lote
POST /message/batches
Content-Type: application/json
Authorization: Bearer <jwt-do-usuario>{
"name": "Aviso de manutenção",
"instances": ["codechat-01", "codechat-02"],
"recipients": ["5531999999999", "5531888888888"],
"message": {
"type": "interactive",
"payload": {
"title": "Confirmação",
"description": "Deseja continuar?",
"footerText": "CodeChat",
"buttons": [
{
"displayText": "Repo",
"url": "https://github.com/code-chat-br/whatsapp-api-go"
},
{
"displayText": "Código de barras",
"copyCode": "32546413216543165489869589416558645341864321365468435469465"
},
{
"displayText": "Sim",
"id": "1"
},
{
"displayText": "Não",
"id": "0"
},
{
"displayText": "Talvez",
"id": "-1"
}
]
}
},
"options": {
"replicable": true,
"presence": "composing",
"delay": {"minMs": 3000, "maxMs": 12000}
},
"autoStart": true,
"autoResume": true,
"schedule": {
"windowStartAt": "2026-07-16T00:00:00-03:00",
"windowEndAt": "2026-07-16T03:00:00-03:00",
"recurrence": "DAILY",
"timezone": "America/Sao_Paulo"
},
"externalAttributes": {"origin": "admin-panel"}
}Resposta 201 Created:
{
"id": "01900000-0000-7000-8000-000000000001",
"ownerUserId": "11111111-1111-4111-8111-111111111111",
"name": "Aviso de manutenção",
"status": "SCHEDULED",
"autoResume": true,
"schedule": {
"windowStartAt": "2026-07-16T00:00:00-03:00",
"windowEndAt": "2026-07-16T03:00:00-03:00",
"recurrence": "DAILY",
"timezone": "America/Sao_Paulo"
},
"nextRunAt": "2026-07-16T00:00:00-03:00",
"lastInterruptedAt": null,
"interruptionReason": null,
"lastRecoveredAt": null,
"instances": [
{"id": 1, "name": "codechat-01", "connectionStatus": "offline"},
{"id": 2, "name": "codechat-02", "connectionStatus": "online"}
],
"counts": {
"received": 2,
"total": 2,
"processed": 0,
"pending": 2,
"sending": 0,
"success": 0,
"failed": 0,
"skipped": 0,
"unknown": 0
},
"progress": 0,
"createdAt": "2026-07-13T12:00:00-03:00",
"updatedAt": "2026-07-13T12:00:00-03:00"
}Com autoStart: true, lote, itens, instâncias e eventos são gravados na mesma transação. Sem agenda, o estado inicial continua sendo QUEUED. Antes da primeira janela ele é SCHEDULED; dentro da janela é QUEUED; depois da janela diária é WAITING_FOR_WINDOW. Com autoStart: false, o estado permanece DRAFT e /start aplica a agenda antes de enfileirar.
Agenda diária e recuperação automática
schedule é opcional. windowStartAt e windowEndAt usam RFC 3339 e precisam ser enviados juntos, com o fim posterior ao início. recurrence aceita NONE ou DAILY. timezone precisa ser um identificador IANA; quando omitido, usa MESSAGE_BATCH_TIMEZONE. A recorrência trabalha com time.Location e preserva o horário local, inclusive em mudanças de horário de verão. Janelas que atravessam a meia-noite são representadas normalmente, por exemplo, início às 22:00 e fim às 02:00 do dia seguinte.
Com recurrence: NONE, existe somente a janela inicial. Se ela terminar com itens PENDING, o lote permanece WAITING_FOR_WINDOW sem nextRunAt; nesta tarefa não existe parâmetro force nem endpoint para editar a agenda, portanto o operador pode apenas pausar ou encerrar esse lote. Use DAILY quando os itens restantes precisarem continuar em outra janela.
autoResume é opcional e assume true. Ele controla somente recovery após shutdown, crash ou lease expirado. Nunca ignora PAUSED: uma pausa manual só termina com /start. Lotes sem agenda preservam a execução contínua anterior e respondem com schedule: null e nextRunAt: null.
Ao atingir o fim da janela, o worker não inicia outro envio. Um envio que já começou pode concluir durante o timeout de shutdown/contexto; timers de delay são cancelados, itens restantes continuam PENDING, o lote vai para WAITING_FOR_WINDOW, nextRunAt recebe a próxima abertura e o lease é liberado. Nenhuma goroutine fica dormindo até o dia seguinte. Se o delay foi interrompido pelo fim da janela, ele não é repetido integralmente na retomada.
Destinatários
Somente contatos individuais são aceitos. Os valores são normalizados pelo mesmo parser de endereço usado pelo projeto e a constraint (batch_id, normalized_recipient) impede duplicidade após normalização. A resposta informa counts.received, counts.total e counts.duplicatesIgnored.
Qualquer valor terminado em @g.us ou normalizado como grupo rejeita o lote inteiro com 422 e mantém os índices originais:
{
"statusCode": 422,
"error": "unprocessable-entity",
"message": [
"recipients[3] não pode ser um grupo",
"recipients[7] não pode terminar com @g.us"
]
}Mensagens compatíveis
Os tipos persistentes suportados são text, link, media, audio, contact, location e interactive. Os aliases ptt, whatsapp-audio e whatsapp_audio são aceitos na criação e gravados internamente como audio.
O message.payload não inclui number, chat, recipient, options, textMessage, mediaMessage ou nomes de wrapper dos endpoints individuais. Ele contém diretamente o objeto interno que seria colocado dentro desses campos no envio individual. O destinatário vem de recipients[]; as opções comuns vêm de options.
message.type | Formato de payload | Observações |
|---|---|---|
text | objeto com text | Texto obrigatório, não vazio, até 65536 caracteres. |
link | objeto com link, thumbnailUrl, title, description | link é obrigatório e precisa ser HTTP(S). thumbnailUrl, quando enviado, também precisa ser HTTP(S). |
media | objeto com mediatype, media ou mediaUploadId, fileName, caption | mediatype aceita image, document, video, audio e ptv. Use URL HTTP(S) em media ou um pré-upload em mediaUploadId; os dois campos são mutuamente exclusivos. |
audio, ptt, whatsapp-audio, whatsapp_audio | objeto com audio ou mediaUploadId, e ptt opcional | Use URL HTTP(S) em audio ou um pré-upload de áudio em mediaUploadId; os dois campos são mutuamente exclusivos. ptt, quando enviado, precisa ser true. |
contact | array de contatos | Cada contato precisa de fullName e pelo menos um entre phoneNumber, wuid ou vcard. Com mais de um item, o envio usa mensagem de múltiplos contatos. |
location | objeto com latitude, longitude, name, address, url | latitude e longitude são obrigatórios. Latitude deve estar entre -90 e 90; longitude entre -180 e 180. |
interactive | objeto de botões | Usa o modo buttons persistente. description e buttons são obrigatórios; title, footerText e mediaUploadId são opcionais. |
Exemplo text:
{
"message": {
"type": "text",
"payload": {
"text": "Olá, esta é uma mensagem em lote."
}
}
}Exemplo link:
{
"message": {
"type": "link",
"payload": {
"link": "https://example.com/oferta",
"thumbnailUrl": "https://example.com/thumb.jpg",
"title": "Oferta",
"description": "Confira os detalhes."
}
}
}Exemplo media por URL:
{
"message": {
"type": "media",
"payload": {
"mediatype": "image",
"media": "https://example.com/banner.jpg",
"fileName": "banner.jpg",
"caption": "Banner da campanha"
}
}
}Exemplo media por pré-upload:
{
"message": {
"type": "media",
"payload": {
"mediaUploadId": 42,
"fileName": "contrato.pdf",
"caption": "Contrato"
}
}
}mediaUploadId em media precisa pertencer à instância que fará o envio e o tipo salvo no pré-upload. Como a escolha de instância é feita item a item, use pré-upload em lote apenas quando o lote tiver uma única instância ou quando houver garantia operacional de que o ID é válido para a instância selecionada. Para várias instâncias, a forma mais portátil é usar URL em media.
Exemplo audio:
{
"message": {
"type": "audio",
"payload": {
"audio": "https://example.com/audio.ogg"
}
}
}Exemplo audio por pré-upload:
{
"message": {
"type": "audio",
"payload": {
"mediaUploadId": 4,
"ptt": true
}
}
}mediaUploadId em audio precisa apontar para um pré-upload com mediaType=audio, pertencente à instância que fará o envio. Como o pré-upload pertence a uma única instância, lote de áudio com mediaUploadId exige exatamente uma instância. O campo ptt é opcional porque o envio de áudio do lote já é PTT/voz; se enviado, apenas true é aceito.
Exemplo contact:
{
"message": {
"type": "contact",
"payload": [
{
"fullName": "Alice Silva",
"phoneNumber": "+55 31 99999-9999",
"wuid": "5531999999999"
},
{
"fullName": "Suporte CodeChat",
"organization": "CodeChat",
"vcard": "BEGIN:VCARD\nVERSION:3.0\nFN:Suporte CodeChat\nTEL;type=CELL:+5531888888888\nEND:VCARD"
}
]
}
}Exemplo location:
{
"message": {
"type": "location",
"payload": {
"latitude": -23.55052,
"longitude": -46.633308,
"name": "São Paulo",
"address": "São Paulo, SP",
"url": "https://maps.google.com/?q=-23.55052,-46.633308"
}
}
}Exemplo interactive:
{
"message": {
"type": "interactive",
"payload": {
"title": "Confirmação",
"description": "Deseja continuar?",
"footerText": "CodeChat",
"buttons": [
{"displayText": "Abrir site", "url": "https://example.com"},
{"displayText": "Copiar código", "copyCode": "ABC-123"},
{"displayText": "Sim", "id": "confirmar"}
]
}
}
}Em interactive, cada botão precisa de displayText e de uma ação. A ação pode ser inferida por url, copyCode ou id; se usar value sem esses campos, envie também type com url, copy ou reply. É permitido misturar botões de URL, cópia e resposta no mesmo payload, até o limite de 10 botões. title e footerText são opcionais; description é obrigatória. mediaUploadId também é opcional: omitido, 0 ou string vazia enviam sem mídia. Como o pré-upload pertence a uma única instância, um ID positivo exige que o lote tenha exatamente uma instância; em lotes com múltiplas instâncias, mantenha o campo omitido, 0 ou vazio.
Upload multipart por requisição (media-file e audio-file), reaction, form, payment-request, pix e review-order são rejeitados explicitamente porque não formam um payload persistente e repetível neste worker. mentionAll é rejeitado por ser exclusivo de grupos.
replicable é aceito e preservado no JSON de opções. O código anterior não possuía comportamento executável para essa propriedade; portanto, o Message Batch não inventa uma nova semântica nem altera o envio individual.
O payload da mensagem tem limite de 1 MiB; cada objeto de atributos externos tem limite de 64 KiB. A lista recebida respeita MESSAGE_BATCH_MAX_RECIPIENTS.
Delay e múltiplas instâncias
options.delay.minMs deve ser >= 0, maxMs >= minMs e maxMs <= MESSAGE_BATCH_MAX_DELAY_MS. Depois de cada item concluído, o worker escolhe um valor inclusivo e criptograficamente aleatório, persiste em selectedDelayMs e usa timer cancelável. Pause, stop, fim da janela ou encerramento do processo interrompem a espera sem time.Sleep irrecuperável.
Para cada item, o worker filtra as instâncias conectadas do lote, embaralha a lista e persiste a escolhida antes de enviar. Se ela cair antes do envio, a tentativa vira INSTANCE_UNAVAILABLE e outra instância conectada é tentada. Se nenhuma estiver disponível, o item volta para PENDING e o lote entra em WAITING_FOR_INSTANCE; a reavaliação ocorre no intervalo configurado e o lote volta automaticamente a PROCESSING.
Estados e transições
| Origem | Destino permitido |
|---|---|
DRAFT | SCHEDULED, QUEUED, WAITING_FOR_WINDOW |
SCHEDULED | QUEUED, PAUSED, STOPPED, INTERRUPTED |
QUEUED | PROCESSING, WAITING_FOR_INSTANCE, WAITING_FOR_WINDOW, INTERRUPTED |
PROCESSING | PAUSE_REQUESTED, STOP_REQUESTED, WAITING_FOR_INSTANCE, WAITING_FOR_WINDOW, COMPLETED, COMPLETED_WITH_ERRORS, INTERRUPTED, FAILED estrutural |
PAUSE_REQUESTED | PAUSED, INTERRUPTED |
PAUSED | QUEUED |
WAITING_FOR_INSTANCE | PROCESSING, PAUSE_REQUESTED, STOP_REQUESTED, WAITING_FOR_WINDOW, INTERRUPTED |
WAITING_FOR_WINDOW | QUEUED, PAUSED, STOPPED, INTERRUPTED |
STOP_REQUESTED | STOPPED, INTERRUPTED |
INTERRUPTED | SCHEDULED, QUEUED, WAITING_FOR_WINDOW quando autoResume=true; os mesmos destinos via /start quando falso |
Transição inválida retorna 409 Conflict. start é idempotente em SCHEDULED, QUEUED, PROCESSING e WAITING_FOR_WINDOW, sempre reavaliando a agenda quando necessário; pause é idempotente em PAUSE_REQUESTED e PAUSED; stop é idempotente em STOP_REQUESTED e STOPPED.
Pause não inicia um novo item, deixa o envio já confirmado terminar, cancela o delay e chega a PAUSED. Um novo start continua do próximo PENDING. Stop chega a STOPPED, é terminal e conserva os itens não processados como PENDING.
Endpoints e filtros
| Método | Rota | Operação |
|---|---|---|
POST | /message/batches | Cria o lote (201). |
POST | /message/batches/list | Lista lotes com filtros no body. |
GET | /message/batches/:batchId | Detalhes sem incorporar milhares de itens. |
GET | /message/batches/:batchId/processing | Resumo completo, com todos os destinatários e tentativas. |
GET | /message/batches/:batchId/items | Lista itens. |
GET | /message/batches/:batchId/items/:itemId | Item e todas as suas tentativas. |
GET | /message/batches/:batchId/attempts | Lista tentativas. |
POST | /message/batches/:batchId/start | Enfileira início ou continuação e retorna imediatamente. |
POST | /message/batches/:batchId/pause | Solicita pausa. |
POST | /message/batches/:batchId/stop | Solicita encerramento terminal. |
GET /message/batches/:batchId/processing não usa paginação. A resposta combina a definição completa do lote com recipients, ordenado pela posição original. Cada destinatário expõe o estado final ou atual em status, os dados persistidos do envio e attempts com todas as tentativas, payloads, respostas e erros disponíveis. Assim, destinatários enviados e com falha são identificados por SUCCESS e FAILED, enquanto os demais estados permanecem visíveis. Em lotes grandes, prefira os endpoints paginados de itens e tentativas quando não for necessário carregar o processamento inteiro.
Filtros de lotes são enviados no body de POST /message/batches/list: status, instanceName, createdFrom, createdTo, name, query, limit, nextCursor, previousCursor. name pesquisa pelo nome do lote com correspondência parcial e sem diferenciar maiúsculas de minúsculas. Envie {} para usar os padrões. Datas usam RFC 3339.
Filtros de itens: status, recipient, instanceName, limit, nextCursor, previousCursor. Estados: PENDING, SENDING, SUCCESS, FAILED, SKIPPED, INTERRUPTED, UNKNOWN.
Filtros de tentativas: status, recipient, instanceName, limit, nextCursor, previousCursor. Estados: SENDING, SUCCESS, FAILED, INSTANCE_UNAVAILABLE, INTERRUPTED, UNKNOWN.
O limite padrão é 50 e o máximo 200. Não envie nextCursor e previousCursor juntos. Os cursores são opacos para clientes; atualmente usam UUID para lotes/tentativas e posição numérica para itens. O envelope é:
{
"nextCursor": "01900000-0000-7000-8000-000000000010",
"previousCursor": "01900000-0000-7000-8000-000000000002",
"totalRecords": 100,
"records": []
}Exemplos com curl
curl -X POST "http://localhost:8084/message/batches" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
--data-binary @batch.json
curl -X POST "http://localhost:8084/message/batches/list" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status":"SCHEDULED","limit":50}'
curl -X POST "http://localhost:8084/message/batches/$BATCH_ID/start" \
-H "Authorization: Bearer $USER_TOKEN"
curl "http://localhost:8084/message/batches/$BATCH_ID" \
-H "Authorization: Bearer $USER_TOKEN"
curl "http://localhost:8084/message/batches/$BATCH_ID/processing" \
-H "Authorization: Bearer $USER_TOKEN"
curl -X POST "http://localhost:8084/message/batches/$BATCH_ID/pause" \
-H "Authorization: Bearer $USER_TOKEN"Recovery, leases e resultado desconhecido
Cada lote ativo possui lease_owner e lease_expires_at. Claim, item SENDING e tentativa são gravados com transações e FOR UPDATE SKIP LOCKED, impedindo dois workers/réplicas de confirmarem o mesmo item. A consulta de claim considera somente estados elegíveis e next_run_at IS NULL OR next_run_at <= now(), usando a hora do PostgreSQL. Heartbeats renovam os leases; lotes agendados permanecem no banco e não são carregados integralmente em memória.
No encerramento gracioso por SIGINT, SIGTERM, cancelamento do contexto ou shutdown normal, o worker impede novos claims, interrompe polling e delays, aguarda envios em andamento por MESSAGE_BATCH_SHUTDOWN_TIMEOUT_MS, persiste INTERRUPTED, preenche lastInterruptedAt e interruptionReason, e libera leases. Se o envio não puder ser confirmado no timeout, item e tentativa viram UNKNOWN. SIGKILL, queda de energia, OOM fatal e crashes podem impedir qualquer escrita de shutdown; nesses casos, o lease persistido é o mecanismo de recuperação.
Na inicialização e em cada ciclo, leases expirados são tomados com bloqueio concorrente seguro. Tentativas e itens abandonados em SENDING viram UNKNOWN, a interrupção recebe LEASE_EXPIRED e, quando autoResume=true, o lote volta automaticamente a QUEUED, SCHEDULED ou WAITING_FOR_WINDOW conforme a agenda. Sem agenda, volta a QUEUED. lastRecoveredAt registra a recuperação. Com autoResume=false, ele permanece INTERRUPTED até /start. PAUSED, STOPPED, estados concluídos e falhas estruturais nunca reiniciam sozinhos; PAUSE_REQUESTED abandonado chega a PAUSED e STOP_REQUESTED chega a STOPPED.
O worker gera e persiste clientMessageId antes de chamar o WhatsApp e o reutiliza nas tentativas de troca de instância. Isso reduz duplicidade, mas não oferece garantia de exactly once: uma mensagem pode ter sido aceita pelo WhatsApp e o processo cair antes do commit local. UNKNOWN significa exatamente que o resultado externo não pôde ser confirmado; o sistema nunca o transforma automaticamente em PENDING nem o reenvia. A recuperação continua somente os itens PENDING; SUCCESS, FAILED, SKIPPED e UNKNOWN ficam intocados, e UNKNOWN conta como processado para conclusão com erros.
Webhooks e outbox
Eventos de lote usam somente WEBHOOK_GLOBAL_URL. A mudança de item/estado e a linha de outbox são confirmadas juntas; a chamada HTTP ocorre fora da transação, com retry e backoff. message.batch.progress só é criado quando o percentual inteiro muda ou chega a 100. Consulte Webhooks para o mapa e os exemplos completos.
Variáveis de ambiente
| Variável | Padrão |
|---|---|
MESSAGE_BATCH_WORKER_ENABLED | true |
MESSAGE_BATCH_WORKER_POLL_INTERVAL_MS | 1000 |
MESSAGE_BATCH_INSTANCE_RECHECK_INTERVAL_MS | 5000 |
MESSAGE_BATCH_MAX_DELAY_MS | 86400000 |
MESSAGE_BATCH_MAX_RECIPIENTS | 10000 |
MESSAGE_BATCH_SHUTDOWN_TIMEOUT_MS | 15000 |
MESSAGE_BATCH_TIMEZONE | UTC |
Se o worker estiver desabilitado, criação, consulta e alterações de estado permanecem disponíveis, mas a fila não é consumida e a outbox não é entregue por este processo.
