← Zpět na blog
19. května 2026·6 min čtení·Tým TopSMS

Bulk SMS API: paralelní odesílání 10 000+ zpráv bez rate limit problémů

Jak architektonicky řešit hromadné odesílání SMS přes API — paralelizace, rate limit handling, retry strategie, idempotence. Code příklady v Node.js a Pythonu.

TL;DR: Pro odeslání 10 000+ SMS přes API použijte batched paralelní volání (10-20 concurrent requests), token bucket rate limiter pro respektování limitu (default 60 SMS/min), exponential backoff pro retry a idempotence přes vlastní externí ID. Tento článek má praktické code patterns v Node.js a Pythonu pro produkční bulk odesílání.

Pokud máte e-shop s 10 000 zákazníky a chcete poslat Black Friday akci v 14:00 přesně, neuděláte to sériově (10 000 × 3 s = 8 hodin). Potřebujete paralelizovat, ale respektovat rate limit API (default 60 SMS/min, lze zvýšit). V tomto článku ukážu produkční patterns.

Architektura — 3 vrstvy

[Vaše DB s kontakty]
        ↓ batch read
[Job queue (Redis / DB)]
        ↓ worker pool
[Rate-limited dispatcher]
        ↓ HTTP POST
[TopSMS API]
        ↓
[Webhook delivery report]
        ↓
[Vaše DB — status update]

Vrstva 1: Job queue

  • Načte kontakty z DB, vytvoří entries (message_id, phone, text, status: pending)
  • Persistent (přežije restart)
  • Možnosti: Redis (BullMQ, RQ), nebo prosté DB tabulka s status polem

Vrstva 2: Worker pool

  • N paralelních workerů (typicky 10-20)
  • Každý worker drží otevřené HTTP connection
  • Plus jeden shared rate limiter

Vrstva 3: Dispatcher s rate limit

  • Token bucket: 60 tokenů, doplňuje 1 token/sekundu
  • Pokud token není, čeká
  • Tím garantuje max 60 SMS/min napříč všemi workery

Token bucket — Node.js implementace

class TokenBucket {
  constructor(capacity, refillPerSecond) {
    this.capacity = capacity
    this.tokens = capacity
    this.refillPerSecond = refillPerSecond
    this.lastRefill = Date.now()
  }

  refill() {
    const now = Date.now()
    const elapsed = (now - this.lastRefill) / 1000
    this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillPerSecond)
    this.lastRefill = now
  }

  async acquire() {
    while (true) {
      this.refill()
      if (this.tokens >= 1) {
        this.tokens -= 1
        return
      }
      // Wait for next token (rough estimate)
      const waitMs = ((1 - this.tokens) / this.refillPerSecond) * 1000
      await new Promise(r => setTimeout(r, Math.min(waitMs, 100)))
    }
  }
}

// Usage: max 60 SMS/min = 1 SMS/sec, burst capacity 60
const limiter = new TokenBucket(60, 1)

Worker pool s rate limit (Node.js)

const PARALLEL_WORKERS = 10
const TOPSMS_AUTH = `Bearer ${process.env.TOPSMS_CLIENT_ID}:${process.env.TOPSMS_SECRET}`

async function worker(jobQueue, limiter) {
  while (jobQueue.length > 0) {
    const job = jobQueue.shift()
    if (!job) break

    await limiter.acquire()  // wait for rate limit slot

    try {
      const res = await fetch('https://www.topsms.cz/api/sms/send', {
        method: 'POST',
        headers: {
          'Authorization': TOPSMS_AUTH,
          'Content-Type': 'application/json',
          'X-Idempotency-Key': job.externalId,  // optional: prevent duplicates on retry
        },
        body: JSON.stringify({
          to: job.phone,
          from: 'Eshop123',
          text: job.text,
          externalId: job.externalId,
        }),
      })

      const data = await res.json()
      if (data.ok) {
        await db.message.update({ where: { id: job.id }, data: { status: 'sent', topsmsId: data.messageId } })
      } else {
        await handleError(job, data.error, res.status)
      }
    } catch (e) {
      await handleError(job, e.message, 0)
    }
  }
}

async function dispatch(jobs) {
  const limiter = new TokenBucket(60, 1)  // 60/min
  const queue = [...jobs]
  const workers = Array.from({ length: PARALLEL_WORKERS }, () => worker(queue, limiter))
  await Promise.all(workers)
}

Retry s exponential backoff

async function handleError(job, errorMsg, httpStatus) {
  const isRetryable = httpStatus === 429 || httpStatus >= 500 || httpStatus === 0
  if (!isRetryable) {
    // Permanent failure (400, 401, 402) — don't retry
    await db.message.update({
      where: { id: job.id },
      data: { status: 'failed', error: errorMsg },
    })
    return
  }

  job.attempts = (job.attempts || 0) + 1
  if (job.attempts >= 5) {
    await db.message.update({
      where: { id: job.id },
      data: { status: 'failed', error: `Max retries: ${errorMsg}` },
    })
    return
  }

  // Exponential backoff: 1s, 2s, 4s, 8s, 16s
  const waitMs = Math.min(1000 * 2 ** (job.attempts - 1), 30_000)
  setTimeout(() => {
    queue.push(job)  // re-enqueue
  }, waitMs)
}

