Tasks

Sell event tickets

Sell paid and free event tickets with Events, Payments and Stripe, and run code when a ticket is paid.

What you'll build: a public event list and event pages, where signed-in users buy tickets through Stripe Checkout. Users see their tickets on a "my tickets" page, and staff create events in the admin area.

Prerequisites

The paths below are for the starter's apps/web. In another host app, leave out the apps/web/ prefix.

1. Install and configure

Events brings the pages and the ticket logic. Install only Events: it depends on Stripe and Payments, and installs them with itself.

pnpm --filter ./apps/web add @frigear-nu/events

Events needs NuxtHub key-value storage (KV). Stripe and Payments use it too.

apps/web/nuxt.config.ts
export default defineNuxtConfig({
  modules: [
    // ...
    '@frigear-nu/events',
  ],
  hub: { kv: true, db: { dialect: 'sqlite', driver: 'libsql', connection: { url: 'file:./.data/db/sqlite.db' } } },
  events: {
    settings: {
      pages: { events: true, event: true, tickets: true, admin: true, adminEvent: true },
      reservationMinutes: 30,
    },
  },
})
PagePathWho
Event list/eventsEveryone
Event page/events/:slugEveryone
My tickets/account/ticketsSigned-in users
Event admin/admin/eventsStaff

2. Set the Stripe keys

Payments learns that a ticket is paid only from Stripe's webhook, so the app needs the keys and the webhook secret:

apps/web/.env
NUXT_STRIPE_SECRET_KEY=sk_...
NUXT_STRIPE_WEBHOOK_SECRET=whsec_...
NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_...

Point the Stripe webhook at /api/stripe/webhook, the default stripe.settings.webhookPath. Subscribe it to the Payments events listed in Stripe setup.

A ticket becomes paid only from those verified events, never from the success redirect.

3. Let staff manage events

Without this step only the admin role can manage events. Staff access is your app's decision: a role holds a permission only when it is granted.

Events registers events:manage, events:refund (cookie sessions only, since it moves money) and events:pii (attendee names and emails), and gives them to admin by default. Give them to another role in authorization.roles:

apps/web/nuxt.config.ts
export default defineNuxtConfig({
  authorization: {
    roles: {
      eventmanager: {
        label: 'Event manager',
        grant: ['admin:access', 'events:*', 'events:pii'], // `events:*` never covers the sensitive `events:pii`
        inherit: ['user'],
      },
    },
  },
})

Instead of defining it yourself, you can turn on the eventmanager role Events ships, holding events:manage, events:refund and events:pii, with events: { settings: { roles: true } }.

Assign the role on the user's page in the admin. Buying and reading your own tickets (tickets:read, tickets:write) is implicit: every signed-in user holds it without a role. For a rule of your own, such as "only events of your venue", write a policy on events:manage.

4. Generate the migration

Without a migration the new tables do not exist, and every write to them fails.

Events, Payments and Stripe add tables. Generate the migration, commit it, then restart pnpm dev:

pnpm db:generate

5. Create an event and sell a ticket

Now try the whole flow as staff, then as a buyer:

  1. Open /admin/events and choose New event. Add a ticket type in the Tickets tab. Prices are stored in minor units, and a price of 0 is a free ticket.
  2. Publish the event. It now appears at /events.
  3. Signed in, buy a ticket from /events/<slug>.

A free ticket is issued at once, without Stripe. A paid ticket redirects to Stripe Checkout. It stays pending, and holds its seat for reservationMinutes, until the payment arrives.

From code, useEvent(slug) gives purchase(ticketTypeId, addonIds?). The server route is POST /api/events/:slug/purchase, with the scope tickets:write.

What you should see: after you pay in Stripe Checkout, your ticket on /account/tickets.

6. React to a paid ticket

A paid ticket is the moment to send a confirmation. events:ticket:paid fires when a ticket becomes paid, and at once for a free ticket:

apps/web/server/plugins/ticket-paid.ts
export default defineNitroPlugin((nitro) => {
  nitro.hooks.hook('events:ticket:paid', async ({ ticket }) => {
    console.log(`Ticket ${ticket.id} for ${ticket.eventSlug} is paid`)
  })
})

Replace the log with an email. The ticket carries ticketTypeName, amount, currency and the buyer's locale.

Troubleshooting

SymptomCauseFix
/admin/events refuses youNone of your roles grants admin:access and events:manage.Grant them as in step 3, or check GET /api/auth/me/access.
A paid ticket stays pendingThe webhook events do not reach the app.Check the endpoint, its events and NUXT_STRIPE_WEBHOOK_SECRET. Locally, run stripe listen, as in Stripe setup.
Creating an event failsThe migration is missing.Run step 4.

Next steps

  • Send an email – send the confirmation from the events:ticket:paid hook.
  • Events – add capacity, sale windows, add-ons, membership rules and QR check-in.
  • Payments – handle refunds and the payments:* hooks.
Copyright © 2026