Przejdź do treści
SMS API

Jak odbierać SMS przez API i webhook (z weryfikacją podpisu HMAC)

Odbieraj SMS-y w swojej aplikacji przez webhook: format zdarzeń, weryfikacja podpisu HMAC w Node.js, Pythonie i PHP, ponowienia, deduplikacja i odpowiedzi TAK.

Zespół smsportalOpublikowano: 7 min czytania
Konfiguracja webhooka w panelu smsportal: adres, sekret podpisu i zdarzenia

Wysyłanie SMS-ów to połowa rozmowy. Druga połowa to odpowiedzi klientów: „TAK”, „odwołuję”, „proszę o telefon”. Najprostszy sposób, żeby dostać je w swojej aplikacji, to webhook SMS: serwer sam wywołuje Twój adres, gdy telefon odbierze wiadomość. Poniżej: jak go skonfigurować, jak wygląda zdarzenie, jak zweryfikować podpis w Node.js, Pythonie i PHP oraz jak poradzić sobie z ponowieniami i duplikatami.

Jak działa webhook SMS?

Webhook to adres URL w Twojej aplikacji. Gdy w smsportal dzieje się coś z wiadomością, serwer wysyła na ten adres żądanie POST z JSON-em. Ty przetwarzasz zdarzenie i odpowiadasz kodem 2xx. Nie musisz odpytywać API w pętli.

Droga SMS-a wygląda tak:

  1. Klient odpisuje na Twój zwykły numer.
  2. Aplikacja na telefonie odbiera wiadomość i przekazuje ją na serwer smsportal (jeśli przechodzi przez filtry odbieranych SMS-ów).
  3. Serwer tworzy zdarzenie MESSAGE_RECEIVED i wysyła je na Twój adres.
  4. Twój kod weryfikuje podpis, zapisuje wiadomość i reaguje.
Aplikacja smsportal: filtry odbieranych SMS-ów decydują, które wiadomości trafiają do webhooka

Dostępne zdarzenia:

ZdarzenieKiedy
MESSAGE_RECEIVEDtelefon odebrał SMS od klienta
MESSAGE_SENTtelefon przekazał Twoją wiadomość do sieci operatora
MESSAGE_DELIVEREDoperator potwierdził doręczenie
MESSAGE_FAILEDwysyłka się nie powiodła

Jeśli dopiero zaczynasz, najpierw przejdź przez wysyłkę SMS przez API, a potem wróć tutaj.

Jak dodać webhook w panelu?

W panelu otwórz sekcję Webhooks i utwórz subskrypcję. Podaj adres HTTPS, wybierz zdarzenia i ustaw tajny klucz podpisu (co najmniej 20 znaków). Ten klucz zapisz w zmiennej środowiskowej po stronie serwera, tak jak klucz API.

Panel smsportal: urządzenia, klucze API i ustawienia integracji
Panel smsportal: urządzenia, klucze API i ustawienia integracji

Dwie rzeczy, które często zaskakują:

  • Adresy lokalne są odrzucane. localhost i adresy z sieci prywatnych nie przejdą walidacji. Do testów użyj tunelu, np. ngrok albo Cloudflare Tunnel.
  • Liczba webhooków jest ograniczona. Jeden adres może obsłużyć wiele zdarzeń, więc nie twórz osobnego webhooka na każde.

Wskazówka: na początek zapisz się tylko na MESSAGE_RECEIVED. Statusy wysyłki dodasz później, gdy odbiór będzie działał.

Jak wygląda zdarzenie z odebranym SMS-em?

Każde zdarzenie ma pola wspólne, a do nich dochodzą pola zależne od typu. Przy MESSAGE_RECEIVED dochodzą nadawca i czas odbioru.

json
{
  "smsId": "6720f1c2a9b3d4e5f6a7b8c9",
  "message": "TAK",
  "deviceId": "671fe0b1a2c3d4e5f6a7b8c0",
  "webhookSubscriptionId": "6720e9d8c7b6a5f4e3d2c1b0",
  "webhookEvent": "MESSAGE_RECEIVED",
  "idempotencyKey": "b1c7a1de-5c1f-4d64-9a35-0d6d1a7a5c11",
  "sender": "+48600100200",
  "receivedAt": "2026-10-07T08:15:02.000Z"
}

Pola wspólne to smsId, message, deviceId, webhookSubscriptionId, webhookEvent i idempotencyKey. Zdarzenia statusów dodają:

  • MESSAGE_SENT: smsBatchId, status, recipient, sentAt,
  • MESSAGE_DELIVERED: te same pola oraz deliveredAt,
  • MESSAGE_FAILED: smsBatchId, status, recipient, errorCode, errorMessage, failedAt.

Jak zweryfikować podpis X-Signature?

Każde żądanie ma nagłówek X-Signature: szesnastkowy HMAC-SHA256 ciała żądania, policzony tajnym kluczem Twojego webhooka. Policz ten sam skrót z surowego ciała i porównaj w stałym czasie. Dzięki temu nikt, kto zna tylko Twój adres, nie podrzuci fałszywego SMS-a.

