All integration guides

Go integration

Send WhatsApp messages with Go

A complete, runnable Go example for sending WhatsApp messages through the SPWP REST API. There is no SDK to install — Go talks to the API over ordinary HTTP, so you can paste this straight into an existing project.

About 5 minutes to your first delivered message

1. Setup

Create an API key in the dashboard under Accounts → your account → API keys, then make it available to your Go code as an environment variable. Never commit the key or ship it in client-side code.

# Standard library only.
go mod init example.com/whatsapp
export SPWP_API_KEY="wak_live_your_key_here"

2. Send a text message

One POST with a recipient and a body. The API replies 202 Accepted with a message id in QUEUED state; delivery happens asynchronously and you can track it by id or webhook.

package spwp

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

const APIBase = "https://api.spwp.app/api/v1"

type Message struct {
	ID     string `json:"id"`
	Status string `json:"status"`
}

type envelope struct {
	Data Message `json:"data"`
}

func SendText(to, text string) (*Message, error) {
	payload, _ := json.Marshal(map[string]string{"to": to, "text": text})

	req, err := http.NewRequest(http.MethodPost, APIBase+"/public/messages/text", bytes.NewReader(payload))
	if err != nil {
		return nil, err
	}
	req.Header.Set("X-API-Key", os.Getenv("SPWP_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	client := &http.Client{Timeout: 10 * time.Second}
	res, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()

	if res.StatusCode >= 400 {
		return nil, fmt.Errorf("spwp: unexpected status %d", res.StatusCode)
	}

	var env envelope
	if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
		return nil, err
	}
	return &env.Data, nil
}

3. Send an image, document or other media

Media endpoints take the same shape, with a source that is either a public HTTPS URL we fetch server-side or a base64 data URL you upload inline. Swap the path segment for video, audio, document or location.

func SendImage(to, source, caption string) (*Message, error) {
	payload, _ := json.Marshal(map[string]string{
		"to":      to,
		"source":  source,
		"caption": caption,
	})

	req, _ := http.NewRequest(http.MethodPost, APIBase+"/public/messages/image", bytes.NewReader(payload))
	req.Header.Set("X-API-Key", os.Getenv("SPWP_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	res, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()

	var env envelope
	err = json.NewDecoder(res.Body).Decode(&env)
	return &env.Data, err
}

4. Receive replies and delivery receipts

Inbound messages and status changes arrive as signed POSTs to your webhook URL. Recompute the HMAC over the timestamp and the raw request body — re-serialised JSON will not match — and reject anything older than five minutes.

package spwp

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"os"
	"regexp"
	"strconv"
	"time"
)

var sigRe = regexp.MustCompile(`v1=([a-f0-9]+)`)

func WebhookHandler(w http.ResponseWriter, r *http.Request) {
	body, _ := io.ReadAll(r.Body)
	ts := r.Header.Get("X-WA-Timestamp")
	match := sigRe.FindStringSubmatch(r.Header.Get("X-WA-Signature"))
	if ts == "" || len(match) < 2 {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	// Replay protection.
	sent, _ := strconv.ParseInt(ts, 10, 64)
	if time.Since(time.UnixMilli(sent)).Abs() > 5*time.Minute {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	mac := hmac.New(sha256.New, []byte(os.Getenv("SPWP_WEBHOOK_SECRET")))
	mac.Write([]byte(ts + "." + string(body)))
	expected := hex.EncodeToString(mac.Sum(nil))

	if !hmac.Equal([]byte(match[1]), []byte(expected)) {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}

	w.WriteHeader(http.StatusOK) // Acknowledge first, process asynchronously.
}

Error handling

Errors carry a non-2xx status and a stable code string you can branch on: VALIDATION_FAILED, INVALID_API_KEY, ACCOUNT_NOT_CONNECTED, RATE_LIMITED, MEDIA_FETCH_FAILED. Retry on 429 while honouring Retry-After; treat 401 as a configuration problem rather than a transient one.

Next steps

The full reference covers every endpoint, field and error code, plus the webhook payload shapes.

Other languages and frameworks