Documentation

Events and tickets

List events, buy a ticket, and support QR check-in from a Flutter app.

The events module powers public-facing event listings, ticket types, purchase, transfer, and at-the-door check-in. The endpoints are under /client/events for end-users (read + buy) and /events for operators. This recipe shows the customer side — discovery through ticket-in-hand.

Endpoints

GET/client/eventsJWT
GET/client/events/:eventIdJWT
GET/client/events/:eventId/ticket-typesJWT
POST/client/events/tickets/purchaseJWT
POST/client/events/tickets/confirm-orderJWT
GET/client/events/tickets/mineJWT
GET/client/events/tickets/:ticketId/qrJWT
POST/client/events/tickets/:ticketId/transferJWT

The full set lives in /Users/imzee/projects/appengine/src/events/events-client.controller.ts.

EventsService

// lib/services/events_service.dart
import '../models/event_model.dart';
import '../models/ticket_model.dart';
import '../services/base_model.dart';
import 'http_client.dart';

class EventsService {
  final _http = appmintHttp;

  Future<List<EventModel>> list({int page = 1}) async {
    final response = await _http.get('/client/events', queryParams: {
      'page': page.toString(),
    });
    return BaseModel.unwrapList(response)
        .map((j) => EventModel.fromJson(j as Map<String, dynamic>))
        .toList();
  }

  Future<EventModel> byId(String eventId) async {
    final response = await _http.get('/client/events/$eventId');
    return EventModel.fromJson(response as Map<String, dynamic>);
  }

  Future<List<TicketTypeModel>> ticketTypes(String eventId) async {
    final response = await _http.get('/client/events/$eventId/ticket-types');
    return BaseModel.unwrapList(response)
        .map((j) => TicketTypeModel.fromJson(j as Map<String, dynamic>))
        .toList();
  }

  /// Reserve tickets and get a Stripe Payment Intent client secret.
  /// Pair this with the Stripe sheet in checkout-with-stripe recipe.
  Future<Map<String, dynamic>> purchase({
    required String eventId,
    required Map<String, int> quantitiesByTicketType,
    required String holderEmail,
  }) async {
    final response = await _http.post('/client/events/tickets/purchase', body: {
      'eventId': eventId,
      'tickets': quantitiesByTicketType.entries
          .map((e) => {'ticketTypeId': e.key, 'quantity': e.value})
          .toList(),
      'holderEmail': holderEmail,
    });
    return Map<String, dynamic>.from(response as Map);
  }

  Future<Map<String, dynamic>> confirmOrder(String paymentIntentId) async {
    final response =
        await _http.post('/client/events/tickets/confirm-order', body: {
      'paymentIntentId': paymentIntentId,
    });
    return Map<String, dynamic>.from(response as Map);
  }

  Future<List<TicketModel>> myTickets() async {
    final response = await _http.get('/client/events/tickets/mine');
    return BaseModel.unwrapList(response)
        .map((j) => TicketModel.fromJson(j as Map<String, dynamic>))
        .toList();
  }

  /// Returns a base64 PNG (or URL — depends on tenant config) of the QR code.
  Future<String> qrFor(String ticketId) async {
    final response =
        await _http.get('/client/events/tickets/$ticketId/qr');
    return (response['qr'] ?? response['data'] ?? '') as String;
  }
}

Event list and detail

A simple list with title, hero image, and date.

// lib/screens/events/events_screen.dart
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';

import '../../models/event_model.dart';
import '../../services/events_service.dart';
import 'event_detail_screen.dart';

class EventsScreen extends StatefulWidget {
  const EventsScreen({super.key});
  @override
  State<EventsScreen> createState() => _EventsScreenState();
}

class _EventsScreenState extends State<EventsScreen> {
  late Future<List<EventModel>> _future;
  final _date = DateFormat.yMMMd().add_jm();

  @override
  void initState() {
    super.initState();
    _future = EventsService().list();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Events')),
      body: FutureBuilder<List<EventModel>>(
        future: _future,
        builder: (context, snap) {
          if (snap.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }
          if (snap.hasError) {
            return Center(child: Text(snap.error.toString()));
          }
          final events = snap.data ?? [];
          if (events.isEmpty) {
            return const Center(child: Text('No upcoming events.'));
          }
          return ListView.builder(
            itemCount: events.length,
            itemBuilder: (_, i) {
              final e = events[i];
              return ListTile(
                leading: e.heroImage != null
                    ? SizedBox(
                        width: 56,
                        height: 56,
                        child: ClipRRect(
                          borderRadius: BorderRadius.circular(8),
                          child: CachedNetworkImage(
                            imageUrl: e.heroImage!,
                            fit: BoxFit.cover,
                          ),
                        ),
                      )
                    : null,
                title: Text(e.title),
                subtitle: Text(_date.format(e.startsAt)),
                onTap: () => Navigator.of(context).push(
                  MaterialPageRoute(
                    builder: (_) => EventDetailScreen(eventId: e.pk),
                  ),
                ),
              );
            },
          );
        },
      ),
    );
  }
}

