Build a QR Ticket Scanner

Add a browser-based QR scanner to validate event tickets in real time.

Introduction

30 Second Summary

Think about the last time you went to a concert or conference. The organizer probably scanned a QR code on your phone in seconds to let you through the door.

In this project, you will build a browser-based QR ticket scanner that event organizers open on their phone to check attendees in at the door. Behind the scenes, a Django API validates each ticket and prevents the same code from being scanned twice.

What You'll Build

A mobile-friendly ticket check-in page that uses the phone's camera to scan QR codes and validates them against your Django database in real time.

By the end of this project, you'll have:

  • A Django API endpoint that validates ticket UUIDs and prevents duplicate entry using database-level row locking.
  • A browser-based QR scanner page that uses the organizer's phone camera to read attendee tickets.
  • Real-time visual feedback showing green for valid, red for already used, and amber for not found results.
  • Secret Mission: A live check-in counter dashboard that auto-refreshes to show scanning progress in real time.

Are there any prerequisites?

This project assumes you already have a Django project with a Ticket model that stores a UUID field and a QR code image URL. You should also have Python and a code editor installed on your machine.

Update Your Ticket Model

Your QR scanner needs to know whether a ticket has already been checked in. Right now, your Ticket model stores the ticket UUID and QR code image, but it has no way to track whether someone has actually walked through the door.

In this step, you'll add two new fields to your Ticket model that record scan status. Then you'll create a migration to apply those changes to your database.

In this step, get ready to:
  • Add tracking fields to your Ticket model.
  • Create and apply a database migration.
  • Verify the new fields appear in Django admin.
Add tracking fields to your Ticket model

Your scanner will need two pieces of information for every ticket: whether it has been used, and when it was scanned. Two new model fields will store this.

  • Open models.py in your code editor.
  • Find your Ticket class and locate the last field definition (after your existing fields like ticket_id and your QR code URL field).
  • Add the following two fields below your existing fields:
    # Track whether this ticket has been scanned at check-in
    is_used = models.BooleanField(default=False)
    used_at = models.DateTimeField(null=True, blank=True)

What do these fields do?

is_used is a boolean that defaults to False. When a ticket gets scanned successfully, your validation endpoint will flip this to True.

used_at records the exact timestamp of the scan. It starts as null because unused tickets have no scan time yet.

  • Save models.py.

Not sure where to add the fields?

Place them inside your Ticket class, at the same indentation level as your existing fields. They should sit after your ticket_id and QR code URL fields, but before any methods like __str__.

Help me figure out where to add the new fields in my Ticket model.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here's what your models.py should look like with the new fields added. Your existing fields will differ, but the new additions should match:

from django.db import models
import uuid


class Ticket(models.Model):
    # ... your existing fields ...
    ticket_id = models.UUIDField(default=uuid.uuid4, unique=True)
    # qr_code_url = models.URLField()  # your existing Cloudinary QR URL
    is_used = models.BooleanField(default=False)
    used_at = models.DateTimeField(null=True, blank=True)

    def __str__(self):
        return str(self.ticket_id)
Create and run the migration

Django doesn't apply model changes to the database automatically. You need to generate a migration file that describes the change, then run it to update your database schema.

  • Generate the migration file by running this command in your terminal:
python manage.py makemigrations
  • You should see output showing that Django detected the two new fields. It will list is_used and used_at as additions to your Ticket model.
  • Apply the migration to your database by running:
python manage.py migrate
  • You should see a confirmation that the migration was applied successfully.

Seeing "No changes detected"?

Make sure you saved models.py before running makemigrations. Django reads the file from disk, not from your editor's buffer.

Also check that the new fields are indented inside the Ticket class body (four spaces in from the class keyword). Fields outside the class won't be picked up.

Help me debug why Django says No changes detected when I added new fields.

Verify the fields in Django admin

A quick check in Django admin confirms the database schema updated correctly.

  • Start your development server by running:
python manage.py runserver
  • Open your browser and go to http://127.0.0.1:8000/admin/.
  • Log in with your admin credentials.
  • Click on Tickets to view the ticket list.
  • Click on any existing ticket (or create a new one).
  • You should see the Is used checkbox and the Used at date field in the detail view.

