payload-reservepayload-reserve

Examples

Real-world use case configurations — salon, hotel, restaurant, event venue, staff scheduling, guest bookings — plus Stripe and email integration patterns.

Real-world use case configurations and integration patterns.

Use Cases

Salon / Barbershop

Staff are resources, services are treatments. Each stylist has their own recurring weekly schedule.

payloadReserve({
  adminGroup: 'Salon',
  defaultBufferTime: 0,
  cancellationNoticePeriod: 24,
  slugs: {
    resources: 'stylists',
    services: 'treatments',
    reservations: 'appointments',
  },
})

Typical services: duration: 30, durationType: 'fixed', bufferTimeAfter: 10


Hotel

Rooms are resources with quantity equal to the number of identical rooms of that type. Each guest stays for a full calendar day — use full-day duration.

payloadReserve({
  adminGroup: 'Hotel',
  cancellationNoticePeriod: 48,
  slugs: {
    resources: 'rooms',
    services: 'room-types',
    reservations: 'bookings',
  },
})

// Room type service
await payload.create({
  collection: 'room-types',
  data: {
    name: 'Standard Double',
    duration: 1440,
    durationType: 'full-day',
    price: 149.00,
  },
})

// Room resource (10 identical standard doubles)
await payload.create({
  collection: 'rooms',
  data: {
    name: 'Standard Double',
    services: [standardDoubleId],
    quantity: 10,
    capacityMode: 'per-reservation',
  },
})

Restaurant / Event Space

Group bookings where total guest count matters. Use per-guest capacity mode with a maximum party size enforced via guestCount.

payloadReserve({
  adminGroup: 'Restaurant',
  cancellationNoticePeriod: 2,
})

// Dining room resource (max 60 guests total)
await payload.create({
  collection: 'resources',
  data: {
    name: 'Main Dining Room',
    services: [diningServiceId],
    quantity: 60,
    capacityMode: 'per-guest',
  },
})

Bookings with guestCount: 4 occupy 4 of the 60 total seats. The room is full when total booked guests reach 60.


Event Venue (Custom Status Machine)

Events go through an approval workflow before being confirmed. Use a custom status machine.

payloadReserve({
  adminGroup: 'Events',
  statusMachine: {
    statuses: ['enquiry', 'quote-sent', 'deposit-paid', 'confirmed', 'completed', 'cancelled'],
    defaultStatus: 'enquiry',
    terminalStatuses: ['completed', 'cancelled'],
    blockingStatuses: ['deposit-paid', 'confirmed'],
    transitions: {
      enquiry: ['quote-sent', 'cancelled'],
      'quote-sent': ['deposit-paid', 'cancelled'],
      'deposit-paid': ['confirmed', 'cancelled'],
      confirmed: ['completed', 'cancelled'],
      completed: [],
      cancelled: [],
    },
  },
  hooks: {
    afterStatusChange: [
      async ({ doc, newStatus }) => {
        if (newStatus === 'quote-sent') {
          await sendQuoteEmail(doc)
        }
        if (newStatus === 'confirmed') {
          await sendContractEmail(doc)
        }
      },
    ],
  },
})

Staff Scheduling with Auto-Provisioning

Every staff user automatically gets a paired bookable Resource they own. Opt in with staffProvisioning (requires resourceOwnerMode). When a user is created or updated with a staff role, a Resource is created and owned by that user — idempotently, and never auto-deleted on demotion.

payloadReserve({
  userCollection: 'users',
  resourceOwnerMode: {
    adminRoles: ['admin'], // these roles see all records
    ownerField: 'owner',
  },
  staffProvisioning: {
    staffRoles: ['stylist', 'therapist'], // users with these roles get a Resource
    roleField: 'role', // user field holding the role (default 'role')
    resourceType: 'staff', // stamped on the Resource (must be a valid resourceType)
    nameFrom: 'name', // user field copied into Resource.name (falls back to email)
    // Optional: stamp tenant / custom fields before the Resource is saved
    beforeCreate: ({ data, user }) => ({ ...data, tenant: user.tenant }),
  },
})

The Resource is created by impersonating the staff user, so resource.owner is always the user themselves. Provisioning is non-blocking — failures are logged and never block user create/update. Staff can then manage their own schedule (including endDate/type time-off on Schedule.exceptions).

Guest Bookings (No Account Required)

Allow bookings without a customer account. Enable globally with allowGuestBooking, or per-service via the service's allowGuestBooking field (enabled / disabled / inherit). A guest booking must supply guest.name plus at least one of guest.email / guest.phone (and must NOT also set customer).

payloadReserve({
  allowGuestBooking: true, // plugin-wide default; services can override
})

// A guest booking via the public endpoint
await fetch('/api/reserve/book', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    service: serviceId,
    resource: resourceId,
    startTime: selectedSlot,
    guest: { name: 'Jamie Doe', email: 'jamie@example.com' },
  }),
})

On a guest booking the plugin generates a cancellationToken. It is never returned in the HTTP response and is not readable by customer-collection users — deliver it to the guest yourself via the afterBookingCreate hook:

