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.
Getting started
- Sign in and open Dashboard → API keys.
- Create a key, choose its permissions, and copy it — it is shown once.
- 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.
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.
| Scope | What it allows |
|---|---|
availability:read | List services and read open slots. |
bookings:read | Read bookings (client details masked without customers:read). |
bookings:write | Create, edit, reschedule, cancel, approve and reject bookings. |
customers:read | See 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:
| Sent | Why 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
| Endpoint | Scope | What it does |
|---|---|---|
GET/api/v1/health | — | Liveness and the authoritative server clock. |
GET/api/v1/me | any | Business, timezone, current time, your scopes, limits, datetime rules. |
GET/api/v1/services | availability: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/availability | availability:read | Open slots across a date range (max 31 days). |
GET/api/v1/availability/days | availability:read | Which days have anything free (max 92 days). Much smaller. |
POST/api/v1/bookings | bookings:write | Create a booking. |
GET/api/v1/bookings | bookings: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}/reschedule | bookings:write | Move a booking to a new time. |
POST/api/v1/bookings/{id}/cancel | bookings:write | Cancel. Safe to retry. |
POST/api/v1/bookings/{id}/approve | bookings:write | Confirm a booking awaiting approval. |
POST/api/v1/bookings/{id}/reject | bookings: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" } }
| Status | What to do |
|---|---|
400 | Fix the request. The message names the field. |
401 | The key is missing, wrong, revoked or expired. |
403 | The key lacks a scope. required_scope says which. |
404 | No such record — or it belongs to another business. |
409 | Valid request, impossible right now. For times, offer
alternatives to the customer. |
429 | Rate limited, or the daily booking cap is reached. Back off. |
503 | Retry 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.
- Cancelling something already cancelled returns
200withchanged: false, so a retry is safe. - Rescheduling a
pendingbooking leaves itpending— it is not an approval. - Cancelled bookings cannot be rescheduled. Create a new one.
- There is no cancellation deadline. If you need one, enforce it in your own agent.
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:
- Never invent a time. Call
check_availabilityand use a slot it returned. - Resolve "tomorrow" yourself from the business clock in
get_business. - On a 409, read
alternativesand offer those. - On a 503, retry the identical request.
- Never quote a price — this system doesn't store them.
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.