Building an Offline-First QR Check-In System

How I built a production-ready QR code check-in system using Cloudflare Workflows, offline-first iOS architecture, and smart merge protection to handle event check-ins without network connectivity.

Overview

This system automates guest check-ins for events by generating personalized QR codes and delivering them via email, then enabling staff to scan them on iOS—fully offline. When a guest is added to an event, a Postgres trigger fires a webhook to a Cloudflare Worker which orchestrates QR generation, R2 storage, and email delivery. On event day, staff use an iOS app that caches the entire guest list locally, performs instant QR lookups without network calls, and queues check-ins for sync when connectivity returns.

Three components make this work reliably: Cloudflare Workflows for durable, retryable backend orchestration; SwiftData local caching for offline-first iOS; and smart merge protection to prevent local check-in state from being overwritten during re-syncs.

System Architecture

QR Check-In System Architecture

  1. Guest Added → Postgres trigger fires a signed webhook to the Cloudflare Worker
  2. Workflow Orchestration → insert QR token → generate PNG → upload to R2 → send email
  3. Event Day → staff sync guest list to device before the event (full offline cache)
  4. Scan → local QR lookup, instant result, no network required
  5. Check-In → UI updates immediately; if offline, operation is queued locally
  6. Sync → pending check-ins flush to server automatically when network returns

Backend: Cloudflare Worker

Webhook-Driven Triggers

A Postgres trigger fires on guest insert/update when an email or phone is present:

CREATE TRIGGER handle_guest_qr_webhook
AFTER INSERT OR UPDATE ON guests
FOR EACH ROW
WHEN (NEW.email IS NOT NULL OR NEW.phone IS NOT NULL)
EXECUTE FUNCTION handle_guest_qr_webhook();

The Worker verifies the HMAC-SHA256 signature (Standard Webhooks spec) before proceeding. Timestamp validation prevents replay attacks.

