SMS API · Java 17+

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.

Jedna zależność: JacksonJava 17+Klucz pk_test_… od razu po rejestracji

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, HttpClient i 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 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 ani do application.properties w gicie.
Zależność Mavenpom.xml
<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.

Rekordy i wyjątekMessage.java
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);
    }
}
Wyjątek z kodem błęduPrzypominamyException.java
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; }
}
Klient SMSPrzypominamyClient.java
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.

Wysyłka SMSMain.java
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.

Wysyłka masowaBulk.java
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.

Ograniczona równoległośćPersonalized.java
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.

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-KeyRetry.java
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.

Weryfikacja podpisuWebhookSignature.java
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
    }
}
Kontroler webhookaSmsWebhookController.java
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 HttpClient na aplikację. W Springu zarejestruj PrzypominamyClient jako @Bean — reużywa połączenia TLS i pulę wątków. Nie twórz klienta per żądanie.
  • Timeouty na obu poziomach: connectTimeout na kliencie i timeout na żą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.
  • reference do korelacji. Wraca w webhookach i w GET /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 na toString() rekordu w logach — zawiera numer i treść.
  • Sekrety poza repozytorium: klucz API i webhook_secret przez zmienne środowiskowe lub Vault, w Springu jako ${PRZYPOMINAMY_API_KEY} w application.yml.
  • Sprawdzaj saldo przez GET /v1/account w zadaniu @Scheduled 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 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łóż konto

Inne języki: Go · 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