SMS API · Go 1.22+

SMS API w Go: net/http, retry i webhooki

Wysyłka SMS z Go to jedno żądanie POST /v1/messages z biblioteki standardowej. Poniżej kompletny klient jako struct z metodą Send, obsługa błędów po error.code, ponawianie 429/5xx z nagłówkiem Idempotency-Key i handler webhooka z weryfikacją HMAC w crypto/hmac. Bez zewnętrznych modułów.

Zero zależnościGo 1.22+Klucz pk_test_… od razu po rejestracji

Instalacja i wymagania

Nie ma czego instalować. Cały kod z tej strony korzysta wyłącznie z biblioteki standardowej Go, więc wystarczy moduł i klucz API.

  • Go 1.22 lub nowszy — używamy wzorców tras mux.HandleFunc("POST /webhooks/sms", …) dodanych w 1.22.
  • Klucz API pk_live_… lub testowy pk_test_… z panelu app.przypominamy.com. Konto testowe ma od razu 25 SMS-ów gratis na 2 własne numery.
  • Zmienna środowiskowa PRZYPOMINAMY_API_KEY — klucz nigdy nie trafia do repozytorium.
Nowy modułterminal
go mod init example.com/sms
export PRZYPOMINAMY_API_KEY=pk_test_...

API to zwykły REST z JSON-em: nagłówek Authorization: Bearer, odpowiedź 201 z obiektem wiadomości, błędy w polu error z kodem, komunikatem i nazwą parametru. Wszystko, co robi oficjalne SDK dla Node.js czy Pythona, w Go mieści się w jednym pliku.

Pierwszy SMS: klient jako struct

Klient trzyma klucz, bazowy URL i *http.Client z timeoutem. Metoda Send serializuje żądanie, wysyła je i mapuje odpowiedź na struct Message.

Klient SMSsms/client.go
package sms

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

const BaseURL = "https://api.przypominamy.com/v1"

// Message to obiekt wiadomości zwracany przez API (HTTP 201).
type Message struct {
	ID          string  `json:"id"`
	Status      string  `json:"status"`
	To          string  `json:"to"`
	From        string  `json:"from"`
	Text        string  `json:"text"`
	Parts       int     `json:"parts"`
	CostGrosze  int     `json:"cost_grosze"`
	Reference   *string `json:"reference"`
	SendAt      *string `json:"send_at"`
	DeliveredAt *string `json:"delivered_at"`
	Error       *string `json:"error"`
	CreatedAt   string  `json:"created_at"`
	UpdatedAt   string  `json:"updated_at"`
}

// SendRequest to ciało POST /v1/messages. To może być string lub []string.
type SendRequest struct {
	To        any    `json:"to"`
	Text      string `json:"text"`
	From      string `json:"from,omitempty"`
	SendAt    string `json:"send_at,omitempty"`
	Reference string `json:"reference,omitempty"`
}

// APIError odwzorowuje { "error": { code, message, param }, "request_id" }.
type APIError struct {
	Status     int
	Code       string
	Message    string
	Param      string
	RequestID  string
	RetryAfter time.Duration
}

func (e *APIError) Error() string {
	return fmt.Sprintf("przypominamy: %s (%d): %s [request_id=%s]", e.Code, e.Status, e.Message, e.RequestID)
}

type Client struct {
	apiKey string
	base   string
	http   *http.Client
}

func New(apiKey string) *Client {
	return &Client{apiKey: apiKey, base: BaseURL, http: &http.Client{Timeout: 10 * time.Second}}
}

// Send wysyła SMS. idempotencyKey może być pusty.
func (c *Client) Send(ctx context.Context, req SendRequest, idempotencyKey string) (*Message, error) {
	var msg Message
	if err := c.do(ctx, http.MethodPost, "/messages", req, idempotencyKey, &msg); err != nil {
		return nil, err
	}
	return &msg, nil
}

