API SMS w PHP. Wysyłka SMS z curl, Laravel, WordPress i webhooki HMAC
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
- to — numer albo tablica do 500 numerów (jedno żądanie, duplikaty usuwane).
- from — nadpis (do 11 znaków), musi być na liście GET /v1/senders.
- send_at — wysyłka odroczona (ISO 8601, do 90 dni), status scheduled.
- reference — Twój identyfikator; wraca w odpowiedzi, webhookach i jako filtr historii.
- nagłówek Idempotency-Key — retry z tym samym kluczem nie wyśle SMS-a drugi raz.
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
- Timeout 10 s w każdym żądaniu (CURLOPT_TIMEOUT, ->timeout(10), 'timeout' => 10).
- Idempotency-Key per wiadomość, np. order-123-processing — retry nigdy nie zdubluje SMS-a.
- Wysyłkę rób w tle — job w kolejce (Laravel), WP-Cron albo Action Scheduler (WooCommerce). Użytkownik nie czeka na dostawcę SMS.
- Nie loguj treści ani numerów (RODO). Loguj id, status i request_id z błędu.
- Tablica to do kampanii z tą samą treścią — 500 numerów w jednym żądaniu.
- Sprawdzaj saldo programowo (GET /v1/account → balance_grosze) i alertuj poniżej progu.
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