Why check admin?

Django admin auto-generates forms from your model fields. If the new fields appear in the admin detail page, the migration applied correctly and your database is ready to track ticket scans.

Don't see the new fields in admin?

If your admin.py uses a custom fields or fieldsets list, you'll need to add is_used and used_at to that list manually. If you don't have custom field lists, admin should display them automatically.

Help me figure out why my new model fields aren't showing in Django admin.

Your database is ready to track ticket scans. Next up, you'll build the API endpoint that your scanner will call to validate and mark tickets as used.

Build the Validation API Endpoint

Your Ticket model now tracks whether a ticket has been used and when. The next piece is the brains of the operation: an API endpoint that receives a scanned UUID, checks whether the ticket is valid, and marks it as used.

The tricky part is what happens when two organizers scan the same ticket at the exact same moment. Without protection, both scans could read the ticket as unused and mark it valid. You will solve this with a database lock that ensures only one scan can process at a time.

In this step, get ready to:
  • Create a validation API endpoint that accepts POST requests with a scanned UUID.
  • Use database locking to prevent race conditions when two people scan the same ticket simultaneously.
  • Wire up URL patterns so the scanner page and API are accessible.
Set up your views file

Your views file needs imports for JSON handling, authentication decorators, and database transactions. You will also create a simple view that serves the scanner page (which you will build in the next step).

  • Open views.py in your tickets app.
  • Replace or add the following imports and scanner page view:
import json
from django.http import JsonResponse
from django.shortcuts import render
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_POST
from django.db import transaction
from django.utils import timezone

from .models import Ticket


@login_required
def scanner_page(request):
    return render(request, "tickets/scanner.html")

What does this code do?

The imports bring in everything the validation endpoint will need: json for parsing request bodies, JsonResponse for returning JSON to the frontend, and transaction for database locking.

The scanner_page view simply renders an HTML template. The @login_required decorator ensures only logged-in organizers can access the scanner. You will create the template in Step 3.

  • Save views.py.
  • You won't see a change yet. The next chunk adds the validation logic that makes this file useful.
Build the ticket validation logic

At a real event, two volunteers might scan the same ticket at the same instant. A naive read-then-write would let both through.

What is select_for_update?

Django's select_for_update() acquires a row-level database lock inside a transaction.atomic() block. While one request holds the lock, any other request trying to read the same row waits until the first one finishes.

This guarantees that only one scan can mark a ticket as used. The second scan will see the updated is_used=True value and return an "already used" response.

  • Add the validate_ticket function below scanner_page in views.py:
@login_required
@require_POST
def validate_ticket(request):
    # Parse the JSON body from the scanner's fetch() call
    try:
        body = json.loads(request.body)
        ticket_uuid = body.get("ticket_uuid", "")
    except json.JSONDecodeError:
        return JsonResponse(
            {"status": "error", "message": "Invalid request body"},
            status=400,
        )

    if not ticket_uuid:
        return JsonResponse(
            {"status": "error", "message": "No ticket UUID provided"},
            status=400,
        )

    # Lock the ticket row to prevent race conditions
    with transaction.atomic():
        try:
            ticket = (
                Ticket.objects.select_for_update().get(ticket_id=ticket_uuid)
            )
        except Ticket.DoesNotExist:
            return JsonResponse(
                {"status": "not_found", "message": "Ticket not found"},
                status=404,
            )

        # Reject tickets that have already been scanned
        if ticket.is_used:
            return JsonResponse(
                {
                    "status": "already_used",
                    "message": "This ticket has already been scanned",
                    "used_at": ticket.used_at.strftime("%Y-%m-%d %H:%M:%S"),
                },
                status=400,
            )

        # Mark the ticket as used
        ticket.is_used = True
        ticket.used_at = timezone.now()
        ticket.save()

    return JsonResponse(
        {
            "status": "valid",
            "message": "Ticket validated successfully",
            "ticket_id": str(ticket.ticket_id),
        },
        status=200,
    )

What does this endpoint handle?

