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/rate ou 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.

Teste a API de WhatsApp da D-API

Trial de 3 dias com acesso completo. Sem cartão, sem fidelidade.