payloadReserve({
  allowGuestBooking: true,
  hooks: {
    afterBookingCreate: [
      async ({ doc, req }) => {
        const guest = doc.guest as { email?: string; name?: string } | undefined
        if (guest?.email && doc.cancellationToken) {
          await sendEmail({
            to: guest.email,
            template: 'guest-booking',
            variables: {
              name: guest.name,
              // Build your own cancel link with the token (POST /api/reserve/cancel with { reservationId, token })
              cancelUrl: `${process.env.NEXT_PUBLIC_URL}/cancel?id=${doc.id}&token=${doc.cancellationToken}`,
            },
          })
        }
      },
    ],
  },
})

Multi-Resource Bookings (Required Resource Pools)

A service can require extra resource pools beyond the primary resource — e.g. a treatment that needs both a therapist and a room, or a salon haircut that occupies a chair. List them on the service's requiredResources (relationship, hasMany). On create, the plugin auto-expands them into the reservation's items[], and conflict detection runs per item.

// Service that needs a therapist (primary) AND a treatment room (required pool)
await payload.create({
  collection: 'services',
  data: {
    name: 'Hot Stone Massage',
    duration: 60,
    durationType: 'fixed',
    requiredResources: [treatmentRoomPoolId],
  },
})

// Booking the primary resource — the room pool is expanded automatically
await payload.create({
  collection: 'reservations',
  data: {
    service: hotStoneServiceId,
    resource: therapistId, // primary resource
    customer: customerId,
    startTime: selectedSlot,
    // items[] is populated automatically with the required room pool
  },
})

If any required pool is fully booked at that time, the create fails with a conflict error that identifies the offending item (e.g. items.1.startTime).

Configurable Vocabularies (Resource Types & Leave Types)

resourceType (on Resources) and exceptions[].type (on Schedules) draw from configurable string vocabularies. The first resourceTypes entry is the field default. These are descriptive only — they drive no business logic.

payloadReserve({
  resourceTypes: ['practitioner', 'room', 'equipment'], // default value is 'practitioner'
  leaveTypes: ['vacation', 'sick', 'conference', 'closure'],
})

Both must be non-empty arrays. When staffProvisioning.resourceType is set, it must be one of resourceTypes.


Integration Patterns

Stripe Payment Gate

Hold the time slot with a pending reservation while the customer pays. Confirm on successful payment.

// 1. Create reservation on your booking page (slot is held, conflict detection runs)
const reservation = await payload.create({
  collection: 'reservations',
  data: {
    service: serviceId,
    resource: resourceId,
    customer: customerId,
    startTime: selectedSlot,
  },
  // status defaults to 'pending'
})

// 2. Create a Stripe Checkout Session with the reservation ID in metadata
const session = await stripe.checkout.sessions.create({
  line_items: [{ price: stripePriceId, quantity: 1 }],
  metadata: { reservationId: String(reservation.id) },
  mode: 'payment',
  success_url: `${process.env.NEXT_PUBLIC_URL}/booking/success`,
  cancel_url: `${process.env.NEXT_PUBLIC_URL}/booking/cancel`,
})

// 3. In your Stripe webhook handler, confirm the reservation
// app/api/stripe-webhook/route.ts
export async function POST(req: Request) {
  const body = await req.text()
  const sig = req.headers.get('stripe-signature')!
  const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session
    const reservationId = session.metadata?.reservationId

    if (reservationId) {
      const payload = await getPayload({ config })
      await payload.update({
        collection: 'reservations',
        id: reservationId,
        data: { status: 'confirmed' },
        context: { skipReservationHooks: false }, // hooks run — validates transition
      })
    }
  }

  return new Response('OK', { status: 200 })
}

Email Notifications

Use afterBookingCreate and afterStatusChange hooks to send transactional emails:

payloadReserve({
  hooks: {
    afterBookingCreate: [
      async ({ doc, req }) => {
        const customer = await req.payload.findByID({
          collection: 'customers',
          id: doc.customer as string,
          depth: 0,
          req,
        })
        await sendEmail({
          subject: 'Booking received',
          template: 'booking-created',
          to: customer.email as string,
          variables: { bookingId: doc.id, startTime: doc.startTime },
        })
      },
    ],
    afterStatusChange: [
      async ({ doc, newStatus, req }) => {
        if (newStatus === 'confirmed') {
          await sendEmail({ template: 'booking-confirmed', variables: doc })
        }
        if (newStatus === 'cancelled') {
          await sendEmail({ template: 'booking-cancelled', variables: doc })
        }
      },
    ],
  },
})

Multi-Tenant Deployments

Scope all queries to a tenant using beforeBookingCreate to inject tenant metadata, and access control functions to filter by tenant:

payloadReserve({
  access: {
    reservations: {
      read: ({ req }) => {
        if (!req.user) {return false}
        return { tenant: { equals: req.user.tenant } }
      },
      create: ({ req }) => !!req.user,
    },
  },
  hooks: {
    beforeBookingCreate: [
      async ({ data, req }) => {
        // Inject tenant ID from the authenticated user
        return { ...data, tenant: req.user?.tenant }
      },
    ],
  },
})
Edit on GitHub

On this page