{"components":{"schemas":{"Error":{"properties":{"error":{"description":"Stable machine-readable code.","type":"string"},"message":{"description":"Human-readable explanation.","type":"string"},"ok":{"const":false,"type":"boolean"}},"required":["ok","error","message"],"type":"object"}},"securitySchemes":{"ApiKeyAuth":{"bearerFormat":"ofk_\u2026","scheme":"bearer","type":"http"}}},"info":{"description":"Real-time appointment booking. Designed to be driven by an AI agent: every error is a stable machine-readable code, and a rejected time comes back with alternative slots so you can re-offer without another call.\n\n**Authentication.** `Authorization: Bearer ofk_\u2026`. Server-to-server only \u2014 there is no CORS support by design, so never put a key in a browser.\n\n**Times.** Send either an ISO 8601 instant with an explicit UTC offset or Z (\"2026-08-20T14:30:00+08:00\"), or an object {\"date\": \"2026-08-20\", \"time\": \"14:30\"} which is read in the business timezone. A time with no timezone is REJECTED rather than guessed. Never guess a time \u2014 call check_availability first and use a slot it returned.","title":"OtterFlow Bookings API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/availability":{"get":{"description":"The ONLY way to find a bookable time. Every returned slot start can be passed straight to create_booking. Days with nothing free carry a `reason` (closed, time_off, fully_booked) \u2014 'closed' means that weekday isn't worked, which is different from being fully booked. Maximum range 31 days.","operationId":"check_availability","parameters":[{"description":"Numeric id or slug of the service.","in":"query","name":"service_id","required":true,"schema":{"type":"string"}},{"description":"First date, YYYY-MM-DD. Defaults to today.","in":"query","name":"from","required":false,"schema":{"type":"string"}},{"description":"Last date, YYYY-MM-DD. Defaults to `from` + 6 days.","in":"query","name":"to","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Success."},"400":{"description":"Malformed request \u2014 fix the JSON and retry."},"401":{"description":"Missing or invalid API key."},"403":{"description":"The key lacks the required scope."},"404":{"description":"No such record, or it belongs to another business."},"409":{"description":"Well-formed but not possible right now. For times, `alternatives` carries slots you can offer instead."},"429":{"description":"Rate limited or the daily booking cap was reached."},"503":{"description":"Momentarily busy \u2014 retry the identical request."}},"security":[{"ApiKeyAuth":["availability:read"]}],"summary":"Open slots for a service across a date range.","x-required-scope":"availability:read"}},"/availability/days":{"get":{"description":"Much smaller than check_availability. Use it to narrow a long period down to candidate days, then call check_availability for one of them. Maximum range 92 days.","operationId":"check_available_days","parameters":[{"description":"","in":"query","name":"service_id","required":true,"schema":{"type":"string"}},{"description":"","in":"query","name":"from","required":false,"schema":{"type":"string"}},{"description":"","in":"query","name":"to","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Success."},"400":{"description":"Malformed request \u2014 fix the JSON and retry."},"401":{"description":"Missing or invalid API key."},"403":{"description":"The key lacks the required scope."},"404":{"description":"No such record, or it belongs to another business."},"409":{"description":"Well-formed but not possible right now. For times, `alternatives` carries slots you can offer instead."},"429":{"description":"Rate limited or the daily booking cap was reached."},"503":{"description":"Momentarily busy \u2014 retry the identical request."}},"security":[{"ApiKeyAuth":["availability:read"]}],"summary":"Which days have any availability, without the slot lists.","x-required-scope":"availability:read"}},"/bookings":{"get":{"description":"Searching by `phone` needs the customers:read scope. Paginate with the returned `next_cursor`.","operationId":"find_bookings","parameters":[{"description":"Comma-separated: confirmed, pending, cancelled, rejected.","in":"query","name":"status","required":false,"schema":{"type":"string"}},{"description":"","in":"query","name":"service_id","required":false,"schema":{"type":"string"}},{"description":"Find a customer's bookings. Needs customers:read.","in":"query","name":"phone","required":false,"schema":{"type":"string"}},{"description":"Only bookings starting on/after this date (YYYY-MM-DD).","in":"query","name":"from","required":false,"schema":{"type":"string"}},{"description":"Only bookings starting on/before this date (YYYY-MM-DD).","in":"query","name":"to","required":false,"schema":{"type":"string"}},{"description":"1-100, default 25.","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"`next_cursor` from the previous page.","in":"query","name":"cursor","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Success."},"400":{"description":"Malformed request \u2014 fix the JSON and retry."},"401":{"description":"Missing or invalid API key."},"403":{"description":"The key lacks the required scope."},"404":{"description":"No such record, or it belongs to another business."},"409":{"description":"Well-formed but not possible right now. For times, `alternatives` carries slots you can offer instead."},"429":{"description":"Rate limited or the daily booking cap was reached."},"503":{"description":"Momentarily busy \u2014 retry the identical request."}},"security":[{"ApiKeyAuth":["bookings:read"]}],"summary":"List bookings, newest first.","x-required-scope":"bookings:read"},"post":{"description":"Send an Idempotency-Key header so a retry can't create a second appointment. If the slot has gone, the 409 response carries `alternatives` \u2014 offer those to the customer instead of calling check_availability again. A 503 means the system was momentarily busy: retry the identical request, the slot is probably still free.","operationId":"create_booking","parameters":[{"description":"Send a unique value so a retried request replays its original response instead of repeating the work.","in":"header","name":"Idempotency-Key","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"client":{"properties":{"address":{"description":"Required only when the service's `requires.client_address` is true.","maxLength":2000,"type":"string"},"email":{"description":"Optional. Omit it if you don't have one \u2014 do NOT invent an address.","maxLength":255,"type":"string"},"name":{"description":"The customer's full name. Required.","maxLength":120,"type":"string"},"phone":{"description":"Philippine mobile number (09XXXXXXXXX, +639XXXXXXXXX or 639XXXXXXXXX). Required when the service sends SMS \u2014 check `requires.client_phone` on the service.","maxLength":40,"type":"string"}},"required":["name"],"type":"object"},"notes":{"description":"Anything the business should know.","maxLength":2000,"type":"string"},"service_id":{"description":"Numeric id or slug of the service.","type":"string"},"start":{"description":"Send either an ISO 8601 instant with an explicit UTC offset or Z (\"2026-08-20T14:30:00+08:00\"), or an object {\"date\": \"2026-08-20\", \"time\": \"14:30\"} which is read in the business timezone. A time with no timezone is REJECTED rather than guessed. Never guess a time \u2014 call check_availability first and use a slot it returned.","oneOf":[{"description":"ISO 8601 with offset or Z.","examples":["2026-08-20T14:30:00+08:00","2026-08-20T06:30:00Z"],"type":"string"},{"description":"Read in the business timezone unless `tz` is given.","properties":{"date":{"description":"YYYY-MM-DD","type":"string"},"time":{"description":"\"14:30\" or \"2:30 PM\"","type":"string"},"tz":{"description":"IANA name, e.g. Asia/Manila","type":"string"}},"required":["date","time"],"type":"object"}]}},"required":["service_id","start","client"],"type":"object"}}},"required":true},"responses":{"200":{"description":"Success."},"400":{"description":"Malformed request \u2014 fix the JSON and retry."},"401":{"description":"Missing or invalid API key."},"403":{"description":"The key lacks the required scope."},"404":{"description":"No such record, or it belongs to another business."},"409":{"description":"Well-formed but not possible right now. For times, `alternatives` carries slots you can offer instead."},"429":{"description":"Rate limited or the daily booking cap was reached."},"503":{"description":"Momentarily busy \u2014 retry the identical request."}},"security":[{"ApiKeyAuth":["bookings:write"]}],"summary":"Create a booking.","x-required-scope":"bookings:write"}},"/bookings/{booking_id}":{"get":{"operationId":"get_booking","parameters":[{"description":"Numeric id or the booking's short code.","in":"path","name":"booking_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Success."},"400":{"description":"Malformed request \u2014 fix the JSON and retry."},"401":{"description":"Missing or invalid API key."},"403":{"description":"The key lacks the required scope."},"404":{"description":"No such record, or it belongs to another business."},"409":{"description":"Well-formed but not possible right now. For times, `alternatives` carries slots you can offer instead."},"429":{"description":"Rate limited or the daily booking cap was reached."},"503":{"description":"Momentarily busy \u2014 retry the identical request."}},"security":[{"ApiKeyAuth":["bookings:read"]}],"summary":"One booking by id or code.","x-required-scope":"bookings:read"},"patch":{"description":"Times are NOT changed here \u2014 use reschedule_booking. Omitted fields are left untouched.","operationId":"update_booking","parameters":[{"description":"","in":"path","name":"booking_id","required":true,"schema":{"type":"string"}},{"description":"Send a unique value so a retried request replays its original response instead of repeating the work.","in":"header","name":"Idempotency-Key","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"client":{"properties":{"address":{"description":"Required only when the service's `requires.client_address` is true.","maxLength":2000,"type":"string"},"email":{"description":"Optional. Omit it if you don't have one \u2014 do NOT invent an address.","maxLength":255,"type":"string"},"name":{"description":"The customer's full name. Required.","maxLength":120,"type":"string"},"phone":{"description":"Philippine mobile number (09XXXXXXXXX, +639XXXXXXXXX or 639XXXXXXXXX). Required when the service sends SMS \u2014 check `requires.client_phone` on the service.","maxLength":40,"type":"string"}},"required":["name"],"type":"object"},"notes":{"maxLength":2000,"type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"description":"Success."},"400":{"description":"Malformed request \u2014 fix the JSON and retry."},"401":{"description":"Missing or invalid API key."},"403":{"description":"The key lacks the required scope."},"404":{"description":"No such record, or it belongs to another business."},"409":{"description":"Well-formed but not possible right now. For times, `alternatives` carries slots you can offer instead."},"429":{"description":"Rate limited or the daily booking cap was reached."},"503":{"description":"Momentarily busy \u2014 retry the identical request."}},"security":[{"ApiKeyAuth":["bookings:write"]}],"summary":"Edit the customer's details or the notes.","x-required-scope":"bookings:write"}},"/bookings/{booking_id}/approve":{"post":{"operationId":"approve_booking","parameters":[{"description":"","in":"path","name":"booking_id","required":true,"schema":{"type":"string"}},{"description":"Send a unique value so a retried request replays its original response instead of repeating the work.","in":"header","name":"Idempotency-Key","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Success."},"400":{"description":"Malformed request \u2014 fix the JSON and retry."},"401":{"description":"Missing or invalid API key."},"403":{"description":"The key lacks the required scope."},"404":{"description":"No such record, or it belongs to another business."},"409":{"description":"Well-formed but not possible right now. For times, `alternatives` carries slots you can offer instead."},"429":{"description":"Rate limited or the daily booking cap was reached."},"503":{"description":"Momentarily busy \u2014 retry the identical request."}},"security":[{"ApiKeyAuth":["bookings:write"]}],"summary":"Confirm a booking that is awaiting approval.","x-required-scope":"bookings:write"}},"/bookings/{booking_id}/cancel":{"post":{"description":"Safe to retry: cancelling an already-cancelled booking returns 200 with changed: false. Pass notify_client: false when you're already speaking to the customer, so they don't also get a cancellation text.","operationId":"cancel_booking","parameters":[{"description":"","in":"path","name":"booking_id","required":true,"schema":{"type":"string"}},{"description":"Send a unique value so a retried request replays its original response instead of repeating the work.","in":"header","name":"Idempotency-Key","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"notify_client":{"description":"Whether to text the customer. Omit to use the business default.","type":"boolean"}},"type":"object"}}},"required":true},"responses":{"200":{"description":"Success."},"400":{"description":"Malformed request \u2014 fix the JSON and retry."},"401":{"description":"Missing or invalid API key."},"403":{"description":"The key lacks the required scope."},"404":{"description":"No such record, or it belongs to another business."},"409":{"description":"Well-formed but not possible right now. For times, `alternatives` carries slots you can offer instead."},"429":{"description":"Rate limited or the daily booking cap was reached."},"503":{"description":"Momentarily busy \u2014 retry the identical request."}},"security":[{"ApiKeyAuth":["bookings:write"]}],"summary":"Cancel a booking.","x-required-scope":"bookings:write"}},"/bookings/{booking_id}/reject":{"post":{"operationId":"reject_booking","parameters":[{"description":"","in":"path","name":"booking_id","required":true,"schema":{"type":"string"}},{"description":"Send a unique value so a retried request replays its original response instead of repeating the work.","in":"header","name":"Idempotency-Key","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Success."},"400":{"description":"Malformed request \u2014 fix the JSON and retry."},"401":{"description":"Missing or invalid API key."},"403":{"description":"The key lacks the required scope."},"404":{"description":"No such record, or it belongs to another business."},"409":{"description":"Well-formed but not possible right now. For times, `alternatives` carries slots you can offer instead."},"429":{"description":"Rate limited or the daily booking cap was reached."},"503":{"description":"Momentarily busy \u2014 retry the identical request."}},"security":[{"ApiKeyAuth":["bookings:write"]}],"summary":"Decline a booking that is awaiting approval.","x-required-scope":"bookings:write"}},"/bookings/{booking_id}/reschedule":{"post":{"description":"A booking awaiting approval stays awaiting approval. Cancelled bookings cannot be rescheduled \u2014 create a new one. Texts the customer their new time, because nobody is watching a screen when an agent moves a booking; pass notify_client=false to move it silently.","operationId":"reschedule_booking","parameters":[{"description":"","in":"path","name":"booking_id","required":true,"schema":{"type":"string"}},{"description":"Send a unique value so a retried request replays its original response instead of repeating the work.","in":"header","name":"Idempotency-Key","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"notify_client":{"type":"boolean"},"start":{"description":"Send either an ISO 8601 instant with an explicit UTC offset or Z (\"2026-08-20T14:30:00+08:00\"), or an object {\"date\": \"2026-08-20\", \"time\": \"14:30\"} which is read in the business timezone. A time with no timezone is REJECTED rather than guessed. Never guess a time \u2014 call check_availability first and use a slot it returned.","oneOf":[{"description":"ISO 8601 with offset or Z.","examples":["2026-08-20T14:30:00+08:00","2026-08-20T06:30:00Z"],"type":"string"},{"description":"Read in the business timezone unless `tz` is given.","properties":{"date":{"description":"YYYY-MM-DD","type":"string"},"time":{"description":"\"14:30\" or \"2:30 PM\"","type":"string"},"tz":{"description":"IANA name, e.g. Asia/Manila","type":"string"}},"required":["date","time"],"type":"object"}]}},"required":["start"],"type":"object"}}},"required":true},"responses":{"200":{"description":"Success."},"400":{"description":"Malformed request \u2014 fix the JSON and retry."},"401":{"description":"Missing or invalid API key."},"403":{"description":"The key lacks the required scope."},"404":{"description":"No such record, or it belongs to another business."},"409":{"description":"Well-formed but not possible right now. For times, `alternatives` carries slots you can offer instead."},"429":{"description":"Rate limited or the daily booking cap was reached."},"503":{"description":"Momentarily busy \u2014 retry the identical request."}},"security":[{"ApiKeyAuth":["bookings:write"]}],"summary":"Move a booking to a different time.","x-required-scope":"bookings:write"}},"/health":{"get":{"operationId":"health","responses":{"200":{"description":"Success."},"400":{"description":"Malformed request \u2014 fix the JSON and retry."},"401":{"description":"Missing or invalid API key."},"403":{"description":"The key lacks the required scope."},"404":{"description":"No such record, or it belongs to another business."},"409":{"description":"Well-formed but not possible right now. For times, `alternatives` carries slots you can offer instead."},"429":{"description":"Rate limited or the daily booking cap was reached."},"503":{"description":"Momentarily busy \u2014 retry the identical request."}},"security":[],"summary":"Service health and the authoritative server clock."}},"/me":{"get":{"description":"Call this first. It reports the business timezone and the current server time, which is what you use to resolve anything relative like \"tomorrow\".","operationId":"get_business","responses":{"200":{"description":"Success."},"400":{"description":"Malformed request \u2014 fix the JSON and retry."},"401":{"description":"Missing or invalid API key."},"403":{"description":"The key lacks the required scope."},"404":{"description":"No such record, or it belongs to another business."},"409":{"description":"Well-formed but not possible right now. For times, `alternatives` carries slots you can offer instead."},"429":{"description":"Rate limited or the daily booking cap was reached."},"503":{"description":"Momentarily busy \u2014 retry the identical request."}},"security":[{"ApiKeyAuth":[]}],"summary":"The business, this key's scopes, the current limits, and the datetime rules."}},"/services":{"get":{"description":"Each service carries its duration, notice period, booking horizon, and a `requires` block telling you which customer fields are mandatory. Prices are not stored by this system.","operationId":"list_services","parameters":[{"description":"Include services that aren't currently bookable.","in":"query","name":"include_inactive","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"Success."},"400":{"description":"Malformed request \u2014 fix the JSON and retry."},"401":{"description":"Missing or invalid API key."},"403":{"description":"The key lacks the required scope."},"404":{"description":"No such record, or it belongs to another business."},"409":{"description":"Well-formed but not possible right now. For times, `alternatives` carries slots you can offer instead."},"429":{"description":"Rate limited or the daily booking cap was reached."},"503":{"description":"Momentarily busy \u2014 retry the identical request."}},"security":[{"ApiKeyAuth":["availability:read"]}],"summary":"List bookable services.","x-required-scope":"availability:read"}},"/services/{service_id}":{"get":{"operationId":"get_service","parameters":[{"description":"Numeric id or slug.","in":"path","name":"service_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Success."},"400":{"description":"Malformed request \u2014 fix the JSON and retry."},"401":{"description":"Missing or invalid API key."},"403":{"description":"The key lacks the required scope."},"404":{"description":"No such record, or it belongs to another business."},"409":{"description":"Well-formed but not possible right now. For times, `alternatives` carries slots you can offer instead."},"429":{"description":"Rate limited or the daily booking cap was reached."},"503":{"description":"Momentarily busy \u2014 retry the identical request."}},"security":[{"ApiKeyAuth":["availability:read"]}],"summary":"One service, with opening hours and upcoming closures.","x-required-scope":"availability:read"}}},"security":[{"ApiKeyAuth":[]}],"servers":[{"url":"https://otterflow.ph/api/v1"}],"x-scopes":{"availability:read":"Read services and open slots.","bookings:read":"Read bookings.","bookings:write":"Create and change bookings.","customers:read":"See full customer details and search by phone."}}
