Why Go is a good fit for WhatsApp integration
Teams that pick Go for this layer are usually building a service that talks to many numbers at once: an internal gateway other systems call, a message router for several customers, or an event consumer that has to handle spikes. Cheap goroutines, a single binary and low memory use fit that role well. The D-API WhatsApp API is REST, so there is no SDK to install: everything below uses only standard library packages.
Types first: request, error and event
Modeling payloads as structs gives you compile-time safety and makes the contract explicit. The API error has a fixed shape, { "success": false, "error": "...", "statusCode": 400 }, and it is worth implementing the error interface on it so you can use 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 shared by every webhook event
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 for 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"`
}The client: transport timeout and per-call deadline
The package's default http.Client has no timeout, and that is the most common mistake in Go services that call external APIs. There are two layers here: the client Timeout acts as an absolute ceiling, and the context passed to the method sets the deadline for that call and carries cancellation from whoever asked for the send.
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) // includes 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) // returns the connection to the pool
return nil
}
// usage
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
err := client.SendText(ctx, dapi.SendText{SessionID: "gateway-01", To: "14155550123", Text: "Order confirmed."})
var apiErr *dapi.APIError
switch {
case errors.As(err, &apiErr) && apiErr.StatusCode < 500:
// request rejected: fix the payload or session, don't retry
case errors.Is(err, context.DeadlineExceeded):
// no response in time: the message may or may not have gone out
}Reading the body to the end with io.Copy(io.Discard, ...) lets the transport reuse the TCP connection. In a gateway sending thousands of messages, that cuts latency and TLS handshakes.
Webhook with a queue and a goroutine pool
The handler decodes the envelope with a size limit, tries to put the event on a buffered channel and responds. Workers read from that channel and do the slow work. If the channel is full, the handler does not block: it returns 503, and D-API's own redelivery policy becomes the backpressure mechanism.
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: "Got it, thanks!"})
cancel()
if err != nil {
slog.Error("reply failed", "traceId", ev.TraceID, "err", err)
}
}
}The token in the path replaces the signature, which the WhatsApp webhook does not have. In production, add deduplication by msg.ID (a SETNX in Redis does it) and a graceful shutdown that closes the channel only after the server stops accepting requests. The event reference is in WhatsApp webhooks.
Test the handler and client with httptest
The net/http/httptest package covers both sides without an external network. For the client, spin up an httptest.NewServer that returns 400 with the API error body, point baseURL at it and check with errors.As that the result is an *APIError with the right status and message. Do the same with a server that takes longer than the context deadline and assert context.DeadlineExceeded.
For the webhook, use httptest.NewRecorder and an unbuffered channel with no reader: the handler must respond 503 instead of hanging. A second case with a wrong token should return 401 without touching the channel. These tests are short, deterministic, and catch exactly the regressions that hurt most in production, like someone replacing the select with a blocking send on the channel.
Scaling to many numbers
The same service handles one connection or hundreds: the event's SessionID tells you where the message came from, and the send uses that same value to reply from the right number. On D-API each connection runs isolated with its own IP, so one number with a problem doesn't drag the others down. Two things, however, are on you:
- Send rate per number. Goroutines make it easy to fire off thousands of messages in seconds, and that is exactly the behavior that leads to bans. Limit throughput per session with
golang.org/x/time/rateor a ticker. The how to avoid bans guide details safe patterns. - State outside the process. If the service runs more than one replica, the in-memory channel only absorbs short spikes. Deduplication and rate control need Redis or an equivalent.
Organizing dozens of connections is covered in multiple WhatsApp numbers. If you are building a product where each customer connects their own number, see the WhatsApp API for SaaS page, with per-connection billing.
