Building blocks
Events
The kit fires typed events when something happens: a payment settles, an
account is purged, someone joins an organization. You react to one by adding a
file to server/listeners/(Licensees only), without editing the code
that fired it. You can declare and fire events of your own the same way.
How it works
- Kit code calls
emit(fromRequest(event), name, payload)after the write the event describes has landed. - When Nuxt starts,
modules/listeners-registry.ts(Licensees only) collects every file inserver/listeners/, andserver/plugins/events.ts(Licensees only) registers each on Nitro's hooks. - Each listener runs in its own
try/catch. A throw is logged with the event and the listener's file name, then swallowed. It can't stop another listener or fail the request, andemitnever rejects. - A
deferredlistener, the default, runs after the response throughwaitUntil. Asynclistener is awaited insideemit.
Nothing on the bus is stored or retried, and the bus never logs a payload.
Add a listener
Create server/listeners/crm-contact.ts and default-export defineListener:
import { defineListener } from '#server/events/types'
export default defineListener({
event: 'auth:emailVerified',
async handle({ userId, email }) {
await $fetch('https://crm.example.com/contacts', {
method: 'POST',
body: { externalId: userId, email },
})
},
})
The payload type follows from event, so there is no type argument and no
list to register the file in. The file name is the listener's id and must be
lowercase kebab-case with an optional numeric prefix (00-billing-email), or
the build fails. The registry is generated when Nuxt starts, so restart
pnpm dev after you add, rename or delete a listener. In development the
server prints each one as it registers:
[events] crm-contact → auth:emailVerified (deferred)
A listener's failure is never retried. When the work has to survive an outage,
have the listener enqueue() a job instead (Background jobs).
What a listener gets
The second argument's event is built from the Worker's bindings and carries
nothing else. useDrizzle(event), useRuntimeConfig(event) and
enqueue(event, …) work; headers, cookies and the session don't exist on it.
Anything a listener needs from the original request has to be in the payload.
Choose a mode
A listener is deferred unless it says otherwise. Deferred work is handed to
waitUntil, so the response doesn't wait for it, and it is lost if the isolate
is evicted or runs out of time after the response. A sync listener is awaited
inside emit, so the request that fired the event waits for it:
export default defineListener({
event: 'org:member:added',
mode: 'sync',
async handle({ orgId, userId }, { event }) {
await useDrizzle(event)
.insert(tables.memberProfiles)
.values({ orgId, userId })
.onConflictDoNothing()
},
})
Use sync only when the request that fired the event reads the listener's
result back, as the members page would read memberProfiles here.
Listeners for one event run one after another in file-name order, sorted
numerically, so 00- runs first and 10- sorts after 2-. That order holds
within a mode only: the deferred batch can start before a sync listener whose
name sorts earlier. Nothing orders listeners across different events.
Fire your own event
Declare it by augmenting BusEvents in server/events/custom.d.ts, a file the
template never writes:
declare module '#server/events/types' {
interface BusEvents {
'app:project:archived': { projectId: number; userId: number }
}
}
export {}
The export {} makes the file a module. Without it the declare module
replaces the kit's types instead of extending them, and every emit and
listener loses its types.
Then fire it after the write lands:
import { emit, fromRequest } from '#server/events/emit'
await useDrizzle(event)
.update(tables.projects)
.set({ archivedAt: new Date() })
.where(eq(tables.projects.id, projectId))
await emit(fromRequest(event), 'app:project:archived', { projectId, userId })
- Emit after a transaction resolves, never inside its callback. A listener could act on a write that rolls back, and a retried transaction would fire the event twice.
- Keep payloads plain JSON: ids, strings, numbers. No
H3Event, binding or function. - Name events in the past tense (
archived, notarchive). A listener can't refuse an event, and an imperative name suggests it can.
Register a listener in code
For a listener that needs a closure, register it from a Nitro plugin with
onEvent. It always runs deferred:
import { onEvent } from '#server/events/emit'
export default defineNitroPlugin(() => {
const endpoint = 'https://crm.example.com/contacts'
onEvent('account:purged', async ({ userId }) => {
await $fetch(`${endpoint}/${userId}`, { method: 'DELETE' })
})
})
onPaymentSettled(nitro, handler) from #server/payments/settled does the same
for payments:settled and hands the handler the payload with event merged
in. server/plugins/notify-payments.ts(Licensees only)
uses it.
The catalogue
The payload types are in server/events/catalogue.ts(Licensees only).
| Event | Fired | Payload |
|---|---|---|
payments:settled | The payments webhook, once per provider event it applies | provider, normalized, rawBody, userId, orgId, eventRowId, country, retried |
payments:refunded | The payments webhook, when a refund is applied | provider, userId, eventRowId, plan, amountMinor, currency |
account:deletionScheduled | DELETE /api/auth/account, with a grace period or without | userId, purgeAt, subscriptions |
account:purged | The end of purgeAccount, after the user row is gone | userId |
auth:registered | A new account from a password, magic-link or OAuth signup | userId, email, method, verified |
auth:emailVerified | An unproven account proves an address, or staff mark it as verified | userId, email |
auth:emailChanged | An account moves to a new address, by its link or by a staff edit | userId, email, previousEmail |
storage:uploaded | putObject, after the object is stored | purpose, key, ownerId, bytes, format |
storage:deleted | deleteObject, only when the delete succeeded | purpose, key, ownerId |
org:* | The tenancy services (Organizations) | See the catalogue |
The eight org:* events are org:created, org:deleted,
org:member:added, org:member:removed, org:member:roleChanged,
org:invitation:created, org:invitation:accepted and
org:invitation:revoked.
A few payloads need care:
auth:registeredfires while a password signup's address is still unproven;verifiedsays which case you have. Provision anything real onauth:emailVerified, which fires once per proof, whether a verify link, a sign-in link or a completed password reset made it. An account created by guest checkout fires noauth:registered; it firesauth:emailVerifiedthe first time its owner signs in by email link.payments:refundedsits next topayments:settledon purpose: take back there whatever you grant on settle.account:purgedcarries only the id, because the address is erased. Use it for data the database cascades can't reach, such as a record in an external CRM.org:invitation:createdcarries the invitation's plaintext token, the only place it exists. Don't log it or store it anywhere readable.
Shipped listeners
server/listeners/ belongs to your app, and an empty directory is fine. The
kit ships three files there:
| Listener | On | Delete it and |
|---|---|---|
00-org-invitation-mail.ts | org:invitation:created | Invitations are still created, but no mail is sent |
00-billing-email.ts | auth:emailChanged | A new address no longer reaches the payment provider's customer |
00-cancel-billing.ts | account:deletionScheduled | A deleted account's subscriptions keep billing at the provider |
Good to know
- Events have no config keys. What fires depends on which parts of the kit are
on in
starter.config.ts(Licensees only). test/unit/events-catalogue.test.ts(Licensees only) reads declared events fromcatalogue.tsonly, so its check for undeclared emits fails on your firstemitof an event fromcustom.d.ts. Add your event names to that check.- In unit tests,
installEvents()fromtest/helpers/events.ts(Licensees only) records every emit and holds deferred work until youawait settled(). Assert after that, or the listener hasn't run yet. storage:uploadedfires insideputObject, before the upload route writes its row. When that write fails, the route deletes the object andstorage:deletedfollows, so undo on the second whatever you did on the first.
A link with a lock opens the kit’s private repository, which every licensee can read.