WhatsApp Operators DailyThe Blueticks DispatchMonday, September 21, 2026
Productivity

How to Send WhatsApp Messages from Go on Your Own Number (2026)

One authenticated POST from Go, zero third-party packages. Then the parts that bite: the http.Get with no timeout, the encoder that ruins the path, and a retry that sends twice.

DRBy Daniel Roth · September 21, 2026 · 11 min read
How to Send WhatsApp Messages from Go on Your Own Number (2026)

Your Go service already knows the order shipped, the invoice is overdue, the cron just fired. Getting that fact into WhatsApp is where it stalls, usually behind a Business Solution Provider contract nobody wants to sign for a few hundred notifications a day.

Here is the plain version. A whatsapp api go integration is one authenticated POST to a REST endpoint, sending from the WhatsApp number you already own, linked as a device over WhatsApp Web or a managed 24/7 gateway. It is not Meta's Cloud API, so there are no templates, no Business verification, and no per-message billing anywhere below. Consent stays yours, and nobody can promise a number will never be actioned.

What is a WhatsApp API integration in Go, really?

It is a single HTTP call from your own code to a number you already control. You POST to a REST endpoint, authenticate with a bearer key, and the message leaves from your linked WhatsApp account over WhatsApp Web or a hosted gateway. This is the own-number model, not Meta's Cloud API, so no templates and no per-message fees apply.

The distinction changes your whole integration. On the Cloud API path you register a number with Meta, get it approved, write message templates, submit them for review, and pay per conversation. On the own-number path you send from a phone your customers already have saved, in plain text, with a bt_live_ key and Go's standard net/http. The auth model and why your own number works this way is the subject of the own-number REST API guide, so this piece stays on the Go mechanics.

Hold onto one thing from the start. Sending from your own number is not a licence to blast. A loop firing unsolicited messages is the fastest way to get a number flagged under WhatsApp's Business Policy. Consent is yours to collect.

What do you need before your first send from Go?

Three things: a recent Go toolchain (1.21 or newer is a safe floor, though the packages here have been stable for years), a WhatsApp number already linked to your Blueticks account, and a bt_live_ API key. The recipient goes in the URL path as an E.164 number like +15551234567, not in the JSON body. That single detail causes most first-attempt failures.

The pre-flight, in order:

  1. Mint a key. Open dev.blueticks.co, sign in, create a key, copy it once. Keys are bearer tokens, so they live in an environment variable or a secret manager, never in a committed file.
  2. Link a number. Blueticks drives your number, not a Meta-provisioned one. If you already connected a phone in the app, you are done.
  3. Check nothing else. The first send uses only net/http, net/url, encoding/json, context and time from the standard library. Zero go get, zero go.mod churn.

Now the trap. The endpoint is POST /v1/scheduled-messages/{chatId} on https://api.blueticks.co, and {chatId} is a path segment. A recipient is an E.164 number with a leading +, and a bare + in a path is ambiguous, so it has to be percent-encoded as %2B. You can also target a chat id directly, such as 120363...@g.us for a group.

How do you send your first WhatsApp message from Go with only the standard library?

Escape the recipient with url.PathEscape, set an Authorization: Bearer header, and POST a flat JSON body of {"type":"text","text":"..."} with one shared *http.Client. Omit sendAt and it sends immediately. This is the send whatsapp message golang baseline, and it needs nothing beyond the standard library.

package main

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"time"
)

// ONE client for the whole process. Never http.Get / http.Post per send.
var client = &http.Client{Timeout: 20 * time.Second}

func main() {
	apiKey := os.Getenv("BLUETICKS_API_KEY") // bt_live_YOUR_KEY
	recipient := "+15551234567"

	// The recipient is a PATH segment. PathEscape (RFC 3986) encodes '+' to %2B.
	// Do NOT use url.QueryEscape here: it encodes a space as '+', a query rule.
	segment := url.PathEscape(recipient) // -> %2B15551234567
	endpoint := "https://api.blueticks.co/v1/scheduled-messages/" + segment

	body := []byte(`{"type":"text","text":"Your invoice #4471 is due tomorrow."}`)

	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Authorization", "Bearer "+apiKey)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Idempotency-Key", "invoice-4471-reminder")

	resp, err := client.Do(req)
	if err != nil {
		panic(err)
	}
	// Always drain and close, or the connection is not returned to the pool.
	defer func() {
		io.Copy(io.Discard, resp.Body)
		resp.Body.Close()
	}()

	out, _ := io.ReadAll(resp.Body)
	fmt.Printf("%d %s\n", resp.StatusCode, out)
}

