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
/client/eventsJWT/client/events/:eventIdJWT/client/events/:eventId/ticket-typesJWT/client/events/tickets/purchaseJWT/client/events/tickets/confirm-orderJWT/client/events/tickets/mineJWT/client/events/tickets/:ticketId/qrJWT/client/events/tickets/:ticketId/transferJWTThe 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
Get ticket types
Fetch
/client/events/:eventId/ticket-typesand present a quantity stepper for each tier. - 2
Reserve and create intent
Call
/client/events/tickets/purchasewith{ eventId, tickets, holderEmail }. The response containsclientSecretfor Stripe and apaymentIntentIdyou'll need on confirmation. - 3
Present Stripe sheet
Same as the storefront checkout — initialize, present, await success.
- 4
Confirm
Call
/client/events/tickets/confirm-orderwith the payment intent ID. The response contains the issued ticket records, each with a uniqueticketCode.
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:
/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:
/client/events/tickets/:ticketId/transferJWTawait 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.