Events
Complete schedule, free RSVP, guest lookup and cancellation with explicit sign-in policy limits.
In the Console, search for a region to choose a time zone. The calendar created with a workspace uses its time zone. New events default to the calendar time zone. Duplicated events retain the source time zone. If calendar settings are unavailable, the workspace time zone is used, followed by Seoul time. Saving an event also saves its time zone. Later changes to parent settings preserve saved events and occurrences.
Enter timed event and occurrence start/end values in the time zone shown beside the picker. Before saving a new event, changing its time zone keeps the entered clock time, such as 10:00, in the new zone. A time skipped by a daylight saving transition must be corrected; a repeated time uses its earlier occurrence. The calendar keeps the entered clock time when you change the date. The time menu omits times skipped in the displayed time zone. Event and occurrence lists show start and end times in each record's time zone, not your browser's. All-day schedules use separate date fields.
Time and venue change notices
Changing an occurrence's start, end, or attendee-visible venue emails the latest details to confirmed (going) registrants. Change notices include the venue's name, address, and online URL when present. Editing only venue instructions does not send a notice. Changing or restoring the event's default venue also applies to future occurrences that inherit it. Consecutive edits are grouped for five quiet minutes, with a maximum wait of thirty minutes from the first change. Actual sending also depends on the notification worker's schedule and backlog.
In workspace settings, open Site & Branding → Notifications and turn off Event time or venue changed to stop change notices. The setting defaults on and appears when events is enabled, including events-only workspaces. Hidden venues remain available in attendee mail; hidden restricts public reads only. Sandbox captures change notices internally.
Prepare
Enable events and prepare a published public calendar, a published public or unlisted event with registrationPolicy: rsvp (or approval), and an open occurrence with capacity. This guide covers free RSVP only, not cart, checkout, paid admission, or operator registration. Show the status the server returns, not "confirmed" for every success:
| Policy | Status |
|---|---|
rsvp | going |
approval | pending |
rsvp with auto-waitlist enabled, when the occurrence is full | waitlisted |
Public schedule reads and guest registration do not need a signed-in customer. If you add sign-in, first copy all the Customers files and configure the same workspace's hosted client, origin, redirect, and HttpOnly session. To call from the browser, also allow your browser origin.
The examples run on each request in a Next.js App Router app. Build Options lists the tested versions and setup.
Implement schedule and free RSVP
Copy these files after the Customers session helper. signupRequired below is an app-owned constant, not a platform event field. When it is true, this app's trusted Server Action checks the hosted session with me() before registering; set it to false to allow guests. An invalid session is sent back to sign-in instead of quietly becoming a guest registration. The app never sends a caller-supplied customer or workspace to the SDK.
The platform has no server-side sign-in requirement for RSVP. Public registration checks the registration policy, publication and visibility, calendar and occurrence, duplicates, and capacity; a customer JWT is optional. Requiring sign-in in this app does not stop another client from registering as a guest with the same Publishable Key, so it is not a workspace-wide sign-in rule.
// app/events/page.tsx
import { createClient } from '@01.software/sdk'
import { registerRsvp } from './actions'
export const dynamic = 'force-dynamic'
export default async function EventsPage() {
const client = createClient({
publishableKey: process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY!,
apiUrl: process.env.SOFTWARE_API_URL,
})
const range = await client.events.getRange({ limit: 10 })
const occurrences = range.docs.filter(
(row) =>
row.status === 'scheduled' &&
['rsvp', 'approval'].includes(row.event.registrationPolicy ?? ''),
)
if (occurrences.length === 0)
return <p>No open RSVP occurrences in this range.</p>
return (
<ul>
{occurrences.map((occurrence) => (
<li key={occurrence.id}>
<h2>{occurrence.event.title ?? 'Untitled event'}</h2>
<p>
{occurrence.isAllDay
? `${occurrence.allDayStartDate} / ${occurrence.allDayEndDateExclusive} (exclusive)`
: occurrence.startsAt}
</p>
<form action={registerRsvp}>
<input
type="hidden"
name="event"
value={String(occurrence.event.id)}
/>
<input
type="hidden"
name="occurrence"
value={String(occurrence.id)}
/>
<input
name="email"
type="email"
required
aria-label="Attendee email"
/>
<button>RSVP</button>
</form>
</li>
))}
</ul>
)
}// app/events/actions.ts
'use server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { currentCustomer, cookieOptions } from '@/lib/customer'
// App-owned policy. false permits guests; true gates this app's action only.
const signupRequired = true
export async function registerRsvp(form: FormData) {
const { client, customer, hadToken } = await currentCustomer()
if ((signupRequired || hadToken) && !customer) redirect('/api/account/login')
const event = String(form.get('event') ?? '')
const occurrence = String(form.get('occurrence') ?? '')
const email = String(form.get('email') ?? '').trim()
if (!event || !occurrence || !email)
throw new Error('Select an event and enter an email')
const result = await client.events.register({
event,
occurrence,
quantity: 1,
attendee: { email },
})
// Token is a capability: never put it in a URL, log, or public response.
const jar = await cookies()
jar.set('__Host-rsvp-token', result.guestToken, {
...cookieOptions,
expires: new Date(result.guestTokenExpiresAt),
})
redirect('/events/manage')
}// app/events/manage/page.tsx
import { cookies } from 'next/headers'
import { customerClient, cookieOptions } from '@/lib/customer'
import { CancelRsvp } from './cancel-form'
export const dynamic = 'force-dynamic'
export default async function ManageRsvp() {
const token = (await cookies()).get('__Host-rsvp-token')?.value
const registration = token
? (await customerClient().events.getGuestRegistration(token)).registration
: null
async function cancel(): Promise<'canceled'> {
'use server'
const jar = await cookies()
const current = jar.get('__Host-rsvp-token')?.value
if (!current) throw new Error('RSVP token missing')
const result =
await customerClient().events.cancelGuestRegistration(current)
if (result.registration.registrationStatus !== 'canceled')
throw new Error('Cancellation unconfirmed')
// Successful cancellation revokes the guest token; never look it up again.
jar.set('__Host-rsvp-token', '', { ...cookieOptions, maxAge: 0 })
return 'canceled'
}
return (
<main>
<h1>Manage RSVP</h1>
<CancelRsvp
registrationStatus={registration?.registrationStatus ?? null}
cancel={cancel}
/>
</main>
)
}// app/events/manage/cancel-form.tsx
'use client'
import { useActionState } from 'react'
type CancelState = 'ready' | 'canceled' | 'failed'
export function CancelRsvp({
registrationStatus,
cancel,
}: {
registrationStatus: string | null
cancel: () => Promise<'canceled'>
}) {
const [state, submit, pending] = useActionState<CancelState, FormData>(
async () => {
try {
return await cancel()
} catch {
return 'failed'
}
},
'ready',
)
if (state === 'canceled') return <p role="status">RSVP canceled.</p>
if (!registrationStatus)
return (
<p>
No RSVP token in this browser. Make a new reservation or contact
support.
</p>
)
if (!['pending', 'going', 'waitlisted'].includes(registrationStatus))
return <p>RSVP: {registrationStatus}. Contact support for changes.</p>
return (
<form action={submit}>
<p>RSVP: {registrationStatus}</p>
{state === 'failed' && (
<p role="alert">
Cancellation unconfirmed. Contact support before retrying.
</p>
)}
<button disabled={pending}>Cancel RSVP</button>
</form>
)
}Normal, empty, and failure results
Open /events. Only scheduled occurrences whose registration policy is rsvp or approval show an RSVP form.
- An empty filtered range is a successful empty state.
noneandexternalevents and completed occurrences have no RSVP form. - These filters only shape the display and do not hold seats. The server checks each registration again.
- The app filters after
getRange({ limit: 10 }), so an empty first page can still hide RSVP occurrences on later pages. Paginate before concluding there are none. - Declined registrations show their status without a Cancel button.
- For all-day rows, show the start date and the exclusive end date as dates. Do not rebuild them from the viewer's time zone.
Then test the guest flow: select an occurrence, submit one RSVP, look it up separately at /events/manage, and cancel it there.
- Only a successful cancel response with
registrationStatus: canceledshows the completion message and deletes the guest-token cookie. - Cancellation revokes the token, so later lookup and cancel calls return 401. Do not redirect or look up again with it; a reload shows the no-token state.
- Status before cancellation comes from the lookup. Checking the result after cancellation needs an authorized workspace operator, not the revoked token.
- The guest token is returned once, and the server keeps only its hash. This example keeps one RSVP token in a Secure HttpOnly cookie, so a later RSVP replaces it. A lost token needs the operator's support flow, not a list of private registrations.
- The guest lookup response can contain attendee personal data. Show only the status and never dump the response.
| Situation | What to show |
|---|---|
| No cookie | The empty management state. |
| Lookup rejected after an operator canceled the registration or occurrence | "RSVP access unavailable. Contact the event organizer to verify the status and reset this browser's RSVP cookie." Repeating the same lookup cannot restore access, and deleting the cookie is not proof of cancellation. |
| Invalid or expired guest token, closed occurrence, full without a waitlist, duplicate, feature denial, network failure | An error. |
A foreign or invalid customer JWT, so me() returns null | Redirect this app's action to sign-in. Never treat it as a guest registration. |
| Failed cancel, including 401 | An error. Do not show success or delete the cookie as if the cancellation were confirmed. |
Add a read retry boundary for pages, and an action error UI that does not resubmit a registration or cancellation by itself. A registration can be saved even when its response is lost, and a guest then has no token to look it up. Before retrying a registration whose response was lost, and whenever a cancel response is lost, ask an authorized workspace operator to check the state; a signed-in customer can also check listMyRegistrations. A rejected token alone is not proof of cancellation.
Starter compatibility and local checks
The create 0.2.10 → init 0.19.4 setup in Quickstart includes the Events 0.1.30 starter with SDK 0.74.0. That starter is guest-first and covers free RSVP/approval and paid admissions. Its optional customer accounts use direct legacy sign-in, not the hosted flow in this guide. Do not add checkout or payment keys to complete a free RSVP. A mock demo or controlled local transport tests only your app; check live registration and hosted sign-in after Deploy.
Test the guest flow (register → look up → cancel), then this app's signed-in and signed-out paths with signupRequired: true. Also confirm that a direct guest call still works while public registration is open.
Next Actions
See Content Operations for calendar ownership and occurrence cancellation, Events SDK: Events Admission for paid admission, and Deploy before launch. Availability is a hint, not a seat reservation. Paid tickets use the Ecommerce flow and are outside this free RSVP task.
Init 0.17.1 and later, including the Quickstart pin, retire the separate events-rsvp starter. Use --template events for new free RSVP, approval, or paid-event apps; the retired name fails with exit code 1 and a migration hint. Events still needs Events and Ecommerce by default. Existing generated apps do not change. See Build Options for the current starters.