Three things about that body. type is required and validation-only, so a bare {"text":"hi"} is rejected with a 400. Text tops out at 4,096 characters. A 2xx that returns a resource is wrapped in {"success":true,"data":{...}}, so the id lives at data.id, never at the top level. The language-agnostic first-send walkthrough shows the same call in shell, Python and Node if you want to sanity-check the request outside Go first.

Now the Go-specific footgun, and it is quiet. The reflex for encoding a URL value is url.QueryEscape. It implements the application/x-www-form-urlencoded rule and turns a space into a +. That is correct for a query string and wrong for a path segment. For a bare phone number you would never notice, because there is no space to mangle. The day a chat id carries a character those rules treat differently, QueryEscape ships a + where the server expected %20 and the send resolves to the wrong recipient. url.PathEscape applies RFC 3986 path escaping, which is the right rule for a path segment. Use it, not QueryEscape.

Why reuse one *http.Client and pool connections instead of http.Get per send?

Create one *http.Client with an explicit Timeout and reuse it for the process lifetime. http.Get and http.Post use http.DefaultClient, which has no timeout, so a hung TLS handshake blocks a goroutine forever. Newing up a client per send throws away the connection pool, so every send pays a fresh handshake.

The net/http docs are explicit that clients and transports are safe for concurrent use and should be reused rather than created as needed. A *http.Client wraps an *http.Transport that keeps a pool of idle keep-alive connections. Reuse the client and a busy sender rides warm connections instead of renegotiating TLS on every message. Under real load, tune the transport:

var client = &http.Client{
	Timeout: 20 * time.Second, // whole-request ceiling, belt-and-suspenders with ctx
	Transport: &http.Transport{
		MaxIdleConns:        100,
		MaxIdleConnsPerHost: 20, // default is 2; raise it for a single busy host
		IdleConnTimeout:     90 * time.Second,
	},
}

There is a correctness catch that has nothing to do with speed. If you do not drain and close the response body, the connection is not returned to the pool. A handler that reads the status code and returns, leaving the body open, leaks a connection every call until the pool starves and sends stall. The fix is two lines you run on every response, even the ones you do not care about:

io.Copy(io.Discard, resp.Body) // drain whatever is left
resp.Body.Close()              // return the connection to the pool

io.ReadAll already consumes the whole body, so after a full read the drain is a no-op. It earns its keep on the paths where you check resp.StatusCode and skip the body: without the drain, those are exactly the sends that silently leak.

How do you build the JSON body safely with encoding/json struct tags?

Model the request as a struct with json tags and json.Marshal it, so a quote or newline in the text can never break the payload. For the response, model waMessageKey as a pointer to a struct, because it is a nullable object. A pointer unmarshals a null to nil instead of panicking or silently zero-valuing a real string.

Hand-concatenating JSON works until a customer's name has a " in it. Marshal a struct instead:

import "encoding/json"

type sendRequest struct {
	Type   string `json:"type"`
	Text   string `json:"text"`
	SendAt string `json:"sendAt,omitempty"` // omitempty: drop it and the send is immediate
}

payload, err := json.Marshal(sendRequest{Type: "text", Text: userText})

The response is where the pointer matters. The trimmed shape on a scheduled send:

{ "success": true, "data": { "id": "6a1f...c2", "status": "pending", "waMessageKey": null } }

Model only the fields you use, with encoding/json:

// waMessageKey is an OBJECT, null until dispatch, never a bare string.
type waMessageKey struct {
	FromMe      bool   `json:"fromMe"`
	Remote      string `json:"remote,omitempty"`
	ID          string `json:"id,omitempty"`
	Serialized  string `json:"_serialized,omitempty"`
	Participant string `json:"participant,omitempty"`
}

type messageData struct {
	ID           string        `json:"id"`
	Status       string        `json:"status"`
	WaMessageKey *waMessageKey `json:"waMessageKey"` // pointer: null -> nil, not a panic
}

type envelope struct {
	Success bool         `json:"success"`
	Data    *messageData `json:"data"`
}

