SMS API · C# / .NET 8

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.

Bez pakietów NuGet.NET 8 LTSKlucz pk_test_… od razu po rejestracji

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 statycznego HMACSHA256.HashData z .NET 6+.
  • 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.
  • Konfiguracja: klucz trzymaj w dotnet user-secrets lokalnie i w zmiennej środowiskowej Przypominamy__ApiKey na produkcji — nigdy w appsettings.json w repozytorium.
Nowy projektterminal
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.

Rekordy DTO i wyjątekPrzypominamy/Models.cs
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;
}
Typed clientPrzypominamy/PrzypominamyClient.cs
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.

Wysyłka SMSProgram.cs
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.

Wysyłka masowaBatch.cs
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.

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

HTTPerror.codeCo robić
400invalid_requestPopraw payload; pole w Param. Nie ponawiaj.
401unauthorizedSprawdź klucz w konfiguracji. Nie ponawiaj.
402insufficient_fundsDoładuj saldo (GET /v1/account). Nie ponawiaj automatycznie.
429rate_limitedOdczekaj RetryAfter i ponów.
502provider_errorRetry z rosnącym odstępem, ten sam Idempotency-Key.
Retry z Idempotency-KeyPrzypominamy/Retry.cs
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.

Endpoint webhooka (Minimal API)Webhook.cs
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 dostaje HttpClient z puli handlerów, więc nie wyczerpiesz gniazd ani nie utkniesz ze starym DNS-em. Nie twórz new HttpClient() per żądanie.
  • CancellationToken od końca do końca. Przekazuj token z żądania HTTP lub hosta do SendAsync; 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.
  • 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, RequestId — strukturalnie, przez ILogger.
  • Sprawdzaj saldo przez GET /v1/account w BackgroundService 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 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łóż konto

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