This function handles three scenarios that the scanner frontend will encounter:

  • Valid ticket: the UUID exists and is_used is False. The endpoint marks it as used and returns status: "valid".
  • Already used: the UUID exists but is_used is already True. Returns status: "already_used" with the timestamp of the first scan.
  • Not found: no ticket matches the UUID. Returns status: "not_found".

The @require_POST decorator rejects any non-POST request automatically, so GET requests to this URL return a 405 error.

  • Save views.py.
  • Run your development server to confirm there are no syntax or import errors:
python manage.py runserver
  • You should see the normal server startup output with no tracebacks. Press Ctrl+C to stop the server for now.

Seeing an ImportError or SyntaxError?

  • Check that all seven import lines at the top of views.py match exactly. A missing from django.db import transaction line is the most common miss.
  • Make sure from .models import Ticket uses a dot before models. The dot means "import from this app."
  • Confirm your indentation is consistent. Python raises IndentationError if you mix tabs and spaces.

Why is my Django server failing to start?

Connect the views to URL patterns

Django needs URL patterns to know which view handles which web address. You will add two routes: one for the scanner page and one for the validation API.

  • Open urls.py in your tickets app.
  • Add the following URL patterns (keeping any existing patterns you already have):
from django.urls import path
from . import views

urlpatterns = [
    # ... your existing URL patterns ...
    path("scanner/", views.scanner_page, name="scanner_page"),
    path("api/validate-ticket/", views.validate_ticket, name="validate_ticket"),
]

What do these URL patterns do?

  • scanner/ serves the camera-based scanner page that organizers open on their phone.
  • api/validate-ticket/ is the endpoint the scanner's JavaScript calls when it decodes a QR code. The name parameter lets you reference these URLs in templates without hardcoding paths.
  • Save urls.py.
  • Start your development server again:
python manage.py runserver
  • The server should start without errors. You will see the normal startup output showing the server address.

Server won't start after adding URLs?

  • If you see AttributeError: module 'views' has no attribute 'scanner_page', make sure urls.py uses from . import views (with the dot).
  • If you see a ModuleNotFoundError, confirm that urls.py is in the same folder as views.py.

Help me fix my Django URL configuration error.

✔️ Awesome, I've got everything!

Great. Double check you have saved both views.py and urls.py.

ⓧ I'd like to double check the full code

Here is the complete views.py file:

import json
from django.http import JsonResponse
from django.shortcuts import render
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_POST
from django.db import transaction
from django.utils import timezone

from .models import Ticket


@login_required
def scanner_page(request):
    return render(request, "tickets/scanner.html")


@login_required
@require_POST
def validate_ticket(request):
    try:
        body = json.loads(request.body)
        ticket_uuid = body.get("ticket_uuid", "")
    except json.JSONDecodeError:
        return JsonResponse(
            {"status": "error", "message": "Invalid request body"},
            status=400,
        )

    if not ticket_uuid:
        return JsonResponse(
            {"status": "error", "message": "No ticket UUID provided"},
            status=400,
        )

    with transaction.atomic():
        try:
            ticket = (
                Ticket.objects.select_for_update().get(ticket_id=ticket_uuid)
            )
        except Ticket.DoesNotExist:
            return JsonResponse(
                {"status": "not_found", "message": "Ticket not found"},
                status=404,
            )

        if ticket.is_used:
            return JsonResponse(
                {
                    "status": "already_used",
                    "message": "This ticket has already been scanned",
                    "used_at": ticket.used_at.strftime("%Y-%m-%d %H:%M:%S"),
                },
                status=400,
            )

        ticket.is_used = True
        ticket.used_at = timezone.now()
        ticket.save()

    return JsonResponse(
        {
            "status": "valid",
            "message": "Ticket validated successfully",
            "ticket_id": str(ticket.ticket_id),
        },
        status=200,
    )

Here is the complete urls.py file:

from django.urls import path
from . import views

urlpatterns = [
    # ... your existing URL patterns ...
    path("scanner/", views.scanner_page, name="scanner_page"),
    path("api/validate-ticket/", views.validate_ticket, name="validate_ticket"),
]

Why not Django REST Framework?

