SMS API w C# / .NET 8: HttpClient, retry i webhooki
Wysyłka SMS z .NET to jedno żądanie POST /v1/messages przez HttpClient. Poniżej kompletny typed client PrzypominamyClient rejestrowany przez IHttpClientFactory, rekordy DTO serializowane w System.Text.Json, wyjątek z polem Code, ponawianie 429/5xx z tym samym Idempotency-Key i endpoint webhooka w Minimal API z HMACSHA256 i porównaniem w stałym czasie. Bez pakietów NuGet.
Instalacja i wymagania
Wszystko, czego potrzebujesz, jest w bibliotece klas .NET 8: klient HTTP, serializacja JSON i kryptografia. Żadnych pakietów NuGet.
- .NET 8 SDK (LTS). Kod używa
JsonNamingPolicy.SnakeCaseLower, dodanego w .NET 8, oraz statycznegoHMACSHA256.HashDataz .NET 6+. - 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. - Konfiguracja: klucz trzymaj w
dotnet user-secretslokalnie i w zmiennej środowiskowejPrzypominamy__ApiKeyna produkcji — nigdy wappsettings.jsonw repozytorium.
dotnet new web -n SmsDemo
cd SmsDemo
dotnet user-secrets init
dotnet user-secrets set "Przypominamy:ApiKey" "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. Cały klient mieści się w jednym pliku i działa tak samo w ASP.NET Core, Worker Service, Azure Functions czy aplikacji konsolowej.
Pierwszy SMS: typed HttpClient
Typed client dostaje w konstruktorze HttpClient skonfigurowany przez IHttpClientFactory: adres bazowy, nagłówek autoryzacji i timeout ustawiasz raz przy rejestracji, a klasa zajmuje się tylko serializacją i błędami.
using System.Text.Json.Serialization;
namespace Przypominamy;
// Obiekt wiadomości zwracany przez API (HTTP 201). Nazwy pól mapuje SnakeCaseLower.
public sealed record SmsMessage(
string Id,
string Status,
string To,
string From,
string Text,
int Parts,
int CostGrosze,
string? Reference,
DateTimeOffset? SendAt,
DateTimeOffset? DeliveredAt,
string? Error,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
// Ciało POST /v1/messages. To jest string albo tablica stringów (do 500 numerów).
public sealed record SendSmsRequest(
object To,
string Text,
string? From = null,
DateTimeOffset? SendAt = null,
string? Reference = null);
// Koperta błędu: { "error": { code, message, param }, "request_id" }
public sealed record ApiErrorBody(string Code, string Message, string? Param);
public sealed record ApiErrorEnvelope(ApiErrorBody Error, string? RequestId);
public sealed class PrzypominamyException : Exception
{
public int StatusCode { get; }
public string Code { get; }
public string? Param { get; }
public string? RequestId { get; }
public TimeSpan? RetryAfter { get; }
public PrzypominamyException(int statusCode, string code, string message,
string? param, string? requestId, TimeSpan? retryAfter)
: base($"przypominamy: {code} ({statusCode}): {message} [request_id={requestId}]")
{
StatusCode = statusCode;
Code = code;
Param = param;
RequestId = requestId;
RetryAfter = retryAfter;
}
// 429 i 5xx warto ponowić; 400/401/402 nie.
public bool IsRetryable => Code == "rate_limited" || StatusCode >= 500;
}
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Przypominamy;
public sealed class PrzypominamyClient
{
private static readonly JsonSerializerOptions Json = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private readonly HttpClient _http;
// HttpClient przychodzi z IHttpClientFactory: BaseAddress, Authorization i Timeout są już ustawione.
public PrzypominamyClient(HttpClient http) => _http = http;
public Task<SmsMessage> SendAsync(string to, string text, string? from = null,
string? reference = null, string? idempotencyKey = null, CancellationToken ct = default)
=> PostAsync<SmsMessage>("messages", new SendSmsRequest(to, text, from, null, reference), idempotencyKey, ct);
public Task<SmsMessage[]> SendManyAsync(IReadOnlyList<string> to, string text, string? from = null,
string? reference = null, string? idempotencyKey = null, CancellationToken ct = default)
{
if (to.Count > 500) throw new ArgumentException("Maksymalnie 500 odbiorców w jednym żądaniu.", nameof(to));
return PostAsync<SmsMessage[]>("messages", new SendSmsRequest(to, text, from, null, reference), idempotencyKey, ct);
}
public Task<SmsMessage> GetAsync(string id, CancellationToken ct = default)
=> SendCoreAsync<SmsMessage>(new HttpRequestMessage(HttpMethod.Get, $"messages/{id}"), ct);
private Task<T> PostAsync<T>(string path, object body, string? idempotencyKey, CancellationToken ct)
{
var request = new HttpRequestMessage(HttpMethod.Post, path)
{
Content = JsonContent.Create(body, options: Json),
};
if (idempotencyKey is not null)
request.Headers.Add("Idempotency-Key", idempotencyKey);
return SendCoreAsync<T>(request, ct);
}
private async Task<T> SendCoreAsync<T>(HttpRequestMessage request, CancellationToken ct)
{
using var response = await _http.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
throw await ToExceptionAsync(response, ct);
return await response.Content.ReadFromJsonAsync<T>(Json, ct)
?? throw new PrzypominamyException((int)response.StatusCode, "empty_response",
"Pusta odpowiedź API", null, null, null);
}
private static async Task<PrzypominamyException> ToExceptionAsync(HttpResponseMessage response, CancellationToken ct)
{
ApiErrorEnvelope? envelope = null;
try { envelope = await response.Content.ReadFromJsonAsync<ApiErrorEnvelope>(Json, ct); }
catch (JsonException) { /* body nie jest JSON-em, np. odpowiedź proxy */ }
var status = (int)response.StatusCode;
return new PrzypominamyException(
status,
envelope?.Error?.Code ?? $"http_{status}",
envelope?.Error?.Message ?? response.ReasonPhrase ?? "Błąd HTTP",
envelope?.Error?.Param,
envelope?.RequestId,
response.Headers.RetryAfter?.Delta);
}
}
Rejestracja w kontenerze DI i pierwszy SMS. Adres bazowy kończy się ukośnikiem, dzięki czemu względna ścieżka messages rozwiązuje się do /v1/messages; bez ukośnika HttpClient obciąłby segment v1.
using System.Net.Http.Headers;
using Przypominamy;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<PrzypominamyClient>(client =>
{
client.BaseAddress = new Uri("https://api.przypominamy.com/v1/"); // ukośnik na końcu jest istotny
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", builder.Configuration["Przypominamy:ApiKey"]);
client.Timeout = TimeSpan.FromSeconds(10);
});
var app = builder.Build();
app.MapPost("/wizyty/{id:int}/przypomnij", async (int id, PrzypominamyClient sms, CancellationToken ct) =>
{
var msg = await sms.SendAsync(
to: "+48600123456",
text: "Przypominamy o wizycie jutro o 14:00.",
reference: $"wizyta-{id}",
idempotencyKey: $"wizyta-{id}-przypomnienie",
ct: ct);
return Results.Ok(new { msg.Id, msg.Status, msg.Parts, msg.CostGrosze }); // msg_… queued 1 15
});
app.Run();
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 ISO 8601 planuje wysyłkę, from ustawia nadpis z listy zatwierdzonych w panelu. DateTimeOffset serializuje się do ISO 8601 bez dodatkowej konfiguracji.
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 — po jednym na numer.
var numery = new[] { "+48600123456", "+48600234567", "+48600345678" };
SmsMessage[] wyniki = await sms.SendManyAsync(
numery,
"Promocja -20% tylko do niedzieli. Kod: SMS20",
from: "SKLEP",
reference: "promo-2026-09",
idempotencyKey: "promo-2026-09-batch-1");
foreach (var m in wyniki)
Console.WriteLine($"{m.To} -> {m.Status} {m.Error}");
var koszt = wyniki.Sum(m => m.CostGrosze) / 100m;
Console.WriteLine($"Koszt: {koszt} zł");
Spersonalizowane treści (imię, godzina) idą osobnymi żądaniami. Przy większej liczbie ogranicz równoległość — Parallel.ForEachAsync z MaxDegreeOfParallelism albo SemaphoreSlim — tak, by nie przekroczyć 120 żądań na minutę, i nadawaj Idempotency-Key per wiadomość. Wtedy ponowienie po błędzie sieci nie zdubluje SMS-a.
var opcje = new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct };
await Parallel.ForEachAsync(wizyty, opcje, async (w, token) =>
{
try
{
await sms.SendAsync(
to: w.Telefon,
text: $"Cześć {w.Imie}, wizyta jutro o {w.Godzina}. Potwierdź odpisując TAK.",
reference: $"wizyta-{w.Id}",
idempotencyKey: $"wizyta-{w.Id}-przypomnienie",
ct: token);
}
catch (PrzypominamyException ex)
{
logger.LogWarning("Wizyta {Id}: {Code} {RequestId}", w.Id, ex.Code, ex.RequestId);
}
});
Obsługa błędów i retry
Każdy błąd HTTP wraca jako PrzypominamyException; decyzję podejmuj po Code, nie po tekście komunikatu. Ponawiaj tylko to, co ma sens ponawiać — zawsze 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 konfiguracji. Nie ponawiaj. |
| 402 | insufficient_funds | Doładuj saldo (GET /v1/account). Nie ponawiaj automatycznie. |
| 429 | rate_limited | Odczekaj RetryAfter i ponów. |
| 502 | provider_error | Retry z rosnącym odstępem, ten sam Idempotency-Key. |
namespace Przypominamy;
public static class PrzypominamyClientExtensions
{
// Ponawia 429 i 5xx oraz błędy sieci, zawsze z tym samym idempotencyKey,
// więc timeout po stronie klienta nie skończy się podwójnym SMS-em.
public static async Task<SmsMessage> SendWithRetryAsync(this PrzypominamyClient client,
string to, string text, string idempotencyKey, string? reference = null,
int attempts = 3, CancellationToken ct = default)
{
for (var i = 0; ; i++)
{
try
{
return await client.SendAsync(to, text, reference: reference, idempotencyKey: idempotencyKey, ct: ct);
}
catch (PrzypominamyException ex) when (ex.IsRetryable && i < attempts - 1)
{
var delay = ex.RetryAfter ?? TimeSpan.FromSeconds(Math.Pow(2, i)); // 1 s, 2 s, 4 s…
await Task.Delay(delay, ct);
}
catch (HttpRequestException) when (i < attempts - 1)
{
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, i)), ct);
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested && i < attempts - 1)
{
// timeout HttpClient (nie anulowanie przez wywołującego)
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, i)), ct);
}
}
}
}
Jeśli wolisz politykę deklaratywną, pakiet Microsoft.Extensions.Http.Resilience dodaje ją jedną linią: .AddStandardResilienceHandler() przy rejestracji klienta. Pamiętaj tylko, że handler ponawia całe żądanie razem z nagłówkami, więc Idempotency-Key musi być ustawiony przed pierwszą próbą — tak jak w kodzie powyżej. Po stronie wywołującego: catch (PrzypominamyException ex) i switch (ex.Code); loguj RequestId, 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.
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
// Rejestracja w Program.cs:
// app.MapPost("/webhooks/sms", SmsWebhook.HandleAsync);
public static class SmsWebhook
{
private static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
public sealed record WebhookMessage(string Id, string Status, string To, string? Reference, string? Error);
public sealed record WebhookData(WebhookMessage Message);
public sealed record WebhookEvent(string Id, string Type, DateTimeOffset CreatedAt, WebhookData Data);
public static async Task<IResult> HandleAsync(HttpRequest req, IConfiguration cfg, ILogger<WebhookEvent> logger)
{
// Surowe body — podpis liczony jest z bajtów, nie ze sparsowanego JSON-a.
using var reader = new StreamReader(req.Body, Encoding.UTF8);
var raw = await reader.ReadToEndAsync();
var secret = cfg["Przypominamy:WebhookSecret"] ?? throw new InvalidOperationException("Brak WebhookSecret");
if (!VerifySignature(req.Headers["X-Przypominamy-Signature"].ToString(), raw, secret, tolerance: 300))
return Results.StatusCode(StatusCodes.Status403Forbidden);
var ev = JsonSerializer.Deserialize<WebhookEvent>(raw, Json);
if (ev is null) return Results.BadRequest();
// Odpowiedz 200 od razu; ciężką pracę oddaj do kolejki / IHostedService.
logger.LogInformation("{Type} {Id} -> {Status} (ref={Reference})",
ev.Type, ev.Data.Message.Id, ev.Data.Message.Status, ev.Data.Message.Reference);
return Results.Ok();
}
public static bool VerifySignature(string header, string rawBody, string secret, int tolerance)
{
string? t = null, v1 = null;
foreach (var part in header.Split(','))
{
var kv = part.Trim().Split('=', 2);
if (kv.Length != 2) continue;
if (kv[0] == "t") t = kv[1];
else if (kv[0] == "v1") v1 = kv[1];
}
if (t is null || v1 is null || !long.TryParse(t, out var ts)) return false;
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(now - ts) > tolerance) return false;
byte[] expected = HMACSHA256.HashData(
Encoding.UTF8.GetBytes(secret),
Encoding.UTF8.GetBytes($"{t}.{rawBody}"));
byte[] received;
try { received = Convert.FromHexString(v1); }
catch (FormatException) { return false; }
return CryptographicOperations.FixedTimeEquals(received, expected); // porównanie w stałym czasie
}
}
Trzy rzeczy, które najczęściej psują weryfikację: liczenie HMAC na obiekcie sparsowanym i ponownie zserializowanym (zawsze surowe body), porównanie == albo SequenceEqual zamiast CryptographicOperations.FixedTimeEquals oraz brak tolerancji czasu. Jeśli endpoint nie odpowie 2xx, zdarzenie przyjdzie ponownie, więc obsłuż duplikaty po id zdarzenia. W ASP.NET Core nie włączaj na tej trasie walidacji antyforgery — to żądanie serwer-serwer.
Najlepsze praktyki
- Zawsze przez
IHttpClientFactory. Typed client dostajeHttpClientz puli handlerów, więc nie wyczerpiesz gniazd ani nie utkniesz ze starym DNS-em. Nie twórznew HttpClient()per żądanie. CancellationTokenod końca do końca. Przekazuj token z żądania HTTP lub hosta doSendAsync; przy zamykaniu aplikacji batch przerwie się czysto.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,RequestId— strukturalnie, przezILogger. - Sprawdzaj saldo przez
GET /v1/accountwBackgroundServicei 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 C#
Czy do wysyłki SMS w C# potrzebuję pakietu NuGet?
Nie. HttpClient, System.Text.Json i System.Security.Cryptography są w bibliotece klas .NET 8. Jeden POST na /v1/messages z nagłówkiem Authorization: Bearer wystarczy; kod klienta z tej strony ma ok. 80 linii bez zależności.
Jak obsłużyć limit 120 żądań na minutę w .NET?
Przy HTTP 429 API zwraca error.code rate_limited i nagłówek Retry-After. HttpClient wystawia go jako response.Headers.RetryAfter.Delta — odczekaj tyle przez Task.Delay i ponów z tym samym Idempotency-Key. Tę samą treść do wielu numerów wysyłaj tablicą to (do 500 numerów) w jednym żądaniu, a spersonalizowane wiadomości przez Parallel.ForEachAsync z ograniczonym MaxDegreeOfParallelism.
Jak zweryfikować podpis webhooka w ASP.NET Core?
Odczytaj surowe body przez StreamReader z req.Body, wyciągnij t i v1 z nagłówka X-Przypominamy-Signature, policz HMACSHA256.HashData z kluczem webhook_secret nad ciągiem "<t>.<body>" i porównaj przez CryptographicOperations.FixedTimeEquals. Odrzuć, gdy t różni się od DateTimeOffset.UtcNow.ToUnixTimeSeconds() 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 — również gdy retry robi za Ciebie AddStandardResilienceHandler.
Ile kosztuje wysyłka SMS przez API z .NET?
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 C# 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 · Ruby · Rust · Kotlin · Node.js, Python, PHP. Pytania: [email protected], +48 533 991 881.