Brandfine Docs
SDK

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.message distinguishes 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.

On this page