API SMS w Node.js. Wysyłka SMS z fetch, paczka npm i webhooki w Express
Jeśli Twoja aplikacja działa na Node.js, Deno, Bun albo Cloudflare Workers i ma wysyłać SMS-y — przypomnienia o wizytach, kody 2FA, powiadomienia o zamówieniach — potrzebujesz jednego żądania HTTP. W tym przewodniku pokazujemy dwie drogi: oficjalną paczkę przypominamy z npm (zero zależności, TypeScript) oraz surowy fetch, jeśli wolisz nie dokładać biblioteki. Do tego webhooki statusów w Express z weryfikacją podpisu HMAC i obsługa błędów, które naprawdę zdarzają się w produkcji.
Wymagania wstępne: Node.js 18+ (wbudowany fetch), konto na Przypominamy.com (testowe działa od razu po rejestracji) i klucz API pk_test_… lub pk_live_….
1. Instalacja i klucz
npm install przypominamy
Klucz trzymaj w zmiennej środowiskowej — nigdy nie commituj go do gita:
# .env (nie commituj!)
PRZYPOMINAMY_API_KEY=pk_live_a1b2c3d4e5f6...
2. Pierwszy SMS w 5 liniach
import { Przypominamy } from 'przypominamy';
const sms = new Przypominamy(process.env.PRZYPOMINAMY_API_KEY);
const msg = await sms.send({
to: '+48600123456',
text: 'Cześć! Przypominamy o wizycie jutro o 14:00.',
reference: 'wizyta-4521',
});
console.log(msg.id, msg.status, msg.cost_grosze); // msg_… queued 9
To samo bez biblioteki, czystym fetch — dokładnie to robi paczka pod spodem:
const res = await fetch('https://api.przypominamy.com/v1/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PRZYPOMINAMY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ to: '+48600123456', text: 'Cześć! Przypominamy o wizycie jutro o 14:00.' }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
const msg = await res.json(); // { id, status, to, parts, cost_grosze, ... }
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"
}
Parametry warte zapamiętania
- from — nadpis, czyli własna nazwa nadawcy (do 11 znaków). Musi być na liście await sms.senders(); domyślny ustawisz przez sms.setSender().
- sendAt — wysyłka odroczona (Date lub ISO 8601, do 90 dni). Wiadomość ma wtedy status scheduled.
- reference — Twój identyfikator (np. id wizyty). Wraca w odpowiedzi, w webhookach i jako filtr historii.
- idempotencyKey — chroni przed podwójną wysyłką przy retry: ta sama treść zwraca pierwotną odpowiedź, inna dostaje 409.
- Polskie znaki — dozwolone; wiadomość idzie wtedy jako UCS-2 (70 znaków na część zamiast 160). Liczbę części i koszt zwraca odpowiedź.
3. Wysyłka do wielu odbiorców
Ta sama treść do wielu numerów to jedno żądanie z tablicą to (do 500 numerów, duplikaty usuwane):
const batch = await sms.send({
to: ['+48600123456', '+48600234567', '+48600345678'],
text: 'Promocja -20% tylko do niedzieli! Kod: SMS20',
from: 'SKLEP',
reference: 'promo-2026-09',
});
console.log(`${batch.accepted} z ${batch.count} przyjęto, koszt ${batch.total_cost_grosze / 100} zł`);
for (const m of batch.messages) console.log(m.to, '→', m.status, m.error ?? '');
Spersonalizowane treści (imię, godzina) wysyłasz osobnymi żądaniami. Przy większej liczbie rób to z ograniczoną równoległością, żeby nie przekroczyć limitu 120 żądań na minutę:
const wizyty = [
{ id: 4521, to: '+48600123456', imie: 'Anna', godzina: '14:00' },
{ id: 4522, to: '+48600234567', imie: 'Piotr', godzina: '15:30' },
];
// prosta pula: max 10 równoległych żądań
const queue = [...wizyty];
await Promise.all(Array.from({ length: 10 }, async () => {
while (queue.length) {
const w = queue.shift();
await sms.send({
to: w.to,
text: `Cześć ${w.imie}, wizyta jutro o ${w.godzina}. Potwierdź odpisując TAK.`,
reference: `wizyta-${w.id}`,
idempotencyKey: `wizyta-${w.id}-przypomnienie`,
});
}
}));
4. Obsługa błędów
Każdy błąd API to PrzypominamyError z polami status, code, param, requestId i retryAfter. Rozróżniaj to, co warto ponowić, od tego, czego nie:
import { PrzypominamyError } from 'przypominamy';
async function sendWithRetry(options, attempts = 3) {
for (let i = 0; i < attempts; i++) {
try {
return await sms.send(options); // options.idempotencyKey = ten sam przy każdej próbie
} catch (e) {
if (!(e instanceof PrzypominamyError)) throw e;
if (e.code === 'rate_limited') {
await new Promise((r) => setTimeout(r, (e.retryAfter ?? 5) * 1000));
continue;
}
if (e.code === 'provider_error' || e.code === 'network_error') {
await new Promise((r) => setTimeout(r, 2 ** i * 1000)); // 1s, 2s, 4s
continue;
}
throw e; // invalid_request, insufficient_funds, unauthorized — nie ponawiaj
}
}
throw new Error('Nie udało się wysłać SMS po 3 próbach');
}
| Kod | Znaczenie | Co robić |
|---|---|---|
| 400 / 422 | invalid_request — zły numer, nadpis, data; pole w param | Popraw payload, nie ponawiaj |
| 401 | unauthorized — zły lub odwołany klucz | Sprawdź zmienną środowiskową |
| 402 | insufficient_funds — komunikat podaje koszt i saldo | Doładuj konto, sprawdzaj sms.account() |
| 409 | idempotency_conflict — ten sam klucz, inna treść | Nowy klucz idempotencji |
| 429 | rate_limited | Odczekaj retryAfter sekund |
| 502 / 504 | provider_error — dostawca SMS | Retry z backoff i tym samym idempotencyKey |
5. Webhooki statusów w Express
Po wysyłce chcesz wiedzieć, czy SMS dotarł. Ustaw adres jednym wywołaniem; w odpowiedzi dostajesz sekret do weryfikacji podpisu:
const { webhook_secret } = await sms.setWebhook('https://twojadomena.pl/webhooks/sms');
// zapisz webhook_secret w sekretach jako PRZYPOMINAMY_WEBHOOK_SECRET
Handler w Express. Kluczowe: surowe body (nie sparsowany JSON), bo podpis liczony jest z bajtów:
import express from 'express';
import { verifyWebhook } from 'przypominamy';
const app = express();
app.post('/webhooks/sms', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = await verifyWebhook(req.body, req.header('X-Przypominamy-Signature'), process.env.PRZYPOMINAMY_WEBHOOK_SECRET);
} catch {
return res.sendStatus(403);
}
const msg = event.data.message;
// event.type: message.sent | message.delivered | message.undelivered | message.failed | message.expired
queue.add(() => db.updateSmsStatus(msg.id, msg.status, msg.reference)); // ciężka praca w tle
res.sendStatus(200); // odpowiedz od razu
});
Bez biblioteki: podpis to t=<unix>,v1=<hex>, gdzie v1 = HMAC-SHA256(secret, `${t}.${body}`). Odrzucaj, gdy podpis się nie zgadza albo t jest starsze niż 5 minut:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, header, secret) {
const [, t, sig] = /t=(\d+),v1=([0-9a-f]+)/.exec(header ?? '') ?? [];
if (!t) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) && Math.abs(Date.now() / 1000 - t) < 300;
}
Przy 5xx lub timeoucie platforma ponawia dostarczenie po 2 s i po 10 s (łącznie 3 próby). Stan zawsze możesz też odczytać z sms.get(id).
6. Cloudflare Workers, Deno, Bun
Paczka używa wyłącznie fetch i Web Crypto, więc działa bez zmian poza Node. W Workerze klucz trzymaj jako sekret:
import { Przypominamy } from 'przypominamy';
export default {
async fetch(request, env) {
const sms = new Przypominamy(env.PRZYPOMINAMY_API_KEY);
const msg = await sms.send({ to: '+48600123456', text: 'Hej z Workera!' });
return Response.json({ id: msg.id });
},
};
7. Najlepsze praktyki produkcyjne
- idempotencyKey per wiadomość (np. `order-${orderId}`) — retry nigdy nie zdubluje SMS-a.
- reference do korelacji — po nim znajdziesz wiadomość w webhooku i w sms.list({ reference }).
- Timeout 10 s — domyślny w paczce (timeoutMs); API ma taki sam do dostawcy.
- Nie loguj treści ani numerów (RODO). Loguj id, status i requestId z błędu — po nim support znajdzie żądanie.
- Sprawdzaj saldo programowo: (await sms.account()).balance_grosze i alert poniżej progu.
- Osobne klucze dla środowisk — produkcja, staging, testy. Łatwiej odwołać przy wycieku.
FAQ
Czy do wysyłki SMS w Node.js potrzebuję SDK?
Nie. Wbudowany fetch w Node 18+ wystarczy: jeden POST JSON na /v1/messages z nagłówkiem Authorization: Bearer. Paczka npm install przypominamy dodaje typy TypeScript, klasę błędu z kodem i requestId oraz weryfikację podpisu webhooków — zero zależności.
Jak obsłużyć rate limit 120 req/min w Node.js?
Łap PrzypominamyError z code === 'rate_limited' i odczekaj retryAfter sekund. Dla kampanii z tą samą treścią wyślij tablicę to (do 500 numerów) w jednym żądaniu. Dla spersonalizowanych wiadomości ogranicz równoległość do ok. 10 żądań naraz.
Jak odbierać webhooki statusów w Express?
Użyj express.raw({ type: 'application/json' }) na trasie webhooka, zweryfikuj nagłówek X-Przypominamy-Signature funkcją verifyWebhook z paczki (albo HMAC-SHA256 z node:crypto) i odpowiedz 200 od razu. Ciężką logikę przenieś do kolejki.
Czy paczka działa w Cloudflare Workers, Deno i Bun?
Tak. Używa wyłącznie fetch i Web Crypto, bez modułów Node. W Workerze klucz API trzymaj jako sekret (wrangler secret put).
Jak zabezpieczyć klucz API w aplikacji produkcyjnej?
Klucz trzymaj w zmiennej środowiskowej lub w secrets managerze, nigdy w repozytorium. W razie wycieku napisz na [email protected] — odwołamy klucz natychmiast i wydamy nowy. Używaj osobnych kluczy dla produkcji i testów.
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