Events server
This page explains how Events decides who may buy a ticket, and how to change those rules. It is for app developers and module authors. Set up the module first: Overview.
How a purchase is checked
POST /api/events/:slug/purchase runs these checks in order:
- The event exists (
404) and is not over (409). - The ticket type exists (
404), and its sale window and sale season are open (409). - The buyer meets the membership rules of the event and the ticket, and is not excluded (
403). - Every add-on belongs to the ticket type, each required group has a choice, and a single-choice group has at most one
(
400). - No chosen add-on is sold out (
409). - The ticket type is not sold out (
409). - Your
events:purchase:beforehook does not throw. - The buyer is under
maxPerUserfor the ticket type (409).
The ticket then takes a seat. A seat counts when the ticket is paid, or pending inside the reservation time. An
expired or failed payment frees its seat.
capacity seat holders, and removes it if it lost a race.A free ticket (total 0) is paid at once, without Stripe. A paid ticket stays pending until Payments receives the
verified Stripe event.
- Prices live in the database, in minor units. Checkout uses
price_data, so there are no Stripe price ids. - A ticket is a snapshot of the amount, currency and add-ons. Editing the catalogue never changes what was bought.
type PurchaseBody = {
ticketTypeId: string
addonIds?: string[]
successUrl?: string
cancelUrl?: string
locale?: string // the buyer's site language (e.g. 'da'), kept for later emails and Stripe texts
}
type PurchaseResponse = {
ticket: TicketDto
url?: string // the Stripe redirect; absent for a free ticket, which is already `paid`
}
Check-in accepts only paid tickets. A second scan of the same ticket returns alreadyCheckedIn with the time of
the first scan.
Refunds start at Stripe. The ticket turns refunded when charge.refunded arrives. A partial refund keeps the
ticket valid.
Sell tickets in a season
A ticket type can carry a saleSchedule on top of saleStartsAt and saleEndsAt. A ticket is sold only while both
are open. The schedule type comes from @frigear-nu/module-kit/schedule:
type Schedule =
| { type: 'yearly', timezone: string, from: { month: number, day: number, time: string }, to: { month: number, day: number, time: string } }
| { type: 'range', from: string, to: string }
// null = always. A yearly `to` before `from` wraps over new year.
Outside the schedule, the ticket type is not_started, with saleStartsAt set to the next opening. A purchase answers
409.
Limit tickets to members
Events does not know what a membership is. A membership provider answers two Nitro hooks:
events:membership:optionsturns on the membership pickers in the admin area and lists the choices.events:membership:checkdecides whether a user holds a requirement.
Subscriptions is the provider when it is installed: its plans are the memberships, and an active subscription to a plan holds it. Add another source, such as a partner list, with a Nitro plugin:
export default defineNitroPlugin((nitro) => {
nitro.hooks.hook('events:membership:options', (ctx) => {
ctx.available = true
ctx.options.push({ value: 'volunteer', label: 'Volunteer' })
})
nitro.hooks.hook('events:membership:check', async (ctx) => {
if (await isVolunteer(ctx.userId)) ctx.satisfied = true
})
})
Let staff manage events
Every staff route calls authorize(event, permission) from
Authorization: events:manage for most routes, and events:refund for refunds.
Who holds them is a matter of roles, as in Quick setup. The full list is under
Permissions.
A route that loads a record authorizes twice. The first call fails with 401 or 403 before anything is looked up, so
a caller who may not act never learns whether the record exists. The second call hands the record to any
policy:
export default defineEventHandler(async (event) => {
await authorize(event, 'events:refund') // 401 or 403 before any 404
const { id } = await getValidatedRouterParams(event, idParams().parse)
const ticket = await loadTicket(id) // then 404
if (!ticket) throw notFound('Ticket not found.')
await authorize(event, 'events:refund', ticket) // record-level policies
// ...
})
To override the answer for the whole app, for instance to lock staff out during an audit, listen to the
authorization:before hook. See policies.
MCP tools
With settings.mcp: true, Events adds these tools to the MCP endpoint. Each tool declares a
permission. The caller must hold it through their roles, and a token must carry it in its scopes, on every listing and
call.
| Tool | Permission | Purpose |
|---|---|---|
list-events, get-event | tickets:read | Published events and their details. |
my-tickets | tickets:read | The caller's tickets. |
list-all-events, get-event-definition, create-event, update-event, update-ticket-type | events:manage | Manage the catalogue as staff. |
list-attendees | events:manage | Attendees. Names and emails are included only when the caller also holds events:pii. |
Amounts in these tools are whole currency units, as MCP explains.
Next steps
- Reference – every hook payload, route and table.
- App – the pages and composables that call these routes.
- Subscriptions – the membership provider that works out of the box.