var env envelope
if err := json.Unmarshal(out, &env); err != nil {
	// handle
}
if env.Data == nil {
	return // 2xx with no payload: do not dereference
}
messageID := env.Data.ID // 24-char hex queue id, your handle
if env.Data.WaMessageKey != nil {
	// only now is it safe to read env.Data.WaMessageKey.Serialized
}

Typing waMessageKey as a string is the mistake that passes every test and breaks in production. On a scheduled send the engine has not dispatched yet, so waMessageKey comes back null and only fills in later as an object with fromMe, remote, id, _serialized and participant. Unmarshalling that object into a string field fails; type it as *waMessageKey and guard the nil. The lifecycle it moves through is pending, then confirmed, received, read, played, or failed. Use the 24-character hex id as your handle in the meantime.

How do you schedule a message for later and get sendAt right in Go?

Add a sendAt field holding an RFC 3339 timestamp with an explicit offset, built with time.Now().Add(...).UTC().Format(time.RFC3339). Always send UTC. The accepted window is roughly 10 seconds to 365 days ahead; anything outside is rejected at validation with a 400.

// UTC() gives a 'Z' offset, so the moment is unambiguous.
sendAt := time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339)
// -> 2026-09-21T14:30:00Z

payload, _ := json.Marshal(sendRequest{Type: "text", Text: "Reminder", SendAt: sendAt})

The Go-specific way this goes wrong is hand-rolling the layout. Go formats time against a reference date, Mon Jan 2 15:04:05 MST 2006, and a mistyped layout string emits a timestamp the server rejects without telling you why. Use the time.RFC3339 constant and let the standard library spell it. A second trap: compute sendAt, let it sit in a queue for nine seconds, and dispatch arrives already inside the floor with 400 sendAt must be at least 10 seconds in the future. Compute it at send time, or leave a minute of headroom. Recurring cadences and deeper timezone hardening are a separate mechanism, covered in the scheduling-in-production guide. Do not rebuild that here.

Ready to wire this into your service? Get a bt_live_ key and POST from the number you already own. No Meta Business verification, no message templates, no per-message fees. Point it at a live number and watch the first send land in seconds.

How do you make sends reliable: retries, context timeouts, idempotency, and a worker pool?

Never fan a batch out as one goroutine per message. Feed jobs into a buffered channel drained by a fixed pool of workers, give each send a context deadline, retry only 429 and 5xx, and carry a stable Idempotency-Key derived from the business object so a retry never double-sends. That last part is the whole game for a whatsapp golang client that survives real traffic.

Overhead flat-lay of an orderly desk with a closed laptop lid, a face-down phone, and a coffee mug, suggesting a steady background process

type job struct {
	Recipient      string
	Text           string
	IdempotencyKey string
}

func sendOne(ctx context.Context, j job) error {
	// Per-send deadline. Propagating the caller's ctx means a shutdown
	// aborts in-flight sends instead of wedging goroutines.
	ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
	defer cancel()

	payload, err := json.Marshal(sendRequest{Type: "text", Text: j.Text})
	if err != nil {
		return err
	}
	endpoint := "https://api.blueticks.co/v1/scheduled-messages/" + url.PathEscape(j.Recipient)

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("BLUETICKS_API_KEY"))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Idempotency-Key", j.IdempotencyKey) // SAME value on every retry

	resp, err := client.Do(req)
	if err != nil {
		return err // transport error: safe to retry with the identical key
	}
	defer func() {
		io.Copy(io.Discard, resp.Body)
		resp.Body.Close()
	}()

	switch {
	case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500:
		return fmt.Errorf("retryable status %d", resp.StatusCode)
	case resp.StatusCode >= 400:
		return fmt.Errorf("permanent status %d", resp.StatusCode) // a 400 fails forever; do not retry
	}
	return nil
}

func worker(ctx context.Context, jobs <-chan job, wg *sync.WaitGroup) {
	defer wg.Done()
	for j := range jobs {
		if err := sendOne(ctx, j); err != nil {
			log.Printf("send %s failed: %v", j.IdempotencyKey, err)
		}
	}
}

func run(ctx context.Context, orders []struct{ Phone, ID string }) {
	jobs := make(chan job, 100) // bounded queue: a burst can't blow up memory
	const workers = 4           // bounded fan-out, not one goroutine per message

	var wg sync.WaitGroup
	for i := 0; i < workers; i++ {
		wg.Add(1)
		go worker(ctx, jobs, &wg)
	}

	for _, o := range orders {
		jobs <- job{
			Recipient:      o.Phone,
			Text:           "Your order " + o.ID + " has shipped.",
			IdempotencyKey: "order-" + o.ID + "-shipped", // deterministic, NOT a fresh UUID
		}
	}
	close(jobs)
	wg.Wait()
}

