Dev

Send WhatsApp messages with Go via REST API: full example

To send WhatsApp messages with Go you only need the standard library: a POST to /api/v1/{instanceId}/send/text with the x-api-key and x-api-secret headers and a JSON body with number and text. This guide builds a small, reusable client with net/http and encoding/json, sends text and media, validates numbers before sending, handles errors with context and receives webhooks with signature verification. The examples use the ZapZap API, which is an unofficial API: the number is connected by QR code, and that use is not covered by the WhatsApp Terms.

Published on · 4 min read

This article is also available in: Português · Español

What do you need before you start?

An account, an instance connected by QR code, the API key and secret and the instance ID. The step by step is in the quick start and authentication is in authentication. Keep the credentials in environment variables: ZAPZAP_API_KEY, ZAPZAP_API_SECRET and INSTANCE_ID.

The base address is https://api.zapzapapi.com/api/v1. Send endpoints live under the instance ID (for example, /{instanceId}/send/text), and every call carries the x-api-key and x-api-secret headers. The destination number goes with country code and area code, digits only, like 5511999999999. No external dependency is needed: the example works with Go's standard library.

How do you build an HTTP client in Go?

Concentrate authentication, timeout and error handling in a single method. That avoids repeating headers, guarantees every call has a deadline and turns responses with status 400 or higher into an error that includes the body, which makes debugging much easier. Always use your own http.Client with a Timeout: the default client has no time limit.

zapzap/client.go

package zapzap

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"
)

const base = "https://api.zapzapapi.com/api/v1"

type Client struct {
	HTTP     *http.Client
	Key      string
	Secret   string
	Instance string
}

func FromEnv() *Client {
	return &Client{
		HTTP:     &http.Client{Timeout: 30 * time.Second},
		Key:      os.Getenv("ZAPZAP_API_KEY"),
		Secret:   os.Getenv("ZAPZAP_API_SECRET"),
		Instance: os.Getenv("INSTANCE_ID"),
	}
}

// do executa a chamada, decodifica o JSON em out e devolve erro com o corpo se status >= 400.
func (c *Client) do(method, path string, in, out any) error {
	var body io.Reader
	if in != nil {
		b, err := json.Marshal(in)
		if err != nil {
			return err
		}
		body = bytes.NewReader(b)
	}
	req, err := http.NewRequest(method, base+path, body)
	if err != nil {
		return err
	}
	req.Header.Set("x-api-key", c.Key)
	req.Header.Set("x-api-secret", c.Secret)
	if in != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	resp, err := c.HTTP.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	raw, _ := io.ReadAll(resp.Body)
	if resp.StatusCode >= 400 {
		return fmt.Errorf("zapzap %s %s: status %d: %s", method, path, resp.StatusCode, raw)
	}
	if out != nil {
		return json.Unmarshal(raw, out)
	}
	return nil
}

How do you send text from Go?

The send text endpoint takes number and text. Useful optional fields: delay (in milliseconds), replyid (to reply to a message) and digitando, which is on by default and shows 'typing...' for a time proportional to the text length. The response carries messageId and status. Keep the messageId if you want to correlate with the status events from the webhook.

zapzap/send.go

package zapzap

// SendText envia uma mensagem de texto e devolve o messageId.
func (c *Client) SendText(number, text string) (string, error) {
	var out map[string]any
	err := c.do("POST", "/"+c.Instance+"/send/text", map[string]any{
		"number": number,
		"text":   text,
	}, &out)
	if err != nil {
		return "", err
	}
	id, _ := out["messageId"].(string)
	return id, nil
}

// SendMedia envia imagem, video, audio, documento etc. a partir de uma URL publica.
// mediaType: image, video, audio, ptt, document, sticker.
func (c *Client) SendMedia(number, mediaType, fileURL, caption string) (string, error) {
	var out map[string]any
	payload := map[string]any{
		"number": number,
		"type":   mediaType,
		"file":   fileURL,
	}
	if caption != "" {
		payload["text"] = caption
	}
	if err := c.do("POST", "/"+c.Instance+"/send/media", payload, &out); err != nil {
		return "", err
	}
	id, _ := out["messageId"].(string)
	return id, nil
}

main.go

package main

import (
	"fmt"
	"log"

	"example.com/app/zapzap"
)

func main() {
	c := zapzap.FromEnv()

	id, err := c.SendText("5511999999999", "Olá! Seu pedido saiu para entrega.")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("texto enviado:", id)

	id, err = c.SendMedia("5511999999999", "document", "https://exemplo.com/nota.pdf", "Segue a sua nota fiscal.")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("documento enviado:", id)
}

How do you validate the number before sending?

Sending to a number that is not on WhatsApp causes failures and, at volume, is a pattern WhatsApp penalizes. The check number endpoint takes a list in numbers and returns, for each item, query, isInWhatsapp and jid. Validate in batches before a campaign and remove the nonexistent ones. The same logic applies to the care with pace and list described in the anti-ban guide.

zapzap/check.go

package zapzap

// Existentes devolve apenas os numeros que estao no WhatsApp.
func (c *Client) Existentes(numbers []string) ([]string, error) {
	var out []map[string]any
	err := c.do("POST", "/"+c.Instance+"/chat/check", map[string]any{"numbers": numbers}, &out)
	if err != nil {
		return nil, err
	}
	var ok []string
	for _, item := range out {
		if in, _ := item["isInWhatsapp"].(bool); in {
			q, _ := item["query"].(string)
			ok = append(ok, q)
		}
	}
	return ok, nil
}