Since this project uses regular Django views (not Django REST Framework), JsonResponse is the right tool for returning JSON. It keeps the project lightweight without adding an extra dependency for a single endpoint.

Your validation API is ready to receive scanned UUIDs and respond with the right status. Next up, you will build the scanner page that uses the phone's camera to read QR codes and calls this endpoint.

Build the Scanner Page

Your validation API is ready to accept scanned ticket UUIDs and return a verdict. Now you need the page that actually does the scanning.

In this step, you'll build a mobile-friendly scanner template that accesses the organizer's camera, decodes QR codes in real time, and sends each scanned UUID to your API. The result comes back as color-coded feedback so the organizer instantly knows whether to let someone in.

In this step, get ready to:
  • Create the scanner template with the html5-qrcode library loaded via CDN.
  • Wire the scan callback to your validation API endpoint.
  • Add color-coded visual feedback for scan results.
Create the scanner template

Django looks for templates inside a templates/ subdirectory within each app. Your scanner_page view renders tickets/scanner.html, so you need to create the matching directory structure inside your tickets app.

  • Create the template directory by running this command from inside your tickets app folder:
mkdir -p templates/tickets
  • Create a new file called scanner.html inside templates/tickets/.
  • Paste the following code into scanner.html:
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Ticket Scanner</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            background-color: #1a1a2e;
            color: #ffffff;
            min-height: 100vh;
            padding: 20px;
        }
        h1 {
            text-align: center;
            margin-bottom: 20px;
            font-size: 1.5rem;
        }
        #reader {
            width: 100%;
            max-width: 500px;
            margin: 0 auto;
        }
        #result {
            max-width: 500px;
            margin: 20px auto;
            padding: 20px;
            border-radius: 12px;
            text-align: center;
            font-size: 1.2rem;
            font-weight: bold;
            display: none;
        }
        #result.valid {
            display: block;
            background-color: #10b981;
            color: #ffffff;
        }
        #result.already-used {
            display: block;
            background-color: #ef4444;
            color: #ffffff;
        }
        #result.not-found {
            display: block;
            background-color: #f59e0b;
            color: #ffffff;
        }
        #result.error {
            display: block;
            background-color: #6b7280;
            color: #ffffff;
        }
        #result p {
            margin-top: 8px;
            font-size: 0.9rem;
            font-weight: normal;
        }
        #scan-again {
            display: none;
            max-width: 500px;
            margin: 16px auto;
            padding: 12px 24px;
            background-color: #3b82f6;
            color: #ffffff;
            border: none;
            border-radius: 8px;
            font-size: 1rem;
            cursor: pointer;
            width: 100%;
        }
    </style>
</head>
<body>
    <h1>🎫 Ticket Scanner</h1>
    <div id="reader"></div>
    <div id="result"></div>
    <button id="scan-again" onclick="resetScanner()">Scan Another Ticket</button>

    <script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
    <script>
        let html5QrcodeScanner = null;
        let lastScannedResult = "";

        function initScanner() {
            html5QrcodeScanner = new Html5QrcodeScanner(
                "reader",
                { fps: 10, qrbox: { width: 250, height: 250 } },
                false
            );
            html5QrcodeScanner.render(onScanSuccess);
        }

        function onScanSuccess(decodedText) {
            if (decodedText === lastScannedResult) {
                return;
            }
            lastScannedResult = decodedText;

            html5QrcodeScanner.clear();

            validateTicket(decodedText);
        }

        initScanner();
    </script>
</body>
</html>

What does this code do?

The template loads the html5-qrcode library from a CDN. No install step needed.

The initScanner() function creates an Html5QrcodeScanner instance that targets the #reader div. It scans at 10 frames per second with a 250x250 pixel scan box.

When a QR code is detected, onScanSuccess fires. It compares the decoded text against lastScannedResult to prevent duplicate scans, then clears the scanner UI and hands the UUID to validateTicket() (which you will add next).

  • Save scanner.html.
  • Make sure your Django dev server is running. Navigate to http://localhost:8000/scanner/ in your browser.
  • You should see a camera permission prompt. After granting access, the QR scanner viewfinder appears inside the page.

