Bramka SMS - dokumentacja API

Wysylka SMS przez proste API HTTP. Ten dokument opisuje wszystko, czego potrzebujesz, zeby zintegrowac swoja aplikacje z bramka.

https://sms.inonet.pl

1. Jak zaczac

Do korzystania z bramki potrzebujesz klucza API. Klucz wystawia administrator bramki i przekazuje go Tobie. Wyglada tak:

Przykladowy klucz API
smsb_PrZyKlAdOwYkLuCz_nIe_DzIaLa_ZaStAp_wLaSnYm
Klucz API to haslo

Klucz jest pokazywany tylko raz, w chwili wygenerowania - bramka przechowuje wylacznie jego skrot i nie potrafi go odtworzyc. Jesli go zgubisz, administrator musi wystawic nowy. Nigdy nie umieszczaj klucza w kodzie strony po stronie przegladarki ani w publicznym repozytorium - trzymaj go w zmiennej srodowiskowej albo w pliku konfiguracyjnym serwera.

Kazde zapytanie do API wysylasz z naglowkiem X-API-Key. Najszybszy test, ktory sprawdza, czy klucz dziala (nie wysyla zadnego SMS-a):

Test klucza
curl -s https://sms.inonet.pl/api/sms/history?limit=1 \
  -H "X-API-Key: smsb_TWOJ_KLUCZ_API"

Poprawna odpowiedz (nawet gdy nie wyslales jeszcze nic) wyglada tak:

200 OK
{"items": [], "total": 0, "limit": 1, "offset": 0}

Jesli zamiast tego dostajesz 401, klucz jest zly, odwolany albo wygasl. Jesli 403 - klucz jest poprawny, ale Twoje konto zostalo wylaczone.

2. Uwierzytelnianie