Two rules keep the retry safe. First, retry only 429, 5xx and transport errors, never a 400; a 400 is a bad body and will fail identically forever. Second, the Idempotency-Key is a field on the job, computed once from the order, ticket or reminder (order-4471-shipped), so every attempt carries the identical value. Generate a fresh UUID per attempt and each retry becomes a new message. The key is capped at 64 characters and scoped per workspace: resend the same key with an identical body and the original response replays, returning the same message id, so detect a replay by the id, not the status. A different body under the same key returns 409 Conflict, which means your key derivation has a bug.

One production limit to design around: the free plan allows 5 requests per 6-hour window, shared across the REST and MCP surfaces, so a loop testing ten sends hits 429 on the sixth within a second. Any active subscription removes the ceiling. The bounded pool above is also your first line against getting a number flagged, since it caps how fast a batch drains. As one operator running nightly reminders put it, "the worker pool wasn't for throughput, it was so a 500-row batch didn't leave in one angry burst."

When should you use this vs the WhatsApp Business (Cloud) API instead?

Use the own-number path for transactional volume, real two-way conversations, and a number your customers already have saved. Use Meta's Cloud API and a Business Solution Provider when you need approved marketing templates at broadcast scale, an Official Business Account badge, or a multi-agent shared inbox. The trade is honest and runs both ways.

The Go-specific reason a hosted REST engine fits is maintenance. The maintained WhatsApp Web clients are Node projects. Running your own from a Go service means supervising a second runtime, a Node process and a browser profile, next to your Go binary forever: a session store, a reconnect supervisor, and a pager rotation for something that is not your product. A hosted /v1 endpoint removes that whole tier, which is the argument the no-Meta-verification guide makes in full. The same first send exists in Java, PHP and C#, and the bot-shaped variants live in the Python and Node.js guides.

One honest warning, because a worker pool makes it easy to get wrong. Point 500 jobs at your channel with the pool sized too wide and you fire sends as fast as the workers drain. That burst is the pattern most likely to get a number flagged, whatever sent it, and WhatsApp's rules on automated and bulk messaging apply either way. Keep the pool small, spread a batch over hours, and remember that consent is the sender's responsibility. Nobody can guarantee a number will never be actioned.

A hand reaching for a smartphone lying face-down on a warm wooden desk in evening light, the screen not visible

FAQ

Can I send a WhatsApp message from Go without any third-party packages?

Yes. The first-send example imports only net/http, net/url, encoding/json, context and time from the standard library. The net/http client does the request, encoding/json builds the body. Packages like an errgroup helper or a retry library add ergonomics for pooling and backoff, not capability.

Why does http.Get break a busy Go sender?

http.Get uses http.DefaultClient, which has no timeout, so one hung TLS handshake blocks a goroutine indefinitely. Create a single *http.Client with an explicit Timeout, reuse it for the process lifetime, and drain plus Close() every response body so connections return to the pool instead of leaking.

Should I use url.PathEscape or url.QueryEscape for the recipient?

url.PathEscape. The recipient sits in the URL path, and url.PathEscape applies RFC 3986 path escaping, encoding the leading + to %2B. url.QueryEscape uses the form/query rule and encodes a space as +, which is wrong for a path segment and can silently ship the message to the wrong recipient.

How do I stop a Go retry from sending the same WhatsApp message twice?

Send an Idempotency-Key header derived once from the business object (order-4471-shipped) and stored on the job so every retry reuses it. A replay returns the original response and the same 24-hex message id instead of sending again. A fresh UUID per attempt defeats the whole mechanism; the key must be stable across retries.

Will sending from Go get my WhatsApp number banned?

Nobody can guarantee it will not. You are messaging from your own number under WhatsApp's Business Policy and its rules on automated and bulk messaging, and a loop firing unsolicited sends is the pattern most likely to get a number flagged. Message people who asked to hear from you, spread batches over hours with a small worker pool, and stop when someone asks. Consent is the sender's responsibility.

Email

The Dispatch, every week.

One sharp WhatsApp growth tactic in your inbox each week. Joined by 238,000+ founders, marketers and support leads.

Free forever. No spam, unsubscribe in one click.