func (c *Client) do(ctx context.Context, method, path string, body any, idem string, out any) error {
	var buf bytes.Buffer
	if body != nil {
		if err := json.NewEncoder(&buf).Encode(body); err != nil {
			return err
		}
	}
	r, err := http.NewRequestWithContext(ctx, method, c.base+path, &buf)
	if err != nil {
		return err
	}
	r.Header.Set("Authorization", "Bearer "+c.apiKey)
	r.Header.Set("Content-Type", "application/json")
	r.Header.Set("Accept", "application/json")
	if idem != "" {
		r.Header.Set("Idempotency-Key", idem)
	}
	resp, err := c.http.Do(r)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
	if err != nil {
		return err
	}
	if resp.StatusCode >= 400 {
		return parseError(resp, data)
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(data, out)
}

func parseError(resp *http.Response, data []byte) error {
	var env struct {
		Error struct {
			Code    string `json:"code"`
			Message string `json:"message"`
			Param   string `json:"param"`
		} `json:"error"`
		RequestID string `json:"request_id"`
	}
	_ = json.Unmarshal(data, &env)
	e := &APIError{Status: resp.StatusCode, Code: env.Error.Code, Message: env.Error.Message,
		Param: env.Error.Param, RequestID: env.RequestID}
	if e.Code == "" {
		e.Code = "http_" + fmt.Sprint(resp.StatusCode)
	}
	if ra := resp.Header.Get("Retry-After"); ra != "" {
		var secs int
		if _, err := fmt.Sscanf(ra, "%d", &secs); err == nil {
			e.RetryAfter = time.Duration(secs) * time.Second
		}
	}
	return e
}

I użycie w programie głównym: jeden SMS z przypomnieniem, identyfikator wizyty jako reference.

Wysyłka SMSmain.go
package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"time"

	"example.com/sms/sms"
)

func main() {
	client := sms.New(os.Getenv("PRZYPOMINAMY_API_KEY"))
	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()

	msg, err := client.Send(ctx, sms.SendRequest{
		To:        "+48600123456",
		Text:      "Przypominamy o wizycie jutro o 14:00.",
		Reference: "wizyta-4521",
	}, "wizyta-4521-przypomnienie")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(msg.ID, msg.Status, msg.Parts, msg.CostGrosze) // msg_… queued 1 15
}

Odpowiedź 201 zawiera parts i cost_grosze: SMS bez polskich znaków to 160 znaków na część (153 przy sklejaniu, GSM-7), z polskimi znakami 70 (67) w UCS-2. Pole send_at w formacie ISO 8601 planuje wysyłkę, from ustawia nadpis z listy zatwierdzonych w panelu.

Wielu odbiorców

Ta sama treść do wielu numerów to jedno żądanie: pole to przyjmuje tablicę do 500 numerów. Liczy się jako jedno żądanie w limicie 120/min, a w odpowiedzi dostajesz tablicę obiektów wiadomości.

Wysyłka masowabatch.go
// SendMany wysyła jedną treść do wielu numerów (do 500 w żądaniu).
func (c *Client) SendMany(ctx context.Context, to []string, text, from, idem string) ([]Message, error) {
	if len(to) > 500 {
		return nil, fmt.Errorf("przypominamy: max 500 odbiorców, podano %d", len(to))
	}
	var msgs []Message
	req := SendRequest{To: to, Text: text, From: from}
	if err := c.do(ctx, http.MethodPost, "/messages", req, idem, &msgs); err != nil {
		return nil, err
	}
	return msgs, nil
}

// Użycie:
// msgs, err := client.SendMany(ctx, numery, "Promocja -20% do niedzieli. Kod: SMS20", "SKLEP", "promo-2026-09")
// for _, m := range msgs { fmt.Println(m.To, m.Status) }

Spersonalizowane treści (imię, godzina) idą osobnymi żądaniami. Przy większej liczbie ogranicz równoległość semaforem z kanału, tak by nie przekroczyć 120 żądań na minutę, i nadawaj Idempotency-Key per wiadomość — wtedy ponowienie po błędzie sieci nie zdubluje SMS-a.

Ograniczona równoległośćpersonalized.go
sem := make(chan struct{}, 8) // max 8 równoległych żądań
var wg sync.WaitGroup
for _, w := range wizyty {
	wg.Add(1)
	go func(w Wizyta) {
		defer wg.Done()
		sem <- struct{}{}
		defer func() { <-sem }()
		_, err := client.Send(ctx, sms.SendRequest{
			To:        w.Telefon,
			Text:      fmt.Sprintf("Cześć %s, wizyta jutro o %s.", w.Imie, w.Godzina),
			Reference: fmt.Sprintf("wizyta-%d", w.ID),
		}, fmt.Sprintf("wizyta-%d-przypomnienie", w.ID))
		if err != nil {
			log.Printf("wizyta %d: %v", w.ID, err)
		}
	}(w)
}
wg.Wait()

Obsługa błędów i retry

Każdy błąd wraca jako *APIError; decyzję podejmuj po Code, nie po tekście komunikatu. Ponawiaj tylko to, co ma sens ponawiać — z tym samym Idempotency-Key.

HTTPerror.codeCo robić
400invalid_requestPopraw payload; pole w param. Nie ponawiaj.
401unauthorizedSprawdź klucz w zmiennej środowiskowej. Nie ponawiaj.
402insufficient_fundsDoładuj saldo (GET /v1/account). Nie ponawiaj automatycznie.
429rate_limitedOdczekaj Retry-After sekund i ponów.
502provider_errorRetry z rosnącym odstępem, ten sam Idempotency-Key.
Retry z Idempotency-Keysms/retry.go
package sms

