SMS API w Javie: HttpClient, Jackson i Spring Boot
Wysyłka SMS z Javy to jedno żądanie POST /v1/messages przez wbudowany java.net.http.HttpClient. Poniżej kompletna klasa klienta z rekordami i Jacksonem, wyjątek PrzypominamyException z kodem błędu, ponawianie 429/5xx z nagłówkiem Idempotency-Key oraz kontroler webhooka w Spring Boot z weryfikacją HMAC przez javax.crypto.
Instalacja i wymagania
HTTP obsługuje biblioteka standardowa. Jedyna zewnętrzna zależność to Jackson do mapowania JSON-u na rekordy.
- Java 17 lub nowsza — rekordy,
HexFormat,HttpClienti wielolinijkowe stringi są dostępne od 17 (kod działa też na 21). - jackson-databind 2.17 — mapowanie snake_case z odpowiedzi na pola rekordu przez
PropertyNamingStrategies.SNAKE_CASE. - 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 ani doapplication.propertiesw gicie.
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>
<!-- tylko dla webhooka w Spring Boot 3: -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
W Gradle to odpowiednio implementation("com.fasterxml.jackson.core:jackson-databind:2.17.2"). API to zwykły REST: nagłówek Authorization: Bearer, odpowiedź 201 z obiektem wiadomości, błędy w polu error z kodem, komunikatem i nazwą parametru.
Pierwszy SMS: klasa klienta
Rekordy opisują żądanie i odpowiedź, ObjectMapper tłumaczy snake_case, a HttpClient jest tworzony raz i współdzielony. Metoda send rzuca PrzypominamyException dla każdego statusu 4xx/5xx.
package pl.example.sms;
import java.util.List;
/** Obiekt wiadomości zwracany przez API (HTTP 201). Pola null są dozwolone. */
public record Message(
String id,
String status,
String to,
String from,
String text,
int parts,
int costGrosze,
String reference,
String sendAt,
String deliveredAt,
String error,
String createdAt,
String updatedAt) {}
/** Ciało POST /v1/messages. Pole "to" to String albo List<String> (do 500 numerów). */
record SendRequest(Object to, String text, String from, String sendAt, String reference) {
static SendRequest single(String to, String text, String reference) {
return new SendRequest(to, text, null, null, reference);
}
static SendRequest bulk(List<String> to, String text, String from) {
return new SendRequest(to, text, from, null, null);
}
}
package pl.example.sms;
/** Odwzorowanie { "error": { code, message, param }, "request_id" } + nagłówka Retry-After. */
public class PrzypominamyException extends RuntimeException {
private final int status;
private final String code;
private final String param;
private final String requestId;
private final int retryAfterSeconds;
public PrzypominamyException(int status, String code, String message, String param,
String requestId, int retryAfterSeconds) {
super("przypominamy: " + code + " (" + status + "): " + message + " [request_id=" + requestId + "]");
this.status = status;
this.code = code;
this.param = param;
this.requestId = requestId;
this.retryAfterSeconds = retryAfterSeconds;
}
public int status() { return status; }
public String code() { return code; }
public String param() { return param; }
public String requestId() { return requestId; }
public int retryAfterSeconds() { return retryAfterSeconds; }
}
package pl.example.sms;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
public class PrzypominamyClient {
public static final String BASE_URL = "https://api.przypominamy.com/v1";
private final String apiKey;
private final HttpClient http;
private final ObjectMapper mapper;
public PrzypominamyClient(String apiKey) {
this.apiKey = apiKey;
this.http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
this.mapper = new ObjectMapper()
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
/** Jeden SMS. idempotencyKey może być null. */
public Message send(String to, String text, String reference, String idempotencyKey) {
String body = post("/messages", SendRequest.single(to, text, reference), idempotencyKey);
return read(body, Message.class);
}
/** Ta sama treść do wielu numerów (do 500). Odpowiedź to tablica wiadomości. */
public List<Message> sendMany(List<String> to, String text, String from, String idempotencyKey) {
if (to.size() > 500) {
throw new IllegalArgumentException("max 500 odbiorców, podano " + to.size());
}
String body = post("/messages", SendRequest.bulk(to, text, from), idempotencyKey);
try {
return mapper.readValue(body, new TypeReference<List<Message>>() {});
} catch (IOException e) {
throw new IllegalStateException("Niepoprawny JSON w odpowiedzi API", e);
}
}
private String post(String path, Object payload, String idempotencyKey) {
try {
String json = mapper.writeValueAsString(payload);
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE_URL + path))
.timeout(Duration.ofSeconds(10))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json));
if (idempotencyKey != null) {
b.header("Idempotency-Key", idempotencyKey);
}
HttpResponse<String> res = http.send(b.build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) {
throw toException(res);
}
return res.body();
} catch (IOException e) {
throw new PrzypominamyException(0, "network_error", e.getMessage(), null, null, 0);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new PrzypominamyException(0, "interrupted", e.getMessage(), null, null, 0);
}
}
private <T> T read(String body, Class<T> type) {
try {
return mapper.readValue(body, type);
} catch (IOException e) {
throw new IllegalStateException("Niepoprawny JSON w odpowiedzi API", e);
}
}
private PrzypominamyException toException(HttpResponse<String> res) {
String code = "http_" + res.statusCode(), message = res.body(), param = null, requestId = null;
try {
JsonNode root = mapper.readTree(res.body());
JsonNode err = root.path("error");
if (!err.isMissingNode()) {
code = err.path("code").asText(code);
message = err.path("message").asText(message);
param = err.path("param").isNull() ? null : err.path("param").asText(null);
}
requestId = root.path("request_id").asText(null);
} catch (IOException ignored) {
// ciało nie jest JSON-em, zostają wartości domyślne
}
int retryAfter = res.headers().firstValue("Retry-After").map(Integer::parseInt).orElse(0);
return new PrzypominamyException(res.statusCode(), code, message, param, requestId, retryAfter);
}
}
Użycie: jeden SMS z przypomnieniem, identyfikator wizyty jako reference i ten sam identyfikator jako Idempotency-Key.
package pl.example.sms;
public class Main {
public static void main(String[] args) {
var client = new PrzypominamyClient(System.getenv("PRZYPOMINAMY_API_KEY"));
Message msg = client.send(
"+48600123456",
"Przypominamy o wizycie jutro o 14:00.",
"wizyta-4521",
"wizyta-4521-przypomnienie");
System.out.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 w jednej części (153 przy sklejaniu, GSM-7), z polskimi znakami 70 (67) w UCS-2. Pole send_at w ISO 8601 planuje wysyłkę, from ustawia nadpis z listy zatwierdzonych w panelu. Jackson serializuje pola rekordu sendAt jako send_at dzięki strategii SNAKE_CASE, a NON_NULL pomija pola puste.
Wielu odbiorców
Ta sama treść do wielu numerów to jedno żądanie: pole to przyjmuje listę do 500 numerów. Liczy się jako jedno żądanie w limicie 120/min, a w odpowiedzi dostajesz tablicę obiektów wiadomości.
List<String> numery = List.of("+48600123456", "+48600234567", "+48600345678");
List<Message> wyniki = client.sendMany(numery,
"Promocja -20% tylko do niedzieli. Kod: SMS20", "SKLEP", "promo-2026-09");
for (Message m : wyniki) {
System.out.println(m.to() + " -> " + m.status() + (m.error() != null ? " (" + m.error() + ")" : ""));
}
Spersonalizowane treści (imię, godzina) idą osobnymi żądaniami. Przy większej liczbie ogranicz równoległość semaforem, tak by nie przekroczyć 120 żądań na minutę, i nadawaj Idempotency-Key per wiadomość — ponowienie po błędzie sieci nie zdubluje wtedy SMS-a. Na Javie 21 zamiast puli wątków możesz użyć Executors.newVirtualThreadPerTaskExecutor(), semafor zostaje ten sam.
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
record Wizyta(long id, String telefon, String imie, String godzina) {}
class Personalized {
static void wyslij(PrzypominamyClient client, List<Wizyta> wizyty) throws InterruptedException {
Semaphore limit = new Semaphore(8); // max 8 równoległych żądań
ExecutorService pool = Executors.newFixedThreadPool(8);
for (Wizyta w : wizyty) {
pool.submit(() -> {
try {
limit.acquire();
client.send(w.telefon(),
"Cześć %s, wizyta jutro o %s.".formatted(w.imie(), w.godzina()),
"wizyta-" + w.id(),
"wizyta-" + w.id() + "-przypomnienie");
} catch (PrzypominamyException e) {
System.err.println("wizyta " + w.id() + ": " + e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
limit.release();
}
});
}
pool.shutdown();
pool.awaitTermination(5, TimeUnit.MINUTES);
}
}
Obsługa błędów i retry
Każdy błąd wraca jako PrzypominamyException; 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 pl.example.sms;
import java.util.function.Supplier;
public final class Retry {
private Retry() {}
/**
* Ponawia 429, 5xx i błędy sieci (max attempts prób). Wywołanie musi używać tego samego
* Idempotency-Key przy każdej próbie, żeby SMS nie poszedł dwa razy.
*/
public static Message send(Supplier<Message> call, int attempts) {
PrzypominamyException last = null;
for (int i = 0; i < attempts; i++) {
try {
return call.get();
} catch (PrzypominamyException e) {
last = e;
long waitMs;
if ("rate_limited".equals(e.code())) {
waitMs = e.retryAfterSeconds() > 0 ? e.retryAfterSeconds() * 1000L : backoffMs(i);
} else if (e.status() >= 500 || e.status() == 0) {
waitMs = backoffMs(i); // provider_error, network_error
} else {
throw e; // invalid_request, unauthorized, insufficient_funds: nie ponawiaj
}
try {
Thread.sleep(waitMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw e;
}
}
}
throw last;
}
private static long backoffMs(int attempt) {
return (1L << attempt) * 1000L; // 1s, 2s, 4s…
}
}
// Użycie:
// Message msg = Retry.send(() -> client.send(to, text, "order-1234", "order-1234-confirm"), 3);
Po stronie wywołującego: catch (PrzypominamyException e) i switch (e.code()). Loguj requestId() — to identyfikator, po którym support znajdzie żądanie. Błędy sieci klient mapuje na kod network_error ze statusem 0, więc retry traktuje je jak 5xx.
Webhook: weryfikacja podpisu w Spring Boot
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 pl.example.sms.webhook;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
import java.util.HexFormat;
public final class WebhookSignature {
private static final Duration TOLERANCE = Duration.ofSeconds(300);
private WebhookSignature() {}
/** header: "t=<unix>,v1=<hex>"; rawBody: surowe body żądania, nie sparsowany JSON. */
public static boolean verify(String header, String rawBody, String secret) {
if (header == null || rawBody == null) return false;
String ts = null, sig = null;
for (String part : header.split(",")) {
String[] kv = part.trim().split("=", 2);
if (kv.length != 2) continue;
if (kv[0].equals("t")) ts = kv[1];
if (kv[0].equals("v1")) sig = kv[1];
}
if (ts == null || sig == null) return false;
long t;
try {
t = Long.parseLong(ts);
} catch (NumberFormatException e) {
return false;
}
Duration skew = Duration.between(Instant.ofEpochSecond(t), Instant.now()).abs();
if (skew.compareTo(TOLERANCE) > 0) return false;
byte[] expected;
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
expected = mac.doFinal((ts + "." + rawBody).getBytes(StandardCharsets.UTF_8));
} catch (GeneralSecurityException e) {
return false;
}
byte[] given;
try {
given = HexFormat.of().parseHex(sig);
} catch (IllegalArgumentException e) {
return false;
}
return MessageDigest.isEqual(given, expected); // porównanie w stałym czasie
}
}
package pl.example.sms.webhook;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.Async;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class SmsWebhookController {
private final String secret;
private final ObjectMapper mapper;
private final SmsStatusService statusService;
public SmsWebhookController(@Value("${przypominamy.webhook-secret}") String secret,
ObjectMapper mapper, SmsStatusService statusService) {
this.secret = secret;
this.mapper = mapper;
this.statusService = statusService;
}
@PostMapping(value = "/webhooks/sms", consumes = "application/json")
public ResponseEntity<Void> receive(
@RequestHeader(value = "X-Przypominamy-Signature", required = false) String signature,
@RequestBody String rawBody) throws Exception {
if (!WebhookSignature.verify(signature, rawBody, secret)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
JsonNode event = mapper.readTree(rawBody);
String type = event.path("type").asText(); // message.delivered itd.
JsonNode message = event.path("data").path("message");
statusService.update(type, message.path("id").asText(),
message.path("status").asText(), message.path("reference").asText(null));
return ResponseEntity.ok().build(); // 200 od razu, ciężka praca w @Async
}
}
@org.springframework.stereotype.Service
class SmsStatusService {
@Async
public void update(String type, String messageId, String status, String reference) {
// repository.updateStatus(messageId, status); obsłuż duplikaty po id zdarzenia
}
}
Kluczowe: @RequestBody String rawBody daje surowe bajty żądania, więc HMAC liczysz dokładnie na tym, co podpisał serwer. Jeśli Spring zdeserializuje body do obiektu i zserializujesz je z powrotem, podpis się nie zgodzi. @Async wymaga @EnableAsync na klasie konfiguracji. Webhooki mogą przyjść ponownie, gdy endpoint nie odpowie 2xx, więc obsłuż duplikaty po id zdarzenia.
Najlepsze praktyki
- Jeden
HttpClientna aplikację. W Springu zarejestrujPrzypominamyClientjako@Bean— reużywa połączenia TLS i pulę wątków. Nie twórz klienta per żądanie. - Timeouty na obu poziomach:
connectTimeoutna kliencie itimeoutna żądaniu. Bez nich wątek może wisieć w nieskończoność przy problemie sieci. 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 bazie bez osobnej tabeli mapującej.- Nie loguj treści ani numerów (RODO). Loguj
id,status,request_id. Uważaj natoString()rekordu w logach — zawiera numer i treść. - Sekrety poza repozytorium: klucz API i
webhook_secretprzez zmienne środowiskowe lub Vault, w Springu jako${PRZYPOMINAMY_API_KEY}wapplication.yml. - Sprawdzaj saldo przez
GET /v1/accountw zadaniu@Scheduledi 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 Java
Czy do wysyłki SMS w Javie potrzebuję zewnętrznego SDK?
Nie. java.net.http.HttpClient z JDK 11+ i Jackson do JSON-u wystarczą: jeden POST na /v1/messages z nagłówkiem Authorization: Bearer. Klasa klienta z tej strony ma ok. 100 linii. Możesz też użyć RestClient ze Spring 6 albo OkHttp — API jest zwykłym REST-em.
Jak obsłużyć limit 120 żądań na minutę w Javie?
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 listy to (do 500 numerów) — to jedno żądanie. Spersonalizowane wiadomości wysyłaj przez ExecutorService z semaforem ograniczającym równoległość.
Jak zweryfikować podpis webhooka w Spring Boot?
Przyjmij body jako @RequestBody String, wyciągnij t i v1 z nagłówka X-Przypominamy-Signature, policz HMAC-SHA256 (Mac.getInstance("HmacSHA256") z javax.crypto) z kluczem webhook_secret nad ciągiem "<t>.<body>" i porównaj przez MessageDigest.isEqual. 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 Javy?
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 Javy 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: Go · C# / .NET · Ruby · Rust · Kotlin · Node.js, Python, PHP. Pytania: [email protected], +48 533 991 881.