// From: worker/utils/webhook.ts
const signedContent = `${webhookId}.${webhookTimestamp}.${body}`;
const secretBytes = Uint8Array.from(atob(secret), c => c.charCodeAt(0));
const key = await crypto.subtle.importKey("raw", secretBytes,
  { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const signature = await crypto.subtle.sign("HMAC", key,
  new TextEncoder().encode(signedContent));
const expectedSignature = btoa(String.fromCharCode(...new Uint8Array(signature)));

if (expectedSignature !== providedSignature) {
  throw new Error("Invalid webhook signature");
}

Cloudflare Workflows

Each step in the workflow is atomic—its result is persisted to durable storage. On failure, the workflow retries from the failed step, not from the beginning. No manual state management or retry logic required.

// From: worker/workflows/guest-qr.ts
export class GuestQrWorkflow extends WorkflowEntrypoint<Env, GuestQrParams> {
  async run(event: WorkflowEvent<GuestQrParams>, step: WorkflowStep) {
    const qrCode = await step.do("insert-qr-code", async () => {
      const supabase = getSupabaseClient(this.env);
      return await insertQrCode(supabase, guestId, email, phone);
    });

    const pngBuffer = await step.do("generate-qr-png", async () => {
      const bytes = await generateQrCodePng(qrCode.token, this.env.FRONTEND_URL);
      return Array.from(bytes); // Serialize for workflow state
    });

    const qrImageUrl = await step.do("upload-to-r2", async () => {
      const bytes = new Uint8Array(pngBuffer);
      return await uploadQrCodeToR2(
        this.env.R2_BUCKET,
        qrCode.token,
        bytes,
        this.env.R2_PUBLIC_DOMAIN
      );
    });

    const emailResult = await step.do("send-email", async () => {
      return await sendQrCodeEmail(supabase, qrCode, qrImageUrl, email);
    });
  }
}

QR Code Generation & Storage

QR codes are generated with error correction level H (30% damage tolerance) and encode a URL of the form https://promoted.club/passes?token={uuid}. Scanning with a regular camera opens a web page; the iOS app extracts the token for local lookup.

// From: worker/services/qr-generator.ts
const pngBuffer = qr.imageSync(qrUrl, {
  type: "png",
  ec_level: "H",
  size: 10,
  margin: 4,
});

R2 filenames use the UUID token rather than the database ID. This prevents sequential enumeration and makes re-uploads idempotent—if the workflow retries, the existing file is detected and skipped.

// From: worker/services/r2-storage.ts
const key = `qr-codes/${token}.png`;

const existing = await bucket.head(key);
if (existing) {
  return `${publicDomain}/${key}`;
}
await bucket.put(key, pngData);

Email

The QR image is embedded as an <img> pointing to the R2 public URL. workers-ses is used instead of the AWS SDK, which is incompatible with Cloudflare’s V8 isolate environment.

// From: worker/services/email.ts
const htmlContent = `
  <html>
    <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;">
      <div style="background: #3B82F6; color: #F8FAFC; padding: 30px 20px; text-align: center;">
        <h1 style="margin: 0;">${eventName}</h1>
        <p style="margin: 10px 0 0 0;">${clubName}</p>
      </div>
      <div style="padding: 20px; text-align: center;">
        <h2>Your Check-In Code</h2>
        <p>Present this QR code at the door:</p>
        <img src="${qrImageUrl}"
             style="border: 4px solid #3B82F6; border-radius: 8px; width: 200px; height: 200px;"
             alt="QR Code" />
        <p style="margin-top: 20px; color: #64748B;">
          Party of ${partySize} • ${eventDate}
        </p>
      </div>
    </body>
  </html>
`;

iOS: Offline-First Architecture

Data Models

CachedGuest is the local mirror of server guest data:

@Model
final class CachedGuest {
    var guestId: UUID
    var eventId: UUID
    var name: String
    var email: String?
    var phone: String?
    var partySize: Int
    var status: GuestStatus  // "pending", "checked_in", "no_show"
    var actualCount: Int?
    var qrToken: String?
    var localCheckInTime: Date?
    var isScannedLocally: Bool
}

PendingCheckIn is a unified queue for all offline operations—QR scans, manual check-ins, and reverts all go into the same table and are routed to the correct RPC function on sync:

// From: promoted/Models/PendingCheckIn.swift
@Model
final class PendingCheckIn {
    var guestId: UUID
    var qrToken: String?      // nil = manual check-in
    var eventId: UUID
    var promoterId: UUID
    var entryTypeId: UUID
    var actualCount: Int
    var isRevert: Bool        // true = revert check-in
    var scannedAt: Date
    var scannedByUserId: UUID

    var syncStatus: String    // "pending", "failed", "synced"
    var syncAttempts: Int
    var lastSyncError: String?
}

QR Scanning

Lookups are performed entirely against the local SwiftData store—no network call.

// From: promoted/Repositories/QRCodeRepository.swift
func lookupGuestByToken(_ token: String, eventId: UUID) async -> CachedGuest? {
    guard let context = modelContext else { return nil }

    let descriptor = FetchDescriptor<CachedGuest>(
        predicate: #Predicate { $0.qrToken == token && $0.eventId == eventId }
    )

    return try? context.fetch(descriptor).first
}

Optimistic UI & Offline Queue

The local cache is updated immediately on check-in. If offline, a PendingCheckIn record is inserted and synced later. From the user’s perspective, check-ins always succeed instantly.

// From: promoted/Repositories/QRCodeRepository.swift
cachedGuest.status = .checkedIn
cachedGuest.actualCount = actualCount
cachedGuest.localCheckInTime = Date()
cachedGuest.isScannedLocally = true
try context.save()

if !isOnline {
    let pending = PendingCheckIn(
        guestId: cachedGuest.guestId,
        qrToken: token,
        eventId: eventId,
        promoterId: cachedGuest.promoterId,
        entryTypeId: cachedGuest.entryTypeId,
        actualCount: actualCount,
        scannedByUserId: userId
    )
    context.insert(pending)
    try context.save()
    return true
}

Smart Merge Protection

Without protection, refreshing the guest list from the server would overwrite locally pending check-ins—a guest scanned offline would revert to pending status when the server’s stale data is downloaded.

The fix: collect the IDs of guests with pending check-ins before the download and skip those records during the upsert.

Smart Merge Protection Flow

// From: promoted/Repositories/CheckInRepository.swift
let protectedGuestIds = (try? guestCacheStore.protectedGuestIds(eventId: eventId)) ?? []

let downloadedLists = try await supabase.from("guest_lists")
    .select("""
        id, event_id, guests (
            id, name, email, status, actual_count, qr_codes (token)
        )
    """)
    .eq("event_id", value: eventId.uuidString)
    .execute()
    .value

let guestsSynced = try guestCacheStore.upsertDownloadedGuests(
    downloadedGuests,
    protectedGuestIds: protectedGuestIds
)
// From: promoted/Stores/GuestCacheStore.swift
for downloadedGuest in guests {
    if protectedGuestIds.contains(downloadedGuest.guestId) {
        continue  // Preserve local pending state
    }

    if let existing = try cachedGuest(...) {
        existing.status = downloadedGuest.status
        existing.actualCount = downloadedGuest.actualCount
    } else {
        // Insert new guest
    }
}

This also handles multi-device scenarios correctly: Device A and Device B can scan different guests offline, sync independently, and see each other’s check-ins without conflict.

Automatic Sync

NWPathMonitor triggers a sync flush whenever the network transitions to .satisfied:

// From: promoted/Repositories/CheckInRepository.swift
private func setupNetworkMonitoring() {
    networkMonitor.pathUpdateHandler = { [weak self] path in
        DispatchQueue.main.async {
            self?._isOnline = path.status == .satisfied

            if path.status == .satisfied {
                Task {
                    let _ = await self?.syncPendingCheckIns()
                }
            }
        }
    }
    networkMonitor.start(queue: offlineQueue)
}

Security

LayerMechanism
Webhook verificationHMAC-SHA256 + timestamp (Standard Webhooks); prevents unauthorized workflow triggers and replay attacks
Token-based QR URLsUUIDs as R2 filenames/QR tokens instead of sequential IDs; prevents enumeration
Audit loggingEvery scan logged to qr_scan_attempts (user, timestamp, token, success/failure)
AuthorizationDatabase-level RPC functions validate user permissions, event ownership, and event active status