Appointments
Public booking endpoints for workspaces running the Brandfine Appointments plugin — read availability, submit visitor requests, look up + cancel by token.
Four public endpoints under /external/appointments/*. Three are
workspace-API-key authenticated (X-Api-Key); the visitor-facing
lookup + cancel use a one-time cancellation token instead.
| Method | Path | Auth | Status |
|---|---|---|---|
GET | /external/appointments/availability | X-Api-Key | v1 — returns bookable slots inside the workspace's booking window. |
POST | /external/appointments/requests | X-Api-Key | v1 — submits a visitor's appointment request. Server validates the slot. |
GET | /external/appointments/requests/:token | token in path | Dormant — not called by any first-party SDK or widget. See note in the section below. |
POST | /external/appointments/requests/:token/cancel | token in path | Dormant — same as above. |
All endpoints inherit the permissive /external/* CORS — any
origin may call them with Content-Type: application/json +
X-Api-Key. In practice, call them server-side. The workspace
API key is broad-scope (also reads posts, navigations, contact
submissions, analytics), so embedding it in browser code exposes
much more than just appointments. Your frontend hits your
backend; your backend hits Brandfine.
The endpoints respond with {"enabled": false, ...}-shaped
responses (instead of throwing) when a workspace has not activated
the Appointments plugin or has toggled "Accepting bookings" off,
so consumer UIs can gracefully degrade.
GET /external/appointments/availability
Returns the slots a visitor can book inside the workspace's configured window. The server already applies business hours, lead time, booking window, and busy-range filtering — render the response as-is.
Accepts either credential in X-Api-Key: the broad workspace
key (server-side), or the workspace's scoped brandfine_pk_…
publishable key (safe for fully-static sites — it is read-only
here; it cannot book, look up, or cancel).
Query params
| Param | Type | Notes |
|---|---|---|
from | ISO 8601 string | Optional clamp. Slots before this are excluded. Ignored if it's before the workspace's lead-time floor (server picks the later of the two). |
to | ISO 8601 string | Optional clamp. Slots after this are excluded. Ignored if it's after the workspace's booking-window ceiling. |
Request
GET /external/appointments/availability HTTP/1.1
Host: api.brandfine.co
X-Api-Key: bfwk_xxx
Accept: application/jsonResponse — 200 OK
{
"enabled": true,
"timezone": "Europe/Istanbul",
"slotDurationMinutes": 30,
"leadTimeHours": 24,
"bookingWindowDays": 14,
"policyText": "Please give 24h notice for cancellations.",
"slots": [
{ "start": "2026-06-08T06:00:00.000Z", "end": "2026-06-08T06:30:00.000Z" },
{ "start": "2026-06-08T06:30:00.000Z", "end": "2026-06-08T07:00:00.000Z" }
],
"windowStart": "2026-06-05T12:00:00.000Z",
"windowEnd": "2026-06-19T12:00:00.000Z"
}| Field | Type | Notes |
|---|---|---|
enabled | boolean | false = plugin not activated for this workspace, or customer toggled bookings off. Render a "not accepting bookings" state. |
timezone | string | IANA timezone the workspace's business hours are configured in. Render visitor slots in their LOCAL timezone; show this as a small "(host's time: HH:MM)" subtext. |
slotDurationMinutes | number | Always 15/30/45/60 in v1. |
leadTimeHours | number | Minimum notice the customer requires. Slots within this window are already filtered out. |
bookingWindowDays | number | How far ahead booking is open. Slots beyond this date are filtered out. |
policyText | string | null | Free-text policy the customer wrote — surface verbatim before the visitor submits. |
slots[] | array | UTC ISO 8601 starts + ends, ordered chronologically. Skipped: confirmed-conflict slots, in-the-past slots, lead-time slots, outside-business-hours slots. |
windowStart / windowEnd | string | UTC ISO 8601. The effective range the slots cover after lead-time + booking-window clamps. |
Response — 200 OK (plugin disabled)
{
"enabled": false,
"timezone": "UTC",
"slotDurationMinutes": 30,
"leadTimeHours": 0,
"bookingWindowDays": 14,
"policyText": null,
"slots": [],
"windowStart": "2026-06-04T07:00:00.000Z",
"windowEnd": "2026-06-04T07:00:00.000Z"
}Same shape, empty slots. Do not throw in your UI — render
a "not accepting bookings right now" state instead.
Errors
- 401 Unauthorized — missing or invalid
X-Api-Key.
POST /external/appointments/requests
Submits a visitor's request for a specific slot. The server re-validates the slot is still bookable (business hours, lead time, not in the past, no overlap with confirmed appointments) before accepting.
Request
POST /external/appointments/requests HTTP/1.1
Host: api.brandfine.co
X-Api-Key: bfwk_xxx
Content-Type: application/json
{
"visitorName": "Visitor Name",
"visitorEmail": "visitor@example.com",
"visitorPhone": "+1 555 0100",
"visitorMessage": "Looking to discuss the product.",
"requestedAt": "2026-06-08T06:00:00.000Z",
"visitorSessionId": "abc123"
}Fields
| Field | Type | Required | Notes |
|---|---|---|---|
visitorName | string | ✓ | 1–120 chars. |
visitorEmail | string | ✓ | Server-validated. Up to 200 chars. Used as the destination for the "request received" + confirmation/decline emails. |
visitorPhone | string | — | Up to 40 chars. Free-text — no formatting enforced. |
visitorMessage | string | — | Up to 2,000 chars. Shown to the customer in the inbox row. |
requestedAt | string | ✓ | UTC ISO 8601 of the slot start. Must match one of the slots from GET /availability. |
visitorSessionId | string | — | Up to 120 chars. Cookie-derived session id from your frontend — an unverified soft link across page loads. For real identity use visitor below. |
visitor | object | — | Signed host-app identity: { externalId, name?, email?, attributes?, identityToken } — the SAME shape and HMAC Live Chat accepts (identityToken = HMAC_SHA256(identitySecret, externalId), hex; the zero-config secret derives from your workspace API key, so liveChat.identityToken() signs for both plugins). Valid → the booking is attributed: verified name/email take precedence, identityVerified: true, and the shared externalId links it to the person's chat threads. Invalid or absent → the booking proceeds anonymously; never a 400. Caps: externalId ≤128, name ≤120, email ≤200, ≤10 attributes (keys ≤40, values ≤100). |
Response — 201 Created
{
"id": "cln9b2k8w0001abc",
"createdAt": "2026-06-04T15:30:00.000Z",
"requestedAt": "2026-06-08T06:00:00.000Z",
"durationMinutes": 30,
"status": "PENDING",
"identityVerified": true,
"cancellationToken": "f8d2c1e3a4b5..."
}| Field | Type | Notes |
|---|---|---|
id | string | Request id. |
createdAt | string | ISO 8601 timestamp the request was persisted. |
requestedAt | string | Echoed back as canonical UTC. |
durationMinutes | number | Slot duration the workspace had configured at submission time. |
status | string | Always "PENDING" on creation. |
identityVerified | boolean | True iff visitor.identityToken HMAC-verified — your signal that the booking landed attributed rather than anonymous. |
cancellationToken | string | null | One-time-use token for visitor self-cancellation. Embed in your confirmation email / "your appointment" page. |
The server immediately fires two emails (fire-and-forget — failure doesn't fail your request):
- Visitor — "request received, you'll hear back".
- Customer — "new request for X, review in inbox" — sent to
the plugin's configured
notificationEmail, or the team's notification settings if none.
Errors
- 400 Bad Request — validation failed (missing required field, invalid email, malformed
requestedAt). - 401 Unauthorized — missing or invalid
X-Api-Key. - 404 Not Found — workspace doesn't have Appointments activated, OR the slot is outside business hours / lead time / booking window, OR the slot was just taken between availability fetch and submit. The body's
messagedistinguishes the cases ("not accepting...", "outside business hours...", "just taken...").
Retry policy: don't retry on 400 or 404. The slot won't appear
in /availability again if it's now busy; re-fetch availability
and let the visitor pick a different slot. Do retry on 5xx +
network errors with exponential backoff.
POST /external/appointments/requests/lookup
Identity-scoped read-back — a signed host app fetches the verified
person's own appointments ("where does my request stand?"). POST
rather than GET so the identityToken never lands in proxy logs or
browser history. Broad workspace key only.
Request
POST /external/appointments/requests/lookup HTTP/1.1
Host: api.brandfine.co
X-Api-Key: bfwk_xxx
Content-Type: application/json
{
"externalId": "crm-user:abc123",
"identityToken": "hex-hmac…",
"id": "cln9b2k8w0001abc"
}| Field | Type | Required | Notes |
|---|---|---|---|
externalId | string | ✓ | The person's host-app id — must HMAC-verify against identityToken. |
identityToken | string | ✓ | Same signature as createRequest.visitor.identityToken. Unlike booking, a bad signature here is a hard 401 — reads require proof. |
id | string | — | Narrow to one appointment. 404 when the id doesn't exist or belongs to a different person — indistinguishable by design. |
Response — 200 OK
{
"timezone": "Europe/Istanbul",
"appointments": [
{
"id": "cln9b2k8w0001abc",
"status": "CONFIRMED",
"requestedAt": "2026-06-08T06:00:00.000Z",
"scheduledAt": "2026-06-08T06:00:00.000Z",
"durationMinutes": 30,
"timezone": "Europe/Istanbul",
"declineReason": null,
"rescheduleCount": 0,
"respondedAt": "2026-06-05T09:12:00.000Z",
"createdAt": "2026-06-04T15:30:00.000Z"
}
]
}Newest first, capped at 50. scheduledAt is non-null only once
CONFIRMED (after a reschedule it reflects the current slot);
declineReason is the customer's note, populated only on
REJECTED. Only bookings created with a verified identity
appear here — anonymous bookings have no externalId to match.
Errors
- 400 Bad Request — missing/oversized fields.
- 401 Unauthorized — invalid
X-Api-Key, or theidentityTokenfailed verification. - 404 Not Found —
idgiven and no matching appointment for this person.
GET /external/appointments/requests/:token
Dormant in v1. The endpoint exists but isn't called by any first-party SDK or widget in v1. The visitor's only browser-side interaction is the initial request submission; all subsequent status changes (approve / decline / reschedule) are driven by the customer in the CMS and communicated via email. This endpoint is kept available for possible future reschedule-respond flows where the email contains a link the visitor clicks to accept a proposed new time.
Visitor-facing lookup. The token IS the auth — no X-Api-Key
required. Returns just enough to render a "your appointment is
confirmed for X" or "this request was already cancelled" page,
without leaking other workspace data.
Request
GET /external/appointments/requests/f8d2c1e3a4b5... HTTP/1.1
Host: api.brandfine.co
Accept: application/jsonResponse — 200 OK
{
"id": "cln9b2k8w0001abc",
"visitorName": "Visitor Name",
"visitorEmail": "visitor@example.com",
"requestedAt": "2026-06-08T06:00:00.000Z",
"durationMinutes": 30,
"status": "PENDING",
"workspace": { "name": "Touchwise", "slug": "touchwise" }
}status may be any of PENDING | CONFIRMED | REJECTED | CANCELLED. Render accordingly:
| Status | UI suggestion |
|---|---|
PENDING | Show "Cancel" button + the requested time. |
CONFIRMED | Show "Confirmed for X". No cancel button (token is revoked). |
REJECTED | Show "Unfortunately X was declined". |
CANCELLED | Show "Already cancelled". |
Errors
- 404 Not Found — token unknown, revoked (already-responded request), or already-consumed (cancelled).
POST /external/appointments/requests/:token/cancel
Dormant in v1. Same status as the lookup endpoint above — the endpoint exists but isn't surfaced through any first-party SDK or widget. Cancellation in v1 is customer-driven from the CMS; no visitor self-cancel on the customer's site.
Visitor self-cancellation. Only works while the request is still
PENDING — once the customer has confirmed or declined, the
token is revoked.
Request
POST /external/appointments/requests/f8d2c1e3a4b5.../cancel HTTP/1.1
Host: api.brandfine.co
Content-Type: application/json
{}(Empty body. The token in the URL IS the credential.)
Response — 200 OK
{ "id": "cln9b2k8w0001abc", "status": "CANCELLED" }Errors
- 400 Bad Request — request is no longer in
PENDING(already confirmed, rejected, or cancelled). - 404 Not Found — token unknown or already revoked.
Where appointment requests show up in the CMS
CMS sidebar → Plugins → Appointments. The inbox aggregates across every workspace in the active team with workspace chips on each row. Each row's Confirm / Decline action fires the corresponding visitor email and clears the cancellation token.
Per-workspace settings (business hours, slot duration, timezone, notification email, policy text) live in the Settings dialog from either the catalog card's "Manage settings" button or the inbox's top-right Settings button.
SDK
The bf.appointments namespace wraps
all four endpoints with typed inputs / outputs. Use it instead of
calling these endpoints directly from TypeScript.
POST /external/submissions
Ingest a contact-form submission for this workspace. Appears in the cms's Submissions inbox.
Live Chat
Public chat endpoints for workspaces running the Brandfine Live Chat plugin — widget config, starting conversations with the publishable key, and sending/reading messages with a per-conversation token.