· 3 min · TextMeFlow Team

Send WhatsApp Messages from Go (Golang) — API Client and Webhook Example

Go's standard library already has everything you need to talk to the TextMeFlow API — no SDK, no code generation, no third-party HTTP client. net/http, encoding/json and crypto/hmac cover sending messages, handling rate limits and verifying inbound webhooks. This guide walks through all three with code you can drop straight into a service.

Before you start

  1. Create a free TextMeFlow account — 50 messages a month, forever, no credit card and no Meta Business verification.
  2. Scan the QR code from the portal with the phone that holds the WhatsApp number you want to send from.
  3. Copy your API key from the dashboard.

The full endpoint reference lives at /docs/api; everything below targets POST /v1/messages.

Sending a message

A minimal client is a struct, a marshalled body and a bearer token:

package textmeflow

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

type SendRequest struct {
	To      string `json:"to"`
	Text    string `json:"text,omitempty"`
	MediaURL string `json:"media_url,omitempty"`
}

type SendResponse struct {
	MessageID string `json:"message_id"`
	Status    string `json:"status"`
}

func Send(apiKey string, msg SendRequest) (*SendResponse, error) {
	body, err := json.Marshal(msg)
	if err != nil {
		return nil, err
	}

	req, err := http.NewRequest(http.MethodPost, "https://api.textmeflow.eu/v1/messages", bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+apiKey)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusAccepted {
		return nil, fmt.Errorf("textmeflow: unexpected status %d", resp.StatusCode)
	}

	var out SendResponse
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, err
	}
	return &out, nil
}

Keep the To field in E.164 format (+ and country code) — anything else gets rejected with a 422. MediaURL is optional and accepts any publicly reachable file up to 100 MB (image, PDF, voice note); leave it empty for a plain text message. A 202 response means the message is queued, not delivered yet — the message_id in the response is what you use to check real status later.

Retrying on 429 without hammering the API

TextMeFlow rate-limits by design — a burst cap per minute and an hourly ceiling per plan, documented on the rate limits page. A 429 response body includes reason (burst_exceeded or hourly_exceeded) and retry_after_seconds, so there's no need to guess a backoff interval:

func SendWithRetry(apiKey string, msg SendRequest, maxAttempts int) (*SendResponse, error) {
	var last error
	for attempt := 1; attempt <= maxAttempts; attempt++ {
		body, _ := json.Marshal(msg)
		req, _ := http.NewRequest(http.MethodPost, "https://api.textmeflow.eu/v1/messages", bytes.NewReader(body))
		req.Header.Set("Content-Type", "application/json")
		req.Header.Set("Authorization", "Bearer "+apiKey)

		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			return nil, err
		}

		if resp.StatusCode == http.StatusTooManyRequests {
			var rl struct {
				Reason            string `json:"reason"`
				RetryAfterSeconds int    `json:"retry_after_seconds"`
			}
			json.NewDecoder(resp.Body).Decode(&rl)
			resp.Body.Close()
			last = fmt.Errorf("rate limited: %s", rl.Reason)
			time.Sleep(time.Duration(rl.RetryAfterSeconds*attempt) * time.Second)
			continue
		}

		defer resp.Body.Close()
		var out SendResponse
		if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
			return nil, err
		}
		return &out, nil
	}
	return nil, fmt.Errorf("gave up after %d attempts: %w", maxAttempts, last)
}

Five attempts is plenty in practice. Multiplying the hinted wait by the attempt number adds a safety margin on top of what the API already told you, without resorting to a fixed sleep(5) loop that either wastes time or retries too soon. For the full reasoning on polling GET /v1/messages/{id} versus just trusting webhooks for delivery status, see the rate limits and status guide.

Receiving replies: a signed webhook handler

Every inbound WhatsApp reply gets POSTed to the endpoint you configure in /admin/webhook, signed with X-TextMeFlow-Signature: sha256=<hex> — an HMAC-SHA256 of the raw request body using the secret shown once at creation time. Go's crypto/hmac package has a constant-time Equal built in, so there's no separate timing-safe comparison to write:

func webhookHandler(secret []byte) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		raw, err := io.ReadAll(r.Body)
		if err != nil {
			http.Error(w, "bad request", http.StatusBadRequest)
			return
		}

		mac := hmac.New(sha256.New, secret)
		mac.Write(raw)
		expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
		received := r.Header.Get("X-TextMeFlow-Signature")

		if !hmac.Equal([]byte(expected), []byte(received)) {
			http.Error(w, "invalid signature", http.StatusUnauthorized)
			return
		}

		eventID := r.Header.Get("X-TextMeFlow-Event-Id")
		// INSERT ... ON CONFLICT (event_id) DO NOTHING, then process async.
		w.WriteHeader(http.StatusOK)
	}
}

Read the body with io.ReadAll before any router middleware re-encodes it — hashing a re-serialized JSON body instead of the original bytes is the most common way this check silently breaks. X-TextMeFlow-Event-Id is stable across retries, so a unique constraint on that column is enough to avoid double-processing: TextMeFlow retries undelivered webhooks up to 5 times with increasing backoff, and anything but a 2xx within 10 seconds counts as a failed attempt. Respond 200 immediately and do the actual work in a goroutine or a queue, not inline — a slow handler looks identical to a timeout from TextMeFlow's side and gets retried needlessly. The full header list and payload shape are in the webhook docs; the signature mechanics are covered in more depth in verifying WhatsApp webhook signatures.

Wrapping up

A Go integration needs three things: a POST with a bearer token, a retry loop that respects retry_after_seconds, and an HMAC check on the way in — all of it standard library, no dependencies to vendor. Start free and you can have a send path and a webhook handler both running in under an hour.

Zelf WhatsApp-berichten versturen via API?

Gratis voor altijd tot 50 berichten/maand. QR scannen en binnen 5 minuten verstuur je je eerste bericht.

Gratis voor altijd