Buying a ticket

The flow is two API calls plus the Stripe payment sheet from the checkout recipe. The shape:

  1. 1

    Get ticket types

    Fetch /client/events/:eventId/ticket-types and present a quantity stepper for each tier.

  2. 2

    Reserve and create intent

    Call /client/events/tickets/purchase with { eventId, tickets, holderEmail }. The response contains clientSecret for Stripe and a paymentIntentId you'll need on confirmation.

  3. 3

    Present Stripe sheet

    Same as the storefront checkout — initialize, present, await success.

  4. 4

    Confirm

    Call /client/events/tickets/confirm-order with the payment intent ID. The response contains the issued ticket records, each with a unique ticketCode.

Future<void> _buy(String eventId, String typeId, int qty, String email) async {
  final purchase = await EventsService().purchase(
    eventId: eventId,
    quantitiesByTicketType: {typeId: qty},
    holderEmail: email,
  );

  final clientSecret = purchase['clientSecret'] as String;
  final intentId = (purchase['paymentIntentId'] ??
          clientSecret.split('_secret_').first)
      as String;

  // Stripe sheet — see checkout-with-stripe recipe
  await Stripe.instance.initPaymentSheet(
    paymentSheetParameters: SetupPaymentSheetParameters(
      paymentIntentClientSecret: clientSecret,
      merchantDisplayName: 'Events',
    ),
  );
  await Stripe.instance.presentPaymentSheet();

  await EventsService().confirmOrder(intentId);
}

My tickets and QR display

After purchase, tickets land on /client/events/tickets/mine. Each ticket has a pk you pass to /qr to fetch a QR you display in-app.

class TicketScreen extends StatefulWidget {
  final String ticketId;
  const TicketScreen({super.key, required this.ticketId});
  @override
  State<TicketScreen> createState() => _TicketScreenState();
}

class _TicketScreenState extends State<TicketScreen> {
  String? _qrData;

  @override
  void initState() {
    super.initState();
    EventsService().qrFor(widget.ticketId).then((q) => setState(() => _qrData = q));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Your ticket')),
      body: Center(
        child: _qrData == null
            ? const CircularProgressIndicator()
            : _qrData!.startsWith('data:image')
                ? Image.memory(_decodeBase64(_qrData!), width: 240, height: 240)
                : QrImageWidget(data: _qrData!), // qr_flutter
      ),
    );
  }
}

If your tenant returns a raw payload instead of a rendered PNG, use qr_flutter to render it client-side. If it returns a base64 PNG, decode and display it directly. Confirm the format from your AppEngine config.

Check-in (operator)

Operators scan the QR at the door. The endpoint:

POST/events/check-in/:eventId/:ticketCodeJWT

(Operator-side only — see the admin mobile section.) Use mobile_scanner for the camera-based QR scanner:

import 'package:mobile_scanner/mobile_scanner.dart';

MobileScanner(
  onDetect: (capture) {
    final code = capture.barcodes.first.rawValue;
    if (code == null) return;
    appmintHttp.post('/events/check-in/$eventId/$code');
  },
);

The response contains the ticket holder's name, the ticket type, and a valid boolean. Show a green check or a red X based on the result.

Transfers

Tickets can be transferred to another email. The endpoint:

POST/client/events/tickets/:ticketId/transferJWT
await appmintHttp.post('/client/events/tickets/$ticketId/transfer', body: {
  'toEmail': '[email protected]',
});

The transferred ticket disappears from the sender's mine list and appears in the recipient's. The recipient receives a notification email with a link to claim it inside the app.

What this recipe didn't cover

  • Sessions / agenda (/client/events/:eventId/sessions, /schedule, /participants) — multi-day or conference-style events.
  • Media (/client/events/:eventId/media) — guest uploads attached to the event.
  • Perks (/client/events/tickets/:ticketId/perks) — VIP add-ons or merchandise bundled with a ticket.

All follow the same pattern as above — call the documented endpoint, parse with BaseModel.unwrapList, render with the same widget shapes. The next recipe — Delivery tracking — switches modules to logistics.