Don't see the scanner UI?

  • Check that your file is at the correct path: tickets/templates/tickets/scanner.html (the app name appears twice in the path).
  • Make sure APP_DIRS is set to True in your TEMPLATES setting in settings.py.
  • Camera access requires HTTPS in production. On localhost it works over plain HTTP.

Help me debug why my scanner template isn't loading.

Wire up the validation API call

The onScanSuccess callback calls validateTicket() but that function does not exist yet. You need to add it so the scanned UUID gets POSTed to your Django API endpoint.

  • In scanner.html, find the initScanner(); call at the bottom of the script block.
  • Add the following two functions directly above that initScanner(); line:
        function validateTicket(ticketUuid) {
            fetch("{% url 'validate_ticket' %}", {
                method: "POST",
                headers: {
                    "Content-Type": "application/json",
                    "X-CSRFToken": getCsrfToken(),
                },
                body: JSON.stringify({ ticket_uuid: ticketUuid }),
            })
                .then((response) => response.json())
                .then((data) => {
                    showResult(data.status, data.message);
                })
                .catch((error) => {
                    showResult("error", "Network error. Please try again.");
                });
        }

        function getCsrfToken() {
            const name = "csrftoken";
            const cookies = document.cookie.split(";");
            for (let cookie of cookies) {
                cookie = cookie.trim();
                if (cookie.startsWith(name + "=")) {
                    return cookie.substring(name.length + 1);
                }
            }
            return "";
        }

What does this code do?

The validateTicket() function sends a POST request to your validate_ticket endpoint using the fetch() API. Django's {% url 'validate_ticket' %} template tag resolves the correct URL at render time.

Django requires a CSRF token on every POST request. The getCsrfToken() helper reads the csrftoken cookie and sends it in the X-CSRFToken header. Django's CSRF middleware accepts this header by default.

  • Save scanner.html.
  • You won't see a visible change yet. The next chunk adds the visual feedback that showResult() needs.
Display color-coded feedback for scan results

When the API returns a response, the organizer needs an instant visual signal. Green means the ticket is valid. Red means it was already scanned. Amber means the UUID was not found.

  • In scanner.html, find the getCsrfToken() function you just added.
  • Add the following two functions directly above getCsrfToken() (between validateTicket and getCsrfToken):
        function showResult(status, message) {
            const resultDiv = document.getElementById("result");
            const scanAgainBtn = document.getElementById("scan-again");

            resultDiv.className = "";

            if (status === "valid") {
                resultDiv.className = "valid";
                resultDiv.innerHTML = "&#10004; VALID<p>" + message + "</p>";
            } else if (status === "already_used") {
                resultDiv.className = "already-used";
                resultDiv.innerHTML = "&#10008; ALREADY USED<p>" + message + "</p>";
            } else if (status === "not_found") {
                resultDiv.className = "not-found";
                resultDiv.innerHTML = "&#9888; NOT FOUND<p>" + message + "</p>";
            } else {
                resultDiv.className = "error";
                resultDiv.innerHTML = "&#9888; ERROR<p>" + message + "</p>";
            }

            scanAgainBtn.style.display = "block";
        }

        function resetScanner() {
            const resultDiv = document.getElementById("result");
            const scanAgainBtn = document.getElementById("scan-again");

            resultDiv.className = "";
            resultDiv.style.display = "none";
            resultDiv.innerHTML = "";
            scanAgainBtn.style.display = "none";
            lastScannedResult = "";

            initScanner();
        }

What does this code do?

The showResult() function sets the CSS class on the #result div based on the API response status. Each class triggers a different background color defined in the styles above (.valid is green, .already-used is red, .not-found is amber, .error is grey).

The resetScanner() function clears the result, hides the button, resets the duplicate-scan guard, and re-initializes the camera scanner so the organizer can scan the next attendee.

  • Save scanner.html.
  • Refresh http://localhost:8000/scanner/ in your browser. The scanner viewfinder should still appear. The feedback UI is hidden until a QR code is actually scanned (you will test this in the next step).

