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,
})

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

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.

On this page