Trzy zasady, które oszczędzają godziny debugowania:

  • licz podpis z surowych bajtów żądania, a nie z ponownie zserializowanego JSON-a (inna kolejność kluczy lub spacje zmienią skrót),
  • porównuj funkcją odporną na ataki czasowe, nie operatorem ==,
  • przy niezgodności odpowiadaj 401 i nic nie przetwarzaj.

Node.js (Express, surowe ciało)

javascript
import express from "express";
import crypto from "node:crypto";

const app = express();
const SECRET = process.env.SMSPORTAL_WEBHOOK_SECRET;

// express.raw zachowuje surowe ciało jako Buffer
app.post("/sms-webhook", express.raw({ type: "application/json" }), (req, res) => {
  const expected = crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
  const received = req.get("X-Signature") ?? "";

  const a = Buffer.from(expected);
  const b = Buffer.from(received);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString("utf8"));
  console.log(event.webhookEvent, event.sender, event.message);
  res.sendStatus(200);
});

app.listen(3000);

Nie używaj express.json() przed tą trasą: zamieni ciało na obiekt i stracisz oryginalne bajty.

Python (Flask)

python
import hashlib
import hmac
import os

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["SMSPORTAL_WEBHOOK_SECRET"].encode()


@app.post("/sms-webhook")
def sms_webhook():
    raw = request.get_data()  # surowe bajty
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    received = request.headers.get("X-Signature", "")

    if not hmac.compare_digest(expected.encode(), received.encode()):
        abort(401)

    event = request.get_json()
    print(event["webhookEvent"], event.get("sender"), event["message"])
    return "", 200

W FastAPI zrobisz to samo: raw = await request.body(), a potem identyczny HMAC i hmac.compare_digest.

PHP

php
<?php
$raw = file_get_contents('php://input');
$secret = getenv('SMSPORTAL_WEBHOOK_SECRET');

$expected = hash_hmac('sha256', $raw, $secret);
$received = $_SERVER['HTTP_X_SIGNATURE'] ?? '';

if (!hash_equals($expected, $received)) {
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true);
error_log($event['webhookEvent'] . ' ' . ($event['sender'] ?? '') . ' ' . $event['message']);
http_response_code(200);

Co z ponowieniami i duplikatami?

Jeśli Twój serwer nie odpowie kodem 2xx, smsportal ponowi dostarczenie. Błędy sieci, przekroczenie czasu i odpowiedzi 5xx są powtarzane z rosnącymi odstępami (od kilku minut do kilku dni), do 10 prób. Odpowiedź 4xx jest uznawana za błąd po Twojej stronie i próby kończą się po trzeciej. Czas oczekiwania na odpowiedź jest ograniczony, więc odpowiadaj szybko: zapisz zdarzenie do kolejki i zwróć 200, a ciężką pracę zrób później.

Skutek uboczny ponowień jest taki, że to samo zdarzenie może dotrzeć dwa razy. Każde ma stały idempotencyKey, po którym odrzucisz duplikat:

javascript
// Redis: SET z NX zwraca OK tylko przy pierwszym zapisie
const first = await redis.set(`sms:${event.idempotencyKey}`, "1", "EX", 60 * 60 * 24 * 7, "NX");
if (!first) return res.sendStatus(200); // już przetworzone, ale potwierdź odbiór

Bez Redisa wystarczy tabela z unikalnym indeksem na kolumnie idempotency_key: próba wstawienia duplikatu się nie uda, więc wiesz, że zdarzenie już było.

Uwaga: jeśli webhook zawodzi bardzo często, system może go automatycznie wstrzymać i wysłać Ci o tym e-mail. Po naprawie serwera włącz go ponownie w panelu.

Jak zrobić rozmowę dwukierunkową, np. potwierdzenie wizyty odpowiedzią TAK?

Klient odpowiada na Twój numer, więc odpowiedź przychodzi jako MESSAGE_RECEIVED z jego numerem w polu sender. Dopasuj numer do oczekującej wizyty, zinterpretuj treść i zaktualizuj stan. To dokładnie ten przepływ, na którym opierają się przypomnienia SMS o wizycie.

Przykład: gabinet wysłał o 18:00 „Dzień dobry, przypominamy o wizycie jutro o 10:00. Odpisz TAK, aby potwierdzić.” Rano webhook dostaje odpowiedź.

javascript
const YES = new Set(["tak", "t", "ok", "potwierdzam"]);
const NO = new Set(["nie", "odwołuję", "odwoluje"]);

function interpret(text) {
  const word = text.trim().toLowerCase().replace(/[.!]+$/, "");
  if (YES.has(word)) return "confirmed";
  if (NO.has(word)) return "cancelled";
  return "unknown";
}

// w obsłudze zdarzenia MESSAGE_RECEIVED:
const appointment = await findPendingAppointmentByPhone(event.sender);
if (!appointment) return res.sendStatus(200);

const answer = interpret(event.message);
if (answer !== "unknown") {
  await updateAppointment(appointment.id, answer);
  await sendSms(event.sender, answer === "confirmed"
    ? "Dziękujemy, wizyta potwierdzona. Do zobaczenia!"
    : "Wizyta odwołana. Zadzwoń, aby umówić nowy termin.");
}

