DEV Community

Jeffrey Inman
Jeffrey Inman

Posted on

QR ticket check-in for a WordPress event, without a scanner app

Most small events check people in off a printed list. It works until the line gets long and two volunteers are flipping through the same three pages looking for "Maria". The usual upgrade is a scanner app, which means everyone at the door installs something and logs into it.

There's a simpler version. Every phone camera already reads QR codes and opens whatever URL is inside. So the QR on a ticket can just be a link to your own site, and "checking someone in" is that page loading on a volunteer's phone.

I built this for a WordPress events plugin this week and a few of the decisions weren't obvious, so here they are. The snippets are plain PHP against WordPress APIs; adapt the table and names to whatever stores your bookings.

Don't put the booking link in the QR

Most booking systems email a private link that lets the guest see or cancel their booking. It's tempting to reuse it in the QR. Don't: people post photos of their tickets, and anyone who scans that photo gets the guest's cancel link.

Put a separate code in the QR that can only do one thing, check in. I derive it from the booking id and the booking's secret token with an HMAC, so it can't be guessed and doesn't reveal the token:

function checkin_code( array $booking ): string {
    $mac = hash_hmac( 'sha256', 'checkin|' . (int) $booking['id'] . '|' . $booking['token'], wp_salt( 'auth' ) );
    return (int) $booking['id'] . '-' . substr( $mac, 0, 24 ); // 96 bits is plenty for this
}

function booking_by_code( string $code ): ?array {
    if ( ! preg_match( '/^(\d+)-([a-f0-9]{24})$/', $code, $m ) ) {
        return null;
    }
    $row = get_booking( (int) $m[1] ); // your lookup
    return ( $row && hash_equals( checkin_code( $row ), $code ) ) ? $row : null;
}
Enter fullscreen mode Exit fullscreen mode

hash_equals rather than === so the comparison takes the same time whether the first character or the last one is wrong. The checkin| prefix keeps this MAC separate from anything else you sign with the same salt.

The QR then holds https://example.com/?checkin=42-3f9c….

Only staff phones can check people in

The check-in URL does nothing for a logged-out visitor except send them to the login page and back:

if ( ! is_user_logged_in() ) {
    wp_safe_redirect( wp_login_url( $checkin_url ) );
    exit;
}
if ( ! current_user_can( 'edit_others_posts' ) ) {
    // "This account cannot check people in."
}
Enter fullscreen mode Exit fullscreen mode

Each volunteer logs in once on their phone before doors open. After that, scan, see the result, next person.

A side effect I like: link previewers, email security scanners and chat apps that fetch URLs do it without your cookies, so a ticket pasted into Slack or forwarded to someone can't check anyone in.

Yes, a GET request changes state

Normally you don't let a GET change anything. Here the phone camera opens the link, so a GET is what you get, and putting a "Confirm" button on the page costs a tap per guest at the busiest moment of the night.

I kept the GET and leaned on two things: the capability check above, and the fact that check-in is a one-way stamp. Loading the page twice doesn't do anything the first load didn't. If your check-in does more (charges something, issues a wristband number), use a POST with a nonce and accept the extra tap.

Two phones, one ticket

This is the bug I actually shipped. My first version did the obvious thing: read the booking, see checked_in is empty, write the time. If two volunteers scan the same ticket at the same moment (a couple sharing one ticket, say), both requests read "not checked in" and both phones go green.

The fix is to make the write itself the check:

global $wpdb;
$n = $wpdb->query( $wpdb->prepare(
    'UPDATE %i SET checked_in = %s WHERE id = %d AND checked_in IS NULL',
    $table, gmdate( 'Y-m-d H:i:s' ), (int) $booking['id']
) );
$result = ( 1 === (int) $n ) ? 'ok' : 'already';
Enter fullscreen mode Exit fullscreen mode

The database only lets one of those updates match the row, so exactly one phone gets "ok". The other re-reads the row and shows "Already checked in at 7:42 pm", which is the information the volunteer actually needs. (%i for identifiers needs WordPress 6.2+.)

I wrote a test that loads the same row twice and checks it in with both copies. It failed on the old code and passes on the new one.

Screens you can read at arm's length

Doors are dark and people are impatient, so the result page is one word on a colored background:

  • green "Checked in", with the name, party size and ticket type
  • amber "Already checked in at 7:42 pm"
  • red for cancelled, unpaid, waitlisted, or a code that doesn't match anything

Send noindex and no-cache headers on both the ticket page and the check-in page. Neither should ever end up in a search index or a shared cache.

Draw the QR in the browser

Don't send the ticket URL to a third-party "QR image" API to get a PNG back. That URL is a credential, and you'd be handing it to someone else's logs. A small MIT library like qrcode-generator draws it client-side as an SVG:

var q = qrcode(0, 'M');           // auto size, medium error correction
q.addData(checkinUrl);
q.make();
document.getElementById('ticket-qr').innerHTML =
    q.createSvgTag({ cellSize: 6, margin: 2, scalable: true });
Enter fullscreen mode Exit fullscreen mode

SVG stays sharp at any screen size. I checked the output by decoding a screenshot of the ticket page with jsQR; it came back as the exact check-in URL.

What this doesn't handle

It needs a data connection at the door. If your venue is a basement with no signal, keep a printed door list as the fallback, with a box to tick per person. And it's one check-in per booking: a booking for four people is one scan, and the screen says "× 4" so the volunteer knows to wave the whole group through.

Disclosure

I built this for Beacon Events Pro, the paid add-on to Beacon Events, a free events calendar on wordpress.org. The QR tickets, check-in screens and door list are in the add-on. Everything above works the same in your own code if you'd rather build it yourself.

Top comments (0)