← Blog

API SMS w PHP. Wysyłka SMS z curl, Laravel, WordPress i webhooki HMAC

Zespół Przypominamy.com · 7 września 2026 · 10 min czytania

PHP wciąż napędza większość polskich sklepów, systemów rezerwacji i paneli klienckich — WordPress, WooCommerce, Laravel, Symfony, PrestaShop. Wysyłka SMS-a z każdego z nich to jedno żądanie HTTP. W tym przewodniku pokazujemy czysty curl, klasę klienta do skopiowania, integrację z Laravelem przez Http facade, wysyłkę z WordPressa przez wp_remote_post oraz webhooki statusów z weryfikacją podpisu HMAC.

Wymagania wstępne: PHP 8.0+ z rozszerzeniami curl i json, konto na Przypominamy.com (testowe działa od razu po rejestracji) i klucz API pk_test_… lub pk_live_….

1. Klucz API

Klucz trzymaj poza kodem — w zmiennej środowiskowej, pliku .env (Laravel) albo wp-config.php (WordPress). Nigdy nie commituj go do gita.

# .env
PRZYPOMINAMY_API_KEY=pk_live_a1b2c3d4e5f6...

2. Pierwszy SMS: czysty curl

<?php
$ch = curl_init('https://api.przypominamy.com/v1/messages');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 10,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . getenv('PRZYPOMINAMY_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'to'        => '+48600123456',
        'text'      => 'Cześć! Przypominamy o wizycie jutro o 14:00.',
        'reference' => 'wizyta-4521',
    ], JSON_UNESCAPED_UNICODE),
]);

$body   = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$msg = json_decode($body, true);
if ($status !== 201) {
    throw new RuntimeException("SMS API {$status}: " . ($msg['error']['message'] ?? $body));
}
echo $msg['id'], ' ', $msg['status'], ' ', $msg['cost_grosze'] / 100, " zł\n";

Odpowiedź (HTTP 201):

{
  "id": "msg_qY08hZ2mmDXAazdRTytS",
  "status": "queued",
  "to": "+48600123456",
  "from": "PRZYPOMINAM",
  "text": "Cześć! Przypominamy o wizycie jutro o 14:00.",
  "parts": 1,
  "cost_grosze": 9,
  "reference": "wizyta-4521",
  "send_at": null,
  "delivered_at": null,
  "error": null,
  "created_at": "2026-09-07T10:30:00.000Z",
  "updated_at": "2026-09-07T10:30:00.000Z"
}

JSON_UNESCAPED_UNICODE nie jest wymagane (API rozumie sekwencje \\u), ale ułatwia czytanie logów. Polskie znaki w treści są dozwolone — wiadomość idzie wtedy jako UCS-2 (70 znaków na część zamiast 160), a liczbę części i koszt zwraca odpowiedź.

Parametry warte zapamiętania

3. Klasa klienta do skopiowania

Jeden plik, zero zależności, obsługa błędów z kodem i request_id oraz retry dla 429 i 5xx:

<?php
declare(strict_types=1);

final class PrzypominamyException extends RuntimeException
{
    public function __construct(
        public readonly int $status,
        public readonly string $code,
        string $message,
        public readonly ?string $param = null,
        public readonly ?string $requestId = null,
        public readonly ?int $retryAfter = null,
    ) {
        parent::__construct("[$status $code] $message");
    }
}

final class Przypominamy
{
    private const BASE = 'https://api.przypominamy.com';

    public function __construct(private readonly string $apiKey, private readonly int $timeout = 10) {}

    /** @param string|string[] $to */
    public function send(string|array $to, string $text, array $opts = []): array
    {
        $body = ['to' => $to, 'text' => $text] + array_filter([
            'from'      => $opts['from'] ?? null,
            'send_at'   => $opts['send_at'] ?? null,
            'reference' => $opts['reference'] ?? null,
        ]);
        $headers = isset($opts['idempotency_key']) ? ['Idempotency-Key: ' . $opts['idempotency_key']] : [];
        return $this->request('POST', '/v1/messages', $body, $headers);
    }

    public function get(string $id): array           { return $this->request('GET', '/v1/messages/' . rawurlencode($id)); }
    public function list(array $query = []): array    { return $this->request('GET', '/v1/messages?' . http_build_query($query)); }
    public function account(): array                  { return $this->request('GET', '/v1/account'); }
    public function senders(): array                  { return $this->request('GET', '/v1/senders'); }
    public function setWebhook(?string $url): array   { return $this->request('PUT', '/v1/account/webhook', ['url' => $url]); }