Wszystkie endpointy klienckie (/api/sms/*) uwierzytelnia sie wylacznie naglowkiem:

Naglowek
X-API-Key: smsb_TWOJ_KLUCZ_API
Numer nadawcy wynika z Twojego konta

To, z ktorej karty SIM wyjdzie SMS, ustala administrator bramki przy Twoim koncie. W zadaniu nie da sie wskazac routera ani numeru nadawcy - ewentualne dodatkowe pola w JSON-ie sa po prostu ignorowane. Jesli potrzebujesz zmiany numeru nadawcy, zglos to administratorowi.

3. Wyslanie SMS-a

POST https://sms.inonet.pl/api/sms/send

Endpoint dodaje wiadomosc do kolejki i natychmiast zwraca odpowiedz. Nie czeka na faktyczne nadanie SMS-a - tym zajmuje sie proces w tle, zwykle w ciagu kilku sekund. Odpowiedz 201 znaczy "przyjeto do kolejki", a nie "dostarczono".

Pola zadania

PoleTypWymaganeOpis
recipient string tak Numer odbiorcy. Format E.164 z prefiksem kraju, np. +48123456789. Spacje, myslniki, nawiasy, kropki i ukosniki sa usuwane automatycznie, wiec +48 123-456-789 jest rownowazne +48123456789. Pole przyjmuje tez nazwy zamienne: phone, number, to - dziala dokladnie tak samo.
message string tak Tresc wiadomosci, od 1 do 1600 znakow. Nie moze skladac sie z samych bialych znakow. Dluzsza tresc rozbija sie u operatora na kilka SMS-ow.
priority integer nie Od 1 do 9, domyslnie 5. 1 = najwyzszy priorytet, 9 = najnizszy. Wiadomosci o nizszej liczbie sa zabierane z kolejki wczesniej. Wartosc spoza zakresu to blad 422.
scheduled_at string (ISO 8601) nie Termin wysylki. Pominiecie pola (albo null) = wyslij natychmiast. Szczegoly nizej.

Pole scheduled_at - strefa czasowa i zakres

Przyklad zadania

Zadanie
curl -s -X POST https://sms.inonet.pl/api/sms/send \
  -H "X-API-Key: smsb_TWOJ_KLUCZ_API" \
  -H "Content-Type: application/json" \
  -d '{
    "recipient": "+48123456789",
    "message": "Przypominamy o wizycie jutro o 10:00.",
    "priority": 5
  }'

Przyklad odpowiedzi

201 Created
{
  "id": 140,
  "status": "pending",
  "recipient": "+48123456789",
  "priority": 5,
  "scheduled_at": "2026-07-25T10:46:45.271058+02:00",
  "created_at": "2026-07-25T10:46:45.271058+02:00"
}
Zapisz id

Pole id to jedyny sposob, zeby pozniej sprawdzic los tej konkretnej wiadomosci przez GET /api/sms/status/{id}. Zapisz je u siebie razem z rekordem, ktorego wiadomosc dotyczy.

Zadanie z terminem wysylki - odpowiedz pokazuje date juz ze strefa:

Wysylka zaplanowana
curl -s -X POST https://sms.inonet.pl/api/sms/send \
  -H "X-API-Key: smsb_TWOJ_KLUCZ_API" \
  -H "Content-Type: application/json" \
  -d '{
    "recipient": "+48123456789",
    "message": "Termin przegladu uplywa za tydzien.",
    "priority": 7,
    "scheduled_at": "2026-10-01T09:00:00"
  }'

# odpowiedz:
# {"id":141,"status":"pending","recipient":"+48123456789","priority":7,
#  "scheduled_at":"2026-10-01T09:00:00+02:00",
#  "created_at":"2026-07-25T10:47:07.746519+02:00"}

4. Sprawdzenie statusu

GET https://sms.inonet.pl/api/sms/status/{id}

Zwraca aktualny stan wiadomosci o podanym id (tym z odpowiedzi POST /api/sms/send). Widzisz wylacznie wlasne wiadomosci - cudze id daje 404, tak samo jak nieistniejace.

Zadanie
curl -s https://sms.inonet.pl/api/sms/status/140 \
  -H "X-API-Key: smsb_TWOJ_KLUCZ_API"
200 OK
{
  "id": 140,
  "recipient": "+48123456789",
  "message": "Przypominamy o wizycie jutro o 10:00.",
  "status": "pending",
  "priority": 5,
  "attempts": 0,
  "max_attempts": 3,
  "last_error": null,
  "scheduled_at": "2026-07-25T10:46:45.271058+02:00",
  "sent_at": null,
  "created_at": "2026-07-25T10:46:45.271058+02:00",
  "updated_at": "2026-07-25T10:46:45.271058+02:00",
  "router_id": 1,
  "router_name": "RUT241 glowny"
}

Statusy wiadomosci

StatusZnaczenieCo robisz
pending Wiadomosc czeka w kolejce. Jesli ma ustawione scheduled_at w przyszlosci, bedzie czekac az do tego terminu. Czekasz. Sprawdzisz pozniej.
processing Wiadomosc jest wlasnie obslugiwana - trwa proba nadania. Czekasz. To stan przejsciowy, trwa sekundy.
sent Stan koncowy. Bramka potwierdzila nadanie SMS-a. Pole sent_at zawiera moment nadania. Nic. Gotowe.
failed Stan koncowy. Wysylka nie powiodla sie i to jest pewne - SMS nie zostal nadany. Powod znajdziesz w last_error, a liczbe wykorzystanych prob w attempts / max_attempts. Mozesz bezpiecznie zlecic wysylke ponownie.
unknown Stan koncowy, wynik NIEROZSTRZYGNIETY. Patrz osobna sekcja nizej - to najwazniejsza rzecz w tej dokumentacji. Nie ponawiaj automatycznie.
Ponawianie wewnetrzne

Pola attempts i max_attempts opisuja wewnetrzne proby bramki, nie Twoje. Bramka sama powtarza wysylke po bledzie rozstrzygnietym, do max_attempts razy. Dopiero po ich wyczerpaniu wiadomosc dostaje status failed.

Status nie jest dostepny wiecznie

Rekordy zrealizowanych wysylek sa okresowo usuwane z kolejki. Po takim sprzataniu GET /api/sms/status/{id} zwroci 404, mimo ze wiadomosc zostala normalnie wyslana. Trwaly slad zostaje w historii. Jesli potrzebujesz statusu na dluzej, odczytaj go w ciagu kilku minut od wyslania i zapisz u siebie.

5. Status unknown - przeczytaj koniecznie

To najczestsze zrodlo nieporozumien

unknown nie znaczy "blad". Znaczy: bramka wywolala urzadzenie nadawcze, ale nie doczekala sie jednoznacznej odpowiedzi - przekroczony czas oczekiwania, zerwane polaczenie, restart procesu. SMS mogl zostac nadany i najczesciej faktycznie zostal.

Dlaczego bramka nie ponawia takiej wiadomosci

Bo nie da sie tego zrobic bezpiecznie. Skoro nie wiadomo, czy SMS poszedl, automatyczna powtorka oznaczalaby ryzyko, ze odbiorca dostanie te sama wiadomosc dwa razy. Przy powiadomieniach o platnosciach, kodach jednorazowych czy potwierdzeniach zamowien to gorszy blad niz brak wiadomosci. Dlatego bramka celowo zatrzymuje sie i oddaje decyzje czlowiekowi.

Jak to wyglada w odpowiedzi

200 OK - status unknown
{
  "id": 143,
  "recipient": "+48123456789",
  "message": "przykladowa tresc",
  "status": "unknown",
  "priority": 5,
  "attempts": 1,
  "max_attempts": 3,
  "last_error": "Wynik wysylki nierozstrzygniety - router nie odpowiedzial, SMS mogl zostac nadany. Rekord nie jest ponawiany automatycznie. Komunikat routera: Przekroczony czas oczekiwania na odpowiedz routera",
  "scheduled_at": "2026-09-01T09:00:00+02:00",
  "sent_at": null,
  "created_at": "2026-07-25T10:47:36.834684+02:00",
  "updated_at": "2026-07-25T10:47:55.748969+02:00",
  "router_id": 1,
  "router_name": "RUT241 glowny"
}

Zwroc uwage: sent_at jest null, a last_error zawiera wyjasnienie doklejone przez bramke. To nie jest komunikat bledu w zwyklym sensie - to opis niepewnosci.

Co MUSI zrobic Twoja aplikacja

  1. Obsluz unknown jako osobny przypadek, nie wrzucaj go do jednego worka z failed. Kod w rodzaju if status != "sent": wyslij_ponownie() to blad - wygeneruje duplikaty.
  2. Nie ponawiaj automatycznie. Nigdy, w zadnym warunku.
  3. Oznacz rekord u siebie jako wymagajacy przejrzenia przez czlowieka (np. status "do sprawdzenia") i pokaz go w swoim interfejsie.
  4. Decyzje podejmuje czlowiek, znajac tresc wiadomosci. Przy przypomnieniu o wizycie powtorka jest zwykle nieszkodliwa. Przy kodzie jednorazowym albo potwierdzeniu platnosci - juz nie.
  5. Pamietaj o limicie: wiadomosc ze statusem unknown liczy sie do Twojego limitu dziennego tak samo jak wyslana - bo najprawdopodobniej kosztowala. Patrz Limity dzienne.
W skrocie

sent - poszlo. failed - nie poszlo, mozesz powtorzyc. unknown - nie wiadomo, nie powtarzaj sam, zapytaj czlowieka.

6. Historia wysylki

GET https://sms.inonet.pl/api/sms/history

Trwala historia zakonczonych wysylek - wylacznie Twoich. W przeciwienstwie do /status/{id} wpisy w historii nie znikaja przy sprzataniu kolejki. Wyniki sa posortowane malejaco po dacie (najnowsze pierwsze).

Parametry zapytania

ParametrTypDomyslnieOpis
limitinteger50 Ile pozycji zwrocic. Zakres 1-200. Wieksza wartosc daje blad 422.
offsetinteger0 Ile pozycji pominac (paginacja). Minimum 0.
statusstringbrak Filtr statusu. Dozwolone tylko: sent, failed, unknown. Wartosci pending i processing nie sa dopuszczalne - historia zawiera wylacznie wysylki zakonczone. Inna wartosc daje blad 400.
date_fromstring (ISO 8601)brak Od tej daty wlacznie.
date_tostring (ISO 8601)brak Do tej daty wlacznie. date_from pozniejsze niz date_to daje blad 400.

Przyklad

Zadanie
curl -s -G https://sms.inonet.pl/api/sms/history \
  -H "X-API-Key: smsb_TWOJ_KLUCZ_API" \
  --data-urlencode "limit=10" \
  --data-urlencode "offset=0" \
  --data-urlencode "status=unknown" \
  --data-urlencode "date_from=2026-07-01T00:00:00" \
  --data-urlencode "date_to=2026-07-31T23:59:59"
200 OK
{
  "items": [
    {
      "id": 73,
      "queue_id": null,
      "recipient": "+48123456789",
      "message": "przykladowa tresc",
      "status": "unknown",
      "attempts": 1,
      "error": "Timeout routera",
      "created_at": "2026-07-25T10:47:55.751554+02:00",
      "router_id": 1,
      "router_name": "RUT241 glowny"
    }
  ],
  "total": 1,
  "limit": 10,
  "offset": 0
}
Jak stronicowac

Pole total to liczba wszystkich pozycji pasujacych do filtrow, niezaleznie od limit. Kolejne strony pobierasz zwiekszajac offset o limit, az offset >= total. Uwaga: pole w historii nazywa sie error (a nie last_error, jak w statusie), a queue_id bywa null, gdy rekord kolejki zostal juz posprzatany.

7. Kody bledow

KodZnaczenieCo zrobic
201 Wiadomosc przyjeta do kolejki (to nie jest blad). Zapisz id z odpowiedzi.
400 Nieprawidlowy parametr filtra w /history - zly status albo date_from pozniejsze niz date_to. Popraw zapytanie. Nie ponawiaj bez zmiany.
401 Brak naglowka X-API-Key, albo klucz jest nieprawidlowy, odwolany lub wygasl. Sprawdz naglowek i klucz. Ponawianie nic nie da.
403 Klucz jest poprawny, ale konto zostalo wylaczone. Skontaktuj sie z administratorem bramki.
404 Nie ma wiadomosci o podanym id - nie istnieje, nalezy do innego konta albo rekord kolejki zostal posprzatany. Sprawdz historie zamiast statusu.
422 Blad walidacji danych: zly numer, pusta lub za dluga tresc, priorytet spoza 1-9, scheduled_at w przeszlosci albo dalej niz 90 dni, limit powyzej 200. Popraw dane. Szczegol jest w detail[].loc i detail[].msg.
429 Wyczerpany limit dzienny. Odpowiedz zawiera naglowek Retry-After z liczba sekund do polnocy. Poczekaj tyle sekund, ile mowi Retry-After.
500 Blad wewnetrzny bramki. Odpowiedz zawiera incident - krotki identyfikator zdarzenia. Sprobuj ponownie za chwile. Przy powtorzeniu podaj administratorowi wartosc incident.
503 Urzadzenie nadawcze przypisane do konta jest niedostepne albo bramka nie ma skonfigurowanego zadnego aktywnego urzadzenia. Wiadomosc NIE zostala przyjeta i nie ma jej w kolejce. Sprobuj ponownie pozniej. Jesli sie powtarza - zglos administratorowi.

Ksztalt odpowiedzi bledu

Bledy 400 / 401 / 403 / 404 maja proste, tekstowe detail:

401 Unauthorized
{"detail": "Brak naglowka X-API-Key"}

Blad 422 zwraca liste problemow - jeden wpis na pole:

422 Unprocessable Entity
{
  "detail": [
    {
      "type": "value_error",
      "loc": ["body", "recipient"],
      "msg": "Value error, nieprawidlowy numer telefonu (oczekiwane np. +48123456789)",
      "input": "abc"
    }
  ]
}

Bledy 429 i 503 maja detail w postaci obiektu z dodatkowymi danymi:

429 Too Many Requests
Retry-After: 47555

{
  "detail": {
    "message": "Dzienny limit wysylki zostal wyczerpany",
    "daily_limit": 100,
    "sent_today": 87,
    "queued_today": 13,
    "remaining_today": 0
  }
}
503 Service Unavailable
{
  "detail": {
    "message": "Router przypisany do konta jest niedostepny, skontaktuj sie z administratorem",
    "reason": "router_konta_niedostepny"
  }
}

Pole reason przy 503 przyjmuje wartosci router_konta_niedostepny, brak_routera_domyslnego albo brak_aktywnego_routera. Rozpoznawaj przypadki po nim, a nie po tresci message - tekst moze sie zmienic.

8. Limity dzienne

Kazde konto ma dzienny limit wiadomosci ustawiany przez administratora bramki. Konto moze tez nie miec limitu w ogole.

Co sie liczy do limitu

PozycjaLiczy sie?Dlaczego
Wiadomosci ze statusem sent TAK Zostaly nadane.
Wiadomosci ze statusem unknown TAK Mogly zostac nadane, wiec mogly kosztowac. Pominiecie ich pozwalaloby przekroczyc oplacony limit.
Wiadomosci ze statusem failed nie Na pewno nie poszly.
Wiadomosci czekajace w kolejce (pending, processing) z terminem na dzis albo zaleglym TAK Rezerwuja limit z gory - inaczej dalby sie obejsc jednym wrzutem tysiaca zadan do kolejki.
Wiadomosci z scheduled_at na kolejna dobe lub pozniej nie (dzis) Rozlicza sie z limitu tego dnia, w ktorym maja wyjsc.

Kiedy limit sie resetuje

O polnocy czasu polskiego (strefa Europe/Warsaw, z uwzglednieniem zmiany czasu letni/zimowy). Doba liczy sie wg zegara bramki, nie wg zegara Twojego serwera - jesli Twoja aplikacja chodzi w UTC, granica doby wypada dla Ciebie o 22:00 (czas letni) albo 23:00 (czas zimowy) UTC.

Nie licz sam - uzyj Retry-After

Odpowiedz 429 zawiera naglowek Retry-After z dokladna liczba sekund do polnocy, wyliczona przez bramke. To jedyna wartosc, ktora nie rozjedzie sie na zmianie czasu ani na rozjezdzie zegarow. Nie licz polnocy samodzielnie.

Co widac w odpowiedzi 429

Gdy limit jest wyczerpany, wiadomosc nie trafia do kolejki - nie zostanie wyslana pozniej sama z siebie. Musisz powtorzyc zadanie po polnocy.

Wysylka zaplanowana a limit dnia docelowego

Wiadomosc z scheduled_at na kolejna dobe nie jest sprawdzana wzgledem limitu przy przyjmowaniu - dostaniesz 201 nawet wtedy, gdy dzisiejszy limit jest juz wyczerpany. Limit sprawdza sie dopiero w dniu wysylki.

Jesli w tym dniu limit okaze sie wyczerpany, bramka przeklada wiadomosc na kolejna dobe (rekord wraca do pending). Po kilku takich przelozeniach - albo od razu, gdy limit konta wynosi 0 - wiadomosc konczy jako failed z opisem w last_error. Odpowiedz 201 na wysylke zaplanowana nie jest wiec gwarancja, ze limit ja pomiesci w dniu docelowym. Sprawdz status po terminie wysylki.

9. Dobre praktyki

  1. Nie odpytuj statusu w petli co sekunde. Wysylka trwa zwykle kilka sekund, ale nie jest natychmiastowa. Rozsadny schemat: pierwsze sprawdzenie po 10 sekundach, potem co 30-60 sekund, i rezygnacja po kilku minutach. Odpytywanie co sekunde obciaza bramke i niczego nie przyspiesza.
  2. Jeszcze lepiej: nie odpytuj wcale w trakcie zadania uzytkownika. Zapisz id, oddaj sterowanie i sprawdz statusy zbiorczo w zadaniu cyklicznym albo przez GET /api/sms/history.
  3. Obsluz 429 zgodnie z Retry-After. Poczekaj tyle sekund, ile podaje naglowek. Natychmiastowe ponawianie po 429 tylko generuje ruch - limit i tak nie odnowi sie przed polnoca.
  4. Nie ponawiaj wiadomosci ze statusem unknown na wlasna reke. Nigdy automatycznie. Oznacz je do przejrzenia przez czlowieka. Patrz sekcja 5.
  5. Ponawiaj tylko po failed. To jedyny status, przy ktorym pewnie wiadomo, ze SMS nie poszedl.
  6. Traktuj 4xx i 5xx inaczej. Bledy 400, 401, 403, 422 to bledy po Twojej stronie - ponawianie bez zmiany danych nigdy nie pomoze. Bledy 500 i 503 sa przejsciowe - ponow z rosnacym odstepem (np. 5 s, 15 s, 60 s).
  7. Zawsze normalizuj numer do E.164 (+48 i dziewiec cyfr) juz u siebie. Nie polegaj na tym, ze bramka domysli sie kraju.
  8. Ustaw sensowny timeout klienta HTTP - 10-15 sekund w zupelnosci wystarcza. Bramka odpowiada od razu po przyjeciu wiadomosci do kolejki.
  9. Uzywaj priority swiadomie. Jesli wszystko oznaczysz jako 1, priorytety przestana cokolwiek znaczyc. Zostaw 5 dla ruchu zwyklego, a 1-2 dla naprawde pilnych powiadomien.
  10. Trzymaj klucz poza kodem. Zmienna srodowiskowa albo plik konfiguracyjny poza repozytorium.

10. Gotowe przyklady

Kazdy przyklad jest kompletny - wystarczy wstawic swoj klucz API i numer odbiorcy. Wszystkie robia to samo: wysylaja wiadomosc, a potem sprawdzaja jej status, poprawnie obslugujac unknown.

bash + curl
#!/usr/bin/env bash
# Wysylka SMS przez bramke + sprawdzenie statusu.
set -euo pipefail

BRAMKA="https://sms.inonet.pl"
KLUCZ="smsb_TWOJ_KLUCZ_API"
NUMER="+48123456789"
TRESC="Przypominamy o wizycie jutro o 10:00."

# --- 1. wyslanie ---------------------------------------------------------
ODPOWIEDZ=$(curl -sS -m 15 -w '\n%{http_code}' \
  -X POST "$BRAMKA/api/sms/send" \
  -H "X-API-Key: $KLUCZ" \
  -H "Content-Type: application/json" \
  -d "{\"recipient\":\"$NUMER\",\"message\":\"$TRESC\",\"priority\":5}")

KOD=$(printf '%s' "$ODPOWIEDZ" | tail -n1)
CIALO=$(printf '%s' "$ODPOWIEDZ" | sed '$d')

if [ "$KOD" != "201" ]; then
  echo "Blad wysylki (HTTP $KOD): $CIALO" >&2
  exit 1
fi

# id bez zewnetrznych narzedzi (jesli masz jq: ID=$(echo "$CIALO" | jq -r .id))
ID=$(printf '%s' "$CIALO" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)
echo "Zakolejkowano, id=$ID"

# --- 2. sprawdzenie statusu (nie czesciej niz co 10 s) --------------------
for _ in 1 2 3 4 5 6; do
  sleep 10
  STATUS=$(curl -sS -m 15 "$BRAMKA/api/sms/status/$ID" -H "X-API-Key: $KLUCZ" \
           | grep -o '"status":"[a-z]*"' | head -n1 | cut -d'"' -f4)
  echo "status: $STATUS"
  case "$STATUS" in
    sent)    echo "OK - wiadomosc nadana"; exit 0 ;;
    failed)  echo "Nie poszlo - mozna powtorzyc"; exit 1 ;;
    unknown) echo "UWAGA: wynik nierozstrzygniety. NIE ponawiaj automatycznie -"
             echo "wiadomosc mogla zostac nadana. Do przejrzenia przez czlowieka."
             exit 2 ;;
  esac
done

echo "Wiadomosc nadal w kolejce - sprawdz pozniej"
Python 3 + httpx
#!/usr/bin/env python3
"""Wysylka SMS przez bramke + sprawdzenie statusu.

Wymaga: pip install httpx
(dziala tak samo z 'requests' - zamien httpx.Client na requests.Session)
"""

import os
import sys
import time

import httpx

BRAMKA = "https://sms.inonet.pl"
KLUCZ = os.environ.get("SMS_API_KEY", "smsb_TWOJ_KLUCZ_API")


class BledneZadanie(Exception):
    """Blad, ktorego nie ma sensu ponawiac bez zmiany danych."""


class LimitWyczerpany(Exception):
    """429 - limit dzienny. Atrybut 'za_ile' mowi, ile sekund czekac."""

    def __init__(self, za_ile: int) -> None:
        super().__init__(f"Limit dzienny wyczerpany, ponow za {za_ile} s")
        self.za_ile = za_ile


def wyslij(klient: httpx.Client, numer: str, tresc: str,
           priorytet: int = 5, termin: str | None = None) -> int:
    """Kolejkuje SMS i zwraca jego id."""
    dane = {"recipient": numer, "message": tresc, "priority": priorytet}
    if termin:
        # bez strefy = czas lokalny Europe/Warsaw
        dane["scheduled_at"] = termin

    odp = klient.post("/api/sms/send", json=dane)

    if odp.status_code == 201:
        return int(odp.json()["id"])

    if odp.status_code == 429:
        # Retry-After podaje bramka - nie licz polnocy samodzielnie
        za_ile = int(odp.headers.get("Retry-After", "3600"))
        raise LimitWyczerpany(za_ile)

    if odp.status_code in (400, 401, 403, 422):
        raise BledneZadanie(f"HTTP {odp.status_code}: {odp.text}")

    # 500 / 503 - blad przejsciowy, ponowienie ma sens
    odp.raise_for_status()
    raise RuntimeError(f"Nieoczekiwany kod {odp.status_code}")


def sprawdz_status(klient: httpx.Client, sms_id: int) -> dict:
    """Zwraca pelny stan wiadomosci."""
    odp = klient.get(f"/api/sms/status/{sms_id}")
    odp.raise_for_status()
    return odp.json()


def czekaj_na_wynik(klient: httpx.Client, sms_id: int,
                    prob: int = 6, odstep: int = 10) -> dict:
    """Odpytuje status co 'odstep' sekund - NIE co sekunde."""
    for _ in range(prob):
        time.sleep(odstep)
        stan = sprawdz_status(klient, sms_id)
        if stan["status"] in ("sent", "failed", "unknown"):
            return stan
    return sprawdz_status(klient, sms_id)


def main() -> int:
    with httpx.Client(
        base_url=BRAMKA,
        headers={"X-API-Key": KLUCZ},
        timeout=15.0,
    ) as klient:
        try:
            sms_id = wyslij(klient, "+48123456789",
                            "Przypominamy o wizycie jutro o 10:00.")
        except LimitWyczerpany as exc:
            print(f"Limit dzienny wyczerpany - ponow za {exc.za_ile} s", file=sys.stderr)
            return 1
        except BledneZadanie as exc:
            print(f"Zadanie odrzucone: {exc}", file=sys.stderr)
            return 1

        print(f"Zakolejkowano, id={sms_id}")
        stan = czekaj_na_wynik(klient, sms_id)

        if stan["status"] == "sent":
            print(f"OK - nadano {stan['sent_at']}")
            return 0

        if stan["status"] == "failed":
            # jedyny status, przy ktorym wolno powtorzyc automatycznie
            print(f"Nie poszlo: {stan['last_error']} - mozna powtorzyc", file=sys.stderr)
            return 1

        if stan["status"] == "unknown":
            # NIE PONAWIAJ. SMS mogl zostac nadany.
            print("UWAGA: wynik nierozstrzygniety - wiadomosc mogla zostac nadana.",
                  file=sys.stderr)
            print("Nie ponawiaj automatycznie. Oznacz do przejrzenia przez czlowieka.",
                  file=sys.stderr)
            print(f"Szczegoly: {stan['last_error']}", file=sys.stderr)
            return 2

        print(f"Nadal w kolejce (status={stan['status']}) - sprawdz pozniej")
        return 0


if __name__ == "__main__":
    raise SystemExit(main())
PHP 8 + cURL
<?php
/**
 * Wysylka SMS przez bramke + sprawdzenie statusu.
 * Wymaga rozszerzenia ext-curl (standardowe w PHP).
 */

declare(strict_types=1);

const BRAMKA = 'https://sms.inonet.pl';
const KLUCZ  = 'smsb_TWOJ_KLUCZ_API';

/**
 * Jedno zapytanie do API. Zwraca ['kod' => int, 'dane' => array, 'naglowki' => array].
 */
function zapytaj(string $metoda, string $sciezka, ?array $cialo = null): array
{
    $ch = curl_init(BRAMKA . $sciezka);

    $naglowki = [];
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $metoda,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 15,
        CURLOPT_HTTPHEADER     => [
            'X-API-Key: ' . KLUCZ,
            'Content-Type: application/json',
            'Accept: application/json',
        ],
        // zbieramy naglowki odpowiedzi - potrzebny bedzie Retry-After przy 429
        CURLOPT_HEADERFUNCTION => function ($ch, string $linia) use (&$naglowki): int {
            $czesci = explode(':', $linia, 2);
            if (count($czesci) === 2) {
                $naglowki[strtolower(trim($czesci[0]))] = trim($czesci[1]);
            }
            return strlen($linia);
        },
    ]);

    if ($cialo !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($cialo, JSON_UNESCAPED_UNICODE));
    }

    $odp = curl_exec($ch);
    if ($odp === false) {
        $blad = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException('Blad polaczenia z bramka: ' . $blad);
    }

    $kod = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    return [
        'kod'      => $kod,
        'dane'     => json_decode((string) $odp, true) ?? [],
        'naglowki' => $naglowki,
    ];
}

