WalletWallet API
Back to Blog

Event Ticketing Software: How to Build and Run a Complete System

Plan ticket rules, create digital event tickets, handle QR check-in, updates, transfers, and cancellations with a complete runnable Node.js example.

2026-07-04 By Alen Todorov event-ticketing digital-tickets check-in apple-wallet google-wallet node-js tutorial api

Event ticketing software needs a source of truth for each ticket, a credential the guest can present, and a controlled check-in action that changes admission state once. Checkout, payment, tax, seat inventory, ticket delivery, and door admission are related systems, but a small event does not always need to build all of them at once.

This guide builds the credential-and-admission layer around an existing RSVP, registration, or checkout flow. Apple Wallet and Google Wallet are the obvious final destination for the ticket: the guest adds it from an email, SMS, or web link without installing an event app, then the same credential can receive a venue change, time update, access upgrade, notice, transfer, or cancellation. At the door, the current ticket is easier to find in either wallet app than in an inbox or among screenshots.

A concert crowd facing a brightly lit stage
Photo by Jonathan Ikemura on Unsplash.

In this guide

What event ticketing software needs to do

The ticket record is the source of truth. It holds the event, entitlement, attendee, current owner, admission state, transfer history, and cancellation. The wallet ticket is a view of that record with a scannable opaque ID.

StateWhat happenedDoor behavior
IssuedRegistration or payment was acceptedAdmit after normal validation
DeliveredGuest received and may have saved the ticketSame as issued
ChangedVenue, time, seat, or access changedValidate the current record
Checked inFirst valid admission was recordedReject or route re-entry according to policy
TransferredA new credential was issued to a recipientReject the old credential; admit the new one
CancelledRefund, cancellation, or fraud decision closed itReject and revoke the wallet pass

The commercial digital event ticket page shows the visible lifecycle: tickets go out, the venue changes, a guest becomes VIP, a refund invalidates a ticket, and a recurring invitation may move to its next date. The system behind those moments also needs deterministic door states and an audit trail.

Phone-based credentials are familiar at the point of service. In the Reserve Bank of Australia’s 2025 diary survey, 43 percent of roughly 1,200 participants used a mobile device for a contactless payment during the survey week, up from 35 percent in 2022 (RBA). That is Australian payment behavior rather than event-ticket adoption, but it supports testing a phone-at-entry workflow with the audience instead of assuming it will feel novel.

Ticketmaster reported that mobile ticketing adoption across the sports leagues in its Nexus partner network grew 53 percent year over year in 2025 (Ticketmaster Business). This is vendor network data, not a market-wide estimate. It shows the direction of a large operating network while leaving your event to measure its own add, scan, and exception rates.

Decide whether to build, buy, or add a wallet layer

ApproachPayments and inventoryTicket credentialAdmissionBest fit
Full ticketing platformIncludedIncludedApp and dashboardPublic sales, tax, refunds, seating, payouts
Registration tool plus wallet layerExisting toolAdd once from a link; updates in placeYour scanner or guest-list appThe default credential when registration already exists
Custom ticketing systemYou build itYou build or integrate itYou build itUnusual rules and a capable product team
PDF or emailed QRExisting toolStatic fileServer validation still requiredA small event with few last-minute changes

Use a full platform when the hard problem is selling seats, collecting and remitting money, managing reserved inventory, or complying across jurisdictions. Whether that platform or a simpler registration tool owns the sale, the guest-facing credential should usually end in Apple Wallet or Google Wallet.

Why Apple Wallet and Google Wallet fit event tickets

Email, SMS, and the web are good delivery channels; Apple Wallet and Google Wallet give guests a focused place to keep the ticket. The guest adds it once without installing an event app, and the same pass can move through issue, venue or time changes, upgrade, notification, check-in, transfer, and cancellation. Each update returns to one current credential instead of adding another message to a crowded inbox.