import (
	"context"
	"errors"
	"time"
)

// SendWithRetry ponawia 429 i 5xx (max attempts prób), zawsze z tym samym idempotencyKey,
// więc w razie timeoutu po stronie sieci SMS nie zostanie wysłany dwa razy.
func (c *Client) SendWithRetry(ctx context.Context, req SendRequest, idempotencyKey string, attempts int) (*Message, error) {
	var lastErr error
	for i := 0; i < attempts; i++ {
		msg, err := c.Send(ctx, req, idempotencyKey)
		if err == nil {
			return msg, nil
		}
		lastErr = err
		var apiErr *APIError
		if !errors.As(err, &apiErr) {
			// błąd sieci / timeout: ponów z backoffem
			if !sleep(ctx, backoff(i)) {
				return nil, ctx.Err()
			}
			continue
		}
		switch {
		case apiErr.Code == "rate_limited":
			wait := apiErr.RetryAfter
			if wait == 0 {
				wait = backoff(i)
			}
			if !sleep(ctx, wait) {
				return nil, ctx.Err()
			}
		case apiErr.Status >= 500:
			if !sleep(ctx, backoff(i)) {
				return nil, ctx.Err()
			}
		default:
			return nil, err // invalid_request, unauthorized, insufficient_funds: nie ponawiaj
		}
	}
	return nil, lastErr
}

func backoff(attempt int) time.Duration {
	return time.Duration(1<<attempt) * time.Second // 1s, 2s, 4s…
}

func sleep(ctx context.Context, d time.Duration) bool {
	select {
	case <-time.After(d):
		return true
	case <-ctx.Done():
		return false
	}
}

Po stronie wywołującego: errors.As(err, &apiErr) i przełącznik po apiErr.Code. Loguj RequestID — to identyfikator, po którym support znajdzie żądanie.

Webhook: weryfikacja podpisu

Adres ustawiasz przez PUT /v1/account/webhook, w odpowiedzi dostajesz webhook_secret. Każde zdarzenie message.sent, message.delivered, message.undelivered, message.failed i message.expired przychodzi jako POST z JSON-em { id, type, created_at, data: { message } } i nagłówkiem X-Przypominamy-Signature: t=<unix>,v1=<hex>, gdzie v1 = HMAC-SHA256(secret, "<t>.<body>"). Odrzucaj, gdy podpis się nie zgadza lub t różni się od zegara o więcej niż 300 s.

Handler webhookawebhook.go
package main

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

type Event struct {
	ID        string `json:"id"`
	Type      string `json:"type"`
	CreatedAt string `json:"created_at"`
	Data      struct {
		Message struct {
			ID        string  `json:"id"`
			Status    string  `json:"status"`
			To        string  `json:"to"`
			Reference *string `json:"reference"`
			Error     *string `json:"error"`
		} `json:"message"`
	} `json:"data"`
}

// verifySignature sprawdza nagłówek t=<unix>,v1=<hex> względem surowego body.
func verifySignature(header string, body []byte, secret string, tolerance time.Duration) bool {
	var ts, sig string
	for _, part := range strings.Split(header, ",") {
		k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
		if !ok {
			continue
		}
		switch k {
		case "t":
			ts = v
		case "v1":
			sig = v
		}
	}
	if ts == "" || sig == "" {
		return false
	}
	t, err := strconv.ParseInt(ts, 10, 64)
	if err != nil {
		return false
	}
	if d := time.Since(time.Unix(t, 0)); d > tolerance || d < -tolerance {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(ts + "."))
	mac.Write(body)
	expected := mac.Sum(nil)
	got, err := hex.DecodeString(sig)
	if err != nil {
		return false
	}
	return hmac.Equal(got, expected) // porównanie w stałym czasie
}

func main() {
	secret := os.Getenv("PRZYPOMINAMY_WEBHOOK_SECRET")
	mux := http.NewServeMux()
	mux.HandleFunc("POST /webhooks/sms", func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
		if err != nil {
			http.Error(w, "bad body", http.StatusBadRequest)
			return
		}
		if !verifySignature(r.Header.Get("X-Przypominamy-Signature"), body, secret, 5*time.Minute) {
			http.Error(w, "invalid signature", http.StatusForbidden)
			return
		}
		var ev Event
		if err := json.Unmarshal(body, &ev); err != nil {
			http.Error(w, "bad json", http.StatusBadRequest)
			return
		}
		// Odpowiedz 200 od razu, ciężką pracę zrób asynchronicznie.
		go func(ev Event) {
			log.Printf("%s %s -> %s (ref=%v)", ev.Type, ev.Data.Message.ID, ev.Data.Message.Status, ev.Data.Message.Reference)
			// db.UpdateStatus(ev.Data.Message.ID, ev.Data.Message.Status)
		}(ev)
		w.WriteHeader(http.StatusOK)
	})
	log.Fatal(http.ListenAndServe(":8080", mux))
}