/** Kolejkuje SMS, zwraca jego id. */
function wyslij(string $numer, string $tresc, int $priorytet = 5): int
{
    $r = zapytaj('POST', '/api/sms/send', [
        'recipient' => $numer,
        'message'   => $tresc,
        'priority'  => $priorytet,
    ]);

    if ($r['kod'] === 201) {
        return (int) $r['dane']['id'];
    }

    if ($r['kod'] === 429) {
        // liczbe sekund do polnocy podaje bramka
        $zaIle = (int) ($r['naglowki']['retry-after'] ?? 3600);
        throw new RuntimeException("Limit dzienny wyczerpany - ponow za {$zaIle} s");
    }

    throw new RuntimeException(
        "Wysylka odrzucona (HTTP {$r['kod']}): " . json_encode($r['dane'], JSON_UNESCAPED_UNICODE)
    );
}

/** Zwraca pelny stan wiadomosci. */
function status(int $smsId): array
{
    $r = zapytaj('GET', '/api/sms/status/' . $smsId);
    if ($r['kod'] !== 200) {
        throw new RuntimeException("Nie mozna odczytac statusu (HTTP {$r['kod']})");
    }
    return $r['dane'];
}

// ---------------------------------------------------------------- uzycie
try {
    $id = wyslij('+48123456789', 'Przypominamy o wizycie jutro o 10:00.');
    echo "Zakolejkowano, id={$id}\n";

    // odpytujemy co 10 s, a nie co sekunde
    $stan = ['status' => 'pending', 'sent_at' => null, 'last_error' => null];
    for ($i = 0; $i < 6; $i++) {
        sleep(10);
        $stan = status($id);
        if (in_array($stan['status'], ['sent', 'failed', 'unknown'], true)) {
            break;
        }
    }

    switch ($stan['status']) {
        case 'sent':
            echo "OK - nadano {$stan['sent_at']}\n";
            break;

        case 'failed':
            // jedyny status, przy ktorym wolno powtorzyc automatycznie
            echo "Nie poszlo: {$stan['last_error']} - mozna powtorzyc\n";
            break;

        case 'unknown':
            // NIE PONAWIAJ. SMS mogl zostac nadany.
            echo "UWAGA: wynik nierozstrzygniety - wiadomosc mogla zostac nadana.\n";
            echo "Nie ponawiaj automatycznie. Oznacz do przejrzenia przez czlowieka.\n";
            echo "Szczegoly: {$stan['last_error']}\n";
            break;

        default:
            echo "Nadal w kolejce (status={$stan['status']}) - sprawdz pozniej\n";
    }
} catch (Throwable $e) {
    fwrite(STDERR, 'Blad: ' . $e->getMessage() . "\n");
    exit(1);
}
JavaScript (Node 18+ / fetch)
// Wysylka SMS przez bramke + sprawdzenie statusu.
// Node 18+ ma wbudowane fetch - nie potrzeba zadnych zaleznosci.
//
// UWAGA: ten kod uruchamiaj WYLACZNIE po stronie serwera.
// Klucz API w kodzie ladowanym przez przegladarke jest jawny dla kazdego.