    private function request(string $method, string $path, ?array $body = null, array $extraHeaders = []): array
    {
        for ($attempt = 0; $attempt < 3; $attempt++) {
            $ch = curl_init(self::BASE . $path);
            curl_setopt_array($ch, [
                CURLOPT_CUSTOMREQUEST  => $method,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HEADER         => true,
                CURLOPT_TIMEOUT        => $this->timeout,
                CURLOPT_HTTPHEADER     => array_merge([
                    'Authorization: Bearer ' . $this->apiKey,
                    'Accept: application/json',
                    'Content-Type: application/json',
                    'User-Agent: przypominamy-php/2.0',
                ], $extraHeaders),
                CURLOPT_POSTFIELDS     => $body === null ? null : json_encode($body, JSON_UNESCAPED_UNICODE),
            ]);
            $raw = curl_exec($ch);
            if ($raw === false) {
                $err = curl_error($ch); curl_close($ch);
                if ($attempt < 2) { sleep(2 ** $attempt); continue; }
                throw new PrzypominamyException(0, 'network_error', $err);
            }
            $status     = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
            $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
            curl_close($ch);
            $headers = substr($raw, 0, $headerSize);
            $data    = json_decode(substr($raw, $headerSize), true) ?? [];

            if ($status >= 200 && $status < 300) {
                return $data;
            }
            $retryAfter = preg_match('/^Retry-After:\s*(\d+)/mi', $headers, $m) ? (int) $m[1] : null;
            if ($status === 429 && $attempt < 2) { sleep($retryAfter ?? 5); continue; }
            if ($status >= 500 && $attempt < 2)  { sleep(2 ** $attempt); continue; }

            throw new PrzypominamyException(
                $status,
                $data['error']['code'] ?? 'internal_error',
                $data['error']['message'] ?? "HTTP $status",
                $data['error']['param'] ?? null,
                $data['request_id'] ?? null,
                $retryAfter,
            );
        }
        throw new PrzypominamyException(429, 'rate_limited', 'Limit żądań po 3 próbach');
    }
}

// użycie
$sms = new Przypominamy(getenv('PRZYPOMINAMY_API_KEY'));
$msg = $sms->send('+48600123456', 'Cześć! Wizyta jutro o 14:00.', [
    'reference'       => 'wizyta-4521',
    'idempotency_key' => 'wizyta-4521-przypomnienie',
]);

Retry ponawia wyłącznie 429 i 5xx — błędy walidacji (invalid_request), brak środków (insufficient_funds) i zły klucz (unauthorized) lecą od razu jako wyjątek, bo powtarzanie ich nic nie da. Dzięki idempotency_key powtórzona wysyłka po timeoucie nie zdubluje SMS-a.

4. Laravel: Http facade i job w kolejce

use Illuminate\Support\Facades\Http;

$response = Http::withToken(config('services.przypominamy.key'))
    ->timeout(10)
    ->withHeaders(['Idempotency-Key' => "wizyta-{$wizyta->id}-przypomnienie"])
    ->post('https://api.przypominamy.com/v1/messages', [
        'to'        => $wizyta->telefon,
        'text'      => "Cześć {$wizyta->imie}, wizyta jutro o {$wizyta->godzina}. Potwierdź odpisując TAK.",
        'reference' => "wizyta-{$wizyta->id}",
    ]);

if ($response->failed()) {
    $err = $response->json('error');
    Log::warning('SMS nie wysłany', ['code' => $err['code'] ?? null, 'request_id' => $response->json('request_id')]);
}
$messageId = $response->json('id');

W config/services.php dodaj 'przypominamy' => ['key' => env('PRZYPOMINAMY_API_KEY')]. Wysyłkę do wielu odbiorców opakuj w job (ShouldQueue) — wtedy limit 120 żądań/min i ewentualne retry nie blokują żądania HTTP użytkownika, a Laravel sam ponowi job po wyjątku.

5. WordPress i WooCommerce

W WordPressie zamiast curl użyj wp_remote_post. Klucz zdefiniuj w wp-config.php:

<?php
// wp-config.php
define( 'PRZYPOMINAMY_API_KEY', 'pk_live_...' );

// w pluginie lub functions.php
function przypominamy_send_sms( string $to, string $text, string $reference = '' ): ?string {
    $response = wp_remote_post( 'https://api.przypominamy.com/v1/messages', [
        'timeout' => 10,
        'headers' => [
            'Authorization' => 'Bearer ' . PRZYPOMINAMY_API_KEY,
            'Content-Type'  => 'application/json',
        ],
        'body' => wp_json_encode( array_filter( [
            'to'        => $to,
            'text'      => $text,
            'reference' => $reference,
        ] ) ),
    ] );

    if ( is_wp_error( $response ) ) {
        error_log( 'Przypominamy SMS: ' . $response->get_error_message() );
        return null;
    }
    $data = json_decode( wp_remote_retrieve_body( $response ), true );
    if ( wp_remote_retrieve_response_code( $response ) !== 201 ) {
        error_log( 'Przypominamy SMS: ' . ( $data['error']['message'] ?? 'błąd' ) . ' (' . ( $data['request_id'] ?? '-' ) . ')' );
        return null;
    }
    return $data['id'];
}

