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.
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 testowypk_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.
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.
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.
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.
// 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.
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.
| HTTP | error.code | Co robić |
|---|---|---|
| 400 | invalid_request | Popraw payload; pole w param. Nie ponawiaj. |
| 401 | unauthorized | Sprawdź klucz w zmiennej środowiskowej. Nie ponawiaj. |
| 402 | insufficient_funds | Doładuj saldo (GET /v1/account). Nie ponawiaj automatycznie. |
| 429 | rate_limited | Odczekaj Retry-After sekund i ponów. |
| 502 | provider_error | Retry z rosnącym odstępem, ten sam Idempotency-Key. |
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.
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.Contextz 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.Clientna proces. Reużywa połączenia TLS. Nie twórz klienta per żądanie i nie używajhttp.DefaultClientbez timeoutu. Idempotency-Key= identyfikator biznesowy (np.order-1234-confirm). Retry po timeoucie zwróci pierwotną odpowiedź zamiast wysłać drugi SMS.referencedo korelacji. Wraca w webhookach i wGET /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/accountw cronie i alarmuj poniżej progu — 402insufficient_fundsw ś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łóż kontoInne języki: Java · C# / .NET · Ruby · Rust · Kotlin · Node.js, Python, PHP. Pytania: [email protected], +48 533 991 881.