GatePass — Event Tickets & QR Check-in for WooCommerce
Sell event tickets through WooCommerce. Every paid attendee receives a unique ticket ID and a cryptographically-random QR code by email (HTML + PDF), visible in My Account → My Tickets. Staff check people in with a mobile scanner; every QR is validated server-side, once, even when several gates scan the same code at the same instant.
The plugin lives in gatepass/. Zip that folder to install.
Table of contents
- Requirements
- Installation
- Five-minute setup
- How the lifecycle works
- Admin screens
- Shortcodes & URLs
- Staff roles
- Scanner
- Refunds, cancellations, resends
- Security model
- Database
- Testing report
- Known limitations
- Roadmap
- Troubleshooting
Requirements
| Minimum | |
|---|---|
| WordPress | 6.2 |
| WooCommerce | 8.0 (HPOS and Cart/Checkout Blocks supported and declared) |
| PHP | 7.4 (tested on 8.1 – 8.3) |
| MySQL / MariaDB | 5.7 / 10.3 (GET_LOCK, IFNULL) |
| HTTPS | Required for the camera scanner (browsers only expose getUserMedia on secure origins) |
No Composer dependencies. QR and PDF generation are implemented in-plugin (no external services, nothing leaves your server).
Installation
- Download / zip the
gatepassdirectory. - Plugins → Add New → Upload Plugin → activate. (Requires WooCommerce active; the plugin refuses to boot otherwise.)
- Activation creates 5 tables (see Database), two roles (Event Ticket Staff, Event Manager), grants all
gatepass_*capabilities to Administrators, and schedules an hourly ticket-expiry cron. - Go to Tickets → Settings and set organizer name, support email and brand colours.
Deactivation keeps all data. Uninstall keeps all data unless you tick Delete ALL ticket data when the plugin is uninstalled in Settings.
Five-minute setup
- Tickets → Events → Add event — name, ticket-ID prefix (e.g.
GRB→GRB-2026-000123), date, venue, capacity, banner. - Tickets → Ticket Types → Add ticket type — pick the event, name (Regular / VIP / Family…), price, quantity, people per ticket, early-bird price + quantity + window, per-customer max, validity window, features, terms, custom attendee fields. Saving creates (or updates) a virtual WooCommerce product with stock management enabled. You can also link an existing product by ID.
- Put
[gatepass_events]on a page (or just sell the WooCommerce product normally). - Tickets → Staff — create scanner accounts. Give staff the scanner URL: Tickets → Scanner in wp-admin or a page containing
[gatepass_scanner]. - Do one test purchase. Check Tickets → All Tickets, the email, the PDF, then scan it.
How the lifecycle works
Admin creates Event → Admin creates Ticket Type → WooCommerce product synced → Customer buys (cart/checkout/payment: 100% WooCommerce) → Order reaches Processing/Completed (configurable) → gp_tickets rows created (one per attendee, or one per group ticket) → 128-bit random token + human ticket ID per row → QR = https://site/?gp_verify=<token> (no personal data in the QR) → Email (HTML + PDF attachment), email_status recorded → Customer sees tickets in My Account → My Tickets (QR, PDF, status) → Staff scans → POST /wp-json/gatepass/v1/check-in → Atomic UPDATE … WHERE status='active' → exactly one scan wins → Ticket = Used; every attempt logged in gp_checkins → Refund / cancel → tickets Refunded / Cancelled → QR rejected → Admin sees everything in All Tickets → ticket detail (scan log + history)
Idempotency. Generation runs under a MySQL GET_LOCK('gatepass_gen_<order_id>') and compares existing rows per order item against the paid quantity, so duplicate webhooks, page refreshes, or processing → completed transitions never create duplicates.
Early bird. Price is decided server-side when the item is added to the cart and re-asserted on every cart calculation (woocommerce_before_calculate_totals), using sold + early_qty and the date window. Add-to-cart validation blocks quantities beyond remaining inventory, event capacity, or per-customer limits (counted across previous paid orders for logged-in users / same billing email).
Group tickets. Ticket type option One QR admits the whole group (one row, people = N, optional partial check-in 2/4 → 4/4) or One QR per person (N rows).
Admin screens
| Screen | What it does |
|---|---|
| Dashboard | Sold / checked-in / unused / remaining / attendees / revenue / today's check-ins, hourly check-in chart, per-type table. Filter by event. |
| Events | CRUD, capacity, banner. |
| Ticket Types | CRUD; syncs WooCommerce product. Shows sold, early-bird remaining. |
| All Tickets | Search (name / email / phone / ticket ID / order / token), filter by event, type, status, date. Bulk: resend, cancel, block, reactivate, regenerate QR, mark checked-in, delete. CSV export. Create ticket manually (walk-in; optionally creates a completed WC order). |
| Ticket detail | Full record, live QR, verify URL, actions, transfer/edit attendee, every scan attempt, full history log. |
| Scanner | Embedded mobile scanner. |
| Check-ins | Chronological log of every scan attempt (valid and rejected), CSV. |
| Reports | Sales by day, staff audit (scans / valid / rejected / first / last), top customers, CSV. |
| Staff | Create staff / manager accounts; revoke access. |
| Settings | Prefix, branding, generation statuses, refund-restore behaviour, attendee-name collection, email templates, PDF attach, scanner sound/vibration/auto-next, manual lookup, rate limit, expired acceptance, admin alerts, low-stock threshold, uninstall behaviour. |
WooCommerce integration points: product edit screen gets a Ticket tab (links to type), order edit screen lists the order's tickets, My Account gets a My Tickets endpoint, order emails/thank-you page list ticket IDs.
Shortcodes & URLs
[gatepass_events] | All published events with their ticket types (banner, date, price, early-bird badge, remaining bar, sold-out state). | |
[gatepass_tickets event_id="3"] | Ticket types for one event. | |
[gatepass_my_tickets] | Same as My Account → My Tickets, for a custom page. | |
[gatepass_verify] | Public token lookup form. | |
[gatepass_scanner] | Full-page mobile scanner (requires gatepass_scan). | |
/?gp_verify=<token> | Public read-only status page (never changes state). | |
/?gp_download=<token> | PDF ticket. | |
/?gp_qr=<token> | SVG QR. | |
/my-account/my-tickets/ | Customer ticket wallet. | |
POST /wp-json/gatepass/v1/check-in | `{token | code, count?} → {result, message, ticket}` — staff only, nonce + capability, rate-limited. |
GET /wp-json/gatepass/v1/lookup?q= | Manual lookup by ticket ID / order / phone / email — staff only. | |
GET /wp-json/gatepass/v1/stats?event= | Live counters. | |
GET /wp-json/gatepass/v1/verify/<token> | Public minimal status. |
Staff roles
| Capability | Administrator | Event Manager | Event Ticket Staff |
|---|---|---|---|
gatepass_scan (scanner, check-in, lookup, stats) | ✓ | ✓ | ✓ |
gatepass_manual_checkin | ✓ | ✓ | ✓ |
gatepass_manage_tickets (resend, cancel, block, transfer) | ✓ | ✓ | — |
gatepass_view_reports | ✓ | ✓ | — |
gatepass_manage_staff | ✓ | ✓ | — |
gatepass_manage_events (events, ticket types) | ✓ | — | — |
gatepass_manage_settings | ✓ | — | — |
| WooCommerce orders, prices, refunds | ✓ | — | — |
Staff never see full phone numbers in scan responses (masked), never see payment data.
Scanner
- Works in any modern mobile browser over HTTPS (Safari iOS 14.3+, Chrome Android). Uses
html5-qrcode. - Asks for camera permission, prefers the rear camera, lets you flip cameras.
- Continuous scanning; the same code is debounced for 4 s so a lingering QR doesn't spam the server.
- Big ENTRY APPROVED (green) / ENTRY DENIED (red) panel with reason (already used at 19:42 by Priya, refunded, not valid yet, wrong event), attendee, ticket type, admits N.
- Sound + vibration, configurable auto-"scan next" countdown.
- Manual lookup (ticket ID, order #, phone, email) with one-tap check-in as fallback for damaged screens / dead phones.
- Live counters: today's check-ins, people inside, sold, rejected.
- Offline: shows a red banner and refuses to validate. Offline validation is intentionally not supported — a single source of truth is what makes one-time QR enforceable across gates.
Refunds, cancellations, resends
| Event | Result |
|---|---|
| Order → Cancelled / Failed / Pending / On-hold (from a paid status) | All order tickets → Cancelled. QR rejected. Stock freed by WooCommerce. |
| Full refund | All tickets → Refunded. |
| Partial refund of N items | The N most recent unused tickets for that item → Refunded (used tickets are never voided by a partial refund). |
| Refunded/cancelled order restored to Processing/Completed | Setting reactivate originals (default off): re-issues new tickets with new tokens so an already-leaked QR stays dead; on: flips originals back to Active. |
| Resend / Regenerate QR | Resend reuses the same ticket + token. Regenerate issues a new token (old QR dead) and emails it. Never creates a new ticket row. |
| Email failure | email_status = failed, logged, admin alerted (if enabled), ticket still valid and visible in My Account; resend from admin. |
| Validity window passed | Hourly cron marks Active tickets past valid_until → Expired (or the scanner rejects live if cron is late). |
Security model
- Tokens:
random_bytes(16)→ 32 hex chars (128 bits). Ticket IDs are separate, human-readable, and cannot be used to check in from the public page — only staff endpoints accept them. - QR payload contains only the verify URL. No name, email, phone, order or payment data.
- One-time use: a single
UPDATE … WHERE id=? AND status='active' AND checked_in_count + ? <= people;rows_affecteddecides the winner. No read-then-write race. - REST: cookie auth +
X-WP-Nonce,current_user_can('gatepass_scan'), per-user and per-IP rate limits (transients), IP + UA logged on each attempt. - All admin mutations via
admin-post.phpwithcheck_admin_referer+ capability checks; all SQL via$wpdb->prepare; all output escaped. - Public verify page returns only status + event name + admits count; it never mutates.
- Screenshots: a static QR cannot stop a screenshot. It can guarantee the screenshot only works once — the first scan wins, the ticket owner's second scan is rejected and staff see who scanned first and when. For higher-risk events pair with ID checks or enable per-person QRs.
Database
| Table | Purpose |
|---|---|
{prefix}gp_events | Events (name, code, dates, venue, capacity, media, status). |
{prefix}gp_ticket_types | Types (pricing, early bird, inventory, limits, group mode, validity, custom fields JSON, product link). |
{prefix}gp_tickets | One row per ticket. Unique ticket_code, unique token. Indexed on order, item, event, type, status, email, phone, created_at. |
{prefix}gp_checkins | Every scan attempt (result, reason, staff, IP, device). |
{prefix}gp_logs | Audit trail (generated, emailed, resent, cancelled, refunded, transferred, regenerated, deleted…). |
Schema is versioned (gatepass_db_version) and upgraded with dbDelta on load. Orders are only ever accessed through wc_get_order() / WC_Order CRUD — HPOS safe.
Testing report
Run php tests/run.php (no WordPress needed). Current result: 28 passed, 0 failed.
| Area | Test | Result |
|---|---|---|
| QR encoder | Payloads 15 → 540 bytes (versions 2 → 18) encoded, rasterised with rsvg-convert, decoded with zbarimg; byte-exact round trip | ✓ |
| QR encoder | Deterministic output | ✓ |
| Tokens | 5,000 tokens unique, 32 lowercase hex | ✓ |
| Scanner payload parsing | Raw token, ?gp_verify= URL, REST verify URL, upper-case token, garbage | ✓ |
Valid header/trailer, xref offsets, stream length, parsed by pdfinfo, rendered by pdftoppm (visual check of layout + QR) | ✓ | |
| Settings | Default generation statuses = processing+completed; keep data on uninstall | ✓ |
| PHP | php -l on every file; PHP 8.1 | ✓ |
Behaviours verified by code review / design (require a live WooCommerce install to exercise end-to-end):
| Scenario | Handling |
|---|---|
| Duplicate order hooks / refresh | Per-order GET_LOCK + per-item quantity diff → no duplicates |
| Two devices scan same QR simultaneously | Single conditional UPDATE; loser gets already_used with first scan's time + staff |
| Multiple tickets in one order | N rows, N tokens, N QRs (or one group row) |
| Early bird exhausted mid-cart | Price re-evaluated on every totals calculation, so the cart shows regular price before payment |
| Inventory / capacity exhausted | Add-to-cart and checkout validation + WooCommerce stock |
| Refund / cancel | Tickets voided; scanner rejects with reason |
| Camera denied | Clear message + manual lookup remains available |
| Invalid / expired / wrong-event QR | Rejected with specific reason, logged |
| Staff without capability | REST 403; admin pages hidden |
| Email failure | Status tracked, resend available, ticket still valid |
| Cache / CDN | Verify + download endpoints send Cache-Control: no-store; REST is uncached by WordPress |
| Thousands of tickets | Indexed tables, paginated admin (50/page), counters via aggregate SQL |
Not yet exercised in a live store in this iteration: WooCommerce Blocks checkout attendee-field capture (classic checkout fields are used; Blocks users get attendee names from billing details), real SMTP delivery, and a physical-camera scan. See Known limitations.
Known limitations
- Static QR. The QR is fixed per ticket. One-time use is guaranteed server-side; screenshot prevention is not possible with any static QR. Dynamic/rotating QR (P2) needs a customer-side app or logged-in wallet page with a signed, time-limited token.
- Offline scanning is intentionally unsupported (see Scanner).
- Attendee fields on Blocks checkout. Custom per-attendee fields are collected on the product page. If you use the Checkout Block, per-attendee capture at checkout is not injected; names default to the billing name and can be edited by admin.
- Seating, affiliates, WhatsApp/SMS, CRM sync are not included (P2).
- PDF fonts are core Helvetica (Latin-1). Non-Latin attendee names render in the HTML email and web wallet correctly but may show as
?in the PDF. - Refund partial-quantity mapping voids the most recent unused tickets for that line item; it cannot know which specific attendee the refund was for.
Roadmap
P1 (next): attendee fields in Checkout Block via the Additional Fields API · per-event staff assignment · Apple/Google Wallet passes · WhatsApp/SMS delivery (Twilio) · bulk ticket import · seating charts. P2: rotating QR in the customer wallet page · offline PWA with signed manifests and conflict resolution · device fingerprint anti-fraud · affiliate tracking via WooCommerce coupons · analytics dashboards.
Troubleshooting
| Symptom | Fix |
|---|---|
| Tickets not generated | Check Settings → Generate tickets when order becomes includes the status your gateway uses. Order note shows GatePass: generated N tickets. |
| Camera doesn't start | Site must be HTTPS; check browser permission; use manual lookup meanwhile. |
rest_cookie_invalid_nonce | Staff session expired — reload the scanner page. |
| Emails not arriving | Configure an SMTP plugin; check ticket email_status and resend. |
| QR in PDF blurry | It's vector — that's the viewer's preview. Print or zoom. |