Seeing a JavaScript error in the console?

  • Open your browser's DevTools console and look for the specific error. A ReferenceError usually means a function name is misspelled or placed in the wrong order.
  • Make sure showResult and resetScanner are placed between validateTicket and getCsrfToken in the script.
  • Confirm the initScanner(); call remains as the very last line before </script>.

Help me debug a JavaScript error on my scanner page.

✔️ Awesome, I've got everything!

Great. Double check you have saved scanner.html and the scanner viewfinder loads when you visit http://localhost:8000/scanner/.

ⓧ I'd like to double check the full code

Here is the complete scanner.html file. Compare it with yours:

{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Ticket Scanner</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            background-color: #1a1a2e;
            color: #ffffff;
            min-height: 100vh;
            padding: 20px;
        }
        h1 {
            text-align: center;
            margin-bottom: 20px;
            font-size: 1.5rem;
        }
        #reader {
            width: 100%;
            max-width: 500px;
            margin: 0 auto;
        }
        #result {
            max-width: 500px;
            margin: 20px auto;
            padding: 20px;
            border-radius: 12px;
            text-align: center;
            font-size: 1.2rem;
            font-weight: bold;
            display: none;
        }
        #result.valid {
            display: block;
            background-color: #10b981;
            color: #ffffff;
        }
        #result.already-used {
            display: block;
            background-color: #ef4444;
            color: #ffffff;
        }
        #result.not-found {
            display: block;
            background-color: #f59e0b;
            color: #ffffff;
        }
        #result.error {
            display: block;
            background-color: #6b7280;
            color: #ffffff;
        }
        #result p {
            margin-top: 8px;
            font-size: 0.9rem;
            font-weight: normal;
        }
        #scan-again {
            display: none;
            max-width: 500px;
            margin: 16px auto;
            padding: 12px 24px;
            background-color: #3b82f6;
            color: #ffffff;
            border: none;
            border-radius: 8px;
            font-size: 1rem;
            cursor: pointer;
            width: 100%;
        }
    </style>
</head>
<body>
    <h1>🎫 Ticket Scanner</h1>
    <div id="reader"></div>
    <div id="result"></div>
    <button id="scan-again" onclick="resetScanner()">Scan Another Ticket</button>

    <script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
    <script>
        let html5QrcodeScanner = null;
        let lastScannedResult = "";

        function initScanner() {
            html5QrcodeScanner = new Html5QrcodeScanner(
                "reader",
                { fps: 10, qrbox: { width: 250, height: 250 } },
                false
            );
            html5QrcodeScanner.render(onScanSuccess);
        }

        function onScanSuccess(decodedText) {
            if (decodedText === lastScannedResult) {
                return;
            }
            lastScannedResult = decodedText;

            html5QrcodeScanner.clear();

            validateTicket(decodedText);
        }

        function validateTicket(ticketUuid) {
            fetch("{% url 'validate_ticket' %}", {
                method: "POST",
                headers: {
                    "Content-Type": "application/json",
                    "X-CSRFToken": getCsrfToken(),
                },
                body: JSON.stringify({ ticket_uuid: ticketUuid }),
            })
                .then((response) => response.json())
                .then((data) => {
                    showResult(data.status, data.message);
                })
                .catch((error) => {
                    showResult("error", "Network error. Please try again.");
                });
        }

        function showResult(status, message) {
            const resultDiv = document.getElementById("result");
            const scanAgainBtn = document.getElementById("scan-again");

            resultDiv.className = "";

            if (status === "valid") {
                resultDiv.className = "valid";
                resultDiv.innerHTML = "&#10004; VALID<p>" + message + "</p>";
            } else if (status === "already_used") {
                resultDiv.className = "already-used";
                resultDiv.innerHTML = "&#10008; ALREADY USED<p>" + message + "</p>";
            } else if (status === "not_found") {
                resultDiv.className = "not-found";
                resultDiv.innerHTML = "&#9888; NOT FOUND<p>" + message + "</p>";
            } else {
                resultDiv.className = "error";
                resultDiv.innerHTML = "&#9888; ERROR<p>" + message + "</p>";
            }

            scanAgainBtn.style.display = "block";
        }

        function resetScanner() {
            const resultDiv = document.getElementById("result");
            const scanAgainBtn = document.getElementById("scan-again");

            resultDiv.className = "";
            resultDiv.style.display = "none";
            resultDiv.innerHTML = "";
            scanAgainBtn.style.display = "none";
            lastScannedResult = "";

            initScanner();
        }

        function getCsrfToken() {
            const name = "csrftoken";
            const cookies = document.cookie.split(";");
            for (let cookie of cookies) {
                cookie = cookie.trim();
                if (cookie.startsWith(name + "=")) {
                    return cookie.substring(name.length + 1);
                }
            }
            return "";
        }

        initScanner();
    </script>
