SMS API w Kotlinie: OkHttp, coroutines i Ktor
Wysyłka SMS z Kotlina to jedno żądanie POST /v1/messages. Poniżej klient na OkHttp i kotlinx.serialization z suspend fun send, wyjątek PrzypominamyException z kodem błędu, ponawianie 429/5xx z nagłówkiem Idempotency-Key, krótka alternatywa na Retrofit i webhook w Ktor z weryfikacją HMAC. Do tego najważniejsza zasada dla Androida: klucz API zostaje na backendzie.
Instalacja i wymagania
Trzy biblioteki, które pewnie już masz w projekcie: OkHttp do HTTP, kotlinx.serialization do JSON-u i coroutines do asynchroniczności.
- Kotlin 1.9 lub 2.x z pluginem
plugin.serialization— generuje serializery dla klas z@Serializable. - OkHttp 4.12 — ten sam klient działa na JVM i na Androidzie (minSdk 21).
- 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_KEYna serwerze — klucz nigdy nie trafia do repozytorium ani doBuildConfigapki.
plugins {
kotlin("jvm") version "2.0.20"
kotlin("plugin.serialization") version "2.0.20"
}
dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
// tylko dla webhooka w Ktor:
implementation("io.ktor:ktor-server-core:2.3.12")
implementation("io.ktor:ktor-server-netty:2.3.12")
}
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: suspend fun send
Klasy z @Serializable opisują żądanie i odpowiedź, @SerialName mapuje snake_case. Klient trzyma jeden OkHttpClient i wykonuje żądanie na Dispatchers.IO, więc send jest zwykłą funkcją zawieszaną.
package pl.example.sms
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/** Obiekt wiadomości zwracany przez API (HTTP 201). */
@Serializable
data class Message(
val id: String,
val status: String,
val to: String,
val from: String? = null,
val text: String,
val parts: Int,
@SerialName("cost_grosze") val costGrosze: Int,
val reference: String? = null,
@SerialName("send_at") val sendAt: String? = null,
@SerialName("delivered_at") val deliveredAt: String? = null,
val error: String? = null,
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String,
)
/** Ciało POST /v1/messages. "to" to string albo tablica do 500 numerów, stąd JsonElement. */
@Serializable
data class SendRequest(
val to: JsonElement,
val text: String,
val from: String? = null,
@SerialName("send_at") val sendAt: String? = null,
val reference: String? = null,
)
@Serializable
data class ApiErrorBody(val code: String, val message: String, val param: String? = null)
@Serializable
data class ApiErrorEnvelope(val error: ApiErrorBody, @SerialName("request_id") val requestId: String? = null)
/** Odwzorowanie { "error": { code, message, param }, "request_id" } + nagłówka Retry-After. */
class PrzypominamyException(
val status: Int,
val code: String,
message: String,
val param: String? = null,
val requestId: String? = null,
val retryAfterSeconds: Int = 0,
cause: Throwable? = null,
) : RuntimeException("przypominamy: $code ($status): $message [request_id=$requestId]", cause)
package pl.example.sms
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.concurrent.TimeUnit
class PrzypominamyClient(
private val apiKey: String,
private val baseUrl: String = "https://api.przypominamy.com/v1",
private val http: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build(),
) {
private val json = Json { ignoreUnknownKeys = true; explicitNulls = false }
private val jsonType = "application/json; charset=utf-8".toMediaType()
/** Jeden SMS. idempotencyKey może być null. */
suspend fun send(
to: String,
text: String,
reference: String? = null,
from: String? = null,
sendAt: String? = null,
idempotencyKey: String? = null,
): Message {
val body = SendRequest(JsonPrimitive(to), text, from, sendAt, reference)
val raw = post("/messages", json.encodeToString(SendRequest.serializer(), body), idempotencyKey)
return json.decodeFromString(Message.serializer(), raw)
}
/** Ta sama treść do wielu numerów (do 500). Odpowiedź to tablica wiadomości. */
suspend fun sendMany(
to: List<String>,
text: String,
from: String? = null,
idempotencyKey: String? = null,
): List<Message> {
require(to.size <= 500) { "max 500 odbiorców, podano ${to.size}" }
val arr = buildJsonArray { to.forEach { add(JsonPrimitive(it)) } }
val body = SendRequest(arr, text, from)
val raw = post("/messages", json.encodeToString(SendRequest.serializer(), body), idempotencyKey)
return json.decodeFromString(ListSerializer(Message.serializer()), raw)
}
private suspend fun post(path: String, payload: String, idempotencyKey: String?): String =
withContext(Dispatchers.IO) {
val request = Request.Builder()
.url(baseUrl + path)
.header("Authorization", "Bearer $apiKey")
.header("Accept", "application/json")
.apply { if (idempotencyKey != null) header("Idempotency-Key", idempotencyKey) }
.post(payload.toRequestBody(jsonType))
.build()
try {
http.newCall(request).execute().use { res ->
val text = res.body?.string().orEmpty()
if (!res.isSuccessful) throw toException(res.code, text, res.header("Retry-After"))
text
}
} catch (e: IOException) {
throw PrzypominamyException(0, "network_error", e.message ?: "I/O error", cause = e)
}
}
private fun toException(status: Int, body: String, retryAfter: String?): PrzypominamyException {
val env = runCatching { json.decodeFromString(ApiErrorEnvelope.serializer(), body) }.getOrNull()
return PrzypominamyException(
status = status,
code = env?.error?.code ?: "http_$status",
message = env?.error?.message ?: body,
param = env?.error?.param,
requestId = env?.requestId,
retryAfterSeconds = retryAfter?.toIntOrNull() ?: 0,
)
}
}
Użycie z runBlocking w programie konsolowym (na serwerze wywołasz send z dowolnego CoroutineScope): jeden SMS z przypomnieniem, identyfikator wizyty jako reference i ten sam identyfikator jako Idempotency-Key.
package pl.example.sms
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val client = PrzypominamyClient(System.getenv("PRZYPOMINAMY_API_KEY"))
val msg = client.send(
to = "+48600123456",
text = "Przypominamy o wizycie jutro o 14:00.",
reference = "wizyta-4521",
idempotencyKey = "wizyta-4521-przypomnienie",
)
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.
Android: ten kod uruchamiasz na serwerze, nie w apce. Klucz pk_live_… wkompilowany w APK da się wyciągnąć w kilka minut i użyć do wysyłki na Twój koszt. Apka mobilna woła własny endpoint backendu (np. POST /api/send-reminder), uwierzytelniony Twoim mechanizmem logowania, a backend wywołuje PrzypominamyClient.
Alternatywa: Retrofit
Jeśli backend w Kotlinie używa już Retrofita, wystarczy interfejs z jedną metodą i konwerter kotlinx.serialization (od Retrofita 2.11 wbudowany jako retrofit2:converter-kotlinx-serialization).
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.POST
interface SmsService {
@POST("v1/messages")
suspend fun send(
@Header("Authorization") auth: String,
@Header("Idempotency-Key") idempotencyKey: String?,
@Body body: SendRequest,
): Message
}
val api: SmsService = Retrofit.Builder()
.baseUrl("https://api.przypominamy.com/")
.addConverterFactory(Json { ignoreUnknownKeys = true; explicitNulls = false }
.asConverterFactory("application/json".toMediaType()))
.build()
.create(SmsService::class.java)
// val msg = api.send("Bearer $apiKey", "wizyta-4521-przypomnienie", SendRequest(JsonPrimitive("+48600123456"), "Cześć!"))
Retrofit przy 4xx/5xx rzuca HttpException; ciało błędu odczytasz z e.response()?.errorBody()?.string() i zdekodujesz jako ApiErrorEnvelope, żeby dostać error.code.
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.
val numery = listOf("+48600123456", "+48600234567", "+48600345678")
val wyniki = client.sendMany(numery, "Promocja -20% tylko do niedzieli. Kod: SMS20", from = "SKLEP", idempotencyKey = "promo-2026-09")
wyniki.forEach { m -> println("${m.to} -> ${m.status} ${m.error ?: ""}") }
Spersonalizowane treści (imię, godzina) idą osobnymi żądaniami. Przy większej liczbie ogranicz równoległość przez Semaphore z kotlinx.coroutines, tak by nie przekroczyć 120 żądań na minutę, i nadawaj Idempotency-Key per wiadomość — ponowienie po błędzie sieci nie zdubluje wtedy SMS-a.
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
data class Wizyta(val id: Long, val telefon: String, val imie: String, val godzina: String)
suspend fun wyslijPrzypomnienia(client: PrzypominamyClient, wizyty: List<Wizyta>) = coroutineScope {
val limit = Semaphore(8) // max 8 równoległych żądań
wizyty.map { w ->
async {
limit.withPermit {
runCatching {
client.send(
to = w.telefon,
text = "Cześć ${w.imie}, wizyta jutro o ${w.godzina}.",
reference = "wizyta-${w.id}",
idempotencyKey = "wizyta-${w.id}-przypomnienie",
)
}.onFailure { e -> System.err.println("wizyta ${w.id}: ${e.message}") }
}
}
}.awaitAll()
}
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 kotlinx.coroutines.delay
/**
* Ponawia 429, 5xx i błędy sieci (max attempts prób). Blok musi używać tego samego
* Idempotency-Key przy każdej próbie, żeby SMS nie poszedł dwa razy.
*/
suspend fun <T> withSmsRetry(attempts: Int = 3, block: suspend () -> T): T {
var last: PrzypominamyException? = null
repeat(attempts) { attempt ->
try {
return block()
} catch (e: PrzypominamyException) {
last = e
val waitMs = when {
e.code == "rate_limited" ->
if (e.retryAfterSeconds > 0) e.retryAfterSeconds * 1000L else backoffMs(attempt)
e.status >= 500 || e.status == 0 -> backoffMs(attempt) // provider_error, network_error
else -> throw e // invalid_request, unauthorized, insufficient_funds: nie ponawiaj
}
delay(waitMs)
}
}
throw last ?: IllegalStateException("attempts musi być > 0")
}
private fun backoffMs(attempt: Int): Long = (1L shl attempt) * 1000L // 1s, 2s, 4s…
// Użycie:
// val msg = withSmsRetry { client.send(to, text, reference = "order-1234", idempotencyKey = "order-1234-confirm") }
Po stronie wywołującego: catch (e: PrzypominamyException) i when (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 Ktor
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 io.ktor.http.HttpStatusCode
import io.ktor.server.application.call
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.request.receiveText
import io.ktor.server.response.respond
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.security.MessageDigest
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlin.math.abs
private const val TOLERANCE_SECONDS = 300L
/** header: "t=<unix>,v1=<hex>"; rawBody: surowe body żądania, nie sparsowany JSON. */
@OptIn(ExperimentalStdlibApi::class)
fun verifySignature(header: String?, rawBody: String, secret: String): Boolean {
if (header == null) return false
val parts = header.split(",").mapNotNull { p ->
val kv = p.trim().split("=", limit = 2)
if (kv.size == 2) kv[0] to kv[1] else null
}.toMap()
val ts = parts["t"] ?: return false
val sig = parts["v1"] ?: return false
val t = ts.toLongOrNull() ?: return false
if (abs(System.currentTimeMillis() / 1000 - t) > TOLERANCE_SECONDS) return false
val mac = Mac.getInstance("HmacSHA256").apply {
init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256"))
}
val expected = mac.doFinal("$ts.$rawBody".toByteArray(Charsets.UTF_8))
val given = runCatching { sig.hexToByteArray() }.getOrNull() ?: return false
return MessageDigest.isEqual(given, expected) // porównanie w stałym czasie
}
fun main() {
val secret = System.getenv("PRZYPOMINAMY_WEBHOOK_SECRET")
val worker = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val json = Json { ignoreUnknownKeys = true }
embeddedServer(Netty, port = 8080) {
routing {
post("/webhooks/sms") {
val raw = call.receiveText()
val sig = call.request.headers["X-Przypominamy-Signature"]
if (!verifySignature(sig, raw, secret)) {
call.respond(HttpStatusCode.Forbidden)
return@post
}
val event = json.parseToJsonElement(raw).jsonObject
val type = event["type"]?.jsonPrimitive?.content // message.delivered itd.
val message = event["data"]?.jsonObject?.get("message")?.jsonObject
val id = message?.get("id")?.jsonPrimitive?.content
val status = message?.get("status")?.jsonPrimitive?.content
worker.launch {
// repository.updateStatus(id, status); obsłuż duplikaty po id zdarzenia
println("$type $id -> $status")
}
call.respond(HttpStatusCode.OK) // 200 od razu, ciężka praca w tle
}
}
}.start(wait = true)
}
Kluczowe: call.receiveText() daje surowe body, więc HMAC liczysz dokładnie na tym, co podpisał serwer. Jeśli Ktor zdeserializuje body przez ContentNegotiation do klasy i zserializujesz je z powrotem, podpis się nie zgodzi. Dekodowanie heksa hexToByteArray jest w bibliotece standardowej od Kotlina 1.9 (jako eksperymentalne API, stąd @OptIn); Webhooki mogą przyjść ponownie, gdy endpoint nie odpowie 2xx, więc obsłuż duplikaty po id zdarzenia.
Najlepsze praktyki
Nigdy nie wkładaj klucza API do apki mobilnej. Wszystko, co jest w APK (BuildConfig, strings.xml, obfuskowany kod), da się odczytać. Wyciek klucza pk_live_… oznacza, że obca osoba wysyła SMS-y na Twoje saldo. Właściwa architektura: apka woła Twój backend (Ktor, Spring, dowolny), backend uwierzytelnia użytkownika własnym mechanizmem (token sesji, Firebase Auth), decyduje, czy SMS ma sens, i dopiero on wywołuje POST /v1/messages..
- Jeden
OkHttpClientna proces. Reużywa pulę połączeń i wątków. Nie twórz klienta per żądanie; w Ktor/Spring zarejestrujPrzypominamyClientjako singleton. Dispatchers.IOpod blokującymexecute(). Dzięki temusendnie blokuje wątków event-loopa Ktora ani głównego wątku.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()data class w logach — zawiera numer i treść. - Sprawdzaj saldo przez
GET /v1/accountw zadaniu cyklicznym 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 Kotlin
Czy mogę wysyłać SMS-y bezpośrednio z aplikacji Android?
Technicznie tak, ale nie rób tego: klucz API wkompilowany w APK da się wyciągnąć i użyć do wysyłki na Twój koszt. Apka powinna wołać Twój backend, uwierzytelniony własnym mechanizmem logowania, a backend wywołuje POST /v1/messages. Klient z tej strony (OkHttp + coroutines) działa na obu stronach, ale klucz zostaje na serwerze.
Czy do wysyłki SMS w Kotlinie potrzebuję SDK?
Nie. OkHttp i kotlinx.serialization wystarczą: jeden POST na /v1/messages z nagłówkiem Authorization: Bearer. Klasa klienta z tej strony ma ok. 80 linii.
Jak obsłużyć limit 120 żądań na minutę w Kotlinie?
Przy HTTP 429 API zwraca error.code rate_limited i nagłówek Retry-After w sekundach. Odczekaj tyle przez delay() 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 przez async z Semaphore z kotlinx.coroutines.
Jak zweryfikować podpis webhooka w Ktor?
Odczytaj body przez call.receiveText(), 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.
Ile kosztuje wysyłka SMS przez API z Kotlina?
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 Kotlina 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 · Java · C# / .NET · Ruby · Rust · Node.js, Python, PHP. Pytania: [email protected], +48 533 991 881.