// WooCommerce: SMS przy zmianie statusu zamówienia
add_action( 'woocommerce_order_status_changed', function ( $order_id, $old, $new, $order ) {
    if ( $new === 'processing' && ( $phone = $order->get_billing_phone() ) ) {
        przypominamy_send_sms( $phone, "Dziękujemy za zamówienie #{$order_id}. Powiadomimy Cię o wysyłce.", "order-{$order_id}-{$new}" );
    }
}, 10, 4 );

Pełny plugin z formatowaniem numerów i szablonami per status opisaliśmy w osobnym przewodniku WooCommerce.

6. Webhooki statusów

Adres ustawiasz jednym wywołaniem (PUT /v1/account/webhook albo $sms->setWebhook($url)). W odpowiedzi dostajesz webhook_secret. Przy każdej zmianie statusu platforma wysyła POST JSON ze zdarzeniem message.sent, message.delivered, message.undelivered, message.failed lub message.expired i pełnym obiektem wiadomości. Podpis: nagłówek X-Przypominamy-Signature: t=<unix>,v1=<hex>, gdzie v1 = HMAC-SHA256(secret, "<t>.<body>").

<?php
// webhook.php — publiczny endpoint https://twojadomena.pl/webhooks/sms.php
$secret = getenv('PRZYPOMINAMY_WEBHOOK_SECRET');
$raw    = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_PRZYPOMINAMY_SIGNATURE'] ?? '';

if (!preg_match('/t=(\d+),v1=([0-9a-f]{64})/', $header, $m)) {
    http_response_code(403); exit;
}
[$_, $t, $sig] = $m;
$expected = hash_hmac('sha256', "$t.$raw", $secret);
if (!hash_equals($expected, $sig) || abs(time() - (int) $t) > 300) {
    http_response_code(403); exit;
}

$event = json_decode($raw, true);
$msg   = $event['data']['message'];
// szybko zapisz i odpowiedz 200 — ciężką pracę zrób w cronie / kolejce
$pdo->prepare('UPDATE sms SET status = ? WHERE api_id = ?')->execute([$msg['status'], $msg['id']]);
http_response_code(200);
echo 'OK';

Przy 5xx lub timeoucie platforma ponawia dostarczenie po 2 s i po 10 s (łącznie 3 próby). Stan wiadomości możesz też zawsze odczytać z GET /v1/messages/{id}.

7. Najlepsze praktyki produkcyjne

FAQ

Czy jest oficjalne SDK PHP dla przypominamy.com?

Nie ma paczki Composer — klasa klienta z tego wpisu (jeden plik, zero zależności) w pełni wystarcza: wysyłka, historia, saldo, nadpisy, webhook, retry. Oficjalne paczki są dla Node.js (npm install przypominamy) i Pythona (pip install przypominamy).

Jak wysłać SMS z Laravela?

Http::withToken($key)->post('https://api.przypominamy.com/v1/messages', [...]). Klucz trzymaj w .env i config/services.php, wysyłkę opakuj w job w kolejce.

Jak wysłać SMS z WordPressa lub WooCommerce?

Użyj wp_remote_post na /v1/messages z nagłówkiem Authorization: Bearer i body JSON. Klucz zdefiniuj w wp-config.php. Do zamówień WooCommerce podepnij się pod hook woocommerce_order_status_changed.

Jak zweryfikować podpis webhooka w PHP?

Wyciągnij t i v1 z nagłówka X-Przypominamy-Signature, policz hash_hmac('sha256', "$t.$rawBody", $secret) i porównaj przez hash_equals. Odrzuć, gdy |time() - t| > 300 sekund. Body czytaj z php://input, nie z $_POST.

Co zrobić z błędem 402 insufficient_funds?

Konto działa prepaid — przy braku środków nic nie jest wysyłane. Komunikat podaje szacowany koszt i dostępne saldo. Doładuj konto i monitoruj balance_grosze z GET /v1/account, żeby alertować przed wyczerpaniem.

Gotowy na pierwszy SMS?

Załóż konto — klucz pk_test_… i 25 SMS-ów gratis dostajesz od razu, bez czekania na weryfikację. Pierwszy SMS wysyłasz minutę później.

Załóż konto
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