Bookings API

A REST API for checking availability and creating, editing, rescheduling and cancelling appointments in real time. It is built to be driven by an AI agent: every failure is a stable machine-readable code, and when a time is taken the response hands back alternative slots so your agent can re-offer without another round trip.

Machine-readable: OpenAPI 3.1 · OpenAI tool definitions · Anthropic tool definitions. The tool bundles include a ready-made system prompt. Point your agent at them and it can book without any glue code.

Getting started

  1. Sign in and open Dashboard → API keys.
  2. Create a key, choose its permissions, and copy it — it is shown once.
  3. Send it on every request as a bearer token.
curl https://otterflow.ph/api/v1/me \
  -H "Authorization: Bearer ofk_EXAMPLE_KEY_DO_NOT_USE"

GET /api/v1/me is the right first call. It returns the business timezone, the current server time, your key's permissions, the live rate limits and the datetime rules — everything an agent needs before it does anything real.

Server-to-server only. There is deliberately no CORS support, and there never will be. An API key in a browser is a leaked API key — anyone can read it out of the page. If a web front-end needs this data, proxy the call through your own backend.

Authentication and permissions

Authorization: Bearer ofk_…, or X-OtterFlow-Key: ofk_… if a bearer header is awkward in your stack. Never put a key in the query string — that request will be rejected, because query strings end up in server access logs and in Referer headers.

ScopeWhat it allows
availability:readList services and read open slots.
bookings:readRead bookings (client details masked without customers:read).
bookings:writeCreate, edit, reschedule, cancel, approve and reject bookings.
customers:readSee full client name, phone, email and address, and search by phone.

Grant the least you need. Without customers:read you can still read bookings, but customer names and phone numbers come back masked ("Juan D.", "•••• ••• 4567") and searching by phone is refused. That is usually the right setting for an agent that only creates bookings.

Dates and times

This is where integrations most often go wrong, so the rule is strict: a time must name exactly one instant. Two forms are accepted.

"start": "2026-08-20T14:30:00+08:00"          // ISO 8601 with an offset
"start": "2026-08-20T06:30:00Z"               // …or Z
"start": { "date": "2026-08-20", "time": "14:30" }    // business-local
"start": { "date": "2026-08-20", "time": "2:30 PM", "tz": "Asia/Manila" }

These are rejected, on purpose:

SentWhy it fails
"2026-08-20T14:30" No timezone, so it names two different instants. The error returns both readings resolved and labelled, so your retry is right first time.
"2026-08-20"A date with no time.
"tomorrow at 3pm" Resolve relative times yourself from now in GET /me. We won't guess which day you meant.
1786000000 Epoch numbers — seconds and milliseconds can't be told apart reliably, and being wrong is a silent 50-year error.

Every timestamp we return carries all four readings, so you never have to format a date yourself:

{ "utc":   "2026-08-20T06:30:00Z",
  "local": "2026-08-20T14:30:00+08:00",
  "tz":    "Asia/Manila",
  "label": "Thu, Aug 20, 2:30 PM" }

Endpoints

EndpointScopeWhat it does
GET/api/v1/health Liveness and the authoritative server clock.
GET/api/v1/meany Business, timezone, current time, your scopes, limits, datetime rules.
GET/api/v1/servicesavailability:read Bookable services with duration, notice period and required fields.
GET/api/v1/services/{id}availability:read One service, plus opening hours and upcoming closures.
GET/api/v1/availabilityavailability:read Open slots across a date range (max 31 days).
GET/api/v1/availability/daysavailability:read Which days have anything free (max 92 days). Much smaller.
POST/api/v1/bookingsbookings:write Create a booking.
GET/api/v1/bookingsbookings:read List bookings with filters and a cursor.
GET/api/v1/bookings/{id}bookings:read One booking, by id or short code.
PATCH/api/v1/bookings/{id}bookings:write Edit customer details or notes. Not times.
POST/api/v1/bookings/{id}/reschedulebookings:write Move a booking to a new time.
POST/api/v1/bookings/{id}/cancelbookings:write Cancel. Safe to retry.
POST/api/v1/bookings/{id}/approvebookings:write Confirm a booking awaiting approval.
POST/api/v1/bookings/{id}/rejectbookings:write Decline a booking awaiting approval.

