By language

WhatsApp API in Go: net/http, typed structs and workers

In Go, the D-API WhatsApp API integrates with nothing but the standard library: an http.Client with a timeout, a context with a deadline on every send, and a webhook handler that decodes the event into typed structs and passes the work to a pool of goroutines. The result is a small, predictable service that is easy to scale.

By D-API engineering team7 min read

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

Frequently asked questions

Do I need any third-party library in Go?
No. The standard library covers everything: net/http to send and receive, encoding/json for the payload and context for deadlines. There is no D-API Go SDK, and the REST API does not need one.
What is the difference between http.Client Timeout and context?
The client Timeout is a ceiling for any request it makes. A context with a deadline applies to one specific call and propagates: if the request that triggered the send is canceled, the send is too. Use both.
Why use json.RawMessage for the data field?
Because the content of data changes with the event. Incoming messages, connection status and group events have different fields. With RawMessage you read the envelope first and decode data into the right struct afterwards.
Which Go version do the examples use?
Go 1.22 or later, because of method and wildcard routing in ServeMux, such as POST and the path with the token. On earlier versions, use a router like chi or parse the path manually.
What happens if the worker pool is full?
The handler returns 503 instead of blocking. D-API treats the delivery as failed and retries later with exponential backoff. That protects the service from spikes without losing the event, as long as the spike does not last for hours.

Try D-API's WhatsApp API

3-day trial with full access. No credit card, no lock-in.