Appointments
Read availability and submit visitor booking requests for workspaces running the Brandfine Appointments plugin.
bf.appointments is the server-side SDK surface for the
Appointments plugin. Once a workspace activates Appointments in
the cms (Sidebar → Plugins → Appointments → Activate), this namespace
lets your backend code read availability and submit visitor
requests on their behalf.
import { createBrandfineClient } from '@brandfine/client'
const bf = createBrandfineClient({
baseUrl: 'https://api.brandfine.co',
apiKey: process.env.BRANDFINE_API_KEY!,
})
// 1. Load slots for the next ~14 days (or whatever booking window
// the workspace configured).
const { slots, timezone } = await bf.appointments.getAvailability()
// 2. Submit a visitor request once they pick a slot.
await bf.appointments.createRequest({
visitorName: 'Visitor Name',
visitorEmail: 'visitor@example.com',
requestedAt: slots[0].start,
})That's the entire visitor-side surface. Post-submission status changes (approve / decline / reschedule) happen via email, driven by the workspace's customer in the CMS — there are no visitor self-management actions on the customer's site.
Wraps the external Appointments REST endpoints. Same shapes, same validation, same auth.
Why server-side only
The workspace API key is broad-scope — it can read posts,
navigations, contact-form submissions, analytics, and everything
else /external/* exposes. Embedding it in browser code to power
an appointments widget would expose your entire workspace surface
along with it.
So every bf.appointments.* call should happen on the server — in
a Next.js route handler, Astro endpoint, Remix loader/action, or
a generic Node/Bun/Deno backend. Your frontend hits your
backend; your backend hits Brandfine. This is the v1 supported
pattern for every framework.
Static-export site? If your site has no server runtime (Astro
output: 'static', Hugo, plain HTML), you'll need either a tiny Node proxy or to add a server runtime — see Static sites for the full options. Scoped publishable keys for browser-safe direct calls are on the roadmap but not shipped yet.
API
getAvailability(opts?) → Promise<AppointmentAvailability>
type GetAvailabilityOptions = {
/** Optional clamp inside the workspace's configured booking
* window. Server-side ignores ranges outside the window — pass
* these to narrow the response, not to widen it. */
from?: Date | string
to?: Date | string
}
type AppointmentAvailability = {
/** False = plugin not activated for this workspace, or the
* customer toggled "Accepting bookings" off in cms settings.
* UI should render a "not accepting bookings right now" state
* rather than throw. */
enabled: boolean
/** Source-of-truth IANA timezone for the workspace's hours
* (e.g. "Europe/Istanbul"). Render slots in the visitor's
* local TZ; show this as the "(local time: HH:MM)" subtext. */
timezone: string
slotDurationMinutes: number
leadTimeHours: number
bookingWindowDays: number
policyText: string | null
/** UTC ISO 8601 slot starts + ends, ordered chronologically. */
slots: Array<{ start: string; end: string }>
windowStart: string
windowEnd: string
}Throws BrandfineApiError on
non-2xx. 401 = invalid X-Api-Key.
createRequest(input) → Promise<CreatedAppointmentRequest>
type CreateAppointmentRequestInput = {
visitorName: string // required, 1–120 chars
visitorEmail: string // required, validated server-side
visitorPhone?: string // optional, up to 40 chars
visitorMessage?: string // optional, up to 2,000 chars
/** UTC ISO 8601 timestamp of the requested slot start. Server
* re-validates against business hours + busy ranges + lead
* time before accepting. */
requestedAt: string
/** Optional cookie-derived session id from your frontend. Lets
* a returning visitor see their own requests later without an
* account. */
visitorSessionId?: string
}
type CreatedAppointmentRequest = {
id: string
createdAt: string
requestedAt: string
durationMinutes: number
status: 'PENDING'
/** Token field stored on the request row server-side. Not used
* by any v1 SDK or widget surface — kept on the wire response
* because the underlying token endpoints still exist for
* possible future reschedule-respond flows. Treat as opaque /
* ignorable in v1. */
cancellationToken: string | null
}Throws BrandfineApiError:
- 400 — validation failure (missing required field, invalid email, malformed
requestedAt). - 401 — missing or invalid
X-Api-Key. - 404 — workspace doesn't have Appointments activated, OR the slot is outside business hours / window / lead-time, OR the slot is already taken.
err.body.messagedistinguishes the case. - 5xx — server error; usually transient, safe to retry on a backoff.
The server emails both the visitor ("request received") and the customer ("new request for X — review in inbox") immediately on success. Fire-and-forget on the server side — failures to send don't fail your request.
What the SDK deliberately does NOT have
No visitor self-management methods. The visitor's only browser- side interaction with Brandfine is the initial request submission. All subsequent status changes (approve, decline, reschedule) are driven by the workspace's customer in the CMS and communicated to the visitor by email.
The underlying /external/appointments/requests/:token lookup
and cancel endpoints still exist server-side (for possible
future reschedule-respond flows) but are not surfaced through
the SDK in v1.
Framework recipes
Next.js (App Router)
Two route handlers — one for availability (GET), one for submit (POST). Frontend hits these, not Brandfine.
// app/api/appointments/availability/route.ts
import { NextResponse } from 'next/server'
import { createBrandfineClient } from '@brandfine/client'
const bf = createBrandfineClient({
baseUrl: 'https://api.brandfine.co',
apiKey: process.env.BRANDFINE_API_KEY!,
})
export async function GET() {
const availability = await bf.appointments.getAvailability()
return NextResponse.json(availability)
}// app/api/appointments/route.ts
import { NextResponse } from 'next/server'
import { createBrandfineClient, BrandfineApiError } from '@brandfine/client'
const bf = createBrandfineClient({
baseUrl: 'https://api.brandfine.co',
apiKey: process.env.BRANDFINE_API_KEY!,
})
export async function POST(req: Request) {
const body = await req.json()
// Validate body shape (zod recommended) before forwarding.
try {
const created = await bf.appointments.createRequest(body)
return NextResponse.json(created)
} catch (err) {
if (err instanceof BrandfineApiError && err.status === 404) {
return NextResponse.json({ error: err.body }, { status: 409 })
}
throw err
}
}Astro (server-rendered, no API roundtrip)
Render availability at request time, post submissions through an Astro API route.
---
// src/pages/book.astro
import { createBrandfineClient } from '@brandfine/client'
const bf = createBrandfineClient({
baseUrl: 'https://api.brandfine.co',
apiKey: import.meta.env.BRANDFINE_API_KEY,
})
const { slots, timezone, policyText } = await bf.appointments.getAvailability()
---
<form method="POST" action="/api/appointments">
<select name="requestedAt" required>
{slots.map((s) => (
<option value={s.start}>{new Date(s.start).toLocaleString()}</option>
))}
</select>
<input name="visitorName" required />
<input name="visitorEmail" type="email" required />
<textarea name="visitorMessage"></textarea>
<button type="submit">Request appointment</button>
</form>
{policyText && <p>{policyText}</p>}// src/pages/api/appointments.ts
import type { APIRoute } from 'astro'
import { createBrandfineClient } from '@brandfine/client'
const bf = createBrandfineClient({
baseUrl: 'https://api.brandfine.co',
apiKey: import.meta.env.BRANDFINE_API_KEY,
})
export const POST: APIRoute = async ({ request }) => {
const form = await request.formData()
await bf.appointments.createRequest({
visitorName: String(form.get('visitorName')),
visitorEmail: String(form.get('visitorEmail')),
visitorMessage: String(form.get('visitorMessage') || ''),
requestedAt: String(form.get('requestedAt')),
})
return new Response(null, {
status: 303,
headers: { Location: '/book/thanks' },
})
}Remix (loader + action in one route)
// app/routes/book.tsx
import { json, type ActionFunctionArgs } from '@remix-run/node'
import { useLoaderData, Form } from '@remix-run/react'
import { createBrandfineClient } from '@brandfine/client'
const bf = createBrandfineClient({
baseUrl: 'https://api.brandfine.co',
apiKey: process.env.BRANDFINE_API_KEY!,
})
export async function loader() {
return json(await bf.appointments.getAvailability())
}
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData()
const created = await bf.appointments.createRequest({
visitorName: String(form.get('name')),
visitorEmail: String(form.get('email')),
requestedAt: String(form.get('slot')),
})
return json(created)
}
export default function Book() {
const { slots } = useLoaderData<typeof loader>()
return (
<Form method="post">
<select name="slot">
{slots.map((s) => (
<option key={s.start} value={s.start}>
{new Date(s.start).toLocaleString()}
</option>
))}
</select>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">Book</button>
</Form>
)
}Generic Node server (Express, Hono, Fastify, Bun, Deno)
import { createBrandfineClient } from '@brandfine/client'
const bf = createBrandfineClient({
baseUrl: 'https://api.brandfine.co',
apiKey: process.env.BRANDFINE_API_KEY!,
})
app.get('/api/appointments/availability', async (_req, res) => {
res.json(await bf.appointments.getAvailability())
})
app.post('/api/appointments', async (req, res) => {
res.json(await bf.appointments.createRequest(req.body))
})Best practices
Render slots in the visitor's local timezone, not the workspace's. Use slot.start (UTC) + new Date(slot.start).toLocaleString(). Show the workspace's timezone as a subtle "(host's local time: HH:MM)" to avoid confusion across time zones. | |
| Cache availability for ~30 seconds at most. New CONFIRMED appointments invalidate the slot grid; stale data leads to double-booking attempts (the server rejects them, but the visitor's UX is worse). | |
| Don't retry on 404. The slot was likely taken between availability fetch and submit. Re-fetch availability and let the visitor pick again. | |
| Do retry on 5xx and network failures. Both are transient. Exponential backoff with a few attempts is fine. | |
Don't expose the workspace API key client-side. Every recipe above is server-side for a reason. Front-end variables (NEXT_PUBLIC_*, PUBLIC_*, VITE_*) leak to the bundle. | |
Surface policyText to visitors before they submit. The customer wrote it for a reason (cancellation rules, what to expect). |
CORS
The Appointments endpoints inherit the permissive /external/*
CORS policy — any origin can call them with
Content-Type: application/json + X-Api-Key. Practically:
your customers' visitors never hit Brandfine directly (server-
side only), so CORS rarely matters in v1.
Where appointment requests show up
CMS sidebar → Plugins → Appointments. The inbox aggregates across every workspace in the active team. Each row shows the visitor's info + the requested slot + a workspace chip; clicking Confirm or Decline emails the visitor the outcome.
A team member can also reach the per-workspace settings (business hours, slot duration, notification email, policy text) from the inbox's Settings button. Multiple workspaces can activate Appointments independently — each gets its own config and shows up as a chip on the catalog card.