Which errors should you handle and how do you retry?

StatusLikely meaningAction in code
400Invalid request (missing field, malformed number)Do not retry. Fix the data
401 or 403Key, secret or permissionDo not retry. Check credentials
402Low balanceAlert operations. Do not retry
404Wrong instance IDCheck the ID
409Operation in progress (for example, QR generation)Wait and try again
429Too many requestsRetry with growing backoff
5xx or timeoutMomentary failureRetry with growing backoff and an attempt limit

In Go, the pattern is a loop with a maximum number of attempts and exponential backoff, retrying only for 429, 5xx and network errors. For validation 4xx errors, retrying is pointless. If the send is important, keep the messageId and confirm the status through the webhook instead of resending blindly, because resending a message that already went out duplicates the text to the customer. The full list of endpoints is in the API reference and on the WhatsApp API page.

How do you receive the webhook in Go with a signature?

Set the URL in set webhook and keep the webhook_secret. Every delivery comes with X-ZapZap-Signature in the format t=<unix>,v1=<hmac>, where the HMAC is SHA-256 of t, a dot and the raw body. Read the whole body before decoding, compute the HMAC and compare with hmac.Equal. Return 200 fast and process in a goroutine or a queue. The idempotency and retry rules are in webhooks in production.

webhook.go

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"log"
	"math"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

func assinaturaValida(secret string, raw []byte, header string) bool {
	var t int64
	var v1 string
	for _, p := range strings.Split(header, ",") {
		kv := strings.SplitN(strings.TrimSpace(p), "=", 2)
		if len(kv) != 2 {
			continue
		}
		switch kv[0] {
		case "t":
			t, _ = strconv.ParseInt(kv[1], 10, 64)
		case "v1":
			v1 = kv[1]
		}
	}
	if t == 0 || v1 == "" || math.Abs(float64(time.Now().Unix()-t)) > 300 {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
	mac.Write(raw)
	esperado := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(esperado), []byte(v1))
}

func main() {
	secret := os.Getenv("ZAPZAP_WEBHOOK_SECRET")

	http.HandleFunc("/webhooks/zapzap", func(w http.ResponseWriter, r *http.Request) {
		raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 2<<20))
		if err != nil || !assinaturaValida(secret, raw, r.Header.Get("X-ZapZap-Signature")) {
			w.WriteHeader(http.StatusUnauthorized)
			return
		}
		var evento map[string]any
		if json.Unmarshal(raw, &evento) != nil {
			w.WriteHeader(http.StatusBadRequest)
			return
		}
		w.WriteHeader(http.StatusOK) // confirma primeiro

		go func(id string, e map[string]any) { // processa depois
			data, _ := e["data"].(map[string]any)
			msg, _ := data["message"].(map[string]any)
			if fromMe, _ := msg["fromMe"].(bool); fromMe || msg == nil {
				return
			}
			log.Println("entrega", id, "mensagem de", msg["chatid"], ":", msg["text"])
		}(r.Header.Get("X-ZapZap-Id"), evento)
	})

	log.Fatal(http.ListenAndServe(":3000", nil))
}

Production questions: what else to watch?

Concurrency: if you send in a loop, keep a variable pause between messages and do not fire thousands in parallel; pace is part of caring for the number, as the anti-ban guide explains. Connection: before campaigns, check the instance status. Logs: record the messageId, the number (masked) and the status, never the key and secret.

If you would rather see the same flow in Node, Python and PHP, there is the guide on sending messages by API in Node, Python and PHP. For .NET, see the guide on C# with HttpClient. Per-number prices are on the pricing page.

Quick summary

  • Sending WhatsApp messages in Go is a POST with x-api-key and x-api-secret to /{instanceId}/send/text, with number and text.
  • Centralize authentication, timeout and errors in one method and use an http.Client with a Timeout.
  • Validate the list with the number verification endpoint before campaigns.
  • Retry only 429, 5xx and network errors. Retrying validation 4xx errors does not help.
  • On the webhook, verify X-ZapZap-Signature over the raw body, return 200 and process afterward.

Also read

Frequently asked questions

Do I need a library to use the API in Go?

No. The standard library (net/http and encoding/json) is enough. This guide's examples use no external dependencies.

How do I authenticate calls in Go?

With the x-api-key and x-api-secret headers on every request. Keep the values in environment variables.

How do I send an image or PDF from Go?

Use the send media endpoint with number, type (image, document and so on) and file with the file's public URL. The text field becomes the caption.

How do I verify the webhook signature in Go?

Compute the HMAC SHA-256 of t, a dot and the raw body with the webhook_secret, compare it with hmac.Equal against the v1 value of the X-ZapZap-Signature header and reject timestamps more than 5 minutes old.

Can I send bulk messages from Go?

Technically yes, but pace and list quality determine the risk to the number. Use variable pauses, validate the list and read the anti-ban guide. No tool removes the risk of restriction.

Run your first Go send today

Create your account, verify your phone and get the welcome credit, no deposit needed (one per phone, subject to the current campaign). Connect by QR code and copy this guide's client.