Python — asyncio implementace

import asyncio
import aiohttp
import os
from datetime import datetime

class TokenBucket:
    def __init__(self, capacity, refill_per_second):
        self.capacity = capacity
        self.tokens = capacity
        self.refill_per_second = refill_per_second
        self.last_refill = datetime.now()
        self.lock = asyncio.Lock()

    async def acquire(self):
        async with self.lock:
            while True:
                now = datetime.now()
                elapsed = (now - self.last_refill).total_seconds()
                self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_per_second)
                self.last_refill = now
                if self.tokens >= 1:
                    self.tokens -= 1
                    return
                wait = (1 - self.tokens) / self.refill_per_second
                await asyncio.sleep(min(wait, 0.1))


async def send_sms(session, limiter, job):
    await limiter.acquire()
    auth = f"Bearer {os.environ['TOPSMS_CLIENT_ID']}:{os.environ['TOPSMS_SECRET']}"
    try:
        async with session.post(
            'https://www.topsms.cz/api/sms/send',
            headers={'Authorization': auth, 'Content-Type': 'application/json'},
            json={'to': job['phone'], 'from': 'Eshop123', 'text': job['text']},
        ) as response:
            data = await response.json()
            return {'job_id': job['id'], 'ok': data.get('ok'), 'message_id': data.get('messageId'), 'status_code': response.status}
    except Exception as e:
        return {'job_id': job['id'], 'ok': False, 'error': str(e), 'status_code': 0}


async def dispatch(jobs, parallel=10):
    limiter = TokenBucket(60, 1)
    timeout = aiohttp.ClientTimeout(total=10)
    async with aiohttp.ClientSession(timeout=timeout) as session:
        results = await asyncio.gather(*[send_sms(session, limiter, j) for j in jobs])
    return results


# Usage
jobs = [{'id': i, 'phone': '+420600000001', 'text': f'Message {i}'} for i in range(10000)]
results = asyncio.run(dispatch(jobs))
print(f"OK: {sum(1 for r in results if r['ok'])} / {len(results)}")

Idempotence — proč a jak

Pokud worker crashne mid-request, můžeš po restartu retry → potenciálně odeslat 2× stejnou zprávu. To je drahé a UX katastrofa (klient dostane 2× OTP).

Řešení: pošlete v requestu vlastní externalId:

{
  "to": "+420...",
  "text": "...",
  "externalId": "order-12345-otp"
}

TopSMS pak při retry stejného externalId vrátí původní výsledek místo odeslání nové SMS. Idempotence garantovaná na 24 h.

Měření výkonu

Pro 10 000 SMS s 60/min rate limit:

  • Sériově: 10 000 × 3 s = ~8 hodin (nepřijatelné)
  • Paralelně bez limitu: 200 s teoreticky, ale dostanete 429 od ~60. zprávy
  • Paralelně s token bucket: 10 000 / 60 × 60 = 10 000 s = 2 h 47 min — dimensionálně limitované 60/min

Pokud potřebujete rychleji, máte 2 možnosti:

1. Požádat o vyšší rate limit

V API klíči → "Rate limit" → zvýšit (pro paying users až 600/min). Pak 10 000 SMS / 600 × 60 = 17 min.

2. Použít SMPP

Pro 50k+ SMS/den je SMPP protokol efektivnější — viz REST API vs SMPP. Latence < 1 s, throughput tisíce SMS/s.

Monitoring během běhu

Pro 10k+ kampaně sledujte:

  • Success rate — kolik % se odeslalo bez chyby (cíl: > 99 %)
  • Failed rate — kolik % failed (typicky < 1 % u čisté databáze)
  • Average latency — průměr per SMS (cíl: < 500 ms pro odpověď od API)
  • Cost tracking — kolik kreditu jste utratili (z cost v API odpovědi)
  • Webhook delivery rate — kolik webhook updates dorazilo (klesající = problém)

Pro real-time progress doporučujeme:

  • Update DB row na status sent/failed/retrying při každém kroku
  • Cron job (1×/min) co spočítá progress a aktualizuje UI
  • Dashboard endpoint pro frontend polling

Co NEdělat

Nepoužívejte Promise.all([1000 fetchů]) — pošlete 1000 requestů najednou, API vás zablokuje (429 nebo IP ban)

Nepoužívejte plain for loop s await — sériové, pomalé

Nevypisujte heslo nebo secret do logů — i v debugu

Nezapomeňte na timeoutaiohttp.ClientTimeout(total=10) v Pythonu, AbortController v Node.js

Nezapomeňte na error handling — minimálně retry pro 429 a 5xx

Vzor: Bulk send service (production-ready)

V repo doporučujeme strukturu:

/services/sms-dispatcher/
  src/
    queue.ts          # job queue (Redis / DB)
    rate-limiter.ts   # TokenBucket
    worker.ts         # send one SMS with retry
    dispatcher.ts     # spawn N workers
    metrics.ts        # success rate, latency
  test/
    queue.test.ts
    rate-limiter.test.ts
  package.json

Plný kód s testy najdete v naších GitHub examples (přibudou — pokud nejsou ještě dostupné, kontaktujte support).

Co dál

apibulkdeveloperperformance
Otestujte TopSMS zdarma

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

Registrovat zdarma →