API de WhatsApp em Go: net/http, structs tipadas e workers
Em Go, a API de WhatsApp da D-API se integra só com a biblioteca padrão: um http.Client com timeout, context com prazo em cada envio e um handler de webhook que decodifica o evento em structs tipadas e passa o trabalho para um pool de goroutines. O resultado é um serviço pequeno, previsível e fácil de escalar.
Por que Go combina com integração de WhatsApp
Quem escolhe Go para essa camada geralmente está construindo um serviço que conversa com muitos números ao mesmo tempo: um gateway interno que outros sistemas chamam, um roteador de mensagens para vários clientes ou um consumidor de eventos que precisa aguentar pico. Goroutines baratas, binário único e consumo de memória baixo encaixam bem nesse papel. A API de WhatsApp da D-API é REST, então não há SDK a instalar: tudo abaixo usa só pacotes da biblioteca padrão.
Tipos primeiro: request, erro e evento
Modelar os payloads como structs dá segurança em tempo de compilação e deixa claro o contrato. O erro da API tem formato fixo, { "success": false, "error": "...", "statusCode": 400 }, e vale implementar a interface error nele para usar com errors.As.
package dapi
type SendText struct {
SessionID string `json:"sessionId"`
To string `json:"to"`
Text string `json:"text"`
}
type SendImage struct {
SessionID string `json:"sessionId"`
To string `json:"to"`
Image string `json:"image"`
Caption string `json:"caption,omitempty"`
}
type APIError struct {
Success bool `json:"success"`
Message string `json:"error"`
StatusCode int `json:"statusCode"`
}
func (e *APIError) Error() string {
return fmt.Sprintf("d-api: status %d: %s", e.StatusCode, e.Message)
}
// Envelope comum a todos os eventos do webhook
type Event struct {
Event string `json:"event"`
SessionID string `json:"sessionId"`
Timestamp string `json:"timestamp"`
TraceID string `json:"traceId"`
Data json.RawMessage `json:"data"`
}
// data de messages.received
type ReceivedMessage struct {
ID string `json:"id"`
Type string `json:"type"`
Message string `json:"message"`
FromMe bool `json:"fromMe"`
IsGroup bool `json:"is_group"`
From struct {
JID string `json:"jid"`
Name string `json:"name"`
} `json:"from"`
}O client: timeout no transporte e prazo por chamada
O http.Client padrão do pacote não tem timeout, e esse é o erro mais comum em serviços Go que chamam APIs externas. Aqui há duas camadas: o Timeout do client funciona como teto absoluto, e o context recebido pelo método define o prazo daquela chamada e carrega o cancelamento de quem pediu o envio.
type Client struct {
baseURL string
apiKey string
http *http.Client
}
func New(apiKey string) *Client {
return &Client{
baseURL: "https://api.d-api.cloud",
apiKey: apiKey,
http: &http.Client{Timeout: 15 * time.Second},
}
}
func (c *Client) SendText(ctx context.Context, msg SendText) error {
return c.post(ctx, "/api/v1/messages/send/text", msg)
}
func (c *Client) SendImage(ctx context.Context, msg SendImage) error {
return c.post(ctx, "/api/v1/messages/send/image", msg)
}
func (c *Client) post(ctx context.Context, path string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", c.apiKey)
req.Header.Set("Content-Type", "application/json")
res, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("d-api: %s: %w", path, err) // inclui context.DeadlineExceeded
}
defer res.Body.Close()
if res.StatusCode >= 400 {
apiErr := &APIError{StatusCode: res.StatusCode}
_ = json.NewDecoder(io.LimitReader(res.Body, 64<<10)).Decode(apiErr)
return apiErr
}
_, _ = io.Copy(io.Discard, res.Body) // devolve a conexão ao pool
return nil
}
// uso
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
err := client.SendText(ctx, dapi.SendText{SessionID: "gateway-01", To: "5511999999999", Text: "Pedido confirmado."})
var apiErr *dapi.APIError
switch {
case errors.As(err, &apiErr) && apiErr.StatusCode < 500:
// pedido recusado: corrigir payload ou sessão, não repetir
case errors.Is(err, context.DeadlineExceeded):
// sem resposta no prazo: a mensagem pode ou não ter saído
}Ler o corpo até o fim com io.Copy(io.Discard, ...) permite que o transporte reutilize a conexão TCP. Em um gateway que envia milhares de mensagens, isso reduz latência e handshakes TLS.
Webhook com fila e pool de goroutines
O handler decodifica o envelope com limite de tamanho, tenta colocar o evento em um canal com buffer e responde. Os workers leem desse canal e fazem o trabalho demorado. Se o canal estiver cheio, o handler não bloqueia: devolve 503, e a própria política de reentrega da D-API vira o mecanismo de contrapressão.
func main() {
client := dapi.New(os.Getenv("DAPI_API_KEY"))
jobs := make(chan dapi.Event, 512)
for i := 0; i < 8; i++ {
go worker(client, jobs)
}
mux := http.NewServeMux()
mux.HandleFunc("POST /webhooks/whatsapp/{token}", webhook(jobs, os.Getenv("WEBHOOK_TOKEN")))
srv := &http.Server{Addr: ":8080", Handler: mux, ReadTimeout: 10 * time.Second}
log.Fatal(srv.ListenAndServe())
}
func webhook(jobs chan<- dapi.Event, token string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if subtle.ConstantTimeCompare([]byte(r.PathValue("token")), []byte(token)) != 1 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var ev dapi.Event
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&ev); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
select {
case jobs <- ev:
w.WriteHeader(http.StatusOK)
default:
http.Error(w, "busy", http.StatusServiceUnavailable)
}
}
}
func worker(client *dapi.Client, jobs <-chan dapi.Event) {
for ev := range jobs {
if ev.Event != "messages.received" {
continue
}
var msg dapi.ReceivedMessage
if err := json.Unmarshal(ev.Data, &msg); err != nil || msg.FromMe {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
to := strings.Split(msg.From.JID, "@")[0]
err := client.SendText(ctx, dapi.SendText{SessionID: ev.SessionID, To: to, Text: "Recebido, obrigado!"})
cancel()
if err != nil {
slog.Error("resposta falhou", "traceId", ev.TraceID, "err", err)
}
}
}O token no caminho substitui a assinatura, que o webhook de WhatsApp não tem. Em produção, some a isso a deduplicação pelo msg.ID (um SETNX no Redis resolve) e um desligamento gracioso que feche o canal só depois de o servidor parar de aceitar requisições. A referência de eventos está em webhook de WhatsApp.
Testar handler e client com httptest
O pacote net/http/httptest cobre os dois lados sem rede externa. Para o client, suba um httptest.NewServer que devolve 400 com o corpo de erro da API, aponte o baseURL para ele e confira com errors.As que o retorno é um *APIError com o status e a mensagem certos. Faça o mesmo com um servidor que demora mais que o prazo do context e verifique context.DeadlineExceeded.
Para o webhook, use httptest.NewRecorder e um canal sem buffer e sem leitor: o handler precisa responder 503 em vez de travar. Um segundo caso com token errado deve devolver 401 sem tocar no canal. São testes curtos, determinísticos, e pegam justamente as regressões que mais doem em produção, como alguém trocar o select por um envio bloqueante no canal.
Escalando para muitos números
O mesmo serviço atende uma ou centenas de conexões: o SessionID do evento diz de onde a mensagem veio, e o envio usa esse mesmo valor para responder pelo número certo. Na D-API cada conexão roda isolada e com IP próprio, então um número com problema não arrasta os outros. Dois cuidados, porém, são seus:
- Ritmo de envio por número. Goroutines deixam fácil disparar milhares de mensagens em segundos, e isso é exatamente o comportamento que leva a bloqueio. Limite a vazão por sessão com
golang.org/x/time/rateou um ticker. O guia de como evitar banimento detalha os padrões seguros. - Estado fora do processo. Se o serviço roda em mais de uma réplica, o canal em memória vale só para absorver picos curtos. Deduplicação e controle de vazão precisam de Redis ou equivalente.
A organização de dezenas de conexões está em múltiplos números no WhatsApp. Se você está construindo um produto em que cada cliente conecta o próprio número, veja a página de API de WhatsApp para SaaS, com o modelo de cobrança por conexão.
Perguntas frequentes
Preciso de alguma biblioteca de terceiros em Go?
Não. A biblioteca padrão resolve tudo: net/http para enviar e receber, encoding/json para o payload e context para os prazos. Não existe SDK Go da D-API, e a API REST não exige um.
Qual a diferença entre o Timeout do http.Client e o context?
O Timeout do client é um teto para qualquer requisição feita por ele. O context com prazo vale para uma chamada específica e se propaga: se o request que originou o envio for cancelado, o envio também é. Use os dois.
Por que usar json.RawMessage no campo data?
Porque o conteúdo de data muda conforme o evento. Mensagem recebida, status de conexão e eventos de grupo têm campos diferentes. Com RawMessage você lê o envelope primeiro e decodifica data no struct certo depois.
Qual versão do Go os exemplos usam?
Go 1.22 ou superior, por causa do roteamento com método e curinga no ServeMux, como POST e o caminho com token. Em versões anteriores, use um roteador como chi ou leia o caminho manualmente.
O que acontece se o pool de workers estiver lotado?
O handler devolve 503 em vez de bloquear. A D-API considera a entrega falha e tenta de novo depois, com intervalo crescente. Isso protege o serviço de picos sem perder o evento, desde que o pico não dure horas.
Continue lendo
Teste a API de WhatsApp da D-API
Trial de 3 dias com acesso completo. Sem cartão, sem fidelidade.