На концерті на 3,000 місць у тебе є приблизно 30 хвилин між відкриттям дверей і першим актом. Це 100 сканувань на хвилину при одному сканері. Не вкладешся — отримаєш натовп біля входу, злих промоутерів і проблему з безпекою.
Я побудував систему QR check-in для E7 Platform — тикетинг-платформи, через яку пройшло 12,400+ квитків на десятках заходів. Система працює на телефонах персоналу, функціонує офлайн і валідує скан за менше ніж 200мс. Ось архітектура.
Обмеження
- Швидкість: менше 200мс від скану до зеленого/червоного екрану. Персонал не може чекати.
- Офлайн: На площадках жахливий WiFi. Сканер має працювати без зв'язку.
- Виявлення дублікатів: Якщо квиток сканують двічі, другий скан має показати попередження — навіть на різних сканерах.
- Жодного перепродажу: QR-код по поверненому чи переданому квитку має бути відхилений миттєво.
- Простота для персоналу: Сканер працює на будь-якому телефоні з камерою. Ніякого спецобладнання.
Патерн 1: JWT-підписані QR-коди (без звернення до БД при скані)
Ключова ідея: перенести звернення до бази даних на момент покупки, а не на момент скану.
Коли Stripe підтверджує оплату через вебхук, сервер генерує QR-код з підписаним JWT:
import { SignJWT } from 'jose'
async function generateTicketQR(ticket: {
id: string
eventId: string
seat: string
}) {
const token = await new SignJWT({
tid: ticket.id,
eid: ticket.eventId,
seat: ticket.seat,
})
.setProtectedHeader({ alg: 'ES256' })
.setIssuedAt()
.setExpirationTime('30d')
.sign(privateKey)
return generateQRCode(token)
}
Чому ES256 (ECDSA), а не HS256 (HMAC):
- Приватний ключ залишається на сервері (підписує квитки)
- Публічний ключ відправляється на сканери (верифікує квитки)
- Якщо пристрій-сканер загублено чи скомпрометовано, атакуючий може перевіряти, але не підробляти квитки
QR кодує ~180 байт. Камера будь-якого телефону зчитує за один кадр.
Патерн 2: Офлайн-сканер
Сканер — це екран React Native за авторизацією персоналу. При запуску він завантажує повний маніфест квитків заходу:
async function syncManifest(eventId: string) {
const manifest = await api.get(`/events/${eventId}/manifest`)
// Зберігаємо локально: ticket ID → статус
await AsyncStorage.setItem(`manifest:${eventId}`, JSON.stringify(manifest))
return manifest
}
Маніфест — це мапа ticketId → { status, seat, checkedInAt, scannerId }. Для площадки на 3,000 місць це ~150КБ — синхронізується менш ніж за секунду навіть на 3G.
Процес скану (працює офлайн):
async function handleScan(qrData: string) {
// 1. Верифікація JWT підпису (локально, без мережі)
const payload = await verifyJWT(qrData, publicKey)
if (!payload) return { result: 'invalid' }
// 2. Перевірка локального маніфесту
const manifest = await getLocalManifest(payload.eid)
const ticket = manifest[payload.tid]
if (!ticket) return { result: 'invalid' }
if (ticket.status === 'refunded') return { result: 'invalid' }
if (ticket.checkedInAt) {
return {
result: 'duplicate',
checkedInAt: ticket.checkedInAt,
scannerId: ticket.scannerId,
}
}
// 3. Позначаємо як зареєстрованого локально
ticket.checkedInAt = Date.now()
ticket.scannerId = currentScannerId
await updateLocalManifest(payload.eid, manifest)
// 4. Синхронізація з сервером (fire-and-forget, ретраї у фоні)
syncQueue.push({ ticketId: payload.tid, checkedInAt: ticket.checkedInAt })
return { result: 'valid', seat: payload.seat }
}
Кроки 1–3 повністю локальні. Крок 4 синхронізується за наявності зв'язку. Детальніше про офлайн-патерни включаючи вирішення конфліктів.
Патерн 3: Крос-сканерна синхронізація через Redis
Коли зв'язок є, чекіни синхронізуються через Redis pub/sub:
// Сервер: отримує чекін від сканера A
async function recordCheckIn(ticketId: string, scannerId: string) {
const key = `checkin:${ticketId}`
// SET NX — успішно тільки якщо ще не зачекінений
const set = await redis.set(key, scannerId, { NX: true, EX: 86400 })
if (!set) {
// Вже зачекінений іншим сканером
const existingScanner = await redis.get(key)
return { duplicate: true, scannerId: existingScanner }
}
// Розсилка всім сканерам цього заходу
await redis.publish(
`event:${eventId}:checkins`,
JSON.stringify({
ticketId,
scannerId,
timestamp: Date.now(),
})
)
// Запис у Postgres (асинхронно, не блокує відповідь скану)
await db.ticket.update({
where: { id: ticketId },
data: { checkedInAt: new Date(), scannerId },
})
return { duplicate: false }
}
SET NX атомарний — два сканери, що сканують один квиток в одну мілісекунду, все одно отримають коректний результат. Перший перемагає, другий отримує попередження про дублікат.
Кожен сканер підписаний на Redis-канал і оновлює локальний маніфест у реальному часі. Коли сканер повертається онлайн після офлайн-періоду, він програє чергу і підтягує свіжий маніфест.
Патерн 4: Що з конфліктами?
Цікавий edge case: сканер A чекінить квиток №42 офлайн. Сканер B (онлайн) теж сканує квиток №42 через хвилину.
Вирішення:
- Чекін сканера B потрапляє на сервер першим (він онлайн). Сервер записує його.
- Сканер A повертається онлайн, програє чергу. Сервер бачить, що квиток №42 вже зачекінений.
- Сервер відповідає
{ duplicate: true, scannerId: 'B', timestamp: ... }. - Локальний маніфест сканера A оновлюється. Ні втрати даних, ні подвійного запису.
Правило просте: серверний таймстамп перемагає, пріоритет у першого. Конфлікти рідкі на практиці — за 6 місяців продакшну у нас було 23 конфліктних події на 12,400+ квитків. Усі вирішились автоматично.
Патерн 5: Повернення та трансфери
Квиток, повернений після синхронізації маніфесту, має бути відхилений при скані. Це проходить через той самий Redis pub/sub:
// При обробці повернення
async function handleRefund(ticketId: string, eventId: string) {
await redis.publish(
`event:${eventId}:checkins`,
JSON.stringify({
ticketId,
status: 'refunded',
timestamp: Date.now(),
})
)
}
Онлайн-сканери отримують оновлення миттєво. Офлайн-сканери — при наступній синхронізації. Вікно вразливості (офлайн-сканер не знає про повернення) пом'якшується серверною звіркою — офлайн-чекін програється, сервер відхиляє його, сканер оновлюється.
На практиці повернення під час заходу рідкісні. За 6 місяців: 3 випадки. Всі спіймані при синхронізації.
UX, що має значення
Екран сканера має рівно три стани:
- Зелений + галочка: Валідний. Показує номер місця. Звуковий сигнал.
- Жовтий + попередження: Вже сканований. Показує коли і яким сканером. Інший тон.
- Червоний + хрестик: Невалідний або повернений. Гучний відмінний тон.
Навчання персоналу зайняло 5 хвилин. Комбінація колір + звук означає, що їм навіть не потрібно читати екран у темному залі.
Продакшн-числа
Після 6 місяців у продакшні на десятках заходів:
| Метрика | Значення |
|---|---|
| Оброблено квитків | 12,400+ |
| Середній час скан→результат | 120мс |
| P95 час скан→результат | 180мс |
| Аптайм | 99.95% |
| Інцидентів перепродажу | 0 |
| Конфліктів дублікатів | 23 (автовирішені) |
| Пропускна здатність | ~500 осіб/год/сканер |
| Найбільший захід | 3,000 місць, 4 сканери, 25 хв повний чекін |
Старий процес по паперових списках на тій самій площадці: ~120 осіб/год, 2+ години на повний чекін.
Що б я змінив
-
Дельта-синхронізація маніфесту: Зараз сканер тягне повний маніфест при кожній синхронізації. Для заходів понад 5K місць дельта-синхронізація (тільки змінені квитки з останнього синку) скоротила б трафік. Поки не знадобилось — 150КБ повних синків вистачає на поточному масштабі.
-
WebSocket для синхронізації сканерів: Зараз використовується Redis pub/sub → server-sent events на сканери. WebSocket-з'єднання дало б справжню двонаправлену комунікацію і меншу затримку для крос-сканерних оновлень.
-
Ланцюжок трансферів: Зараз трансфери створюють новий ticket ID. Ланцюжок (оригінал → трансфер → трансфер) дав би кращий аудит для промоутерів.
Висновок
Основний патерн простий: підписуємо при покупці, верифікуємо локально, синхронізуємо у фоні. JWT-підписи усувають вузьке місце бази даних при скані. Офлайн-first дизайн означає, що WiFi на площадці не важливий. Redis pub/sub вирішує крос-сканерну консистентність, яку офлайн-режим сам по собі не забезпечить.
Складною була не окрема частина — а змусити їх працювати разом надійно при 100+ сканах на хвилину з персоналом, якому дали 5 хвилин навчання.
Будуєш систему заходів або тикетинг, якому потрібен чекін на реальному масштабі? Напиши — я відвантажив саме цю архітектуру і можу зекономити тобі місяці крайніх випадків.