An event app makes sense when guests also need schedules, networking, maps, or year-round content. For presenting a ticket and keeping its status current, an Apple Wallet or Google Wallet pass provides that lifecycle with less friction at the moment it matters.

This article focuses on the system states and code that keep tickets valid at the door, whether registration comes from a guest list, checkout, or another system.

Design the rules before writing code

Define ticket types and entitlements

Write down what each type grants:

  • general admission to one event;
  • a seat, screen, table, gate, or entry window;
  • VIP or backstage access;
  • a multi-day conference or selected sessions;
  • a plus-one credential; or
  • a recurring series or season entitlement.

Each admitted person should normally have a distinct ticket ID. A plus-one under one order still needs a separate scan state. Put attendee-facing details on the pass and keep internal access logic in the database.

Decide transfer, refund, and re-entry policy

Specify the transfer cutoff, whether the sender can reverse an accepted transfer, and how the recipient is identified. A secure transfer creates a new credential and invalidates the old one. Ticketmaster documents the same core behavior: after an accepted transfer, the recipient gets a new valid barcode and the sender’s no longer works (Ticketmaster Help).

For refunds, decide whether cancellation occurs before or after payment reversal and which system retries a failed pass revocation. For re-entry, choose one of:

  • no re-entry after the first scan;
  • explicit check-out followed by another admission;
  • a time-limited re-entry token; or
  • staff override with a recorded reason.

Define every check-in result

Door staff need a small result set:

  • admitted;
  • already admitted;
  • cancelled;
  • transferred;
  • wrong event or wrong session;
  • not found; and
  • staff review.

Use color, text, and sound together. Keep the fast line moving and route name searches, dead phones, payment disputes, and accessibility needs to a separate exception process.

Architecture for digital tickets and check-in

  1. Registration or checkout creates one authoritative ticket record.
  2. The delivery service turns that record into a wallet credential with an opaque ticket ID in the barcode.
  3. A staff-controlled scanner submits the ID to the validation backend.
  4. The backend validates and atomically records the first admission.
  5. A wallet update follows the database change and can be retried.

The QR image is not the security boundary. A screenshot contains the same ID as the original and should receive already admitted after the first successful scan. Rotating barcodes can reduce screenshot reuse in platforms that support them, which is why Google’s direct event-ticket API documents a separate rotating-barcode feature. WalletWallet currently uses static QR, PDF417, Aztec, or Code128 values, so live state validation matters.

Try the lifecycle on your laptop

This optional example assumes registration or payment has already been approved. It runs on your computer and stores test tickets in event-data.json, so there is nothing to deploy. You can skip the code and still use the ticket rules, event-day checklist, and measurement guidance.

To follow along, install Node.js 22 or newer, use any text editor, and get a WalletWallet API key from signup.

1. Set up the example

mkdir event-ticketing-system
cd event-ticketing-system

Create .env:

WALLETWALLET_API_KEY=ww_live_replace_with_your_key

Create .gitignore:

.env
event-data.json

2. Add the complete program

Create event-tickets.mjs and copy the program below. The full listing is collapsed by default so the ticket lifecycle stays easy to follow.

import { randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";

const API_BASE = "https://api.walletwallet.dev";
const API_KEY = process.env.WALLETWALLET_API_KEY;
const DB_FILE = new URL("./event-data.json", import.meta.url);
const INITIAL_EVENT = {
  eventId: "neon-nights-2026",
  name: "Neon Nights Live",
  date: "2026-08-22",
  doors: "7:00 PM",
  venue: "Harbor Hall",
  address: "100 Harbor Street",
};

if (!API_KEY) throw new Error("Add WALLETWALLET_API_KEY to .env first.");

async function loadDB() {
  try { return JSON.parse(await readFile(DB_FILE, "utf8")); }
  catch (error) {
    if (error.code === "ENOENT") return { event: INITIAL_EVENT, tickets: {}, scans: [] };
    throw error;
  }
}

async function saveDB(db) {
  await writeFile(DB_FILE, `${JSON.stringify(db, null, 2)}\n`);
}

async function walletRequest(path, method, body) {
  const response = await fetch(`${API_BASE}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      ...(body ? { "Content-Type": "application/json" } : {}),
    },
    ...(body ? { body: JSON.stringify(body) } : {}),
  });
  const text = await response.text();
  let data;
  try { data = text ? JSON.parse(text) : {}; }
  catch { data = { message: text }; }
  if (!response.ok) {
    throw new Error(`${method} ${path} failed (${response.status}): ${JSON.stringify(data)}`);
  }
  return data;
}

function passBody(db, ticket) {
  const entry = ticket.entry || "Main entrance";
  const status = ticket.status === "checked-in" ? "CHECKED IN" : "VALID";
  return {
    barcodeValue: ticket.ticketId,
    barcodeFormat: "QR",
    logoText: db.event.name,
    organizationName: db.event.name,
    description: `${db.event.name} ticket`,
    colorPreset: ticket.ticketType === "VIP" ? "purple" : "dark",
    sharingProhibited: true,
    headerFields: [{ label: "STATUS", value: status }],
    primaryFields: [{ label: "EVENT", value: db.event.name }],
    secondaryFields: [
      { label: "GUEST", value: ticket.name },
      { label: "DATE", value: db.event.date },
      { label: "DOORS", value: db.event.doors },
      { label: "ACCESS", value: ticket.ticketType },
    ],
    backFields: [
      { label: "Venue", value: `${db.event.venue}\n${db.event.address}` },
      { label: "Entry", value: entry },
      { label: "Seat", value: ticket.seat || "General admission" },
      { label: "Ticket ID", value: ticket.ticketId },
      { label: "Latest update", value: ticket.notification, changeMessage: "%@" },
    ],
  };
}

function getTicket(db, ticketId) {
  const ticket = db.tickets[ticketId];
  if (!ticket) throw new Error(`Ticket ${ticketId} was not found.`);
  return ticket;
}

function requireUsable(ticket) {
  if (ticket.status === "cancelled") throw new Error("Ticket is cancelled.");
  if (ticket.status === "transferred") throw new Error("Ticket was transferred.");
  return ticket;
}

async function updatePass(db, ticket) {
  const body = passBody(db, ticket);
  const result = await walletRequest(
    `/api/passes/${encodeURIComponent(ticket.serialNumber)}`,
    "PUT",
    body,
  );
  ticket.lastPassBody = body;
  ticket.lastPassApiUpdatedAt = new Date().toISOString();
  return result;
}

async function createTicket(db, email, name, ticketType, seat, status = "issued") {
  if (!email || !name || !ticketType) {
    throw new Error("issue needs EMAIL NAME TICKET_TYPE [SEAT].");
  }
  const ticket = {
    ticketId: `TKT-${randomUUID()}`,
    email: email.toLowerCase(),
    name,
    ticketType,
    seat: seat || "",
    entry: ticketType === "VIP" ? "Stage Door" : "Main entrance",
    status,
    notification: "Ticket issued.",
    createdAt: new Date().toISOString(),
  };
  const body = passBody(db, ticket);
  const result = await walletRequest("/api/passes", "POST", body);
  ticket.serialNumber = result.serialNumber;
  ticket.shareUrl = result.shareUrl;
  ticket.googleSaveUrl = result.googleSaveUrl;
  ticket.lastPassBody = body;
  ticket.lastPassApiUpdatedAt = new Date().toISOString();
  db.tickets[ticket.ticketId] = ticket;
  return ticket;
}

async function issue(db, email, name, ticketType, seat) {
  const ticket = await createTicket(db, email, name, ticketType, seat);
  await saveDB(db);
  console.log(`Issued ${ticket.ticketId} for ${name}`);
  console.log(`Send this install link: ${ticket.shareUrl}`);
}

async function updateEvent(db, venue, address, date, doors, message) {
  if (!venue || !address || !date || !doors || !message) {
    throw new Error("update-event needs VENUE ADDRESS DATE DOORS MESSAGE.");
  }
  db.event.venue = venue;
  db.event.address = address;
  db.event.date = date;
  db.event.doors = doors;
  db.event.updatedAt = new Date().toISOString();
  for (const ticket of Object.values(db.tickets)) {
    if (ticket.status === "issued") ticket.notification = message;
  }
  await saveDB(db);

  const live = Object.values(db.tickets).filter((ticket) => ticket.status === "issued");
  const results = await Promise.allSettled(live.map((ticket) => updatePass(db, ticket)));
  await saveDB(db);
  const failed = results.filter((result) => result.status === "rejected");
  console.log(`Updated ${live.length - failed.length} of ${live.length} live tickets.`);
  if (failed.length) throw new Error(`${failed.length} ticket updates need retry.`);
}

async function upgrade(db, ticketId, ticketType, entry) {
  const ticket = requireUsable(getTicket(db, ticketId));
  if (ticket.status === "checked-in") throw new Error("Ticket is already checked in.");
  if (!ticketType) throw new Error("upgrade needs TICKET_TYPE [ENTRY].");
  ticket.ticketType = ticketType;
  ticket.entry = entry || ticket.entry;
  ticket.notification = `Your ticket is now ${ticketType}. Entry: ${ticket.entry}.`;
  await updatePass(db, ticket);
  await saveDB(db);
  console.log(`${ticketId} upgraded to ${ticketType}.`);
}

async function notify(db, ticketId, message) {
  const ticket = requireUsable(getTicket(db, ticketId));
  if (!message) throw new Error("notify needs message text.");
  if (ticket.notification === message) {
    throw new Error("Use different text so the notification field changes.");
  }
  ticket.notification = message;
  await updatePass(db, ticket);
  await saveDB(db);
  console.log(`Pass update accepted for ${ticketId}; wallet delivery is asynchronous.`);
}

async function checkIn(db, ticketId, scanId) {
  if (!scanId) throw new Error("check-in needs a unique scan ID.");
  const previous = db.scans.find((scan) => scan.scanId === scanId);
  if (previous) {
    if (previous.ticketId !== ticketId) {
      throw new Error(`Scan ${scanId} already belongs to ${previous.ticketId}; use a fresh scan ID.`);
    }
    console.log(`REPLAY: ${previous.result.toUpperCase()} — ${previous.reason}`);
    return;
  }
  const ticket = db.tickets[ticketId];
  if (!ticket) {
    db.scans.push({
      scanId,
      ticketId,
      result: "denied",
      reason: "ticket not found",
      at: new Date().toISOString(),
    });
    await saveDB(db);
    console.log("DENY: ticket not found");
    return;
  }
  let denial;
  if (ticket.status === "cancelled") denial = "cancelled";
  else if (ticket.status === "transferred") denial = "transferred";
  else if (ticket.status === "checked-in") denial = `already admitted at ${ticket.checkedInAt}`;
  else if (ticket.status !== "issued") denial = ticket.status;
  if (denial) {
    db.scans.push({
      scanId,
      ticketId,
      result: "denied",
      reason: denial,
      at: new Date().toISOString(),
    });
    await saveDB(db);
    console.log(`DENY: ${denial}`);
    return;
  }
  ticket.status = "checked-in";
  ticket.checkedInAt = new Date().toISOString();
  ticket.notification = `Checked in to ${db.event.name}.`;
  db.scans.push({
    scanId,
    ticketId,
    result: "admitted",
    reason: `${ticket.name} — ${ticket.ticketType}`,
    at: ticket.checkedInAt,
  });
  await saveDB(db);
  try {
    await updatePass(db, ticket);
    await saveDB(db);
  } catch (error) {
    console.error(`Admission saved; wallet sync needs retry: ${error.message}`);
  }
  console.log(`ADMIT: ${ticket.name} — ${ticket.ticketType}`);
}

async function transfer(db, ticketId, newEmail, newName) {
  const oldTicket = requireUsable(getTicket(db, ticketId));
  if (oldTicket.status === "checked-in") throw new Error("A used ticket cannot transfer.");
  if (!newEmail || !newName) throw new Error("transfer needs NEW_EMAIL NEW_NAME.");

  const newTicket = await createTicket(
    db,
    newEmail,
    newName,
    oldTicket.ticketType,
    oldTicket.seat,
    "pending-transfer",
  );
  newTicket.entry = oldTicket.entry;
  newTicket.transferredFrom = ticketId;
  await saveDB(db);

  try {
    await walletRequest(
      `/api/passes/${encodeURIComponent(oldTicket.serialNumber)}`,
      "DELETE",
    );
  } catch (error) {
    await walletRequest(
      `/api/passes/${encodeURIComponent(newTicket.serialNumber)}`,
      "DELETE",
    ).catch(() => {});
    newTicket.status = "cancelled";
    newTicket.transferError = error.message;
    await saveDB(db);
    throw new Error("Transfer failed; the replacement was revoked. Review the audit record.");
  }

  oldTicket.status = "transferred";
  oldTicket.transferredTo = newTicket.ticketId;
  oldTicket.revokedAt = new Date().toISOString();
  newTicket.status = "issued";
  await saveDB(db);
  try {
    await updatePass(db, newTicket);
    delete newTicket.passSyncError;
    await saveDB(db);
  } catch (error) {
    newTicket.passSyncError = error.message;
    await saveDB(db);
    console.error(`Transfer saved; replacement wallet sync needs retry: ${error.message}`);
  }
  console.log(`Transferred ${ticketId} to ${newTicket.ticketId}.`);
  console.log(`Send this new link: ${newTicket.shareUrl}`);
}

async function cancel(db, ticketId) {
  const ticket = requireUsable(getTicket(db, ticketId));
  if (ticket.status === "checked-in") throw new Error("Review a used ticket manually.");
  await walletRequest(
    `/api/passes/${encodeURIComponent(ticket.serialNumber)}`,
    "DELETE",
  );
  ticket.status = "cancelled";
  ticket.revokedAt = new Date().toISOString();
  await saveDB(db);
  console.log(`${ticketId} cancelled and revoked.`);
}

async function main() {
  const [command, ...args] = process.argv.slice(2);
  if (!command) {
    throw new Error("Usage: node --env-file=.env event-tickets.mjs COMMAND [ARGS]");
  }
  const db = await loadDB();

  if (command === "issue") return issue(db, args[0], args[1], args[2], args[3]);
  if (command === "list") return console.table(Object.values(db.tickets).map(
    ({ ticketId, name, ticketType, status }) => ({ ticketId, name, ticketType, status }),
  ));
  if (command === "update-event") return updateEvent(db, args[0], args[1], args[2], args[3], args.slice(4).join(" "));
  if (command === "upgrade") return upgrade(db, args[0], args[1], args.slice(2).join(" "));
  if (command === "notify") return notify(db, args[0], args.slice(1).join(" "));
  if (command === "check-in") return checkIn(db, args[0], args[1]);
  if (command === "transfer") return transfer(db, args[0], args[1], args.slice(2).join(" "));
  if (command === "cancel") return cancel(db, args[0]);
  if (command === "show") return console.log(JSON.stringify(db.tickets[args[0]] ?? null, null, 2));
  throw new Error(`Unknown command: ${command}`);
}

main().catch((error) => {
  console.error(error.message);
  process.exitCode = 1;
});

The ticket-specific state is stored separately from the event, so a venue or time change can rebuild every live pass while preserving names, seats, access, and barcodes. Each PUT sends the full body because WalletWallet replaces the stored pass body rather than merging a partial patch.

Run the complete ticket lifecycle

1. Issue and deliver a ticket

node --env-file=.env event-tickets.mjs issue [email protected] "Sam Okafor" "General Admission" ""

The program prints a TKT-... ID and a hosted install link. Save the ID for the test commands and send the link to the guest. One POST /api/passes creates the Apple and Google versions and returns the serial used for updates.

The barcode contains the opaque ticket ID. It does not expose the email, name, seat, or admission status.

2. Change the venue or start time

node --env-file=.env event-tickets.mjs update-event "The Paramount" "18 Pine Street" 2026-08-22 "8:00 PM" "Saturday moved to The Paramount. Doors now 8:00 PM."

The local event record changes first, then every live ticket receives its full updated projection. Failed updates are reported for retry. Apple can show the changed notification value on the lock screen; WalletWallet’s Google update banner is generic and the current venue and time appear inside the pass.

3. Upgrade a guest to VIP

node --env-file=.env event-tickets.mjs upgrade TKT-REPLACE-ME VIP "Stage Door"

The same serial and barcode now show VIP access and the new entrance. Do this before admission; a used ticket should go to staff review rather than silently acquiring a different entitlement.

4. Send a doors-open notice

node --env-file=.env event-tickets.mjs notify TKT-REPLACE-ME "Doors are open. Use the Stage Door on Pine Street."

The notification field was seeded at issue. Sending distinct text changes the pass and can produce an update notice. Do not treat the wallet pass as the only emergency channel; event-critical changes should also use the consented email, SMS, or app channel your guests expect.

5. Check in once

node --env-file=.env event-tickets.mjs check-in TKT-REPLACE-ME scan-door-a-0001

This CLI starts with an already-decoded ticket ID, so it simulates the value a staff-controlled QR reader would submit rather than operating the camera. The first valid scan records admission before attempting the cosmetic pass update. A second scan of the same ticket returns already admitted, even if it comes from a screenshot. Retrying the same scan ID returns the stored first result without admitting the ticket twice. Reusing that scan ID for another ticket is rejected as a conflict, so the operator must submit a fresh scan ID.

Do not revoke a normally used ticket at the door. Keep its audit record and mark it checked in; revocation is terminal and better reserved for cancelled, refunded, transferred, or fraudulent credentials.

6. Transfer an unused ticket

node --env-file=.env event-tickets.mjs transfer TKT-REPLACE-ME [email protected] "Priya Nair"

The program creates a recipient credential with a new ticket ID, revokes the sender’s credential, and then activates the replacement. If old-ticket revocation fails, it attempts to revoke the replacement and leaves an audit record for review. Forwarding the same wallet pass would not provide this change of ownership or invalidate screenshots already made.

7. Cancel a refunded ticket

node --env-file=.env event-tickets.mjs cancel TKT-REPLACE-ME

After your payment system confirms the refund rule, cancellation revokes the pass. Apple voids it and Google expires it; exact device refresh timing varies. The record remains in the database so the door and support team can explain the denial.

8. Handle the event after the show

A one-time admission ticket should stay attached to its event and scan history. Issue a new ticket for the next show. If your product is explicitly a season or recurring invitation pass, the entitlement can roll forward through the same update mechanism, provided the database also starts a new admission cycle and your terms make the reuse clear.

How Apple Wallet and Google Wallet differ here

Apple and Google’s direct event-ticket platforms support richer event-specific features. WalletWallet currently uses a narrower cross-wallet model:

CapabilityWalletWallet behavior
Apple layoutgeneric by default; storeCard when stripURL is supplied
Google layoutGeneric pass object
BarcodesStatic QR, PDF417, Aztec, or Code128
Native Apple eventTicket templateNot exposed
Google EventTicketObjectNot used by this API
Rotating barcode, NFC, Smart TapNot exposed
Updates and revocationSupported for both wallet versions from one API

Apple’s official documentation for Apple Wallet supports web and email delivery, updates, scan presentation, and testing with expected hardware (Apple Developer). Google’s direct event-ticket API supports web, email, and SMS issuance plus barcode or NFC redemption (Google for Developers). Those official pages describe their full native platforms; do not assume every feature listed there exists in a generic cross-wallet API.

Prepare for event day

  1. Import and deduplicate the final guest records.
  2. Test the actual phones and scanners with valid, duplicate, transferred, and cancelled tickets.
  3. Walk the venue, identify weak-connectivity areas, and decide how staff handle an uncertain result.
  4. Separate the normal scan line from name lookup and support exceptions.
  5. Charge devices, keep spare scanners, and train staff on each admission result.
  6. Watch scan success, queue length, and overrides while the doors are open.

A saved wallet ticket can remain visible offline. That does not make the server validation available offline. An offline scanner needs a deliberately synced allow-list and a conflict policy for the same ticket appearing at two doors.

Before you use this at a live event

The local file demonstrates the states but should not run a real door. Connect the same lifecycle to your registration or ticket database and confirm that:

  • each ticket and scan has a unique ID, so one credential can be admitted once;
  • staff use an authenticated scanner or check-in screen;
  • the barcode contains an opaque ticket ID rather than attendee details;
  • transfer invalidates the old credential before the new link is sent; and
  • a saved ticket change is retried if its wallet update fails.

Keep a clear exception path for weak connectivity, lost phones, and name lookup. A developer can handle the database and scanner connection while the event team owns admission rules, staff training, and the guest experience.

Measure the system

  • ticket delivery and wallet-add rate where observable;
  • scan success and median validation time;
  • duplicate, not-found, cancelled, and transferred attempts;
  • manual lookup and override rate;
  • check-in throughput and queue observations by entrance;
  • transfer and cancellation completion;
  • wallet-update failures and time to reconciliation;
  • no-show rate by ticket type; and
  • support contacts per 1,000 tickets.

Do not borrow a vendor’s speed benchmark for a different venue. Measure the complete path from a guest reaching the front of the line to the staff decision, including screen finding, scan, validation, and exceptions.

Common event ticketing questions

Can a saved wallet ticket be opened offline?

Usually, yes. The saved pass and static barcode can remain visible. Current server validation, new updates, and cancellation checks require connectivity or a purpose-built offline validation mode.

Can somebody enter with a screenshot?

The first presentation may validate if the copied ticket is still unused. After one atomic check-in, every copy should return already admitted. Static barcodes reduce neither screenshot creation nor the need for live state.

How should a wallet ticket be transferred?

Create a new ticket and barcode for the recipient, then invalidate the sender’s credential and keep the relationship in the audit log. Forwarding the same pass does not establish a safe ownership change.

Should a checked-in ticket be revoked?

Not for normal admission. Store the checked-in state and reject or manage re-entry from the database. Revoke a ticket when its entitlement is cancelled, transferred, refunded, or closed for fraud.

Do guests need a separate event app?

An iPhone can add an Apple Wallet pass without a companion app. Android guests may need Google Wallet installed, depending on device and market. Keep email, web, name-lookup, and accessibility fallbacks.

Does WalletWallet use the native event-ticket layout?

No. It creates an Apple generic pass, or store-card pass with a strip image, and a Google generic object. The ticket can still carry attendee details, a static barcode, live field updates, notifications, and revocation.

Build the door decision before the ticket design

Test one ticket through issue, delivery, venue change, upgrade, notification, first admission, duplicate scan, transfer, and cancellation. A polished pass is useful only when the database and door team reach the same decision under real event conditions.

Use the digital event ticket platform when you are ready to deliver both wallet versions. Send the add link through email, SMS, or the confirmation page, then let the installed pass carry the current ticket through event changes and entry. Keep the create, update, and revoke API references beside the event-day runbook.

Build your first wallet pass

Turn one JSON request into a pass that installs in Apple Wallet and Google Wallet, with live updates that reach both.