SMS z Node.js a Next.js: fetch, idempotence a ověření webhooku
Odeslání SMS z Node.js (18+) nebo Next.js přes TopSMS API bez knihovny: typovaný klient s fetch, opakování s Idempotency-Key, Route Handler pro doručenky s ověřením HMAC podpisu a testovací režim.
TopSMS API je obyčejný JSON přes HTTPS, takže v Node.js 18+ (a tedy i v Next.js, Nest, Express nebo serverless funkcích) stačí vestavěný fetch. Tady je malý typovaný klient, který řeší, co jinak píšete pokaždé znovu: autentizaci, opakování bez zdvojených SMS a ověření doručenek.
Příprava
V dashboardu TopSMS vytvořte API klíč (scopes send a read); pro vývoj klíč v testovacím režimu, se kterým se nic neodesílá ani neúčtuje. Hodnoty dejte do .env:
TOPSMS_CLIENT_ID=...
TOPSMS_SECRET=...
TOPSMS_WEBHOOK_SECRET=... # jen pokud používáte webhooky
Klíč patří jen na server. V Next.js ho nikdy nečtěte v klientské komponentě ani ho nedávejte do NEXT_PUBLIC_*.
1. Klient (lib/topsms.ts)
const API = 'https://www.topsms.cz/api'
const AUTH = `Bearer ${process.env.TOPSMS_CLIENT_ID}:${process.env.TOPSMS_SECRET}`
export type SendResult = { ok: true; id: string; externalId: string | null; to: string; from: string; smsCount: number; price: number; status: string; test?: boolean }
export class TopSmsError extends Error { constructor(public status: number, public code: string | undefined, message: string) { super(message) } }
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
export async function sendSms(opts: { to: string; text: string; from?: string; idempotencyKey: string }, retries = 3): Promise<SendResult> {
for (let attempt = 0; attempt < retries; attempt++) {
let res: Response
try {
res = await fetch(`${API}/sms/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: AUTH, 'Idempotency-Key': opts.idempotencyKey },
body: JSON.stringify({ to: opts.to, text: opts.text, ...(opts.from ? { from: opts.from } : {}) }),
signal: AbortSignal.timeout(15_000),
})
} catch {
await sleep(2 ** attempt * 1000) // timeout / síť: opakovat se STEJNÝM Idempotency-Key
continue
}
const data = await res.json().catch(() => ({}))
if (res.ok) return data as SendResult
if (res.status === 409 || res.status >= 500) { await sleep(2 ** attempt * 1000); continue }
throw new TopSmsError(res.status, data.code, data.error || `HTTP ${res.status}`) // 400/401/402/403/429
}
throw new TopSmsError(503, 'unavailable', 'TopSMS neodpovídá, zkuste později')
}
export async function getStatus(id: string) {
const res = await fetch(`${API}/sms/status/${encodeURIComponent(id)}`, { headers: { Authorization: AUTH } })
if (!res.ok) throw new TopSmsError(res.status, undefined, `HTTP ${res.status}`)
return res.json() as Promise<{ id: string; status: 'sent' | 'delivered' | 'failed' | 'expired'; errorCode: string | null; deliveredAt: string | null }>
}
Použití:
const r = await sendSms({
to: '+420601234567',
text: 'Vase objednavka 1234 je pripravena k vyzvednuti.',
idempotencyKey: 'order-1234-ready', // odvozeno od události, ne od času
})
await db.order.update({ where: { id: 1234 }, data: { smsId: r.id, smsPrice: r.price } })
Proč Idempotency‑Key: když požadavek vyprší nebo proces spadne po odeslání, opakování se stejným klíčem do 24 hodin vrátí původní odpověď (Idempotent-Replayed: true) a SMS neodejde podruhé. Kódy, na které nemá smysl opakovat: 400 (číslo/text), 402 (nedostatek kreditu), 429 (rate limit klíče, výchozí 60/min).
Pole from vynechte, dokud nemáte schválené vlastní Sender ID. Diakritika zkracuje SMS ze 160 na 70 znaků; pro notifikace ji odstraňte: text.normalize('NFD').replace(/[̀-ͯ]/g, '').
2. Hromadné odeslání
export async function sendBulk(to: string[], text: string, idempotencyKey: string, from?: string) {
const res = await fetch(`${API}/sms/send-bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: AUTH, 'Idempotency-Key': idempotencyKey },
body: JSON.stringify({ to, text, ...(from ? { from } : {}) }),
})
return res.json() as Promise<{ ok: boolean; accepted: number; rejected: number; price: number; queued?: boolean; jobId?: string; results: { to: string; status: string; id: string | null; error: string | null }[] }>
}
Až 500 příjemců na požadavek; do 50 se odesílá hned, větší dávka jde do fronty (HTTP 202 s jobId, průběh na GET /api/sms/jobs/{jobId}). Neplatná čísla se neúčtují.
3. Doručenky: Route Handler v Next.js (App Router)
U API klíče nastavte Webhook URL na https://vase-domena.cz/api/webhooks/topsms. Každý webhook nese X-TopSMS-Signature: sha256=<HMAC‑SHA256 těla>; ověřujte ho nad původním textem těla, ne nad rozparsovaným JSON:
// app/api/webhooks/topsms/route.ts
import crypto from 'crypto'
import { NextResponse } from 'next/server'
export async function POST(req: Request) {
const raw = await req.text()
const expected = 'sha256=' + crypto.createHmac('sha256', process.env.TOPSMS_WEBHOOK_SECRET!).update(raw).digest('hex')
const received = req.headers.get('x-topsms-signature') || ''
if (received.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))) {
return NextResponse.json({ error: 'bad signature' }, { status: 401 })
}
const event = JSON.parse(raw) as { event: 'sms.status'; id: string; to: string; status: 'delivered' | 'failed' | 'expired'; errorCode: string | null; deliveredAt: string | null; test?: boolean }
// Idempotentně: webhook může přijít vícekrát (opakování po 30 s a 2 min)
await db.order.updateMany({ where: { smsId: event.id }, data: { smsStatus: event.status, smsError: event.errorCode } })
return NextResponse.json({ ok: true })
}
V Expressu je to totéž s express.raw({ type: 'application/json' }) místo express.json(), aby bylo k dispozici původní tělo. Odpovídejte do 5 s stavem 2xx; endpoint musí být veřejný (ne za Basic Auth ani Cloudflare Access), bezpečnost dává podpis.
4. Testování
S klíčem v testovacím režimu běží kód beze změny: číslo končící 0000 simuluje nedoručení, 0001 expiraci, ostatní doručení za 2–4 s; doručenky chodí s test: true. Pro lokální příjem webhooků použijte tunel (cloudflared tunnel --url http://localhost:3000 nebo ngrok) a jeho adresu dejte do Webhook URL klíče.
Kam dál
- Dokumentace REST API, OpenAPI pro generování klienta, webhooky.
- AI asistenti (Claude, Cursor): oficiální MCP server
npx -y topsms-mcp. - 7 pravidel, jak zabezpečit SMS účet a API klíč.