Por linguagem

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 Time de engenharia D-API7 min de leitura

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.