BlogBezpečnostCeníkKontakt
PřihlásitRegistrovat
Tmavý režim
← Zpět na blog
16. října 2026·4 min čtení·Tým TopSMS

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

návodnode.jsnext.jstypescriptapiwebhooky
Otestujte TopSMS zdarma

10 SMS na start, bez závazku, registrace minuta.

Registrovat zdarma →