webapp/docs/02-modules/events-module/guest-checkout.md
2026-09-06 19:46:16 +02:00

3.6 KiB

Events — guest checkout, fiat return flow and ticket delivery

Buyers can purchase paid tickets without an LNbits account. This note is the wire contract between the webapp and the aiolabs/events extension (≥ v1.6.1-aio.8) for that flow. Free tickets still require a login (events#29: no anonymous free issuance).

Identity on POST /events/api/v1/tickets/{event_id}

Buyer Body fields Auth header
Logged in user_id + email (delivery address, prefilled from the account, editable) Authorization: Bearer <token>
Guest name + email none (the endpoint is anonymous; the instance X-API-KEY is always sent)

Every purchase also sends frontend_url — this app's root (window.location.origin + import.meta.env.BASE_URL, no trailing slash; src/modules/events/lib/frontendRoot.ts). The extension only accepts it when its origin is on the LNbits LNBITS_CORS_ALLOWED_ORIGINS allowlist (or the LNbits base / custom-frontend origin); otherwise the request fails with frontend_url origin is not allowed. and the dialog shows that message.

payment_method is lightning or fiat. The event's enabled rails come from the NIP-52 tag tickets_payment_methods ("lightning,fiat"); when the tag is absent the legacy rule applies (Lightning always, fiat iff tickets_allow_fiat). See lib/paymentMethods.ts. A rail that is off is rejected by the backend with Payment method not enabled for this event.

Return from the fiat provider

The extension composes both Stripe URLs under frontend_url:

Purpose URL
success <root>/events/{event_id}?checkout=success&tickets=<id1,id2>
cancel <root>/events/{event_id}?checkout=cancelled

Before redirecting (same tab, window.location.assign), the purchase dialog writes localStorage['events:pending-checkout'] = { eventId, paymentHash, ticketIds, email, ts } (lib/pendingCheckout.ts). EventDetailPage strips the query on arrival, then:

  • success → opens CheckoutReturnDialog, which polls the anonymous GET /events/api/v1/tickets/{ticket_id} for every id every 2 s (60 s budget, retry button) until all are paid, then renders one QR per ticket, the "emailed to" note (email from the localStorage record) and a link to the ticket page. Logged-in buyers also get useOwnedTickets().refresh().
  • cancelled → info toast, record cleared. Nothing was charged; the extension purges unpaid rows after 24 h.

Public ticket page

/events/ticket/:ticketId (views/TicketPage.vue, requiresAuth: false) is where the emailed link lands. It reads the PublicTicket (no id / email / user_id in the payload; the id in the URL is the door credential, exactly as in the LNbits UI), resolves the event name (Nostr store → scoped relay query → public REST, which 410s on sold-out events and is treated as non-fatal) and renders the QR.

QR rendering

All ticket://<id> QRs (purchase success, return dialog, ticket page, My Tickets) go through lib/ticketQr.ts: error-correction level H with the brand logo (@brand/logo.png) centred at ≤ 20 % width on a white pad, so the in-app image matches the PNG the extension embeds in the ticket email (GET /events/api/v1/qr/{ticket_id}). Canvas failures fall back to the plain QR.

Organizer side

CreateEventDialog publishes extra.payment_methods (Lightning / Card checkboxes; at least one) and keeps allow_fiat in sync for older readers. Card is disabled with a tooltip when the organizer's LNbits user has no fiat provider (useFiatProviders().hasAnyProvider).

resendTicketEmail returns the structured TicketResendResult ({ ticket, email: {attempted, sent, error}, nostr: {...} }).