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
- Create a free TextMeFlow account — 50 messages a month, forever, no credit card and no Meta Business verification.
- Scan the QR code from the portal with the phone that holds the WhatsApp number you want to send from.
- 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