</body>
</html>

Your scanner page is fully wired. Next up, you'll generate a test QR code and run through the complete scan-to-validate flow to confirm everything works end to end.

Test the Full Flow

You've built the model, the validation API, and the scanner frontend. Now it's time to wire it all together and test.

You'll create a test ticket in the Django shell, generate a QR code containing its UUID, and scan it twice. The first scan should return a green "valid" result. The second scan of the same code should return a red "already used" result.

In this step, get ready to:
  • Create a test ticket and generate a QR code from its UUID.
  • Open the scanner page and scan the QR code with your camera.
  • Verify that duplicate scan prevention works correctly.
Create a test ticket in the Django shell

Before you can scan anything, you need a ticket in your database with a UUID. The Django shell lets you create one interactively.

  • Open your terminal in your project's root directory and start the Django shell by running:
python manage.py shell
  • Create a test ticket and print its UUID by running these commands inside the shell:
from tickets.models import Ticket

# Create a new ticket (add values for your existing required fields)
ticket = Ticket.objects.create()
print(ticket.ticket_id)

What does this do?

Ticket.objects.create() inserts a new row in your database. Because ticket_id has default=uuid.uuid4, Django automatically generates a unique UUID for the ticket.

The print() line outputs the UUID string you'll use to generate your QR code.

Got required fields on your Ticket model?

If your existing Ticket model has required fields (like an event foreign key or attendee name), pass them into create(). For example: Ticket.objects.create(event=my_event, attendee_name="Test User").

  • Copy the UUID that prints in your terminal. It looks something like a1b2c3d4-e5f6-7890-abcd-ef1234567890.
  • Exit the shell by typing:
exit()

Now you need a QR code image that contains this UUID string so your scanner can read it.

  • Open any free QR code generator website in your browser (search for "QR code generator").
  • Paste your ticket's UUID into the text field.
  • Generate the QR code and keep it displayed on your screen (or open it on your phone).

Why generate a QR code manually?

Your existing ticket system likely generates QR codes when tickets are purchased. For testing purposes, you just need any QR code image that contains the raw UUID string. The scanner reads whatever text is encoded in the QR code and sends it to your validation endpoint.

Shell won't start?

Make sure you're in the same directory as your manage.py file. If you see a "No module named" error, check that your virtual environment is activated.

help me troubleshoot my Django shell not starting

Start the server and open the scanner page

Your scanner page is protected by @login_required, so you'll need to log in before you can access it.

  • Start the Django development server by running:
python manage.py runserver
  • Open your browser and go to http://localhost:8000/admin/.
  • Log in with your Django superuser credentials.
  • After logging in, navigate to http://localhost:8000/scanner/ in the same browser.

Your browser will ask for camera permission. Grant it so the scanner can access your webcam.

You should see the scanner page with a live camera feed and a scanning box in the center.

Using your phone instead?

