
One Payment, Six Participants: How I Modeled Multi-Person Bundles for the B Lab Italia Summit 2026
In May 2026, the B Lab Italia Summit took place in Milan with three hundred participants spread between singles, pairs, and entire company teams. I built the ticketing platform from scratch: payments, registration management, QR code check-in, and a backoffice for staff. Standard stack (Next.js, Payload CMS v3, MongoDB, Stripe), but the real complexity came from a deceptively simple requirement: sell tickets in bundles up to six people with a single payment.
The Problem: One Payer, Six Participants
A 6-person bundle means one credit card paying, one invoice for the company manager, but six distinct QR codes entering the event, six confirmation emails, six independent session profiles. If the system duplicates even once, maybe from a webhook retry, I'll have six phantom QR codes circulating, six emails sent twice, and staff at check-in won't know which one to scan.
Also, the organizer wanted to swap a participant inside an already-paid bundle, quickly search a single name in admin without getting lost in nested arrays, and export a flat CSV with one row per person for the check-in desk. Nothing impossible, but nothing straightforward either.
Choice 1: BundleAssignments as a Dedicated Collection
I created a separate Payload collection, BundleAssignments, instead of nesting participants inside the order. Each assignment is an autonomous record with relations to the parent order, generated QRCodes, and SessionPreferences.
Three concrete advantages: first, searching a participant by name or email returns a simple, queryable record from admin without digging through nesting; second, the CSV export for check-desk is a linear query on BundleAssignments; third, if staff updates someone's email a week before the event, I don't touch the order, I only update the assignment.
Choice 2: Stripe Embedded Checkout
I chose Embedded Checkout instead of redirecting to Stripe Hosted. The event is premium, the Summit brand must stay on screen throughout the payment funnel. An external redirect breaks the experience and increases abandonment.
Embedded keeps checkout inside a Summit iframe, with consistent logo, colors, and copy. Technically it's one line of SDK, but the user's perception changes dramatically.
Choice 3: Idempotency and Rate Limiting on the Webhook
The checkout.session.completed webhook generates up to 6 QR codes plus 6 emails. If Stripe retries it, the system must not duplicate anything. The solution: an idempotent key on the server side linked to every Stripe session, and a webhook written to be replay-safe.
// Generate idempotent session
const idempotencyKey = `order_${orderId}_${bundleSize}`;
const session = await stripe.checkout.sessions.create({
idempotency_key: idempotencyKey,
// ... payment_intent_data, line_items, etc
});I also added rate limiting: 10 requests per IP every 15 minutes on the checkout endpoint. That way, even an angry bot can't fill my database with duplicate sessions.
Choice 4: Asynchronous QR and Email Generation
The webhook responds immediately to Stripe (200 OK in under a second). The heavy lifting (generate 6 QR codes with unique UUID tokens, build 6 bilingual emails with React Email, send via Resend) happens in a background job with Payload Jobs and 3 automatic retries.
// Webhook: responds fast
app.post('/webhooks/stripe', async (req, res) => {
const session = req.body.data.object;
await db.orders.create({
stripeSessionId: session.id,
status: 'pending_qr_generation'
});
// Schedule job
await jobs.enqueue('generateQRAndEmail', { orderId: order.id });
res.sendStatus(200);
});
// Job: generate QR + email with retry
payload.defineJob({
slug: 'generateQRAndEmail',
handler: async ({ orderId }) => {
const order = await db.orders.findById(orderId);
for (const assignment of order.bundleAssignments) {
const qrToken = uuid();
await db.qrcodes.create({ token: qrToken, assignment });
await resend.emails.send({
to: assignment.email,
template: 'TicketConfirmation',
props: { qrToken, language: assignment.language }
});
}
await db.orders.update(orderId, { status: 'confirmed' });
}
});If a job fails, Payload retries it three times with exponential backoff. If it fails permanently, I have visibility in admin and can trigger the job manually from a button.
Choice 5: Refund Handling Without Ghost Tickets
The charge.refunded webhook automatically invalidates all QR codes linked to the refunded order. No tickets staying active while money goes back to the customer. Staff immediately sees that QR as revoked when they scan it at check-in.
Also, the system sends a refund confirmation email to the order owner. If it's a corporate bundle, the CFO gets a receipt documenting the refund.
The Result: Zero Incidents, Stable Model
The summit ran smoothly with 300 participants, 45 bundles total, zero QR duplications, zero misfired emails. Staff did check-in directly from smartphones using the browser camera to read the codes.
What I Learned
Projects like this teach that complexity isn't in the stack, it's in data modeling and async operations flow. Stripe Embedded, Next.js, and MongoDB are solid tools, but the real work is thinking about webhook replays, state invalidation, and audit trails. A duplicated webhook is rare but not impossible, and building with that in mind from day one avoids refunds and hot debugging during an event.
If you're working on a ticketing system, an event with complex flows, or Stripe integrations with multi-person payments, I'm available for consulting and development. Drop me a line.


