Build a QR Ticketing System with Node.js
Your event app needs tickets. Static QR codes work, but you have no idea who actually showed up.
Dynamic QR codes solve this. Every scan is logged. You see check-ins in real-time. You catch duplicate scans instantly.
In this tutorial, you'll build a QR ticketing system that:
- Generates unique ticket codes for each attendee
- Validates tickets at the door with a single scan
- Detects duplicate or fraudulent scans
- Shows real-time check-in analytics
Architecture overview
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Your App │────▶│ QRWorks │────▶│ Short URL │
│ (tickets) │ │ API │ │ Redirect │
└─────────────┘ └─────────────┘ └─────────────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Analytics │ │ Check-in │
│ Dashboard │ │ Handler │
└─────────────┘ └─────────────┘
When an attendee scans their ticket:
- QRWorks redirects to your check-in endpoint
- Your app validates the ticket and marks attendance
- QRWorks logs the scan with device, location, and timestamp
Set up your project
mkdir event-tickets
cd event-tickets
npm init -y
npm install express node-fetch dotenv
Create .env with your API key:
QRWORKS_API_KEY=your_api_key_here
QRWORKS_BASE_URL=https://api.qrworks.app
Generate ticket QR codes
When a user purchases a ticket, generate a dynamic QR code that points to your check-in handler:
// tickets.js
import fetch from 'node-fetch';
const API_KEY = process.env.QRWORKS_API_KEY;
const BASE_URL = process.env.QRWORKS_BASE_URL;
async function createTicket(eventId, attendeeId, attendeeEmail) {
// Your check-in URL with ticket identifier
const checkInUrl = `https://yourapp.com/checkin/${eventId}/${attendeeId}`;
const response = await fetch(`${BASE_URL}/v1/generate/dynamic`, {
method: 'POST',
headers: {
'X-API-Key': API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
redirect_url: checkInUrl,
metadata: {
event_id: eventId,
attendee_id: attendeeId,
email: attendeeEmail,
type: 'ticket'
}
})
});
const data = await response.json();
return {
ticketId: attendeeId,
qrCodeUrl: data.qr_code_url,
shortUrl: data.short_url,
analyticsId: data.analytics_id
};
}
The metadata field tags the QR code for filtering in analytics. You can search for all ticket scans, or scans for a specific event.
Build the check-in handler
When staff scan a ticket, the attendee's phone opens your check-in URL:
// server.js
import express from 'express';
const app = express();
const checkedIn = new Map(); // In production, use a database
app.get('/checkin/:eventId/:attendeeId', async (req, res) => {
const { eventId, attendeeId } = req.params;
const ticketKey = `${eventId}:${attendeeId}`;
// Check for duplicate scan
if (checkedIn.has(ticketKey)) {
const firstScan = checkedIn.get(ticketKey);
return res.status(400).json({
status: 'duplicate',
message: 'This ticket was already scanned',
firstScanTime: firstScan.timestamp,
firstScanLocation: firstScan.location
});
}
// Validate ticket exists in your database
const ticket = await getTicketFromDatabase(eventId, attendeeId);
if (!ticket) {
return res.status(404).json({
status: 'invalid',
message: 'Ticket not found'
});
}
// Mark as checked in
const scanInfo = {
timestamp: new Date().toISOString(),
location: req.headers['x-forwarded-for'] || req.ip
};
checkedIn.set(ticketKey, scanInfo);
// Update your database
await markAttendeeCheckedIn(eventId, attendeeId);
res.json({
status: 'success',
message: `Welcome, ${ticket.attendeeName}!`,
eventName: ticket.eventName,
checkInTime: scanInfo.timestamp
});
});
app.listen(3000, () => {
console.log('Check-in server running on port 3000');
});
Detect fraud with scan patterns
The analytics API reveals suspicious activity:
async function detectFraud(analyticsId) {
const response = await fetch(
`${BASE_URL}/v1/analytics/${analyticsId}`,
{
headers: { 'X-API-Key': API_KEY }
}
);
const analytics = await response.json();
const scans = analytics.scans;
// Flag 1: Multiple scans from different locations
const uniqueLocations = new Set(
scans.map(s => `${s.city},${s.country}`)
);
if (uniqueLocations.size > 1) {
console.warn('Warning: Ticket scanned from multiple locations');
console.warn('Locations:', [...uniqueLocations]);
}
// Flag 2: Rapid successive scans
for (let i = 1; i < scans.length; i++) {
const timeDiff = new Date(scans[i].scanned_at) -
new Date(scans[i-1].scanned_at);
if (timeDiff < 60000) { // Less than 1 minute apart
console.warn('Warning: Rapid scans detected');
console.warn(`${timeDiff/1000} seconds between scans`);
}
}
// Flag 3: Scan after event end time
const eventEndTime = new Date('2026-03-15T22:00:00Z');
const lateScans = scans.filter(
s => new Date(s.scanned_at) > eventEndTime
);
if (lateScans.length > 0) {
console.warn('Warning: Scans after event ended');
}
return {
totalScans: scans.length,
uniqueLocations: uniqueLocations.size,
suspiciousActivity: uniqueLocations.size > 1 || scans.length > 2
};
}
Real-time check-in dashboard
Query analytics to show live check-in stats:
async function getEventStats(eventId) {
// Get all ticket analytics for this event
const tickets = await getEventTickets(eventId);
let totalCheckedIn = 0;
let recentScans = [];
for (const ticket of tickets) {
const analytics = await fetch(
`${BASE_URL}/v1/analytics/${ticket.analyticsId}`,
{ headers: { 'X-API-Key': API_KEY } }
).then(r => r.json());
if (analytics.total_scans > 0) {
totalCheckedIn++;
recentScans.push({
attendee: ticket.attendeeName,
time: analytics.scans[0].scanned_at,
device: analytics.scans[0].device_type
});
}
}
// Sort by most recent
recentScans.sort((a, b) =>
new Date(b.time) - new Date(a.time)
);
return {
totalTickets: tickets.length,
checkedIn: totalCheckedIn,
percentCheckedIn: ((totalCheckedIn / tickets.length) * 100).toFixed(1),
recentScans: recentScans.slice(0, 10)
};
}
Display output:
Event: TechConf 2026
────────────────────
Total tickets: 500
Checked in: 342 (68.4%)
Recent check-ins:
• Sarah Chen - 2 minutes ago (iOS)
• Marcus Johnson - 4 minutes ago (Android)
• Emily Rodriguez - 5 minutes ago (iOS)
Batch ticket generation
For large events, generate tickets in bulk:
async function generateEventTickets(eventId, attendees) {
const tickets = [];
const batchSize = 50;
for (let i = 0; i < attendees.length; i += batchSize) {
const batch = attendees.slice(i, i + batchSize);
const batchPromises = batch.map(attendee =>
createTicket(eventId, attendee.id, attendee.email)
);
const batchResults = await Promise.all(batchPromises);
tickets.push(...batchResults);
console.log(`Generated ${tickets.length}/${attendees.length} tickets`);
// Rate limit: pause between batches
if (i + batchSize < attendees.length) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
return tickets;
}
// Usage
const attendees = [
{ id: 'att_001', email: 'sarah@example.com', name: 'Sarah Chen' },
{ id: 'att_002', email: 'marcus@example.com', name: 'Marcus Johnson' },
// ... hundreds more
];
const tickets = await generateEventTickets('evt_techconf_2026', attendees);
Send tickets via email
Combine with your email service:
async function sendTicketEmail(attendee, ticket, event) {
const emailHtml = `
<h1>Your ticket to ${event.name}</h1>
<p>Hi ${attendee.name},</p>
<p>Here's your ticket for ${event.name} on ${event.date}.</p>
<div style="text-align: center; margin: 30px 0;">
<img src="${ticket.qrCodeUrl}" alt="Your ticket QR code"
style="width: 200px; height: 200px;" />
</div>
<p><strong>How to check in:</strong></p>
<ol>
<li>Open this email on your phone</li>
<li>Show the QR code to staff at the entrance</li>
<li>They'll scan it and you're in!</li>
</ol>
<p>Can't scan? Use this link: ${ticket.shortUrl}</p>
`;
await sendEmail({
to: attendee.email,
subject: `Your ticket to ${event.name}`,
html: emailHtml
});
}
Handle offline check-ins
For venues with poor connectivity, cache ticket data locally:
// Pre-load valid tickets before the event
async function preloadTickets(eventId) {
const tickets = await getEventTickets(eventId);
const ticketCache = new Map();
for (const ticket of tickets) {
ticketCache.set(ticket.attendeeId, {
name: ticket.attendeeName,
email: ticket.email,
checkedIn: false
});
}
// Save to local storage or SQLite
await saveToLocalCache(eventId, ticketCache);
console.log(`Cached ${tickets.length} tickets for offline use`);
}
// Offline check-in
function offlineCheckIn(eventId, attendeeId) {
const cache = getLocalCache(eventId);
const ticket = cache.get(attendeeId);
if (!ticket) {
return { status: 'invalid', message: 'Ticket not found' };
}
if (ticket.checkedIn) {
return { status: 'duplicate', message: 'Already checked in' };
}
// Mark in local cache
ticket.checkedIn = true;
ticket.checkedInAt = new Date().toISOString();
cache.set(attendeeId, ticket);
saveToLocalCache(eventId, cache);
// Queue for sync when online
queueForSync({ eventId, attendeeId, timestamp: ticket.checkedInAt });
return { status: 'success', message: `Welcome, ${ticket.name}!` };
}
Analytics queries for event organizers
After the event, analyze check-in patterns:
async function generateEventReport(eventId) {
const tickets = await getEventTickets(eventId);
let checkedIn = 0;
let noShows = 0;
const checkInTimes = [];
const devices = { ios: 0, android: 0, other: 0 };
for (const ticket of tickets) {
const analytics = await fetch(
`${BASE_URL}/v1/analytics/${ticket.analyticsId}`,
{ headers: { 'X-API-Key': API_KEY } }
).then(r => r.json());
if (analytics.total_scans > 0) {
checkedIn++;
checkInTimes.push(new Date(analytics.scans[0].scanned_at));
const device = analytics.scans[0].device_type.toLowerCase();
if (device.includes('ios')) devices.ios++;
else if (device.includes('android')) devices.android++;
else devices.other++;
} else {
noShows++;
}
}
// Calculate check-in velocity
checkInTimes.sort((a, b) => a - b);
const firstCheckIn = checkInTimes[0];
const lastCheckIn = checkInTimes[checkInTimes.length - 1];
const duration = (lastCheckIn - firstCheckIn) / 1000 / 60; // minutes
return {
totalTickets: tickets.length,
checkedIn,
noShows,
attendanceRate: ((checkedIn / tickets.length) * 100).toFixed(1) + '%',
devices,
checkInDuration: `${duration.toFixed(0)} minutes`,
averagePerMinute: (checkedIn / duration).toFixed(1)
};
}
Output:
Event Report: TechConf 2026
───────────────────────────
Total tickets sold: 500
Checked in: 423 (84.6%)
No-shows: 77
Device breakdown:
• iOS: 262 (62%)
• Android: 149 (35%)
• Other: 12 (3%)
Check-in duration: 127 minutes
Average check-ins per minute: 3.3
Error handling
Handle API errors gracefully:
async function safeCreateTicket(eventId, attendeeId, email) {
try {
return await createTicket(eventId, attendeeId, email);
} catch (error) {
if (error.status === 429) {
// Rate limited - wait and retry
await new Promise(resolve => setTimeout(resolve, 5000));
return await createTicket(eventId, attendeeId, email);
}
if (error.status === 401) {
throw new Error('Invalid API key - check your credentials');
}
if (error.status === 402) {
throw new Error('Insufficient credits - top up your account');
}
// Log and continue for non-critical errors
console.error(`Failed to create ticket for ${attendeeId}:`, error);
return null;
}
}
Summary
You now have a QR ticketing system that:
- Generates unique dynamic QR codes for each attendee
- Validates tickets and detects duplicates at check-in
- Flags suspicious scan patterns (multiple locations, rapid scans)
- Provides real-time check-in analytics
- Works offline with local caching
- Generates post-event attendance reports
The key advantage over static QR codes: you know exactly who showed up, when, and on what device.
Ready to build your ticketing system? Get your API key and start generating trackable tickets.