There is no /customers endpoint. This system has no separate customer record — a customer's history is GET /api/v1/bookings?phone=…, which is why customers:read gates a filter rather than a resource.

Making a booking

Find a slot, then book the exact start.utc value it gave you.

curl "https://otterflow.ph/api/v1/availability?service_id=4&from=2026-08-20&to=2026-08-22" \
  -H "Authorization: Bearer ofk_EXAMPLE_KEY_DO_NOT_USE"
curl -X POST https://otterflow.ph/api/v1/bookings \
  -H "Authorization: Bearer ofk_EXAMPLE_KEY_DO_NOT_USE" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8f14e45f-ea2b-4c1f-9f2a-1d3c5b7a9e01" \
  -d '{
    "service_id": 4,
    "start": "2026-08-20T14:30:00+08:00",
    "client": { "name": "Juan Dela Cruz", "phone": "09171234567" },
    "notes": "First visit"
  }'

Always send an Idempotency-Key on writes. Networks fail between "booking created" and "response received". With a key, the retry replays the original response; without one, it books a second appointment. Reusing a key with a different body is an error rather than a silent replay of the wrong thing.

Email is optional — omit it if you don't have one. Please don't invent an address: a fabricated one looks real to the business and silently swallows mail. Phone is required whenever the service sends SMS; check requires.client_phone on the service.

Errors

Every failure has the same shape, with a stable error code to branch on:

{ "ok": false,
  "error": "slot_taken",
  "message": "That time is already booked.",
  "requested": { "utc": "…", "local": "…", "tz": "…", "label": "…" },
  "alternatives": [ { "utc": "…", "label": "Thu, Aug 20, 3:00 PM" } ],
  "next_available": { "utc": "…", "label": "Thu, Aug 20, 3:00 PM" } }
StatusWhat to do
400Fix the request. The message names the field.
401The key is missing, wrong, revoked or expired.
403The key lacks a scope. required_scope says which.
404No such record — or it belongs to another business.
409Valid request, impossible right now. For times, offer alternatives to the customer.
429Rate limited, or the daily booking cap is reached. Back off.
503Retry the identical request. The system was momentarily busy — the slot is probably still free. Don't offer alternatives here.

That last distinction matters. A 409 means the time is genuinely gone; a 503 means we couldn't take the write lock in time. Treating a 503 as "taken" would push a customer off a slot they could have had.

Why a day has nothing free

Empty days in an availability response carry a reason: closed (that weekday isn't worked), time_off (the business is away), fully_booked, past, or beyond_horizon. Read it before you tell a customer there's "nothing available" — "we're closed Sundays" is a different conversation.

Booking states

A booking is pending, confirmed, cancelled or rejected. Services with approval enabled start at pending. Always read status off the response rather than assuming.

Limits

Roughly 120 requests and 20 writes per minute, and a daily cap on bookings created through the API. Live values for your key are in GET /me, and every response carries X-RateLimit-Remaining. Repeated bad keys are locked out for a while, so fix a 401 rather than retrying it.

Webhooks

Rather than polling, you can have booking events pushed to you: booking.created, booking.cancelled, booking.rescheduled, booking.approved, booking.rejected and booking.updated. They fire for every change — dashboard, customer link, chat or API — so your copy stays correct. Configure the URL in your dashboard; each delivery is HMAC-signed.

Wiring up an AI agent

Fetch the OpenAI bundle or the Anthropic one and hand the tools array straight to your model. The bundle also carries a system_guidance string worth putting in your system prompt — it encodes the rules agents most often break:

approve and reject are deliberately left out of the tool bundles. An agent that can approve its own pending request defeats the point of asking for approval. They remain available over REST for a human's tooling.