To use your phone as the scanner, you'll need to start the server with python manage.py runserver 0.0.0.0:8000 and access it via your computer's local IP address (e.g. http://192.168.1.x:8000/scanner/). Both devices must be on the same Wi-Fi network.

Camera access requires either HTTPS or localhost. Your phone browser will only grant camera permission on a secure origin.

Camera not showing up?

Check that you clicked **Allow** when the browser asked for camera permission. If you accidentally denied it, click the camera icon in your browser's address bar to change the permission.

help me get camera permissions working for my QR scanner page

Scan the QR code and verify both responses

This is the moment of truth. You'll scan your test ticket's QR code twice to confirm the full validation flow works end-to-end.

  • Hold your QR code (displayed on your phone or a second screen) in front of your webcam.
  • Position it inside the scanning box until the scanner reads it.

The scanner should detect the QR code within a few seconds. You should see a green result box with a checkmark and the message "Ticket validated successfully".

  • Click the Scan Another Ticket button to restart the scanner.
  • Scan the exact same QR code again.

This time you should see a red result box with an X and the message "This ticket has already been scanned". This proves your duplicate scan prevention is working.

What just happened behind the scenes?

On the first scan, your validate_ticket view found the ticket, confirmed is_used was False, set it to True, and recorded the timestamp in used_at.

On the second scan, the view found the same ticket but is_used was already True. It returned an already_used status with the original scan time. The select_for_update() lock ensures this check is safe even if two people scan simultaneously.

  • As a final check, go to http://localhost:8000/admin/ and open your Tickets list.
  • Find your test ticket and confirm that is_used is checked and used_at shows a timestamp.

Seeing a 403 Forbidden error?

This usually means the CSRF token isn't being sent correctly. Make sure you're logged in to Django admin in the same browser. The getCsrfToken() function reads the csrftoken cookie that Django sets when you log in.

help me fix a 403 CSRF error on my Django ticket validation endpoint

Your ticket scanner is fully operational. You've confirmed that valid tickets get checked in, duplicates get rejected, and the database reflects the real-time state. Next up, push this further with a live check-in dashboard.

Secret mission

Add a Real-Time Check-in Counter Dashboard

Your scanner works great for one-at-a-time validation. But what if you want a bird's-eye view of check-in progress? Build a live dashboard that shows how many attendees have arrived, updates automatically every 10 seconds, and displays the last 5 check-ins.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using your Django development server, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Django development server (running process).
  • Template files: templates/tickets/scanner.html and templates/tickets/dashboard.html.
  • Code additions to models.py, views.py, and urls.py.
  • Database migration adding is_used and used_at fields.

Keep everything running

No action needed. Choose this if you're still actively building on your events platform or plan to use the ticket scanner at an upcoming event.

  • Your Django development server stays running and the scanner page remains accessible.
  • All template files, views, and model changes stay in place.
  • The html5-qrcode library loads from a CDN each time, so nothing is stored locally for it.

Pause - I'll come back to this later

Shut down the running Django server to free up your terminal. Your code stays saved and ready to resume.

  • Switch to the terminal running your Django server.
  • Press Ctrl+C to stop the server.
  • All your files and database changes remain intact. Run python manage.py runserver when you're ready to pick up again.

Delete - I don't want to use this again

Remove all the scanner-related code and revert the database changes. This brings your Django project back to its state before you started this project.

  • Switch to the terminal running your Django server and press Ctrl+C to stop it.
  • Delete the scanner and dashboard template files by running:

macOS

rm templates/tickets/scanner.html
rm templates/tickets/dashboard.html

Windows

del templates\tickets\scanner.html
del templates\tickets\dashboard.html
  • Open views.py and remove the scanner_page, validate_ticket, and checkin_stats functions.
  • Open urls.py and remove the three URL patterns for scanner/, api/validate-ticket/, and api/checkin-stats/.
  • Open models.py and remove the is_used and used_at fields from your Ticket model.
  • Create a new migration to drop the fields by running:
python manage.py makemigrations
python manage.py migrate
  • Delete the migration file that was created for this project from your app's migrations/ folder.

Nice Work!

Nice Work!

You just built a complete ticket validation system for your events platform. Organizers can now check attendees in at the door using nothing but their phone camera and a browser.

You've learned how to:

  • Build a browser-based QR scanner using html5-qrcode that accesses the phone camera and decodes ticket UUIDs in real time.
  • Create a secure Django API endpoint that validates tickets and prevents duplicate scans using select_for_update() for race-condition-safe database operations.
  • Implement color-coded visual feedback that instantly tells the organizer whether a scanned ticket is valid, already used, or not found.
  • Add a live check-in counter dashboard that auto-refreshes every 10 seconds to show scan progress and recent check-ins.

Ready to quiz yourself?