const BRAMKA = 'https://sms.inonet.pl';
const KLUCZ = process.env.SMS_API_KEY || 'smsb_TWOJ_KLUCZ_API';

class LimitWyczerpany extends Error {
  constructor(zaIle) {
    super(`Limit dzienny wyczerpany - ponow za ${zaIle} s`);
    this.zaIle = zaIle;
  }
}

const spij = (ms) => new Promise((r) => setTimeout(r, ms));

async function zapytaj(metoda, sciezka, cialo) {
  const odp = await fetch(BRAMKA + sciezka, {
    method: metoda,
    headers: {
      'X-API-Key': KLUCZ,
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    body: cialo ? JSON.stringify(cialo) : undefined,
    signal: AbortSignal.timeout(15000),
  });

  const dane = await odp.json().catch(() => ({}));
  return { kod: odp.status, dane, naglowki: odp.headers };
}

/** Kolejkuje SMS, zwraca jego id. */
async function wyslij(numer, tresc, priorytet = 5, termin = null) {
  const cialo = { recipient: numer, message: tresc, priority: priorytet };
  // termin bez strefy jest rozumiany jako czas lokalny Europe/Warsaw
  if (termin) cialo.scheduled_at = termin;

  const r = await zapytaj('POST', '/api/sms/send', cialo);

  if (r.kod === 201) return r.dane.id;

  if (r.kod === 429) {
    // liczbe sekund do polnocy podaje bramka - nie licz jej samodzielnie
    throw new LimitWyczerpany(Number(r.naglowki.get('retry-after') || 3600));
  }

  throw new Error(`Wysylka odrzucona (HTTP ${r.kod}): ${JSON.stringify(r.dane)}`);
}

/** Zwraca pelny stan wiadomosci. */
async function status(smsId) {
  const r = await zapytaj('GET', `/api/sms/status/${smsId}`);
  if (r.kod !== 200) throw new Error(`Nie mozna odczytac statusu (HTTP ${r.kod})`);
  return r.dane;
}

/** Odpytuje status co 10 s - NIE co sekunde. */
async function czekajNaWynik(smsId, prob = 6, odstepMs = 10000) {
  for (let i = 0; i < prob; i++) {
    await spij(odstepMs);
    const stan = await status(smsId);
    if (['sent', 'failed', 'unknown'].includes(stan.status)) return stan;
  }
  return status(smsId);
}

async function main() {
  const id = await wyslij('+48123456789', 'Przypominamy o wizycie jutro o 10:00.');
  console.log(`Zakolejkowano, id=${id}`);

  const stan = await czekajNaWynik(id);

  switch (stan.status) {
    case 'sent':
      console.log(`OK - nadano ${stan.sent_at}`);
      break;

    case 'failed':
      // jedyny status, przy ktorym wolno powtorzyc automatycznie
      console.error(`Nie poszlo: ${stan.last_error} - mozna powtorzyc`);
      process.exitCode = 1;
      break;

    case 'unknown':
      // NIE PONAWIAJ. SMS mogl zostac nadany.
      console.error('UWAGA: wynik nierozstrzygniety - wiadomosc mogla zostac nadana.');
      console.error('Nie ponawiaj automatycznie. Oznacz do przejrzenia przez czlowieka.');
      console.error(`Szczegoly: ${stan.last_error}`);
      process.exitCode = 2;
      break;

    default:
      console.log(`Nadal w kolejce (status=${stan.status}) - sprawdz pozniej`);
  }
}

main().catch((e) => {
  if (e instanceof LimitWyczerpany) {
    console.error(e.message);
  } else {
    console.error(`Blad: ${e.message}`);
  }
  process.exitCode = 1;
});

11. Najczestsze bledy

Zly format numeru

422 Unprocessable Entity
{"detail":[{"type":"value_error","loc":["body","recipient"],
  "msg":"Value error, nieprawidlowy numer telefonu (oczekiwane np. +48123456789)",
  "input":"abc"}]}

Przyczyna: numer zawiera litery albo ma mniej niz 6 lub wiecej niz 15 cyfr.
Rozwiazanie: podawaj numer w formacie E.164: +48123456789. Spacje i myslniki sa usuwane automatycznie, ale litery i inne znaki - nie.

Pulapka: numer bez prefiksu kraju przechodzi walidacje

Bramka przyjmie 123456789 (bez +48) i zwroci 201, bo taki ciag miesci sie w regule "6-15 cyfr". Dopiero pozniej wysylka moze sie nie udac albo trafic pod zly numer. Zawsze podawaj pelny numer miedzynarodowy z + i prefiksem kraju. Poprawna odpowiedz 201 nie jest dowodem, ze numer jest sensowny.

Brak naglowka albo zle uwierzytelnienie

401 Unauthorized
{"detail":"Brak naglowka X-API-Key"}
{"detail":"Nieprawidlowy, odwolany lub wygasly klucz API"}

Przyczyna i rozwiazanie:

Przekroczony limit dzienny

429 Too Many Requests
Retry-After: 47555

{"detail":{"message":"Dzienny limit wysylki zostal wyczerpany",
  "daily_limit":100,"sent_today":87,"queued_today":13,"remaining_today":0}}

Przyczyna: suma wiadomosci juz wyslanych dzisiaj i czekajacych w kolejce z terminem na dzis osiagnela Twoj limit.
Rozwiazanie: poczekaj liczbe sekund z naglowka Retry-After (limit odnawia sie o polnocy czasu polskiego) albo poproś administratora o podniesienie limitu. Odrzucona wiadomosc nie trafila do kolejki - musisz powtorzyc zadanie samodzielnie.

Pozostale czeste pomylki

ObjawPrzyczynaRozwiazanie
404 przy sprawdzaniu statusu wiadomosci, ktora na pewno wyslales Rekord kolejki zostal posprzatany po zrealizowaniu wysylki. Uzyj GET /api/sms/history. Status odczytuj w ciagu kilku minut od wyslania.
400 przy /history?status=pending Historia zawiera wylacznie wysylki zakonczone. Filtruj po sent, failed albo unknown. Wiadomosci w kolejce sprawdzaj przez /status/{id}.
SMS wyszedl o innej godzinie niz w scheduled_at Podano date bez strefy, zakladajac UTC. Data bez strefy = czas polski. Podawaj strefe jawnie (...+02:00 albo ...Z) i sprawdz, co zwrocila odpowiedz.
Duplikaty u odbiorcow Kod ponawia wysylke dla kazdego statusu innego niz sent, wiec takze dla unknown. Ponawiaj wylacznie po failed. Patrz sekcja 5.
Ustawiono priority: 1, a wiadomosc dalej czeka Priorytet ustala kolejnosc w kolejce, nie omija terminu scheduled_at ani niedostepnosci urzadzenia. Sprawdz scheduled_at w odpowiedzi.
422 mimo poprawnej tresci Tresc przekracza 1600 znakow albo sklada sie z samych spacji. Skroc tresc albo podziel na kilka wiadomosci.
503 przy kazdym zadaniu Urzadzenie nadawcze przypisane do konta jest wylaczone. Zglos administratorowi, podajac pole detail.reason.