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,
})For anonymous visitors that's the whole flow — status changes
(approve / decline / reschedule) reach them by email. For
authenticated sites there's more: sign the booking with a
verified identity and the same person
can read their own status back with list / get, and spend the
returned cancellationToken with cancel().
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 — an
* UNVERIFIED soft link across page loads. For real identity,
* use `visitor` below instead. */
visitorSessionId?: string
/** Signed host-app identity — same payload + signature Live
* Chat accepts (`liveChat.identityToken()` signs for both).
* Invalid or absent → the booking proceeds anonymously. */
visitor?: VerifiedVisitor
}
type CreatedAppointmentRequest = {
id: string
createdAt: string
requestedAt: string
durationMinutes: number
status: 'PENDING'
/** True iff visitor.identityToken HMAC-verified. */
identityVerified: boolean
/** One-time self-cancel credential — pass it to
* `appointments.cancel()` (or embed it in your own UI/emails).
* Cleared once any party acts on the request. */
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.
Verified visitor identity
If your site is authenticated, you already know who's booking — don't make them retype it, and don't trust free-text strings. Sign the person once, server-side, with the SAME helper Live Chat uses (one signature works for both plugins):
const identityToken = await bf.liveChat.identityToken(user.id)
await bf.appointments.createRequest({
visitorName: user.name, // still required (fallback contact)
visitorEmail: user.email,
requestedAt: slot.start,
visitor: {
externalId: user.id, // your stable id for the person
name: user.name, // verified values take precedence
email: user.email,
attributes: { plan: user.plan },
identityToken,
},
})A valid signature attributes the booking (identityVerified: true)
and links it to the same person's Live Chat conversations via the
shared externalId. An invalid or absent signature never
rejects the booking — it just proceeds anonymously (a broken
integration must not block a visitor).
One caveat: the zero-config signing secret is derived from your workspace API key. If you set an explicit Live Chat identity secret instead, appointments can't verify those tokens yet and will book anonymously — stay on the derived path for cross-plugin identity.
list(identity) → Promise<Appointment[]>
Every appointment belonging to the verified person, newest first — the "where does my request stand?" surface for authenticated sites.
const appointments = await bf.appointments.list({
externalId: user.id,
identityToken: await bf.liveChat.identityToken(user.id),
})
// → [{ id, status: 'PENDING'|'CONFIRMED'|'REJECTED'|'CANCELLED',
// requestedAt, scheduledAt, // non-null once CONFIRMED
// durationMinutes, timezone,
// declineReason, // populated on REJECTED
// rescheduleCount, respondedAt, createdAt }]Reads require proof: a bad signature throws BrandfineApiError
401 (there is no anonymous downgrade for reading someone's
history). Sent as a POST so the token never lands in URLs or logs.
get(id, identity) → Promise<Appointment>
One appointment, identity-scoped. Throws 404 when the id doesn't exist OR belongs to someone else — indistinguishable by design.
cancel(cancellationToken) → Promise<{ id, status: 'CANCELLED' }>
Spends the one-time token from createRequest to cancel a
still-PENDING request. The token is the credential — no API key
needed. Throws 400 once the request is no longer PENDING,
404 for an unknown or already-spent token.
Static sites: availability with the publishable key
getAvailability (and only it) also accepts the workspace's
scoped brandfine_pk_… publishable key — shown in the
Appointments settings after activation. A fully-static site
(output: 'export', plain nginx) can construct the client with
the publishable key and render slots without standing up a proxy.
Booking, list/get and identity signing stay on the broad
server-side key.
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.