Trzy rzeczy, które najczęściej psują weryfikację: liczenie HMAC na sparsowanym i ponownie zserializowanym JSON-ie (zawsze używaj surowych bajtów), porównanie == zamiast hmac.Equal oraz brak tolerancji czasu. Webhooki mogą przyjść ponownie, jeśli endpoint nie odpowie 2xx, więc obsłuż duplikaty po id zdarzenia.

Najlepsze praktyki

  • context.Context z timeoutem w każdym wywołaniu — klient ma 10 s na HTTP, ale kontekst pozwala anulować cały batch, gdy proces się zamyka.
  • Jeden *http.Client na proces. Reużywa połączenia TLS. Nie twórz klienta per żądanie i nie używaj http.DefaultClient bez timeoutu.
  • Idempotency-Key = identyfikator biznesowy (np. order-1234-confirm). Retry po timeoucie zwróci pierwotną odpowiedź zamiast wysłać drugi SMS.
  • reference do korelacji. Wraca w webhookach i w GET /v1/messages, więc łączysz status z rekordem w swojej bazie bez osobnej tabeli mapującej.
  • Nie loguj treści ani numerów (RODO). Loguj id, status, request_id.
  • Sprawdzaj saldo przez GET /v1/account w cronie i alarmuj poniżej progu — 402 insufficient_funds w środku kampanii to najgorszy moment na doładowanie.
  • Testuj na koncie testowym. Klucz pk_test_… wysyła prawdziwe SMS-y na 2 zweryfikowane numery, więc przetestujesz kodowanie polskich znaków i webhooki bez kosztów.

Częste pytania: SMS API i Go

Czy do wysyłki SMS w Go potrzebuję zewnętrznej biblioteki?

Nie. net/http i encoding/json z biblioteki standardowej wystarczą: jeden POST na /v1/messages z nagłówkiem Authorization: Bearer. Kod klienta z tej strony ma ok. 100 linii i nie ma żadnych zależności poza Go 1.22+.

Jak obsłużyć limit 120 żądań na minutę w Go?

Przy HTTP 429 API zwraca error.code rate_limited i nagłówek Retry-After w sekundach. Odczekaj tyle i ponów z tym samym Idempotency-Key. Przy wysyłce tej samej treści do wielu numerów użyj tablicy to (do 500 numerów) — to jedno żądanie. Spersonalizowane wiadomości wysyłaj z semaforem ograniczającym równoległość.

Jak zweryfikować podpis webhooka w Go?

Odczytaj surowe body przez io.ReadAll, wyciągnij t i v1 z nagłówka X-Przypominamy-Signature, policz HMAC-SHA256 z kluczem webhook_secret nad ciągiem "<t>.<body>" i porównaj przez hmac.Equal. Odrzuć, gdy t różni się od czasu serwera o więcej niż 300 sekund.

Czy Idempotency-Key jest wymagany?

Nie, jest opcjonalny, ale przy retry jest jedyną gwarancją, że SMS nie pójdzie dwa razy. Ustaw go na identyfikator biznesowy wiadomości, np. "order-1234-confirm", i używaj tej samej wartości przy każdej próbie.

Ile kosztuje wysyłka SMS przez API z Go?

Od 0,10 zł za część SMS (stawkę ustala kwota doładowania: 0,15 zł przy 50 zł, 0,10 zł od 500 zł, i zostaje na stałe), bez abonamentu i opłat za API. Wiadomość bez polskich znaków mieści 160 znaków w jednej części (153 przy wieloczęściowej), z polskimi znakami 70 (67). Liczbę części i koszt zwraca odpowiedź w polach parts i cost_grosze. Konto testowe ma 25 SMS-ów gratis. Szczegóły w cenniku.

Pierwszy SMS z Go w kwadrans

Rejestracja daje od razu klucz testowy i 25 darmowych SMS-ów na dwa własne numery. Bez karty, bez abonamentu: płacisz od 0,10 zł za część SMS, doładowanie od 50 zł.

Załóż konto

Inne języki: Java · C# / .NET · Ruby · Rust · Kotlin · Node.js, Python, PHP. Pytania: [email protected], +48 533 991 881.

Przypominamy.com – logo platformy SMS, MMS i IVR
Asystent Przypominamy ● Online. Odpowiada od razu
Cześć! 👋 Jestem asystentem platformy Przypominamy.com.

Pomogę Ci dobrać plan, wyjaśnię jak działa API lub odpowiem na pytania o SMS, MMS i IVR. Jak mogę pomóc?
teraz