BookFlow
developer guide

BookFlow — Developer API Guide

The BookFlow API is a HTTP/JSON service that powers the dashboard, the public booking pages, and the embeddable widget. This guide is for developers integrating with BookFlow from a server, browser, or serverless function. If you are a business owner reading about the public booking flow, see the Business Guide. If you are operating BookFlow itself, see the Admin Guide.

The full endpoint reference is in `openapi.yaml` (OpenAPI 3.1). This document covers the practical how-to: authentication, common patterns, webhooks, rate limits, and a working JavaScript SDK example.

Table of contents

  1. Base URL and versioning
  2. Authentication
  1. Common conventions
  1. Endpoint reference
  2. JavaScript SDK example
  3. Python example
  4. Webhooks
  1. Rate limits
  2. Sandbox environment
  3. Pagination deep dive
  4. Best practices
  5. Support

1. Base URL and versioning

The API is versioned by URL prefix. The current version is v1, and all paths documented here are stable:

Breaking changes will be released as v2, etc. The current v1 will keep working for at least 12 months after a successor is released. Non-breaking additions (new optional fields, new endpoints) happen without a version bump; they're called out in the changelog at <https://bookflow.app/changelog>.

The legacy unversioned paths (/api/businesses, /api/bookings, …) are still served for backwards compatibility but new integrations should target /v1.

2. Authentication

BookFlow supports two credential types: short-lived user JWTs for per-user actions, and long-lived API keys for server-to-server integration. The right one depends on the use case.

2.1 User JWTs

A user JWT represents a logged-in BookFlow dashboard user. It is issued by POST /v1/auth/login (or POST /v1/auth/register):

bash
curl https://api.bookflow.app/v1/auth/login \ -H "Content-Type: application/json" \ -d '{ "email": "[email protected]", "password": "correct-horse-battery-staple" }'

Response:

json
{ "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "user": { "id": "u_3k9p2m", "email": "[email protected]", "name": "Alex Rivera", "role": "owner", "tenantId": "tn_3k9p2m" } } }

Pass the token on every authenticated request:

bash
curl https://api.bookflow.app/v1/bookings \ -H "Authorization: Bearer $TOKEN"

Tokens are HS256 JWTs, signed with your tenant's secret, and expire after 30 days. The payload contains:

json
{ "sub": "u_3k9p2m", "tenantId": "tn_3k9p2m", "role": "owner", "iat": 1721904000, "exp": 1724496000 }

The API uses the tenantId claim to scope every authenticated request — you cannot access data from a different tenant even if you spoof the request. The role claim controls write permissions.

2.2 API keys

For server-to-server integrations, use an API key. Keys are issued per-tenant from the dashboard at Settings → Developers → API keys. Each key is a 40-character base64url string with the prefix bfk_live_ (production) or bfk_test_ (sandbox). Example:

Pass it in the Authorization header using the Bearer scheme:

bash
curl https://api.bookflow.app/v1/bookings \ -H "Authorization: Bearer bfk_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"

API keys:

  • do not expire automatically; the owner can revoke them at any time
  • are scoped to a single tenant (the one that issued them)
  • have a configurable role (read, write, or admin)
  • are rate-limited at 600 requests per minute per key

To rotate a key: in the dashboard, **Settings → Developers → API keys → Rotate**. The old key keeps working for a 24-hour grace period shown in the UI; after that, requests with the old key return 401 unauthorized. The grace period lets you deploy the new key before the old one stops working.

2.3 Choosing the right auth method

Use a user JWT when:

  • you are building the dashboard itself (the Next.js app at

apps/web)

  • you are acting on behalf of a specific user, and the user must

approve (e.g. OAuth-style "Sign in with BookFlow")

  • you need to call APIs as a user with a specific role (e.g.

staff vs admin)

Use an API key when:

  • you are building a server-side integration (e.g. syncing bookings

into your CRM)

  • you are running a long-lived background process (cron, queue

consumer)

  • you don't want to handle token refresh

Use no auth for:

  • the public booking flow (/v1/public/*) — anyone can list

services, check availability, and create a booking without authentication

  • webhooks — verified by signature, not by Authorization

3. Common conventions

3.1 Envelope

Successful responses use a consistent envelope:

json
{ "data": <payload>, "pagination": <optional> }

The data field carries the actual response body. The pagination field appears only on list endpoints.

3.2 Pagination

Lists are cursor-paginated. A typical request:

Response:

json
{ "data": [ { "id": "bk_1", "startsAt": "2026-07-26T14:00:00Z" }, { "id": "bk_2", "startsAt": "2026-07-26T15:00:00Z" } ], "pagination": { "hasMore": true, "cursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA3LTI2VDE0OjAwOjAwWiIsImlkIjoiYmtfMiJ9" } }

To fetch the next page, pass the cursor back:

When the last page is reached, pagination.hasMore is false and pagination.cursor is null. Always use the cursor instead of an offset; cursors are stable under concurrent inserts.

3.3 Errors

Errors use:

json
{ "error": "string_code", "message": "human readable", "details": <optional> }

Common error codes:

CodeHTTPMeaning
unauthorized401Missing or invalid Authorization header.
forbidden403Authenticated but lacking permission.
not_found404Resource doesn't exist (or belongs to another tenant).
invalid_request400Body or query failed validation. details is zod's flatten() output.
conflict409E.g. unique-constraint violation, slot already booked.
rate_limited429Too many requests; Retry-After header has seconds.
internal_error500Server bug. requestId in details for support.

The requestId is also returned in the x-request-id response header for all requests. Always include it when reporting bugs.

3.4 Timestamps and timezones

All timestamps are ISO 8601 strings in UTC:

When the request includes an offset, it is preserved and interpreted as the user's local time, then converted to UTC for storage:

json
{ "startsAt": "2026-07-26T10:00:00-04:00" }

means 14:00 UTC. The tenant's timezone is used for working hours computation; the customer-facing time is rendered in the customer's local timezone (derived from Intl.DateTimeFormat() in the browser, or from an explicit timezone query parameter for server-to-server calls).

3.5 CORS

The API allows browser requests from any origin (`Access-Control- Allow-Origin: *`) but with credentials disabled. If you need to send cookies, set up a server-side proxy. For most use cases, send the JWT or API key in the Authorization header — no cookies needed.

4. Endpoint reference

The full reference is in `openapi.yaml`. A summary of the groups:

GroupAuthDescription
GET /healthznoneLiveness probe.
/v1/public/*nonePublic booking flow: list business, services, availability, create booking, manage booking by code.
/v1/auth/*none (in) / Bearer (me)Register, login, logout, current user.
/v1/businesses/currentBearerRead and update the current tenant.
/v1/servicesBearerCRUD for services.
/v1/staffBearerCRUD for staff.
/v1/bookingsBearerList, get, create, update, cancel bookings.
/v1/customersBearerList, get, create, update, delete customers.
/v1/paymentsBearerStripe Connect onboarding, intent, refund.
/v1/webhooks/stripesignaturePublic Stripe webhook receiver.
/v1/admin/*x-admin-keyPlatform admin (see Admin Guide).

For a worked example, listing bookings:

bash
curl "https://api.bookflow.app/v1/bookings?status=confirmed&from=2026-07-25T00:00:00Z" \ -H "Authorization: Bearer $TOKEN"
json
{ "data": [ { "id": "bk_3k9p2m", "tenantId": "tn_3k9p2m", "serviceId": "sv_1", "staffId": "st_1", "customerId": "cu_1", "startsAt": "2026-07-26T14:00:00Z", "endsAt": "2026-07-26T14:45:00Z", "status": "confirmed", "notes": null, "manageCode": "mng_8x7y...", "totalCents": 5000, "currency": "USD", "paymentStatus": "paid", "createdAt": "2026-07-25T08:11:02Z", "updatedAt": "2026-07-25T08:11:02Z" } ], "pagination": { "hasMore": false, "cursor": null } }

5. JavaScript SDK example

The official TypeScript SDK lives at @bookflow/sdk and is published to npm. There is no official SDK for other languages yet; the API is plain HTTP, so any HTTP client works.

bash
npm install @bookflow/sdk
ts
import { BookFlowClient } from "@bookflow/sdk"; const client = new BookFlowClient({ apiKey: process.env.BOOKFLOW_API_KEY!, // bfk_live_… // baseUrl defaults to https://api.bookflow.app }); // List services const services = await client.services.list({ limit: 50 }); console.log(services.data); // Create a booking (admin path; customer-facing path is // client.public.bookings.create) const booking = await client.bookings.create({ serviceId: "sv_1", staffId: "st_1", startsAt: "2026-07-26T14:00:00Z", customer: { name: "Alice Smith", email: "[email protected]", phone: "+15555550100", }, }); console.log(booking.data.id); // Stream all bookings, handling pagination for await (const page of client.bookings.iterate({ status: "confirmed" })) { for (const b of page) { console.log(b.id, b.startsAt); } } // Webhook signature verification helper import { verifyWebhookSignature } from "@bookflow/sdk/webhooks"; const ok = verifyWebhookSignature({ payload: req.body, // raw bytes signature: req.headers["bookflow-signature"]!, secret: process.env.BOOKFLOW_WEBHOOK_SECRET!, toleranceSeconds: 300, // 5 min skew }); if (!ok) { return res.status(400).send("invalid signature"); }

If you prefer not to use the SDK, here is the equivalent raw HTTP request (Node 20+, fetch is built-in):

ts
const res = await fetch("https://api.bookflow.app/v1/bookings", { headers: { Authorization: `Bearer ${apiKey}` }, }); if (!res.ok) { throw new Error(`BookFlow API ${res.status}: ${await res.text()}`); } const body = await res.json() as { data: Booking[] }; for (const b of body.data) { console.log(b.id, b.startsAt); }

6. Python example

bash
pip install httpx
python
import os import httpx API_KEY = os.environ["BOOKFLOW_API_KEY"] BASE = "https://api.bookflow.app/v1" def list_bookings(status: str = "confirmed"): with httpx.Client(base_url=BASE, timeout=10) as client: cursor = None while True: params = {"status": status, "limit": 100} if cursor: params["cursor"] = cursor r = client.get( "/bookings", params=params, headers={"Authorization": f"Bearer {API_KEY}"}, ) r.raise_for_status() body = r.json() for b in body["data"]: yield b if not body["pagination"]["hasMore"]: return cursor = body["pagination"]["cursor"] for b in list_bookings(): print(b["id"], b["startsAt"])

7. Webhooks

Webhooks let BookFlow push events to your server as they happen. You register one or more URLs in Settings → Developers → Webhooks. Each registration includes a signing secret (a 32-byte random string, base64url-encoded) used to verify the signature on every delivery.

7.1 Event types

EventTrigger
booking.createdA booking is created (any source: public, dashboard, API).
booking.updatedA booking is updated (reschedule, notes, staff change).
booking.cancelledA booking is cancelled.
booking.completedA booking is marked completed (after the end time, or manually).
booking.no_showA booking is marked no-show.
payment.succeededA charge succeeded.
payment.refundedA refund was issued.
payment.failedA charge failed.
customer.createdA new customer record.
customer.updatedA customer record updated.

You can subscribe to a subset. Webhook events are emitted **at least once**; design your handlers to be idempotent (see §7.4).

7.2 Signature verification

Every webhook delivery includes three headers:

  • bookflow-signature — the signature, format t=<timestamp>,v1=<hex>
  • bookflow-event-id — unique event id
  • bookflow-event-type — event name, e.g. booking.created

The signed string is:

where <timestamp> is the unix-seconds value from t=. The HMAC is SHA-256 with your signing secret. Verify in Node:

ts
import { createHmac, timingSafeEqual } from "node:crypto"; export function verifyWebhook(opts: { rawBody: string; // raw request body, NOT parsed JSON signatureHeader: string; // value of bookflow-signature secret: string; toleranceSeconds?: number; // default 300 }): boolean { const tolerance = opts.toleranceSeconds ?? 300; const parts = Object.fromEntries( opts.signatureHeader.split(",").map((kv) => { const i = kv.indexOf("="); return [kv.slice(0, i), kv.slice(i + 1)]; }), ); const t = Number(parts["t"]); const sig = parts["v1"]; if (!t || !sig) return false; const age = Math.floor(Date.now() / 1000) - t; if (Math.abs(age) > tolerance) return false; const expected = createHmac("sha256", opts.secret) .update(`${t}.${opts.rawBody}`) .digest("hex"); const a = Buffer.from(expected, "hex"); const b = Buffer.from(sig, "hex"); if (a.length !== b.length) return false; return timingSafeEqual(a, b); }

Always use the raw body for verification — re-serializing JSON can change whitespace and break the signature.

7.3 Retry policy

If your endpoint returns a non-2xx status, or times out (10 seconds), BookFlow retries with exponential backoff:

AttemptDelay (s)
10 (initial)
230
3120
4600
53600
621600

After 6 failed attempts, the event is moved to the **dead-letter queue and surfaces in the dashboard at Settings → Developers → Webhooks → Failed**. From there you can replay any single event with POST /v1/webhooks/events/:id/replay (admin auth) or via the dashboard.

7.4 Idempotency

Every event has a unique id in the payload:

json
{ "id": "evt_3k9p2m", "type": "booking.created", "createdAt": "2026-07-25T08:11:02Z", "data": { "booking": { ... } } }

Your handler should de-duplicate by id. A simple pattern:

ts
const seen = await redis.set(`wh:${event.id}`, "1", "EX", 86400, "NX"); if (seen !== "OK") { return; // already processed } // process event

The Redis key TTL of 24 hours is more than the maximum retry window (6 + 30 + 120 + 600 + 3600 + 21600 = ~7 hours), so you can safely drop the key after 24 hours.

8. Rate limits

The API enforces rate limits per IP for unauthenticated requests and per credential for authenticated ones. Limits are sliding-window counters, implemented in Cloudflare KV.

GroupLimitWindowNotes
Public (no auth)30 req60 sPer IP.
User JWT600 req60 sPer user.
API key600 req60 sPer key.
AdminunlimitedSoft-capped at 10,000 req / min per key.

When you hit the limit, the response is 429 Too Many Requests with a Retry-After header (seconds) and a JSON body:

json
{ "error": "rate_limited" }

A successful retry strategy:

  1. Read Retry-After.
  2. Sleep that long.
  3. Retry once. If still rate-limited, surface the error to the user.
  4. For batch operations, prefer streaming endpoints over looping

single requests.

For very high-volume integrations (millions of requests per day), contact support for a quota bump.

9. Sandbox environment

The sandbox at https://api.sandbox.bookflow.app is a fully separate environment with its own data store. Use it for development and testing. Sandbox data is wiped weekly; never put real customer data there.

To get a sandbox account:

  1. Sign up at <https://sandbox.bookflow.app/register>.
  2. Use a sandbox API key from Settings → Developers → API keys

(prefixed bfk_test_).

  1. Use Stripe's test-mode cards (e.g. 4242 4242 4242 4242) — no

real money moves.

Sample data is seeded on signup: one tenant, three services, two staff, and 50 historical bookings so your analytics screens have something to render.

Differences from production:

  • All emails go to a MailHog inbox at

<https://mailhog.sandbox.bookflow.app> instead of being delivered.

  • SMS messages are logged but not sent.
  • Stripe is in test mode.
  • Webhooks fire against a local webhook receiver at

<https://webhooks.sandbox.bookflow.app> if you don't have your own endpoint.

10. Pagination deep dive

Cursor pagination is opaque but the cursor encodes (createdAt, id) of the last item. To implement it correctly:

  • Always treat the cursor as an opaque string. Don't parse or

construct it.

  • Save the cursor between page fetches; if your process restarts

mid-pagination, resume from the saved cursor.

  • Set limit to the largest value that doesn't trigger rate

limits (100 is a good default for server-side iterators).

  • For very long-lived sync jobs, periodically checkpoint by

recording the last successfully processed id in your own database, so you can resume on restart without re-walking the entire history.

A typical pattern for a cron-driven sync:

ts
let cursor = await getCheckpoint(); // null on first run while (true) { const res = await client.bookings.list({ cursor, limit: 100 }); for (const b of res.data) { await syncBooking(b); } if (!res.pagination.hasMore) break; cursor = res.pagination.cursor; await setCheckpoint(cursor); }

11. Best practices

  • Cache the OpenAPI spec — fetch

https://api.bookflow.app/v1/openapi.yaml once a day and generate types from it (e.g. with openapi-typescript).

  • Use the SDK — the JS SDK is small, type-safe, and handles

retries, pagination, and webhook verification.

  • Idempotency keys — for POST endpoints that may be retried

(booking creation, payment intent), pass Idempotency-Key: <uuid> to make the request safe to retry.

  • Subscribe to webhooks instead of polling — the API has both,

but webhooks are faster, cheaper, and don't burn rate-limit budget.

  • Version your integration — pin to a specific OpenAPI

revision, not to latest. The spec carries a version field.

  • Log the `x-request-id` — when reporting an issue, this id is

the fastest way for support to find the trace.

  • Test against sandbox — never test against production.
  • Keep secrets out of source control — load API keys from env

vars or a secret manager. Revoke any key that has been committed publicly.

12. Support

  • Documentation — you're reading it. The full reference is

`openapi.yaml`.

  • Discord — <https://bookflow.app/discord> for community

questions and integration help.

rate-limit questions.

  • Status — <https://status.bookflow.app> for live uptime.
  • Security issues — email <[email protected]> (PGP key at

<https://bookflow.app/pgp.txt>). We aim to respond within 24 hours.