sendSms to zwykłe POST /gateway/send-sms z nagłówkiem x-api-key, jak w przewodniku po wysyłce. Dwa zabezpieczenia na przyszłość:

  • Nie odpowiadaj automatycznie na każdą wiadomość. Dwa automaty piszące do siebie zrobią pętlę. Odpowiadaj tylko numerom, dla których czeka wizyta.
  • Ręcznie obsłuż „unknown”. Niezrozumiałą odpowiedź przekaż człowiekowi zamiast zgadywać.

Takie same webhooki obsłużysz też w narzędziach bez kodu: SMS z n8n używa węzła Webhook jako wyzwalacza.

Jak śledzić doręczenie wysłanych wiadomości?

Zapisz się dodatkowo na MESSAGE_SENT, MESSAGE_DELIVERED i MESSAGE_FAILED. Dzięki nim aktualizujesz status w swojej bazie bez odpytywania GET /gateway/messages. Pamiętaj o rozróżnieniu: „wysłana” znaczy, że telefon przekazał SMS operatorowi, a „dostarczona” oznacza potwierdzenie z sieci. Nie każdy operator zwraca potwierdzenia dla każdego numeru, więc brak MESSAGE_DELIVERED nie zawsze oznacza porażkę.

Oś czasu jednej wiadomości w aplikacji: kolejka, wysłana, dostarczona

Przykłady kodów dla pozostałych języków, w tym wysyłkę, znajdziesz w artykule o SMS przez API w PHP, Pythonie i Node.js.

Jak debugować webhook, który nie dochodzi?

Gdy zdarzenia nie docierają, sprawdź po kolei:

  1. Czy webhook jest włączony i zapisany na właściwe zdarzenie. Po wielu nieudanych próbach może zostać automatycznie wstrzymany.
  2. Czy adres jest publiczny i działa po HTTPS. Z adresem lokalnym lub prywatnym webhook nie zostanie nawet utworzony.
  3. Czy telefon odebrał SMS-a. Wiadomość odrzucona przez filtr odbieranych SMS-ów nie wygeneruje zdarzenia.
  4. Co zwraca Twój serwer. Odpowiedź 401 z powodu złego podpisu to najczęstsza przyczyna: sprawdź, czy używasz surowego ciała i dokładnie tego klucza, który ustawiłeś w panelu.
  5. Czy odpowiadasz w rozsądnym czasie. Wolny handler kończy się przekroczeniem czasu i ponowieniem.

Wskazówka: na czas testów loguj nagłówek X-Signature, surowe ciało i obliczony skrót obok siebie. Różnicę zobaczysz od razu, a najczęściej jest nią niechciany parser JSON przed trasą.

Następny krok

Utwórz bezpłatne konto, podłącz telefon, dodaj webhook na MESSAGE_RECEIVED z adresem tunelu i napisz SMS-a na swój numer. Pierwsze zdarzenie zobaczysz w logach w kilka sekund. Pełny opis zapytań jest w dokumentacji API, a plany w sekcji cennik.

Najczęściej zadawane pytania

Czym jest webhook SMS?

Webhook SMS to adres w Twojej aplikacji, który serwer wywołuje żądaniem POST, gdy coś się stanie z wiadomością: przyjdzie SMS, zostanie wysłany, dostarczony albo się nie uda. Dzięki temu nie musisz co chwilę odpytywać API.

Jak sprawdzić, że webhook pochodzi z smsportal?

Każde żądanie ma nagłówek X-Signature z szesnastkowym HMAC-SHA256 ciała żądania, liczonym Twoim tajnym kluczem webhooka. Policz ten sam skrót z surowego ciała i porównaj funkcją odporną na ataki czasowe, np. crypto.timingSafeEqual albo hmac.compare_digest.

Co się stanie, gdy mój serwer będzie niedostępny?

Dostarczenie jest ponawiane. Błędy sieci, przekroczenie czasu i kody 5xx są powtarzane z rosnącymi odstępami, od kilku minut do kilku dni, maksymalnie do 10 prób. Kody 4xx traktowane są jako błąd po Twojej stronie i przerywają próby po trzeciej.

Dlaczego dostaję to samo zdarzenie dwa razy?

Przy ponowieniach to samo zdarzenie może dotrzeć więcej niż raz, np. gdy Twój serwer przetworzył żądanie, ale nie zdążył odpowiedzieć. Każde zdarzenie ma stały idempotencyKey, po którym odrzucasz duplikaty.

Czy mogę testować webhook na localhost?

Nie bezpośrednio: adresy typu localhost i adresy prywatne są odrzucane. Użyj tunelu (np. ngrok albo Cloudflare Tunnel), który udostępni lokalny serwer pod publicznym adresem HTTPS.

Wyślij pierwszego SMS-a jeszcze dziś

Załóż darmowe konto, podłącz telefon i sprawdź, jak klienci reagują na wiadomości z Twojego numeru.