Events

Events server

How Events checks a ticket purchase, and how to add sale seasons, membership rules, staff access and MCP tools.

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:

  1. The event exists (404) and is not over (409).
  2. The ticket type exists (404), and its sale window and sale season are open (409).
  3. The buyer meets the membership rules of the event and the ticket, and is not excluded (403).
  4. Every add-on belongs to the ticket type, each required group has a choice, and a single-choice group has at most one (400).
  5. No chosen add-on is sold out (409).
  6. The ticket type is not sold out (409).
  7. Your events:purchase:before hook does not throw.
  8. The buyer is under maxPerUser for 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.

D1 has no interactive transactions. So after inserting, the module checks that the ticket is among the first 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:options turns on the membership pickers in the admin area and lists the choices.
  • events:membership:check decides 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:

server/plugins/volunteer-membership.ts
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
  })
})
Without a provider, the API rejects membership rules. A requirement then fails closed, and an exclusion excludes nobody.

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:

server/api/admin/events/tickets/[id]/refund.post.ts
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.

ToolPermissionPurpose
list-events, get-eventtickets:readPublished events and their details.
my-ticketstickets:readThe caller's tickets.
list-all-events, get-event-definition, create-event, update-event, update-ticket-typeevents:manageManage the catalogue as staff.
list-attendeesevents:manageAttendees. 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.
Copyright © 2026