Documentation
Base URL https://use-flywheel.com/api/v1.
This page lists every endpoint that exists, with request and response parameters derived
from the same OpenAPI document agents download. Endpoints from the roadmap are not listed
here.
Quickstart
Two calls from nothing to a lead being answered. The first needs no credentials at all.
Before you can actually send
An account needs its own number.
POST /api/v1/accounts/{id}/phone-number
buys one in a local area_code
you name, then nearby codes — never toll-free, which is a separate carrier
registration. It's idempotent — calling it
twice returns the number you already have rather than billing you for a second one — and
it answers 503
when local inventory is dry, so retrying with another local code is the right
response.
curl -sX POST https://use-flywheel.com/api/v1/accounts \
-H 'Content-Type: application/json' \
-d '{"account":{"name":"Northside Plumbing","time_zone":"America/Chicago","vertical":"plumbing","serves_minors":false}}'
curl -sX POST https://use-flywheel.com/api/v1/accounts/$ID/contacts \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"contact":{"first_name":"Alex","phone":"+13125550142"},"sms_consented":true,"start_conversation":true}'
Conventions
- Authentication
-
Authorization: Bearer <token>on every request exceptGET /health,POST /accounts, andPOST /sessions. The token from signup is shown exactly once and stored only as a digest — we cannot recover it for you. - Identifiers
- Accounts and contacts are addressed by an opaque URL-safe slug. Conversations, messages, templates, flows, steps, appointments, availability rules, review requests and webhook endpoints use integer ids.
- Scope
-
The account is always resolved from the URL and then authorised against your key,
never the other way round — so naming somebody else's account cannot widen your own
scope. A mismatch is
403 forbidden, not404. - Pagination
-
page(from 1) andper_page(default 50, maximum 200) on list endpoints. Ordered newest first unless noted on the operation. - Errors
-
Always
{"error": {"code", "message", "details"}}. Branch oncode; treatmessageas human-facing and liable to change.unauthorizedforbiddennot_foundvalidation_failedbad_requestconflictsend_failedslot_takentoo_soonin_the_past
Endpoints
Request and response fields below come from openapi.json. Prefer that file if you are a machine.
Health
Liveness. Unauthenticated.
-
GET
/healthLiveness probeNo authentication required
Verifies the Flywheel database connection specifically, not the host process.
Response 200
-
statusstring -
servicestring -
api_versionstring -
sms_modestring ("live", "sandbox")This process. Whether it would text real people.
-
worker_sms_modestring ("live", "sandbox", "unknown")The worker that drains StartJob. Conformance requires both sandbox (D249).
-
timestring, format date-time
-
Sessions
Trading a dashboard login for a short-lived API key. Exists so a human-facing client needs no privileged route in.
-
POST
/sessionsExchange an email and password for an API keyNo authentication required
Unauthenticated. Returns a short-lived `session` key for the account whose `login_email` and `password` were sent, together with the account itself. This exists so that a human-facing client has no privileged route in. Flywheel's own subscriber dashboard signs in through this endpoint and then makes ordinary bearer-token calls for everything else, which means anyone building their own UI has exactly the same capability — there is no in-process shortcut being kept back. A...
Request body
-
login_emailstring, format email required -
passwordstring, format password required
Response 201
-
accountobjectThe tenant, and the voice. Messages in an operated account still speak as the account, never as its operator.
-
idstring required — Opaque slug. -
namestring required -
display_namestring | null required -
contact_first_namestring | null required -
contact_emailstring | null required — Where mail about the account goes (webhook disable, dead deliveries). Distinct from login_email. Required on standalone signup (D419). Partner-operated creat... -
contact_phonestring | null required — E.164. Where account-level SMS notices go, including the needs_human owner pull-in (D419). Required on standalone signup. -
website_urlstring | null required -
brand_colorstring | null required -
time_zonestring required — IANA name, e.g. America/Chicago. -
phone_numberstring | null required — E.164 sending number. -
verticalstring | null required — Drives which shipped defaults this account falls back to. -
statusstring ("active", "paused", "canceled") required -
serves_minorsboolean required — Defaults to true: a business that does not declare gets the protective posture. -
default_content_consentstring ("none", "anonymous", "first_name", "photo_video") required — Ordered most to least restrictive. `anonymous` is the notice-only default; naming or picturing someone requires an actively recorded decision. -
quiet_hoursobject required — Minutes past midnight, evaluated in the RECIPIENT's timezone. A blocked message is requeued to the next open window, not dropped. -
minimum_notice_minutesinteger | null required — Minutes before a slot that it may be offered. Defaults to 60 so a new lead can still book the same day. -
deemed_attendance_after_hoursinteger | null required — When set, an appointment left unactioned this many hours past its end is recorded attended with `attendance_source: deemed`. Null means never. -
lapsed_after_daysinteger required — Days after a last visit that a contact with no usual gap between visits yet counts as `lapsed`. Default 60. A contact with two or more visits lapses on their... -
review_ask_after_attendancesinteger required — How many attended appointments a family needs before a review is asked for. 1 (the default) asks after the first. -
review_ask_retirement_daysinteger required — Days a family is left alone after a review ask. Default 90. The visit-number ceiling scales with this number. -
review_urlstring | null required — Where a review ask sends people. Prefer the Google write-a-review URL (`https://search.google.com/local/writereview?placeid=…`). A pasted Place ID is expande... -
google_place_idstring | null required — Google Place ID. Saving one opts the account into a live dashboard read of Google reviews. We store only this field. Review text, ratings, and authors are ne... -
default_locationstring | null required — Shop address stamped on in_person bookings and included in the confirmation. Unused when meeting_kind is at_customer or virtual. -
booking_urlstring | null required — Public page for picking a time. Used when close_style is booking_link. -
close_stylestring ("handoff", "sms_slots", "booking_link") required — How a ready lead books. handoff is the default. Changing this rewrites only the qualify close. -
meeting_kindstring ("in_person", "at_customer", "virtual") required — in_person stamps the account default address. at_customer stamps the contact's address. virtual mints a Whereby room at book time. -
external_refstring | null required — Your own identifier. Unique across Flywheel when set — two accounts cannot share one. -
login_emailstring | null required — Sign-in address for `POST /sessions`. Distinct from `contact_email`, which is only where mail goes and carries no authority. Null on an account driven entire... -
owner_reachableboolean required — True when contact_email or login_email is present. False means webhook-disable and dead-delivery notices cannot reach the owner; Flywheel operators are the r... -
owner_sms_alertsboolean required — When true (the default), Flywheel texts the owner on conversation.needs_human for standalone accounts. Partner-operated accounts never get this SMS. Webhook ... -
owner_alert_coveragestring ("operated", "sms", "webhook", "none", "unreachable") required — How an escalate reaches a human. `none` means SMS is off and no active webhook is subscribed to conversation.needs_human — allowed, loud. `unreachable` means... -
calendar_urlstring, format uri required — Subscribable ICS feed of this account's appointments, keyed by an unguessable token. Read-only and unauthenticated by design, because calendar apps poll it w... -
operated_bystring | null required — Slug of the operating account, if any. -
created_atstring, format date-time required
-
-
api_keyobject-
idinteger required -
namestring | null required -
kindstring ("account", "partner", "session") required -
last_fourstring | null required -
last_used_atstring | null, format date-time required -
expires_atstring | null, format date-time required — When this key stops working. Null means it does not expire on its own; only `session` keys carry one. -
created_atstring, format date-time required -
tokenstring required — Shown exactly once. Store it now.
-
-
-
DELETE
/sessions/currentRevoke the key presented on this requestSigns out by revoking the credential rather than by forgetting it. A key left live after sign-out is a credential nobody is holding and nobody can withdraw. `current` rather than an id: the only session a caller may destroy is the one it is presenting. Any key may be used here, so an integration can retire its own credential without an administrative endpoint existing that could retire someone else's.
Response 204
No JSON body (or empty success).
Accounts
The tenant: a business using Flywheel. Signup is unauthenticated.
-
POST
/accountsSign upNo authentication required
Unauthenticated. Send a partner key in `Authorization` to create the account as operated by that partner; omit it and the account stands alone. Same endpoint either way — a partner receives no scope a stranger could not obtain. Standalone signup requires `contact_email` and `contact_phone` (D419) so the owner can be paged on `conversation.needs_human`. Partner-operated create may omit either; that account is an honest unreachable rather than a 422. `api_key.token` is pre...
Request body
-
accountobject required-
namestring -
display_namestring -
contact_first_namestring -
contact_emailstring — Required on standalone signup (D419). Partner-operated create may omit. Where webhook-disable and dead-delivery notices go. -
contact_phonestring — Required on standalone signup (D419). E.164. Where account-level SMS notices go, including the needs_human owner pull-in. -
owner_sms_alertsboolean — Default true. Set false to handle conversation.needs_human only via webhook. Turning it off without an active needs_human webhook is allowed; owner_alert_cov... -
website_urlstring -
time_zonestring -
verticalstring -
external_refstring -
brand_colorstring -
login_emailstring, format email — Optional. Set it, with a password, to enable sign-in at `POST /sessions`. Both or neither — a login email with no password is a sign-in that can never succee... -
passwordstring, format password — Optional, and never returned. Omit both this and `login_email` for an account that is only ever driven by API key, which is the normal case for a partner-pro... -
serves_minorsboolean -
default_content_consentstring ("none", "anonymous", "first_name", "photo_video") — Ordered most to least restrictive. `anonymous` is the notice-only default; naming or picturing someone requires an actively recorded decision. -
quiet_hoursobject — Same shape as Account.quiet_hours. Minutes past midnight, evaluated in the RECIPIENT's timezone. The two flat `quiet_hours_*_minute` fields are not accepted. -
minimum_notice_minutesinteger — Minutes before a slot that it may be offered. Defaults to 60. Raise it if the business needs more runway. -
lapsed_after_daysinteger — Days after a last visit that a contact with no usual gap between visits yet counts as `lapsed`. Default 60. -
deemed_attendance_after_hoursinteger | null — Hours an appointment may sit unactioned past its end before it is recorded attended with `attendance_source: deemed`. Null (the default) means never. Only op... -
review_ask_after_attendancesinteger — How many attended appointments a family needs before Flywheel asks them for a review, counted across the whole family. Default 1: ask after the first, which ... -
review_ask_retirement_daysinteger — Days a family is left alone after a review ask, across every outcome. Default 90, which is what Flywheel has always done. Lengthen-only: shorter than 90 woul... -
review_urlstring — Google Place ID or write-a-review URL. A Place ID is stored as `https://search.google.com/local/writereview?placeid=…`. A Maps listing is left as-is. Blank m... -
google_place_idstring — Google Place ID. Opts this account into a live Reviews-page read. We store only the ID. A pasted Place ID in `review_url` is copied here on save. -
default_locationstring -
booking_urlstring, format uri -
close_stylestring ("handoff", "sms_slots", "booking_link") -
meeting_kindstring ("in_person", "at_customer", "virtual")
-
Response 201
-
accountobjectThe tenant, and the voice. Messages in an operated account still speak as the account, never as its operator.
-
idstring required — Opaque slug. -
namestring required -
display_namestring | null required -
contact_first_namestring | null required -
contact_emailstring | null required — Where mail about the account goes (webhook disable, dead deliveries). Distinct from login_email. Required on standalone signup (D419). Partner-operated creat... -
contact_phonestring | null required — E.164. Where account-level SMS notices go, including the needs_human owner pull-in (D419). Required on standalone signup. -
website_urlstring | null required -
brand_colorstring | null required -
time_zonestring required — IANA name, e.g. America/Chicago. -
phone_numberstring | null required — E.164 sending number. -
verticalstring | null required — Drives which shipped defaults this account falls back to. -
statusstring ("active", "paused", "canceled") required -
serves_minorsboolean required — Defaults to true: a business that does not declare gets the protective posture. -
default_content_consentstring ("none", "anonymous", "first_name", "photo_video") required — Ordered most to least restrictive. `anonymous` is the notice-only default; naming or picturing someone requires an actively recorded decision. -
quiet_hoursobject required — Minutes past midnight, evaluated in the RECIPIENT's timezone. A blocked message is requeued to the next open window, not dropped. -
minimum_notice_minutesinteger | null required — Minutes before a slot that it may be offered. Defaults to 60 so a new lead can still book the same day. -
deemed_attendance_after_hoursinteger | null required — When set, an appointment left unactioned this many hours past its end is recorded attended with `attendance_source: deemed`. Null means never. -
lapsed_after_daysinteger required — Days after a last visit that a contact with no usual gap between visits yet counts as `lapsed`. Default 60. A contact with two or more visits lapses on their... -
review_ask_after_attendancesinteger required — How many attended appointments a family needs before a review is asked for. 1 (the default) asks after the first. -
review_ask_retirement_daysinteger required — Days a family is left alone after a review ask. Default 90. The visit-number ceiling scales with this number. -
review_urlstring | null required — Where a review ask sends people. Prefer the Google write-a-review URL (`https://search.google.com/local/writereview?placeid=…`). A pasted Place ID is expande... -
google_place_idstring | null required — Google Place ID. Saving one opts the account into a live dashboard read of Google reviews. We store only this field. Review text, ratings, and authors are ne... -
default_locationstring | null required — Shop address stamped on in_person bookings and included in the confirmation. Unused when meeting_kind is at_customer or virtual. -
booking_urlstring | null required — Public page for picking a time. Used when close_style is booking_link. -
close_stylestring ("handoff", "sms_slots", "booking_link") required — How a ready lead books. handoff is the default. Changing this rewrites only the qualify close. -
meeting_kindstring ("in_person", "at_customer", "virtual") required — in_person stamps the account default address. at_customer stamps the contact's address. virtual mints a Whereby room at book time. -
external_refstring | null required — Your own identifier. Unique across Flywheel when set — two accounts cannot share one. -
login_emailstring | null required — Sign-in address for `POST /sessions`. Distinct from `contact_email`, which is only where mail goes and carries no authority. Null on an account driven entire... -
owner_reachableboolean required — True when contact_email or login_email is present. False means webhook-disable and dead-delivery notices cannot reach the owner; Flywheel operators are the r... -
owner_sms_alertsboolean required — When true (the default), Flywheel texts the owner on conversation.needs_human for standalone accounts. Partner-operated accounts never get this SMS. Webhook ... -
owner_alert_coveragestring ("operated", "sms", "webhook", "none", "unreachable") required — How an escalate reaches a human. `none` means SMS is off and no active webhook is subscribed to conversation.needs_human — allowed, loud. `unreachable` means... -
calendar_urlstring, format uri required — Subscribable ICS feed of this account's appointments, keyed by an unguessable token. Read-only and unauthenticated by design, because calendar apps poll it w... -
operated_bystring | null required — Slug of the operating account, if any. -
created_atstring, format date-time required
-
-
api_keyobject-
idinteger required -
namestring | null required -
kindstring ("account", "partner", "session") required -
last_fourstring | null required -
last_used_atstring | null, format date-time required -
expires_atstring | null, format date-time required — When this key stops working. Null means it does not expire on its own; only `session` keys carry one. -
created_atstring, format date-time required -
tokenstring required — Shown exactly once. Store it now.
-
-
-
GET
/accounts/{account_id}Fetch an accountResponse 200
-
accountobjectThe tenant, and the voice. Messages in an operated account still speak as the account, never as its operator.
-
idstring required — Opaque slug. -
namestring required -
display_namestring | null required -
contact_first_namestring | null required -
contact_emailstring | null required — Where mail about the account goes (webhook disable, dead deliveries). Distinct from login_email. Required on standalone signup (D419). Partner-operated creat... -
contact_phonestring | null required — E.164. Where account-level SMS notices go, including the needs_human owner pull-in (D419). Required on standalone signup. -
website_urlstring | null required -
brand_colorstring | null required -
time_zonestring required — IANA name, e.g. America/Chicago. -
phone_numberstring | null required — E.164 sending number. -
verticalstring | null required — Drives which shipped defaults this account falls back to. -
statusstring ("active", "paused", "canceled") required -
serves_minorsboolean required — Defaults to true: a business that does not declare gets the protective posture. -
default_content_consentstring ("none", "anonymous", "first_name", "photo_video") required — Ordered most to least restrictive. `anonymous` is the notice-only default; naming or picturing someone requires an actively recorded decision. -
quiet_hoursobject required — Minutes past midnight, evaluated in the RECIPIENT's timezone. A blocked message is requeued to the next open window, not dropped. -
minimum_notice_minutesinteger | null required — Minutes before a slot that it may be offered. Defaults to 60 so a new lead can still book the same day. -
deemed_attendance_after_hoursinteger | null required — When set, an appointment left unactioned this many hours past its end is recorded attended with `attendance_source: deemed`. Null means never. -
lapsed_after_daysinteger required — Days after a last visit that a contact with no usual gap between visits yet counts as `lapsed`. Default 60. A contact with two or more visits lapses on their... -
review_ask_after_attendancesinteger required — How many attended appointments a family needs before a review is asked for. 1 (the default) asks after the first. -
review_ask_retirement_daysinteger required — Days a family is left alone after a review ask. Default 90. The visit-number ceiling scales with this number. -
review_urlstring | null required — Where a review ask sends people. Prefer the Google write-a-review URL (`https://search.google.com/local/writereview?placeid=…`). A pasted Place ID is expande... -
google_place_idstring | null required — Google Place ID. Saving one opts the account into a live dashboard read of Google reviews. We store only this field. Review text, ratings, and authors are ne... -
default_locationstring | null required — Shop address stamped on in_person bookings and included in the confirmation. Unused when meeting_kind is at_customer or virtual. -
booking_urlstring | null required — Public page for picking a time. Used when close_style is booking_link. -
close_stylestring ("handoff", "sms_slots", "booking_link") required — How a ready lead books. handoff is the default. Changing this rewrites only the qualify close. -
meeting_kindstring ("in_person", "at_customer", "virtual") required — in_person stamps the account default address. at_customer stamps the contact's address. virtual mints a Whereby room at book time. -
external_refstring | null required — Your own identifier. Unique across Flywheel when set — two accounts cannot share one. -
login_emailstring | null required — Sign-in address for `POST /sessions`. Distinct from `contact_email`, which is only where mail goes and carries no authority. Null on an account driven entire... -
owner_reachableboolean required — True when contact_email or login_email is present. False means webhook-disable and dead-delivery notices cannot reach the owner; Flywheel operators are the r... -
owner_sms_alertsboolean required — When true (the default), Flywheel texts the owner on conversation.needs_human for standalone accounts. Partner-operated accounts never get this SMS. Webhook ... -
owner_alert_coveragestring ("operated", "sms", "webhook", "none", "unreachable") required — How an escalate reaches a human. `none` means SMS is off and no active webhook is subscribed to conversation.needs_human — allowed, loud. `unreachable` means... -
calendar_urlstring, format uri required — Subscribable ICS feed of this account's appointments, keyed by an unguessable token. Read-only and unauthenticated by design, because calendar apps poll it w... -
operated_bystring | null required — Slug of the operating account, if any. -
created_atstring, format date-time required
-
-
-
PATCH
/accounts/{account_id}Update an accountRequest body
-
accountobject required-
namestring -
display_namestring -
contact_first_namestring -
contact_emailstring — Required on standalone signup (D419). Partner-operated create may omit. Where webhook-disable and dead-delivery notices go. -
contact_phonestring — Required on standalone signup (D419). E.164. Where account-level SMS notices go, including the needs_human owner pull-in. -
owner_sms_alertsboolean — Default true. Set false to handle conversation.needs_human only via webhook. Turning it off without an active needs_human webhook is allowed; owner_alert_cov... -
website_urlstring -
time_zonestring -
verticalstring -
external_refstring -
brand_colorstring -
login_emailstring, format email — Optional. Set it, with a password, to enable sign-in at `POST /sessions`. Both or neither — a login email with no password is a sign-in that can never succee... -
passwordstring, format password — Optional, and never returned. Omit both this and `login_email` for an account that is only ever driven by API key, which is the normal case for a partner-pro... -
serves_minorsboolean -
default_content_consentstring ("none", "anonymous", "first_name", "photo_video") — Ordered most to least restrictive. `anonymous` is the notice-only default; naming or picturing someone requires an actively recorded decision. -
quiet_hoursobject — Same shape as Account.quiet_hours. Minutes past midnight, evaluated in the RECIPIENT's timezone. The two flat `quiet_hours_*_minute` fields are not accepted. -
minimum_notice_minutesinteger — Minutes before a slot that it may be offered. Defaults to 60. Raise it if the business needs more runway. -
lapsed_after_daysinteger — Days after a last visit that a contact with no usual gap between visits yet counts as `lapsed`. Default 60. -
deemed_attendance_after_hoursinteger | null — Hours an appointment may sit unactioned past its end before it is recorded attended with `attendance_source: deemed`. Null (the default) means never. Only op... -
review_ask_after_attendancesinteger — How many attended appointments a family needs before Flywheel asks them for a review, counted across the whole family. Default 1: ask after the first, which ... -
review_ask_retirement_daysinteger — Days a family is left alone after a review ask, across every outcome. Default 90, which is what Flywheel has always done. Lengthen-only: shorter than 90 woul... -
review_urlstring — Google Place ID or write-a-review URL. A Place ID is stored as `https://search.google.com/local/writereview?placeid=…`. A Maps listing is left as-is. Blank m... -
google_place_idstring — Google Place ID. Opts this account into a live Reviews-page read. We store only the ID. A pasted Place ID in `review_url` is copied here on save. -
default_locationstring -
booking_urlstring, format uri -
close_stylestring ("handoff", "sms_slots", "booking_link") -
meeting_kindstring ("in_person", "at_customer", "virtual")
-
Response 200
-
accountobjectThe tenant, and the voice. Messages in an operated account still speak as the account, never as its operator.
-
idstring required — Opaque slug. -
namestring required -
display_namestring | null required -
contact_first_namestring | null required -
contact_emailstring | null required — Where mail about the account goes (webhook disable, dead deliveries). Distinct from login_email. Required on standalone signup (D419). Partner-operated creat... -
contact_phonestring | null required — E.164. Where account-level SMS notices go, including the needs_human owner pull-in (D419). Required on standalone signup. -
website_urlstring | null required -
brand_colorstring | null required -
time_zonestring required — IANA name, e.g. America/Chicago. -
phone_numberstring | null required — E.164 sending number. -
verticalstring | null required — Drives which shipped defaults this account falls back to. -
statusstring ("active", "paused", "canceled") required -
serves_minorsboolean required — Defaults to true: a business that does not declare gets the protective posture. -
default_content_consentstring ("none", "anonymous", "first_name", "photo_video") required — Ordered most to least restrictive. `anonymous` is the notice-only default; naming or picturing someone requires an actively recorded decision. -
quiet_hoursobject required — Minutes past midnight, evaluated in the RECIPIENT's timezone. A blocked message is requeued to the next open window, not dropped. -
minimum_notice_minutesinteger | null required — Minutes before a slot that it may be offered. Defaults to 60 so a new lead can still book the same day. -
deemed_attendance_after_hoursinteger | null required — When set, an appointment left unactioned this many hours past its end is recorded attended with `attendance_source: deemed`. Null means never. -
lapsed_after_daysinteger required — Days after a last visit that a contact with no usual gap between visits yet counts as `lapsed`. Default 60. A contact with two or more visits lapses on their... -
review_ask_after_attendancesinteger required — How many attended appointments a family needs before a review is asked for. 1 (the default) asks after the first. -
review_ask_retirement_daysinteger required — Days a family is left alone after a review ask. Default 90. The visit-number ceiling scales with this number. -
review_urlstring | null required — Where a review ask sends people. Prefer the Google write-a-review URL (`https://search.google.com/local/writereview?placeid=…`). A pasted Place ID is expande... -
google_place_idstring | null required — Google Place ID. Saving one opts the account into a live dashboard read of Google reviews. We store only this field. Review text, ratings, and authors are ne... -
default_locationstring | null required — Shop address stamped on in_person bookings and included in the confirmation. Unused when meeting_kind is at_customer or virtual. -
booking_urlstring | null required — Public page for picking a time. Used when close_style is booking_link. -
close_stylestring ("handoff", "sms_slots", "booking_link") required — How a ready lead books. handoff is the default. Changing this rewrites only the qualify close. -
meeting_kindstring ("in_person", "at_customer", "virtual") required — in_person stamps the account default address. at_customer stamps the contact's address. virtual mints a Whereby room at book time. -
external_refstring | null required — Your own identifier. Unique across Flywheel when set — two accounts cannot share one. -
login_emailstring | null required — Sign-in address for `POST /sessions`. Distinct from `contact_email`, which is only where mail goes and carries no authority. Null on an account driven entire... -
owner_reachableboolean required — True when contact_email or login_email is present. False means webhook-disable and dead-delivery notices cannot reach the owner; Flywheel operators are the r... -
owner_sms_alertsboolean required — When true (the default), Flywheel texts the owner on conversation.needs_human for standalone accounts. Partner-operated accounts never get this SMS. Webhook ... -
owner_alert_coveragestring ("operated", "sms", "webhook", "none", "unreachable") required — How an escalate reaches a human. `none` means SMS is off and no active webhook is subscribed to conversation.needs_human — allowed, loud. `unreachable` means... -
calendar_urlstring, format uri required — Subscribable ICS feed of this account's appointments, keyed by an unguessable token. Read-only and unauthenticated by design, because calendar apps poll it w... -
operated_bystring | null required — Slug of the operating account, if any. -
created_atstring, format date-time required
-
-
-
POST
/accounts/{account_id}/pausePause an accountState pushed onto the account rather than queried at send time, so pausing cannot slow the reply path.
No request body.
Response 200
-
accountobjectThe tenant, and the voice. Messages in an operated account still speak as the account, never as its operator.
-
idstring required — Opaque slug. -
namestring required -
display_namestring | null required -
contact_first_namestring | null required -
contact_emailstring | null required — Where mail about the account goes (webhook disable, dead deliveries). Distinct from login_email. Required on standalone signup (D419). Partner-operated creat... -
contact_phonestring | null required — E.164. Where account-level SMS notices go, including the needs_human owner pull-in (D419). Required on standalone signup. -
website_urlstring | null required -
brand_colorstring | null required -
time_zonestring required — IANA name, e.g. America/Chicago. -
phone_numberstring | null required — E.164 sending number. -
verticalstring | null required — Drives which shipped defaults this account falls back to. -
statusstring ("active", "paused", "canceled") required -
serves_minorsboolean required — Defaults to true: a business that does not declare gets the protective posture. -
default_content_consentstring ("none", "anonymous", "first_name", "photo_video") required — Ordered most to least restrictive. `anonymous` is the notice-only default; naming or picturing someone requires an actively recorded decision. -
quiet_hoursobject required — Minutes past midnight, evaluated in the RECIPIENT's timezone. A blocked message is requeued to the next open window, not dropped. -
minimum_notice_minutesinteger | null required — Minutes before a slot that it may be offered. Defaults to 60 so a new lead can still book the same day. -
deemed_attendance_after_hoursinteger | null required — When set, an appointment left unactioned this many hours past its end is recorded attended with `attendance_source: deemed`. Null means never. -
lapsed_after_daysinteger required — Days after a last visit that a contact with no usual gap between visits yet counts as `lapsed`. Default 60. A contact with two or more visits lapses on their... -
review_ask_after_attendancesinteger required — How many attended appointments a family needs before a review is asked for. 1 (the default) asks after the first. -
review_ask_retirement_daysinteger required — Days a family is left alone after a review ask. Default 90. The visit-number ceiling scales with this number. -
review_urlstring | null required — Where a review ask sends people. Prefer the Google write-a-review URL (`https://search.google.com/local/writereview?placeid=…`). A pasted Place ID is expande... -
google_place_idstring | null required — Google Place ID. Saving one opts the account into a live dashboard read of Google reviews. We store only this field. Review text, ratings, and authors are ne... -
default_locationstring | null required — Shop address stamped on in_person bookings and included in the confirmation. Unused when meeting_kind is at_customer or virtual. -
booking_urlstring | null required — Public page for picking a time. Used when close_style is booking_link. -
close_stylestring ("handoff", "sms_slots", "booking_link") required — How a ready lead books. handoff is the default. Changing this rewrites only the qualify close. -
meeting_kindstring ("in_person", "at_customer", "virtual") required — in_person stamps the account default address. at_customer stamps the contact's address. virtual mints a Whereby room at book time. -
external_refstring | null required — Your own identifier. Unique across Flywheel when set — two accounts cannot share one. -
login_emailstring | null required — Sign-in address for `POST /sessions`. Distinct from `contact_email`, which is only where mail goes and carries no authority. Null on an account driven entire... -
owner_reachableboolean required — True when contact_email or login_email is present. False means webhook-disable and dead-delivery notices cannot reach the owner; Flywheel operators are the r... -
owner_sms_alertsboolean required — When true (the default), Flywheel texts the owner on conversation.needs_human for standalone accounts. Partner-operated accounts never get this SMS. Webhook ... -
owner_alert_coveragestring ("operated", "sms", "webhook", "none", "unreachable") required — How an escalate reaches a human. `none` means SMS is off and no active webhook is subscribed to conversation.needs_human — allowed, loud. `unreachable` means... -
calendar_urlstring, format uri required — Subscribable ICS feed of this account's appointments, keyed by an unguessable token. Read-only and unauthenticated by design, because calendar apps poll it w... -
operated_bystring | null required — Slug of the operating account, if any. -
created_atstring, format date-time required
-
-
-
POST
/accounts/{account_id}/resumeResume a paused accountNo request body.
Response 200
-
accountobjectThe tenant, and the voice. Messages in an operated account still speak as the account, never as its operator.
-
idstring required — Opaque slug. -
namestring required -
display_namestring | null required -
contact_first_namestring | null required -
contact_emailstring | null required — Where mail about the account goes (webhook disable, dead deliveries). Distinct from login_email. Required on standalone signup (D419). Partner-operated creat... -
contact_phonestring | null required — E.164. Where account-level SMS notices go, including the needs_human owner pull-in (D419). Required on standalone signup. -
website_urlstring | null required -
brand_colorstring | null required -
time_zonestring required — IANA name, e.g. America/Chicago. -
phone_numberstring | null required — E.164 sending number. -
verticalstring | null required — Drives which shipped defaults this account falls back to. -
statusstring ("active", "paused", "canceled") required -
serves_minorsboolean required — Defaults to true: a business that does not declare gets the protective posture. -
default_content_consentstring ("none", "anonymous", "first_name", "photo_video") required — Ordered most to least restrictive. `anonymous` is the notice-only default; naming or picturing someone requires an actively recorded decision. -
quiet_hoursobject required — Minutes past midnight, evaluated in the RECIPIENT's timezone. A blocked message is requeued to the next open window, not dropped. -
minimum_notice_minutesinteger | null required — Minutes before a slot that it may be offered. Defaults to 60 so a new lead can still book the same day. -
deemed_attendance_after_hoursinteger | null required — When set, an appointment left unactioned this many hours past its end is recorded attended with `attendance_source: deemed`. Null means never. -
lapsed_after_daysinteger required — Days after a last visit that a contact with no usual gap between visits yet counts as `lapsed`. Default 60. A contact with two or more visits lapses on their... -
review_ask_after_attendancesinteger required — How many attended appointments a family needs before a review is asked for. 1 (the default) asks after the first. -
review_ask_retirement_daysinteger required — Days a family is left alone after a review ask. Default 90. The visit-number ceiling scales with this number. -
review_urlstring | null required — Where a review ask sends people. Prefer the Google write-a-review URL (`https://search.google.com/local/writereview?placeid=…`). A pasted Place ID is expande... -
google_place_idstring | null required — Google Place ID. Saving one opts the account into a live dashboard read of Google reviews. We store only this field. Review text, ratings, and authors are ne... -
default_locationstring | null required — Shop address stamped on in_person bookings and included in the confirmation. Unused when meeting_kind is at_customer or virtual. -
booking_urlstring | null required — Public page for picking a time. Used when close_style is booking_link. -
close_stylestring ("handoff", "sms_slots", "booking_link") required — How a ready lead books. handoff is the default. Changing this rewrites only the qualify close. -
meeting_kindstring ("in_person", "at_customer", "virtual") required — in_person stamps the account default address. at_customer stamps the contact's address. virtual mints a Whereby room at book time. -
external_refstring | null required — Your own identifier. Unique across Flywheel when set — two accounts cannot share one. -
login_emailstring | null required — Sign-in address for `POST /sessions`. Distinct from `contact_email`, which is only where mail goes and carries no authority. Null on an account driven entire... -
owner_reachableboolean required — True when contact_email or login_email is present. False means webhook-disable and dead-delivery notices cannot reach the owner; Flywheel operators are the r... -
owner_sms_alertsboolean required — When true (the default), Flywheel texts the owner on conversation.needs_human for standalone accounts. Partner-operated accounts never get this SMS. Webhook ... -
owner_alert_coveragestring ("operated", "sms", "webhook", "none", "unreachable") required — How an escalate reaches a human. `none` means SMS is off and no active webhook is subscribed to conversation.needs_human — allowed, loud. `unreachable` means... -
calendar_urlstring, format uri required — Subscribable ICS feed of this account's appointments, keyed by an unguessable token. Read-only and unauthenticated by design, because calendar apps poll it w... -
operated_bystring | null required — Slug of the operating account, if any. -
created_atstring, format date-time required
-
-
-
GET
/accounts/{account_id}/phone-numberFetch the account's sending numberA singular resource: an account has exactly one sending number in v1.
Response 200
-
phone_numberobjectThe account's one sending number. `area_code` is present only in the response to the request that provisioned it.
-
numberstring required — E.164. -
providerstring required -
area_codestring -
smsboolean required -
voiceboolean required -
sandboxboolean required — True when this number was invented in sandbox (555-01xx / PNsandbox). A restore of a sandbox dump onto a live host still shows this (D255).
-
-
-
POST
/accounts/{account_id}/phone-numberProvision a sending numberThe last step of unattended onboarding, and the one that makes an account able to send. Idempotent: called again it returns the existing number with `200` rather than buying a second one. A number carries a monthly cost and a retried HTTP request is a certainty, not an edge case, so the safe behaviour is the default one. A dry local inventory is `503`, not `422`. The request named a local code and there is nothing on the shelves — try another code, or wait. Toll-free is ...
Request body
-
area_codestringLocal area code to search first, then adjacent codes. Omit only when retrying an already-provisioned account. Toll-free codes (800, 833, …) are refused.
Response 200
-
phone_numberobjectThe account's one sending number. `area_code` is present only in the response to the request that provisioned it.
-
numberstring required — E.164. -
providerstring required -
area_codestring -
smsboolean required -
voiceboolean required -
sandboxboolean required — True when this number was invented in sandbox (555-01xx / PNsandbox). A restore of a sandbox dump onto a live host still shows this (D255).
-
-
Templates
Message copy. Account rows override shipped defaults by key.
-
GET
/accounts/{account_id}/message-templatesList effective templatesReturns what will actually be used: the account's own rows plus every shipped default it has not overridden. `scope` is `account` or `default`. PATCH and DELETE only reach `account` rows. A default id answers 422 `not_account_owned` — POST a template with the same key to create an override. Do not PATCH a listed default; the id in the list is not writable.
Response 200
-
message_templatesarray of object-
idinteger required -
keystring required -
verticalstring | null required -
channelstring ("sms") required — SMS is the only channel. Flywheel is a conversation engine and its only inbound path is SMS, so a channel that cannot carry a reply cannot run a flow. -
bodystring required — Supports {{variable}} interpolation. -
delay_minutesinteger | null required — For `nurture.` keys, this is the rung's position in the ladder. -
guardsobject required — Halt conditions declared next to the copy they protect: `halt_if_replied`, `halt_if_pipeline_state_in`, `require_pipeline_state_in`. -
promotionalboolean required — Promotional copy requires recorded consent and is never sent without it. -
transactionalboolean required — Transactional copy survives a human takeover and quiet hours. Kept separate from `promotional` on purpose — a qualification question is neither. -
expects_answerstring | null ("boolean", nil) required — `boolean`: the reply to a send of this template is read as yes or no and emitted as `message.answered` (without the reply text). Ignored for keys that are st... -
activeboolean required -
scopestring ("default", "account") required — `account` rows are yours and accept PATCH/DELETE. `default` rows are shipped copy; PATCH/DELETE that id is 422 `not_account_owned`. POST the same key to over...
-
-
-
POST
/accounts/{account_id}/message-templatesOverride a templateRequest body
-
message_templateobject required-
keystring -
verticalstring -
channelstring ("sms") -
bodystring -
delay_minutesinteger -
activeboolean -
promotionalboolean -
transactionalboolean -
expects_answerstring | null ("boolean", nil) -
guardsobject
-
Response 201
-
message_templateobject-
idinteger required -
keystring required -
verticalstring | null required -
channelstring ("sms") required — SMS is the only channel. Flywheel is a conversation engine and its only inbound path is SMS, so a channel that cannot carry a reply cannot run a flow. -
bodystring required — Supports {{variable}} interpolation. -
delay_minutesinteger | null required — For `nurture.` keys, this is the rung's position in the ladder. -
guardsobject required — Halt conditions declared next to the copy they protect: `halt_if_replied`, `halt_if_pipeline_state_in`, `require_pipeline_state_in`. -
promotionalboolean required — Promotional copy requires recorded consent and is never sent without it. -
transactionalboolean required — Transactional copy survives a human takeover and quiet hours. Kept separate from `promotional` on purpose — a qualification question is neither. -
expects_answerstring | null ("boolean", nil) required — `boolean`: the reply to a send of this template is read as yes or no and emitted as `message.answered` (without the reply text). Ignored for keys that are st... -
activeboolean required -
scopestring ("default", "account") required — `account` rows are yours and accept PATCH/DELETE. `default` rows are shipped copy; PATCH/DELETE that id is 422 `not_account_owned`. POST the same key to over...
-
-
-
GET
/accounts/{account_id}/message-templates/carrier-help-overridesReport HELP overrides that dropped a carrier promiseAccount-scoped `carrier.help` rows on accounts this key may act on that no longer name the brand, state rates, or give the way out. A partner naming its own account sees the fleet it operates. Does not rewrite. Inherited defaults are not listed.
Response 200
-
carrier_help_overridesarray of object-
account_idstring required -
account_namestring required -
statusstring ("incomplete", "inactive") required -
detailstring required -
bodystring required
-
-
-
PATCH
/accounts/{account_id}/message-templates/{id}Update a template overrideOnly `scope=account` rows. A shipped default id answers 422 `not_account_owned` — POST the same key to create an override. A PATCH that created a row would return a different id than the URL, which is why this stays a refusal (D404).
Request body
-
message_templateobject required-
keystring -
verticalstring -
channelstring ("sms") -
bodystring -
delay_minutesinteger -
activeboolean -
promotionalboolean -
transactionalboolean -
expects_answerstring | null ("boolean", nil) -
guardsobject
-
Response 200
-
message_templateobject-
idinteger required -
keystring required -
verticalstring | null required -
channelstring ("sms") required — SMS is the only channel. Flywheel is a conversation engine and its only inbound path is SMS, so a channel that cannot carry a reply cannot run a flow. -
bodystring required — Supports {{variable}} interpolation. -
delay_minutesinteger | null required — For `nurture.` keys, this is the rung's position in the ladder. -
guardsobject required — Halt conditions declared next to the copy they protect: `halt_if_replied`, `halt_if_pipeline_state_in`, `require_pipeline_state_in`. -
promotionalboolean required — Promotional copy requires recorded consent and is never sent without it. -
transactionalboolean required — Transactional copy survives a human takeover and quiet hours. Kept separate from `promotional` on purpose — a qualification question is neither. -
expects_answerstring | null ("boolean", nil) required — `boolean`: the reply to a send of this template is read as yes or no and emitted as `message.answered` (without the reply text). Ignored for keys that are st... -
activeboolean required -
scopestring ("default", "account") required — `account` rows are yours and accept PATCH/DELETE. `default` rows are shipped copy; PATCH/DELETE that id is 422 `not_account_owned`. POST the same key to over...
-
-
-
DELETE
/accounts/{account_id}/message-templates/{id}Drop an override and fall back to the defaultOnly `scope=account` rows. Deleting a shipped default id answers 422 `not_account_owned` — Flywheel's copy is not yours to remove.
Response 204
No JSON body (or empty success).
Flows
The qualification conversation, defined as data rather than code.
-
POST
/accounts/{account_id}/flow-setupCompile plain-word data points into a qualify draftOne call from "what I need to know about a lead" to a reviewable draft. Send the data points in plain words ("dog breed", "how long since their last groom"); Flywheel writes the question copy, infers the answer shape and choice options, stores each answer under a short stable field it names from the data point — `custom_fields.dog_breed`, not the question's phrasing, so the field survives a later rewording (a person's name lands on `first_name`) — and returns an unpublishe...
Request body
-
setupobject required-
data_pointsarray of any — What you need to know about a lead, in plain words, in the order you want it asked. -
close_stylestring ("handoff", "sms_slots", "booking_link") — How a ready lead books. Optional; updates the account setting before the draft is built.
-
Response 201
-
flowobject-
idinteger required -
keystring required -
verticalstring | null required -
namestring | null required -
activeboolean required -
publishedboolean required — Live ladders are `true`. Drafts are editable; only published account flows answer real leads via lookup. -
scopestring ("default", "account") required — `account` rows are yours and accept PATCH/DELETE. `default` rows are shipped ladders; PATCH/DELETE that id is 422 `not_account_owned`. `forkFlow` copies one ... -
setup_sourcearray of object required — The plain-word data points (and rules) `setupIntakeFlow` built this flow from, exactly as submitted — what an editor prefills. `null` for hand-built flows an... -
stepsarray of object required
-
-
warningsarray of stringRules that could not be applied, in plain words. Empty when every rule compiled.
-
-
GET
/accounts/{account_id}/setup-chatRead the setup-chat conversation and where the draft standsThe setup chat is the conversational authoring surface for the account's intake: one persistent conversation per account with an assistant that builds, rewords, and publishes the qualify flow through the same paths as `setupIntakeFlow` and `publishFlow`. Returns the transcript (oldest turn first; each assistant turn lists the `actions` the server actually executed) and the current qualify flow — the open draft when one exists, else the published version, else null.
Response 200
-
setup_chatobjectOne persistent conversation per account with the setup assistant — the conversational authoring surface for the intake (only the most recent 40 turns are kept).
-
transcriptarray of object required -
flowany required — Where the intake stands: the open qualify draft when one exists, else the published version, else null.
-
-
-
DELETE
/accounts/{account_id}/setup-chatReset the conversationClears the transcript and nothing else — the draft the chat built is rows on the flow, not chat state, and it survives.
Response 204
No JSON body (or empty success).
-
POST
/accounts/{account_id}/setup-chat/messagesSay one thing to the setup assistantOne owner turn. The assistant replies in `reply.say` and may take at most ONE action per turn, named in `reply.actions` only when it actually executed: `set_points` (rebuild the draft from data points, exactly as `setupIntakeFlow` would), `reword_question`, `set_close_style`, `publish` (the same cutover as `publishFlow`, guardrails included), or `discard_draft`. The model only proposes; the server validates and executes, and anything refused — a publish problem, a dropped ...
Request body
-
messageobject required-
bodystring — What the owner says, in plain words.
-
Response 201
-
setup_chatobjectOne persistent conversation per account with the setup assistant — the conversational authoring surface for the intake (only the most recent 40 turns are kept).
-
transcriptarray of object required -
flowany required — Where the intake stands: the open qualify draft when one exists, else the published version, else null.
-
-
replyobject-
saystring -
actionsarray of string ("set_points", "reword_question", "set_close_style", "publish", "discard_draft") — The actions the server executed this turn. Empty when the assistant only talked or a proposed action was refused.
-
-
-
GET
/accounts/{account_id}/flowsList flows, with stepsThe effective catalog: account flows plus shipped defaults this account would fall back to. `scope` is `account` or `default`. PATCH and DELETE only reach `account` rows. A default id answers 422 `not_account_owned` — `forkFlow` copies it into this account. Do not PATCH a listed default.
Parameters
-
catalogstring ("all")`all` returns every shipped default plus the account's own flows, including a shipped qualify the account has already overridden. Omit for the effective catalog: defaults this account would fall back to, minus keys it already owns.
Response 200
-
flowsarray of object-
idinteger required -
keystring required -
verticalstring | null required -
namestring | null required -
activeboolean required -
publishedboolean required — Live ladders are `true`. Drafts are editable; only published account flows answer real leads via lookup. -
scopestring ("default", "account") required — `account` rows are yours and accept PATCH/DELETE. `default` rows are shipped ladders; PATCH/DELETE that id is 422 `not_account_owned`. `forkFlow` copies one ... -
setup_sourcearray of object required — The plain-word data points (and rules) `setupIntakeFlow` built this flow from, exactly as submitted — what an editor prefills. `null` for hand-built flows an... -
stepsarray of object required
-
-
-
POST
/accounts/{account_id}/flowsCreate a draft flowNew flows are unpublished. Live leads keep using the published ladder (or the shipped default) until you publish.
Request body
-
flowobject required-
keystring -
verticalstring -
namestring -
activeboolean
-
Response 201
-
flowobject-
idinteger required -
keystring required -
verticalstring | null required -
namestring | null required -
activeboolean required -
publishedboolean required — Live ladders are `true`. Drafts are editable; only published account flows answer real leads via lookup. -
scopestring ("default", "account") required — `account` rows are yours and accept PATCH/DELETE. `default` rows are shipped ladders; PATCH/DELETE that id is 422 `not_account_owned`. `forkFlow` copies one ... -
setup_sourcearray of object required — The plain-word data points (and rules) `setupIntakeFlow` built this flow from, exactly as submitted — what an editor prefills. `null` for hand-built flows an... -
stepsarray of object required
-
-
-
PATCH
/accounts/{account_id}/flows/{id}Update a flowOnly `scope=account` rows. On a published flow only `name` and `active` may change. Ladder edits require a draft (`editFlowDraft`). A shipped default id answers 422 `not_account_owned` — `forkFlow` copies it into this account.
Request body
-
flowobject required-
keystring -
verticalstring -
namestring -
activeboolean
-
Response 200
-
flowobject-
idinteger required -
keystring required -
verticalstring | null required -
namestring | null required -
activeboolean required -
publishedboolean required — Live ladders are `true`. Drafts are editable; only published account flows answer real leads via lookup. -
scopestring ("default", "account") required — `account` rows are yours and accept PATCH/DELETE. `default` rows are shipped ladders; PATCH/DELETE that id is 422 `not_account_owned`. `forkFlow` copies one ... -
setup_sourcearray of object required — The plain-word data points (and rules) `setupIntakeFlow` built this flow from, exactly as submitted — what an editor prefills. `null` for hand-built flows an... -
stepsarray of object required
-
-
-
DELETE
/accounts/{account_id}/flows/{id}Delete a flowOnly `scope=account` rows. A shipped default id answers 422 `not_account_owned`.
Response 204
No JSON body (or empty success).
-
POST
/accounts/{account_id}/flows/{id}/forkCopy a shipped default into this account as a draftMakes an unpublished copy of a default flow, steps included, and returns it. The step endpoints only reach flows the account owns, so this is how a default's ladder becomes changeable at all. New forks are drafts — publish when ready so live leads cut over. Idempotent on the flow's key: prefers an existing draft, else the published override, else creates a draft. Any shipped default can be forked so a subscriber can try or adopt a pack they are not currently using.
No request body.
Response 200
-
flowobject-
idinteger required -
keystring required -
verticalstring | null required -
namestring | null required -
activeboolean required -
publishedboolean required — Live ladders are `true`. Drafts are editable; only published account flows answer real leads via lookup. -
scopestring ("default", "account") required — `account` rows are yours and accept PATCH/DELETE. `default` rows are shipped ladders; PATCH/DELETE that id is 422 `not_account_owned`. `forkFlow` copies one ... -
setup_sourcearray of object required — The plain-word data points (and rules) `setupIntakeFlow` built this flow from, exactly as submitted — what an editor prefills. `null` for hand-built flows an... -
stepsarray of object required
-
-
-
POST
/accounts/{account_id}/flows/{id}/editOpen an editable draft of a published account flowPublished ladders are immutable. This copies the live flow into a draft with the same key (or returns the existing draft). Edit steps on the draft, try with simulations, then publish to cut over.
No request body.
Response 200
-
flowobject-
idinteger required -
keystring required -
verticalstring | null required -
namestring | null required -
activeboolean required -
publishedboolean required — Live ladders are `true`. Drafts are editable; only published account flows answer real leads via lookup. -
scopestring ("default", "account") required — `account` rows are yours and accept PATCH/DELETE. `default` rows are shipped ladders; PATCH/DELETE that id is 422 `not_account_owned`. `forkFlow` copies one ... -
setup_sourcearray of object required — The plain-word data points (and rules) `setupIntakeFlow` built this flow from, exactly as submitted — what an editor prefills. `null` for hand-built flows an... -
stepsarray of object required
-
-
-
POST
/accounts/{account_id}/flows/{id}/useMake a shipped flow the live conversationSets the account's fallback pack to this default's vertical and removes any account-owned flow with the same key so the shipped ladder answers new leads. Does not rewrite the close style; a later booking-settings save still applies CloseStyle to the new pack.
No request body.
Response 200
-
flowobject-
idinteger required -
keystring required -
verticalstring | null required -
namestring | null required -
activeboolean required -
publishedboolean required — Live ladders are `true`. Drafts are editable; only published account flows answer real leads via lookup. -
scopestring ("default", "account") required — `account` rows are yours and accept PATCH/DELETE. `default` rows are shipped ladders; PATCH/DELETE that id is 422 `not_account_owned`. `forkFlow` copies one ... -
setup_sourcearray of object required — The plain-word data points (and rules) `setupIntakeFlow` built this flow from, exactly as submitted — what an editor prefills. `null` for hand-built flows an... -
stepsarray of object required
-
-
-
POST
/accounts/{account_id}/flows/{id}/publishPublish a draft flowPromotes this draft to the live ladder for its key. Any prior published flow with the same key is destroyed in the same transaction so cutover is atomic. Live leads never see a half-edited ladder. A `qualify` ladder published without an ending — no terminal step and no close rung — gets the account's `close_style` close appended, so you publish your questions and the booking step is composed for you. The returned flow is the recomposed live ladder, whose id may differ fro...
No request body.
Response 200
-
flowobject-
idinteger required -
keystring required -
verticalstring | null required -
namestring | null required -
activeboolean required -
publishedboolean required — Live ladders are `true`. Drafts are editable; only published account flows answer real leads via lookup. -
scopestring ("default", "account") required — `account` rows are yours and accept PATCH/DELETE. `default` rows are shipped ladders; PATCH/DELETE that id is 422 `not_account_owned`. `forkFlow` copies one ... -
setup_sourcearray of object required — The plain-word data points (and rules) `setupIntakeFlow` built this flow from, exactly as submitted — what an editor prefills. `null` for hand-built flows an... -
stepsarray of object required
-
-
-
POST
/accounts/{account_id}/flows/{flow_id}/simulationsStart a try-it conversation against a flowRuns the real FlowRunner with a null SMS gateway — no Twilio, no real phone. Pins the conversation to this flow id so drafts and shipped defaults can be tried without switching the live pack. Quiet hours, opt-out, and Sender guards still apply; the clock is midday in the contact's zone so quiet hours rarely block a first try. Simulation contacts are excluded from the inbox and purged after seven days.
No request body.
Response 201
-
simulationobject-
idinteger required — Conversation id — use with advanceFlowSimulation. -
flow_idinteger required -
contact_idstring required -
outcomestring required — FlowRunner outcome for the last turn (e.g. started, advanced, reasked). -
deliveryobject | null required — What happened to the last turn's outbound send. halt_reason (e.g. missing_template, guard_failed) explains a turn that produced no message. -
messagesarray of object required
-
-
-
POST
/accounts/{account_id}/simulations/{id}/messagesSend a simulated lead replyAdvances the pinned flow with the given SMS body. Same classifier and Sender path as production, null gateway only.
Request body
-
bodystring requiredWhat the lead typed.
Response 200
-
simulationobject-
idinteger required — Conversation id — use with advanceFlowSimulation. -
flow_idinteger required -
contact_idstring required -
outcomestring required — FlowRunner outcome for the last turn (e.g. started, advanced, reasked). -
deliveryobject | null required — What happened to the last turn's outbound send. halt_reason (e.g. missing_template, guard_failed) explains a turn that produced no message. -
messagesarray of object required
-
-
-
GET
/accounts/{account_id}/flows/{flow_id}/stepsList the steps of a flowResponse 200
-
stepsarray of object-
idinteger required -
positioninteger required -
template_keystring | null required -
expectsstring | null ("choice", "number", "boolean", "freeform", "confirm", "name", nil) required -
writes_tostring | null required — Contact field the captured answer lands in, e.g. `age` or `custom_fields.issue`. -
choicesarray of object required — ORDERED. Position is what a numbered SMS reply refers to — texting '2' selects the second entry — so this is an array, never an object. -
branch_onobject required — Captured value to next position. -
route_whenobject | null required — A judged route: when the captured answer MEANS this plain-word condition ("the dog is a large breed"), the conversation finishes at `position` — an ending st... -
default_next_positioninteger | null required — Where to go when no branch matches. Lets branches rejoin somewhere other than the next row. -
on_confirm_actionstring | null required -
terminalboolean required -
handoffboolean required — A terminal ending whose copy promises a person follows up. Reaching it flags the conversation `flow_handoff` (needs human, owner alerted) and deliberately do...
-
-
-
POST
/accounts/{account_id}/flows/{flow_id}/stepsAdd a step`choices` is an ordered array. Order is what a numbered SMS reply refers to, so it is preserved exactly as sent. Fails with `flow_published` on a live flow. A shipped default's id answers 422 `not_account_owned` — `forkFlow` first.
Request body
-
stepobject required-
positioninteger -
template_keystring -
expectsstring ("choice", "number", "boolean", "freeform", "confirm", "name") -
writes_tostring -
choicesarray of object — ORDERED. Position is what a numbered SMS reply refers to — texting '2' selects the second entry — so this is an array, never an object. -
branch_onobject -
default_next_positioninteger -
on_confirm_actionstring -
terminalboolean
-
Response 201
-
stepobject-
idinteger required -
positioninteger required -
template_keystring | null required -
expectsstring | null ("choice", "number", "boolean", "freeform", "confirm", "name", nil) required -
writes_tostring | null required — Contact field the captured answer lands in, e.g. `age` or `custom_fields.issue`. -
choicesarray of object required — ORDERED. Position is what a numbered SMS reply refers to — texting '2' selects the second entry — so this is an array, never an object. -
branch_onobject required — Captured value to next position. -
route_whenobject | null required — A judged route: when the captured answer MEANS this plain-word condition ("the dog is a large breed"), the conversation finishes at `position` — an ending st... -
default_next_positioninteger | null required — Where to go when no branch matches. Lets branches rejoin somewhere other than the next row. -
on_confirm_actionstring | null required -
terminalboolean required -
handoffboolean required — A terminal ending whose copy promises a person follows up. Reaching it flags the conversation `flow_handoff` (needs human, owner alerted) and deliberately do...
-
-
-
PATCH
/accounts/{account_id}/flows/{flow_id}/steps/{id}Update a stepOnly steps on `scope=account` flows. A shipped default's id answers 422 `not_account_owned`.
Request body
-
stepobject required-
positioninteger -
template_keystring -
expectsstring ("choice", "number", "boolean", "freeform", "confirm", "name") -
writes_tostring -
choicesarray of object — ORDERED. Position is what a numbered SMS reply refers to — texting '2' selects the second entry — so this is an array, never an object. -
branch_onobject -
default_next_positioninteger -
on_confirm_actionstring -
terminalboolean
-
Response 200
-
stepobject-
idinteger required -
positioninteger required -
template_keystring | null required -
expectsstring | null ("choice", "number", "boolean", "freeform", "confirm", "name", nil) required -
writes_tostring | null required — Contact field the captured answer lands in, e.g. `age` or `custom_fields.issue`. -
choicesarray of object required — ORDERED. Position is what a numbered SMS reply refers to — texting '2' selects the second entry — so this is an array, never an object. -
branch_onobject required — Captured value to next position. -
route_whenobject | null required — A judged route: when the captured answer MEANS this plain-word condition ("the dog is a large breed"), the conversation finishes at `position` — an ending st... -
default_next_positioninteger | null required — Where to go when no branch matches. Lets branches rejoin somewhere other than the next row. -
on_confirm_actionstring | null required -
terminalboolean required -
handoffboolean required — A terminal ending whose copy promises a person follows up. Reaching it flags the conversation `flow_handoff` (needs human, owner alerted) and deliberately do...
-
-
-
DELETE
/accounts/{account_id}/flows/{flow_id}/steps/{id}Delete a stepResponse 204
No JSON body (or empty success).
-
GET
/accounts/{account_id}/faqsList the account's FAQ answers, plus the suggested questions not yet answeredAn answered FAQ is answered inline when a lead asks it mid-flow, without losing their place in the ladder. `suggested` is the shipped question set (price, hours, location, service area, cancellation) minus the topics this account has already answered — prompt the owner with those, and let them add their own. `asked` is the recent questions real leads asked that no FAQ could answer (the bot told them a person would follow up, and flagged the thread `unanswered_question`) — ...
Response 200
-
faqsarray of object-
idinteger required -
topicstring | null required — Set for the suggested question set (`price`, `hours`, `location`, `service_area`, `cancellation`); null for questions the owner wrote themselves. -
questionstring required — What a lead would ask. Used to match inbound texts. -
answerstring required — Sent verbatim as the reply, in the owner's own words.
-
-
suggestedarray of objectShipped question prompts this account has not answered yet, in display order.
-
topicstring -
questionstring
-
-
askedarray of objectRecent lead questions no FAQ answered, newest first, deduped by wording. Each was deferred to a person.
-
questionstring -
asked_atstring, format date-time
-
-
-
POST
/accounts/{account_id}/faqsAnswer a suggested question, or add your ownSet `topic` to answer a suggested question (its canonical wording is filled in for you), or omit `topic` and supply your own `question`. One entry per topic per account.
Request body
-
faqobject required-
topicstring | null ("price", "hours", "location", "service_area", "cancellation", nil) — Answer a suggested question by topic, or omit to write your own. -
questionstring — Required when `topic` is omitted. -
answerstring
-
Response 201
-
faqobjectOne question leads ask, with the owner's answer. FlowRunner answers a matched question inline — through the shipped `faq.answer` template — instead of treating it as an unclear reply, and the lead keeps their place in the flow.
-
idinteger required -
topicstring | null required — Set for the suggested question set (`price`, `hours`, `location`, `service_area`, `cancellation`); null for questions the owner wrote themselves. -
questionstring required — What a lead would ask. Used to match inbound texts. -
answerstring required — Sent verbatim as the reply, in the owner's own words.
-
-
-
POST
/accounts/{account_id}/faqs/importDraft FAQ answers from a website or pasted textReads the given page (or the pasted text — a price sheet, a brochure) and drafts answers from ONLY what the source states. Returns proposals; nothing is saved. Review each one and POST the keepers to the create endpoint — a saved answer is texted verbatim to leads, so a stale price scraped off an old page must pass a human eye first. Suggested-topic matches come back with the topic set and the canonical question wording; topics this account has already answered are dropped.
Request body
-
importobjectGive one of the two. `text` wins when both are present.
-
urlstring — A public page. A bare domain is fetched over https. -
textstring — Any pasted text — a price sheet, an about page, a brochure.
-
Response 200
-
proposalsarray of object-
topicstring | null -
questionstring -
answerstring
-
-
-
PATCH
/accounts/{account_id}/faqs/{id}Change an FAQ's answer or wordingRequest body
-
faqobject required-
topicstring | null ("price", "hours", "location", "service_area", "cancellation", nil) — Answer a suggested question by topic, or omit to write your own. -
questionstring — Required when `topic` is omitted. -
answerstring
-
Response 200
-
faqobjectOne question leads ask, with the owner's answer. FlowRunner answers a matched question inline — through the shipped `faq.answer` template — instead of treating it as an unclear reply, and the lead keeps their place in the flow.
-
idinteger required -
topicstring | null required — Set for the suggested question set (`price`, `hours`, `location`, `service_area`, `cancellation`); null for questions the owner wrote themselves. -
questionstring required — What a lead would ask. Used to match inbound texts. -
answerstring required — Sent verbatim as the reply, in the owner's own words.
-
-
-
DELETE
/accounts/{account_id}/faqs/{id}Remove an FAQLeads asking this question go back to the old behaviour: the flow re-asks its own question.
Response 204
No JSON body (or empty success).
Contacts
People the account is talking to, across the whole pipeline.
-
GET
/accounts/{account_id}/contactsList contactsParameters
-
pipeline_statestring ("new", "engaged", "qualified", "scheduled", "showed", "won", "lost")One generic ladder for every vertical. Verticals differ in what the stages mean, not in their shape.
-
external_refstringYour identifier for this person. Unique per account when set.
-
phonestringExact E.164. One number is one SMS identity on the account. Use this after a 409 `conflict` on create to find the contact that already holds the number, rather than paging the list.
-
openstring ("true")`true` excludes contacts in `won` or `lost`.
-
retention_statusstring ("lead", "booked", "active", "first_time", "lapsed", "canceled")Limits to contacts whose `insights.retention_status` is this value. `lead` includes contacts who have never been booked. — Where a contact's booking history stands, first match wins: `booked` (something live ahead), `canceled` (latest appointment canceled two or more days ago, nothing rebooked), `lapsed` (past twice their usual gap between visits — at least 7 days — or past the account's `lapsed_after_days` with no usual gap yet), `first_time` (one visit three or more days ago, nothing after it), `active` (attended and within their rhythm), `lead` (never attended). Recomputed on every appointment change and nightly.
-
created_sincestring, format date-timeISO 8601. Returns contacts created at or after this instant, inclusive. Intended for reconciliation sweeps that compare a recent window rather than paging the whole list. A value that cannot be parsed is refused with `validation_failed` rather than ignored, so a broken filter can never look like an empty result. Pair with `created_before` to walk a window in slices.
-
created_beforestring, format date-timeISO 8601. Returns contacts created before this instant, exclusive. Adjacent `[created_since, created_before)` slices therefore partition without overlap, so a sweep that halves a full page can recurse and always terminate (D361). Refused, not ignored, when the value cannot be parsed.
-
updated_sincestring, format date-timeISO 8601, inclusive. Returns contacts CHANGED at or after this instant — a different question from `created_since`, which answers who is new. Use this to reconcile: it finds people who arrived long ago and whose consent tier, name or pipeline state moved since you last looked, which is otherwise one request per contact. When this filter is present the list is ordered by `updated_at` ascending (then `id`), so a contact changed during your pagination appears at the end of the walk rather than at a creation page you have already passed (D414). Pair with `updated_before` to walk a window in slices. Without either bound the list is still newest-created first.
-
updated_beforestring, format date-timeISO 8601. Returns contacts changed before this instant, exclusive. Adjacent `[updated_since, updated_before)` slices therefore partition without overlap, so a sweep that halves a full page can recurse and always terminate (D415). Same pair as `created_since` / `created_before` (D361). Refused, not ignored, when the value cannot be parsed. Either updated bound orders the list by `updated_at` ascending.
-
pageinteger -
per_pageinteger
Response 200
-
contactsarray of object-
idstring required — Opaque slug. -
namestring required — What to call them in a list. Never blank: falls back to the phone, then the email, then `New lead`, so an unnamed lead who has only ever texted is still addr... -
first_namestring | null required -
last_namestring | null required -
phonestring | null required — E.164. Unique per account when set. Inbound SMS is routed by this number; two contacts sharing it would be a routing lottery. -
emailstring | null required -
addressstring | null required — Where to meet them when the account's meeting_kind is at_customer. Stamped onto a booking at book time. -
time_zonestring | null required -
ageinteger | null required -
age_bucketstring | null ("under_13", "13_to_17", "18_plus", nil) required -
minorboolean required — A minor is never the message recipient; their guardian is. -
guardian_idstring | null required -
guardian_namestring | null required -
guardian_phonestring | null required -
pipeline_statestring ("new", "engaged", "qualified", "scheduled", "showed", "won", "lost") required — One generic ladder for every vertical. Verticals differ in what the stages mean, not in their shape. -
lost_reasonstring | null required -
sourcestring | null required -
sms_consentedboolean required -
content_consentstring ("none", "anonymous", "first_name", "photo_video") required — Ordered most to least restrictive. `anonymous` is the notice-only default; naming or picturing someone requires an actively recorded decision. -
content_consent_atstring | null, format date-time required — When this contact's content tier was last decided, and evidence that it WAS decided. Null means nobody has set it for this person and `content_consent` is si... -
custom_fieldsobject required — Whatever your flow steps write. This is where domain-specific answers land, which is why Flywheel needs no domain knowledge. -
external_refstring | null required — Your identifier for this person. Unique per account when set. -
last_inbound_atstring | null, format date-time required -
created_atstring, format date-time required -
insightsobject required — Their booking history, summarised. Derived from appointments; never written directly. A contact nobody has booked reads as `lead` with an empty history.
-
-
-
POST
/accounts/{account_id}/contactsAdd a leadThis is the money path. With `start_conversation: true` the first outbound reply is enqueued on the high-priority lane immediately; it is dispatched asynchronously so your response time never depends on the carrier's. Flywheel replies by SMS only. An email-only contact is still recorded — they may text the tracked number later — but the first reply cannot go out. Starting a conversation in that case flags the thread `needs_human` with reason `unreachable` so you follow up...
Request body
-
contactobject requiredMust have a phone, an email, or a guardian phone. Flywheel texts only — email alone records the person and, if you start a conversation, flags the thread `unreachable`.
-
first_namestring -
last_namestring -
phonestring — E.164. Unique per account when set. -
emailstring -
addressstring — Where to meet them. Used when meeting_kind is at_customer; an explicit appointment location still wins. -
time_zonestring — Used for quiet hours. Theirs, not yours. -
ageinteger -
age_bucketstring ("under_13", "13_to_17", "18_plus") — When the exact age is unknown. An exact `age` still wins, because the model derives the bucket from it. -
guardian_namestring -
guardian_phonestring -
sourcestring -
external_refstring — Your identifier for this person. Unique per account when set. A second create with the same ref returns the existing contact rather than inserting a twin. Bl... -
content_consentstring ("none", "anonymous", "first_name", "photo_video") — Ordered most to least restrictive. `anonymous` is the notice-only default; naming or picturing someone requires an actively recorded decision. -
content_consent_reasonstring — Why the tier is being changed. Not stored on the contact — it is kept on the immutable consent event alongside the change, so a trail can answer on what grou... -
sms_consentedboolean — Assert that this person consented to SMS. Recorded as a timestamp and an immutable consent event (never a bare column write). Promotional sending is gated on... -
custom_fieldsobject
-
-
start_conversationbooleanEnqueue the first reply on the high-priority lane. Email-only contacts still open a thread, flagged `unreachable` — Flywheel does not send email.
Response 200
-
contactobject-
idstring required — Opaque slug. -
namestring required — What to call them in a list. Never blank: falls back to the phone, then the email, then `New lead`, so an unnamed lead who has only ever texted is still addr... -
first_namestring | null required -
last_namestring | null required -
phonestring | null required — E.164. Unique per account when set. Inbound SMS is routed by this number; two contacts sharing it would be a routing lottery. -
emailstring | null required -
addressstring | null required — Where to meet them when the account's meeting_kind is at_customer. Stamped onto a booking at book time. -
time_zonestring | null required -
ageinteger | null required -
age_bucketstring | null ("under_13", "13_to_17", "18_plus", nil) required -
minorboolean required — A minor is never the message recipient; their guardian is. -
guardian_idstring | null required -
guardian_namestring | null required -
guardian_phonestring | null required -
pipeline_statestring ("new", "engaged", "qualified", "scheduled", "showed", "won", "lost") required — One generic ladder for every vertical. Verticals differ in what the stages mean, not in their shape. -
lost_reasonstring | null required -
sourcestring | null required -
sms_consentedboolean required -
content_consentstring ("none", "anonymous", "first_name", "photo_video") required — Ordered most to least restrictive. `anonymous` is the notice-only default; naming or picturing someone requires an actively recorded decision. -
content_consent_atstring | null, format date-time required — When this contact's content tier was last decided, and evidence that it WAS decided. Null means nobody has set it for this person and `content_consent` is si... -
custom_fieldsobject required — Whatever your flow steps write. This is where domain-specific answers land, which is why Flywheel needs no domain knowledge. -
external_refstring | null required — Your identifier for this person. Unique per account when set. -
last_inbound_atstring | null, format date-time required -
created_atstring, format date-time required -
insightsobject required — Their booking history, summarised. Derived from appointments; never written directly. A contact nobody has booked reads as `lead` with an empty history.
-
-
-
GET
/accounts/{account_id}/contacts/{id}Fetch a contactResponse 200
-
contactobject-
idstring required — Opaque slug. -
namestring required — What to call them in a list. Never blank: falls back to the phone, then the email, then `New lead`, so an unnamed lead who has only ever texted is still addr... -
first_namestring | null required -
last_namestring | null required -
phonestring | null required — E.164. Unique per account when set. Inbound SMS is routed by this number; two contacts sharing it would be a routing lottery. -
emailstring | null required -
addressstring | null required — Where to meet them when the account's meeting_kind is at_customer. Stamped onto a booking at book time. -
time_zonestring | null required -
ageinteger | null required -
age_bucketstring | null ("under_13", "13_to_17", "18_plus", nil) required -
minorboolean required — A minor is never the message recipient; their guardian is. -
guardian_idstring | null required -
guardian_namestring | null required -
guardian_phonestring | null required -
pipeline_statestring ("new", "engaged", "qualified", "scheduled", "showed", "won", "lost") required — One generic ladder for every vertical. Verticals differ in what the stages mean, not in their shape. -
lost_reasonstring | null required -
sourcestring | null required -
sms_consentedboolean required -
content_consentstring ("none", "anonymous", "first_name", "photo_video") required — Ordered most to least restrictive. `anonymous` is the notice-only default; naming or picturing someone requires an actively recorded decision. -
content_consent_atstring | null, format date-time required — When this contact's content tier was last decided, and evidence that it WAS decided. Null means nobody has set it for this person and `content_consent` is si... -
custom_fieldsobject required — Whatever your flow steps write. This is where domain-specific answers land, which is why Flywheel needs no domain knowledge. -
external_refstring | null required — Your identifier for this person. Unique per account when set. -
last_inbound_atstring | null, format date-time required -
created_atstring, format date-time required -
insightsobject required — Their booking history, summarised. Derived from appointments; never written directly. A contact nobody has booked reads as `lead` with an empty history.
-
-
-
PATCH
/accounts/{account_id}/contacts/{id}Update a contactRequest body
-
contactobject requiredMust have a phone, an email, or a guardian phone. Flywheel texts only — email alone records the person and, if you start a conversation, flags the thread `unreachable`.
-
first_namestring -
last_namestring -
phonestring — E.164. Unique per account when set. -
emailstring -
addressstring — Where to meet them. Used when meeting_kind is at_customer; an explicit appointment location still wins. -
time_zonestring — Used for quiet hours. Theirs, not yours. -
ageinteger -
age_bucketstring ("under_13", "13_to_17", "18_plus") — When the exact age is unknown. An exact `age` still wins, because the model derives the bucket from it. -
guardian_namestring -
guardian_phonestring -
sourcestring -
external_refstring — Your identifier for this person. Unique per account when set. A second create with the same ref returns the existing contact rather than inserting a twin. Bl... -
content_consentstring ("none", "anonymous", "first_name", "photo_video") — Ordered most to least restrictive. `anonymous` is the notice-only default; naming or picturing someone requires an actively recorded decision. -
content_consent_reasonstring — Why the tier is being changed. Not stored on the contact — it is kept on the immutable consent event alongside the change, so a trail can answer on what grou... -
sms_consentedboolean — Assert that this person consented to SMS. Recorded as a timestamp and an immutable consent event (never a bare column write). Promotional sending is gated on... -
custom_fieldsobject
-
Response 200
-
contactobject-
idstring required — Opaque slug. -
namestring required — What to call them in a list. Never blank: falls back to the phone, then the email, then `New lead`, so an unnamed lead who has only ever texted is still addr... -
first_namestring | null required -
last_namestring | null required -
phonestring | null required — E.164. Unique per account when set. Inbound SMS is routed by this number; two contacts sharing it would be a routing lottery. -
emailstring | null required -
addressstring | null required — Where to meet them when the account's meeting_kind is at_customer. Stamped onto a booking at book time. -
time_zonestring | null required -
ageinteger | null required -
age_bucketstring | null ("under_13", "13_to_17", "18_plus", nil) required -
minorboolean required — A minor is never the message recipient; their guardian is. -
guardian_idstring | null required -
guardian_namestring | null required -
guardian_phonestring | null required -
pipeline_statestring ("new", "engaged", "qualified", "scheduled", "showed", "won", "lost") required — One generic ladder for every vertical. Verticals differ in what the stages mean, not in their shape. -
lost_reasonstring | null required -
sourcestring | null required -
sms_consentedboolean required -
content_consentstring ("none", "anonymous", "first_name", "photo_video") required — Ordered most to least restrictive. `anonymous` is the notice-only default; naming or picturing someone requires an actively recorded decision. -
content_consent_atstring | null, format date-time required — When this contact's content tier was last decided, and evidence that it WAS decided. Null means nobody has set it for this person and `content_consent` is si... -
custom_fieldsobject required — Whatever your flow steps write. This is where domain-specific answers land, which is why Flywheel needs no domain knowledge. -
external_refstring | null required — Your identifier for this person. Unique per account when set. -
last_inbound_atstring | null, format date-time required -
created_atstring, format date-time required -
insightsobject required — Their booking history, summarised. Derived from appointments; never written directly. A contact nobody has booked reads as `lead` with an empty history.
-
-
Conversations
Threads, messages, and human takeover.
-
GET
/accounts/{account_id}/conversationsList conversationsSummaries only — `last_message_preview` and `message_count`, not the transcript. There is no `include=messages` parameter. A thread's messages are `getConversation` (D277).
Parameters
-
needs_humanstring ("true")`true` returns only threads the bot has escalated.
-
pageinteger -
per_pageinteger
Response 200
-
conversationsarray of object-
idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required — Same derivation as `Contact.name`. Here so that rendering a list of threads is one request rather than one per thread. -
channel_numberstring | null required -
flow_keystring | null required -
automation_paused_untilstring | null, format date-time required — Set 24 hours out whenever a human sends into the thread. -
automation_pausedboolean required — Whether `automation_paused_until` is still in the future. Provided because comparing a timestamp to now is the kind of thing every caller would otherwise get... -
needs_humanboolean required -
needs_human_atstring | null, format date-time required -
needs_human_reasonstring | null ("unparsed_reply", "repeated_reschedule", "unreachable", "unanswered_question", "unjudged_rule", "flow_handoff", nil) required — Why the thread was escalated. `unparsed_reply` means the bot could not make sense of the replies; `repeated_reschedule` means this appointment has been moved... -
message_countinteger required -
last_message_atstring | null, format date-time required -
last_message_directionstring | null ("inbound", "outbound", nil) required -
last_message_previewstring | null required — First 140 characters of the most recent message. -
created_atstring, format date-time required
-
-
-
POST
/accounts/{account_id}/conversationsOpen a thread with a contact without sending anythingUse this when a contact needs a transactional message and no onboarding flow. Creating a contact with `start_conversation: true` also runs the account's flow, which greets them. Idempotent: one conversation per contact, so calling twice returns the existing thread with `200` rather than splitting the history.
Request body
-
conversationobject required-
contact_idstring required — Contact id (slug) to open the thread with.
-
Response 200
-
conversationobject-
idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required — Same derivation as `Contact.name`. Here so that rendering a list of threads is one request rather than one per thread. -
channel_numberstring | null required -
flow_keystring | null required -
automation_paused_untilstring | null, format date-time required — Set 24 hours out whenever a human sends into the thread. -
automation_pausedboolean required — Whether `automation_paused_until` is still in the future. Provided because comparing a timestamp to now is the kind of thing every caller would otherwise get... -
needs_humanboolean required -
needs_human_atstring | null, format date-time required -
needs_human_reasonstring | null ("unparsed_reply", "repeated_reschedule", "unreachable", "unanswered_question", "unjudged_rule", "flow_handoff", nil) required — Why the thread was escalated. `unparsed_reply` means the bot could not make sense of the replies; `repeated_reschedule` means this appointment has been moved... -
message_countinteger required -
last_message_atstring | null, format date-time required -
last_message_directionstring | null ("inbound", "outbound", nil) required -
last_message_previewstring | null required — First 140 characters of the most recent message. -
created_atstring, format date-time required
-
-
-
GET
/accounts/{account_id}/conversations/{id}Fetch a conversation with its full message historyAlways includes the transcript. There is no `include` query parameter; the list endpoint never embeds messages (D277).
Response 200
-
conversationobjectShow payload. Same fields as Conversation, plus `messages`. List and create omit that key (D277).
-
idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required — Same derivation as `Contact.name`. Here so that rendering a list of threads is one request rather than one per thread. -
channel_numberstring | null required -
flow_keystring | null required -
automation_paused_untilstring | null, format date-time required — Set 24 hours out whenever a human sends into the thread. -
automation_pausedboolean required — Whether `automation_paused_until` is still in the future. Provided because comparing a timestamp to now is the kind of thing every caller would otherwise get... -
needs_humanboolean required -
needs_human_atstring | null, format date-time required -
needs_human_reasonstring | null ("unparsed_reply", "repeated_reschedule", "unreachable", "unanswered_question", "unjudged_rule", "flow_handoff", nil) required — Why the thread was escalated. `unparsed_reply` means the bot could not make sense of the replies; `repeated_reschedule` means this appointment has been moved... -
message_countinteger required -
last_message_atstring | null, format date-time required -
last_message_directionstring | null ("inbound", "outbound", nil) required -
last_message_previewstring | null required — First 140 characters of the most recent message. -
created_atstring, format date-time required -
messagesarray of object required
-
-
-
GET
/conversations/{conversation_id}/messagesList the messages in a thread, oldest firstResponse 200
-
messagesarray of object-
idinteger required -
directionstring ("inbound", "outbound") required -
channelstring ("sms") required -
fromstring | null required -
tostring | null required -
bodystring | null required -
template_keystring | null required -
statusstring | null required -
classified_intentstring | null required — What the rule-based classifier made of an inbound reply. -
sent_atstring | null, format date-time required -
created_atstring, format date-time required
-
-
-
POST
/conversations/{conversation_id}/messagesTake over the threadA human sending into the thread. This pauses that thread's automation for 24 hours as a side effect — the bot must not talk over a person, and making it automatic means nobody can forget. It skips template lookup, flow logic, and quiet hours. It does not skip opt-out or suppression: "a person typed it" is not a defence against texting someone who replied STOP. `hand_back: true` sends without the pause, and ends one already running, so the contact's next reply goes to aut...
Request body
-
messageobject required-
bodystring required -
hand_backboolean — Send without pausing automation on this thread.
-
Response 201
-
messageobject-
idinteger required -
directionstring ("inbound", "outbound") required -
channelstring ("sms") required -
fromstring | null required -
tostring | null required -
bodystring | null required -
template_keystring | null required -
statusstring | null required -
classified_intentstring | null required — What the rule-based classifier made of an inbound reply. -
sent_atstring | null, format date-time required -
created_atstring, format date-time required
-
-
-
POST
/conversations/{conversation_id}/sendsSend one of your own templatesYour automation sending a message, as opposed to a person typing one. Use this rather than `sendMessage` for anything a machine decided to send. Everything your automation could get wrong on its own is applied here: quiet hours in the recipient's timezone, opt-outs, suppression, and not sending the same template twice to the same person for the same occurrence. A message held for quiet hours answers `202` with status `requeued` and goes out when the window opens — it is n...
Request body
-
sendobject required-
template_keystring required -
occurrenceinteger — Distinguishes repeats of the same template to the same person. -
variablesobject — Values for the template's {{placeholders}}.
-
Response 201
-
sendobjectThe outcome of asking us to send a template. `requeued` means held for quiet hours and already scheduled — do not retry it.
-
statusstring ("sent", "requeued") required -
messageany required -
scheduled_send_idinteger | null required
-
-
-
POST
/conversations/{id}/resumeEnd a takeover earlyClears the 24-hour pause and the needs-human flag, so automation picks the thread back up now.
No request body.
Response 200
-
conversationobject-
idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required — Same derivation as `Contact.name`. Here so that rendering a list of threads is one request rather than one per thread. -
channel_numberstring | null required -
flow_keystring | null required -
automation_paused_untilstring | null, format date-time required — Set 24 hours out whenever a human sends into the thread. -
automation_pausedboolean required — Whether `automation_paused_until` is still in the future. Provided because comparing a timestamp to now is the kind of thing every caller would otherwise get... -
needs_humanboolean required -
needs_human_atstring | null, format date-time required -
needs_human_reasonstring | null ("unparsed_reply", "repeated_reschedule", "unreachable", "unanswered_question", "unjudged_rule", "flow_handoff", nil) required — Why the thread was escalated. `unparsed_reply` means the bot could not make sense of the replies; `repeated_reschedule` means this appointment has been moved... -
message_countinteger required -
last_message_atstring | null, format date-time required -
last_message_directionstring | null ("inbound", "outbound", nil) required -
last_message_previewstring | null required — First 140 characters of the most recent message. -
created_atstring, format date-time required
-
-
Scheduling
When an account will take appointments, and the open times that follow from it. Slots are computed, never stored.
-
GET
/accounts/{account_id}/availability-rulesList the recurring weekly windowsResponse 200
-
availability_rulesarray of object-
idinteger required -
weekdayinteger required — 0 is Sunday, matching Ruby's `Date#wday`. -
start_minuteinteger required — Minutes from local midnight. Stored as a minute rather than a time because a recurring rule has no date to anchor a time to, and attaching one shifts the win... -
end_minuteinteger required — Exclusive, and must be after `start_minute`. -
appointment_typestring required — Which kind of appointment this window is for. Opaque to Flywheel — it only has to match what you ask for slots by. -
slot_duration_minutesinteger required — The window is divided into back-to-back slots of this length. A remainder shorter than one slot is not offered. -
activeboolean required — Inactive rules produce no slots. Appointments already booked inside them are untouched.
-
-
-
POST
/accounts/{account_id}/availability-rulesAdd a weekly windowA rule is a weekday plus minutes from local midnight, not a time. A recurring rule has no date to anchor a time to, and attaching one makes the window shift by an hour across a DST transition.
Request body
-
availability_ruleobject required-
weekdayinteger -
start_minuteinteger -
end_minuteinteger -
appointment_typestring -
slot_duration_minutesinteger -
activeboolean
-
Response 201
-
availability_ruleobjectA recurring weekly window when the account will take appointments.
-
idinteger required -
weekdayinteger required — 0 is Sunday, matching Ruby's `Date#wday`. -
start_minuteinteger required — Minutes from local midnight. Stored as a minute rather than a time because a recurring rule has no date to anchor a time to, and attaching one shifts the win... -
end_minuteinteger required — Exclusive, and must be after `start_minute`. -
appointment_typestring required — Which kind of appointment this window is for. Opaque to Flywheel — it only has to match what you ask for slots by. -
slot_duration_minutesinteger required — The window is divided into back-to-back slots of this length. A remainder shorter than one slot is not offered. -
activeboolean required — Inactive rules produce no slots. Appointments already booked inside them are untouched.
-
-
-
PATCH
/accounts/{account_id}/availability-rules/{id}Update a weekly windowTakes effect on the next slot computation. Appointments already booked into a window you narrow are not disturbed — they are rows, not slots.
Request body
-
availability_ruleobject required-
weekdayinteger -
start_minuteinteger -
end_minuteinteger -
appointment_typestring -
slot_duration_minutesinteger -
activeboolean
-
Response 200
-
availability_ruleobjectA recurring weekly window when the account will take appointments.
-
idinteger required -
weekdayinteger required — 0 is Sunday, matching Ruby's `Date#wday`. -
start_minuteinteger required — Minutes from local midnight. Stored as a minute rather than a time because a recurring rule has no date to anchor a time to, and attaching one shifts the win... -
end_minuteinteger required — Exclusive, and must be after `start_minute`. -
appointment_typestring required — Which kind of appointment this window is for. Opaque to Flywheel — it only has to match what you ask for slots by. -
slot_duration_minutesinteger required — The window is divided into back-to-back slots of this length. A remainder shorter than one slot is not offered. -
activeboolean required — Inactive rules produce no slots. Appointments already booked inside them are untouched.
-
-
-
DELETE
/accounts/{account_id}/availability-rules/{id}Remove a weekly windowResponse 204
No JSON body (or empty success).
-
GET
/accounts/{account_id}/availability-exceptionsList the days the rules do not applyParameters
-
upcomingstring ("true")`true` excludes exceptions whose date has passed.
Response 200
-
availability_exceptionsarray of object-
idinteger required -
datestring, format date required — The account's local date. -
all_dayboolean required -
start_minuteinteger | null required — Present only for a partial block. Minutes from local midnight. -
end_minuteinteger | null required -
reasonstring | null required — Free text, for the account's own benefit. Never sent to anyone.
-
-
-
POST
/accounts/{account_id}/availability-exceptionsBlock a day, or part of oneRequest body
-
availability_exceptionobject requiredDefaults to blocking the whole day. Set `all_day: false` and both minutes to block part of one.
-
datestring, format date -
all_dayboolean -
start_minuteinteger -
end_minuteinteger -
reasonstring
-
Response 201
-
availability_exceptionobjectA day, or part of one, when the recurring rules do not apply.
-
idinteger required -
datestring, format date required — The account's local date. -
all_dayboolean required -
start_minuteinteger | null required — Present only for a partial block. Minutes from local midnight. -
end_minuteinteger | null required -
reasonstring | null required — Free text, for the account's own benefit. Never sent to anyone.
-
-
-
DELETE
/accounts/{account_id}/availability-exceptions/{id}Unblock a dayResponse 204
No JSON body (or empty success).
-
GET
/accounts/{account_id}/slotsCompute open timesRead-only, and there is deliberately no operation that creates a slot. A slot is not a record; it is derived at the moment you ask, from availability rules minus exceptions, minus live appointments, minus the account's minimum notice. Storing slots means eventually serving one that stopped being true, and that is how a double-booking happens. Which also means a slot is an offer, not a hold. Nothing is reserved until an appointment exists. Book one by passing its `starts_a...
Parameters
-
appointment_typestringMatches the `appointment_type` on the account's availability rules. Opaque to Flywheel.
-
limitinteger -
horizon_daysintegerHow far ahead to look. Values above 60 are clamped to 60.
Response 200
-
slotsarray of object-
starts_atstring, format date-time required — Pass this back as an appointment's `starts_at` to book it. -
ends_atstring, format date-time required -
local_datestring, format date required — In the account's timezone. -
local_timestring required -
labelstring required — Ready to show a human, rendered in the account's timezone.
-
-
time_zonestringThe account's IANA zone — the one every `local_*` field is rendered in.
-
Appointments
Booked times, attendance, and cancellation. Flywheel records attendance and never bills it.
-
GET
/accounts/{account_id}/appointmentsList appointments, soonest firstParameters
-
statestring ("booked", "reminded", "attended", "no_show", "canceled", "rescheduled")`booked` and `reminded` are the live states — the ones that hold an instant against someone else booking it. `attended`, `canceled` and `rescheduled` are terminal; `no_show` is reversible to `attended` only.
-
upcomingstring ("true")`true` returns only live appointments still in the future.
-
paststring ("true")`true` returns only appointments whose time has passed, newest first, tiebroken on `id`. Every other listing reads soonest first. The tiebreak matters if you page: two appointments can share a `starts_at`, and without it offset pagination could show one twice and the other never.
-
contact_idstringLimits to one contact, by contact id.
-
attended_sincestring, format date-timeISO 8601. Returns appointments whose attendance was RECORDED at or after this instant, inclusive. Note this windows on when attendance was marked, not when the lesson was scheduled — those are different days, and only the former answers "which `appointment.attended` events fired recently", which is what a subscriber reconciling missed webhooks is asking. A lesson from three weeks ago marked attended this morning fired its event this morning. Listing order is still `starts_at` (soonest first, or newest first with `past=true`), not `attended_at` — a newly recorded attendance of a recent lesson can appear on a page already walked. Changing that order waits on `flywheel:appointments:attended_index` printing due (D401 / D457). A value that cannot be parsed is refused with `validation_failed` rather than ignored, so a broken filter can never look like an empty result.
-
pageinteger -
per_pageinteger
Response 200
-
appointmentsarray of object-
idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required — Same derivation as `Contact.name`. -
contact_phonestring | null required — The number this appointment would be reminded on — the contact's, or their guardian's if the contact is a minor. -
conversation_idinteger | null required — The thread it was booked from, when it was booked by text. -
starts_atstring, format date-time required -
ends_atstring, format date-time required -
local_labelstring required — The start rendered in the account's timezone. -
appointment_typestring required -
statestring ("booked", "reminded", "attended", "no_show", "canceled", "rescheduled") required — `booked` and `reminded` are the live states — the ones that hold an instant against someone else booking it. `attended`, `canceled` and `rescheduled` are ter... -
attendance_sourceany required -
attended_atstring | null, format date-time required -
no_show_contested_atstring | null, format date-time required — When the contact replied that they did attend this no-show. The state stays `no_show`; resolve it with `attend`. -
canceled_atstring | null, format date-time required -
cancel_reasonstring | null required -
locationstring | null required -
meeting_kindstring ("in_person", "at_customer", "virtual") required -
join_urlstring | null required — Flywheel join page for the family. Blank until a virtual room is minted. -
host_join_urlstring | null required — Flywheel join page that enters as host. Blank until a virtual room is minted. -
video_host_joined_atstring | null, format date-time required — First time a host entered the Whereby room. Not attendance. -
video_guest_joined_atstring | null, format date-time required — First time a visitor entered the Whereby room. Not attendance. -
video_session_started_atstring | null, format date-time required — Whereby room.session.started — at least two people in the room. -
video_session_ended_atstring | null, format date-time required -
video_presence_labelstring | null required — Short room-presence summary for the dashboard. A join is never a charge. -
notesstring | null required -
value_centsinteger | null required — What you said this booking is worth, in the currency's minor unit. Yours, for reporting; Flywheel never sets it or acts on it (D487). Carried to a reschedule... -
value_currencystring | null required — ISO 4217, uppercase. Present exactly when `value_cents` is. -
external_refstring | null required — Your identifier for this row, echoed back. Not unique, not a list filter, and not an idempotency key (those are interaction captures). A reschedule is a new ... -
rescheduled_from_idinteger | null required — The appointment this row replaced. Null on a first booking. Walk it to reconstruct which times the lesson held, rather than guessing from timestamps (D357). -
created_atstring, format date-time required -
review_requestobject | null required — The public-review ask attendance created, when one exists.
-
-
-
POST
/accounts/{account_id}/appointmentsBook a timePass a `starts_at` you got from `GET /accounts/{account_id}/slots`. The race between being offered a time and confirming it cannot be closed in application code, so a unique index decides it and the loser is told to pick again rather than being silently double-booked. The three failures are distinguishable on purpose, because they call for different behaviour: - **409 `slot_taken`** — someone else took that instant between the offer and this request. The request itself...
Request body
-
appointmentobject required-
contact_idstring — Contact SLUG, not an integer — same identifier the contact endpoints use. -
starts_atstring, format date-time — Take this from a slot rather than composing it. An instant not on a slot boundary is accepted, which means you can book outside your own availability if you ... -
appointment_typestring -
duration_minutesinteger — Defaults to the matching availability rule's `slot_duration_minutes`, or 30 if there is no rule. -
locationstring -
meeting_kindstring ("in_person", "at_customer", "virtual") — Defaults to the account's meeting_kind. -
external_refstring — Your identifier for this row. Echoed on the appointment that was created, not on a later reschedule successor. -
value_centsinteger — Optional. What this booking is worth to you, in the currency's minor unit. Send with `value_currency`. -
value_currencystring — ISO 4217, e.g. USD. Stored uppercase.
-
Response 201
-
appointmentobject-
idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required — Same derivation as `Contact.name`. -
contact_phonestring | null required — The number this appointment would be reminded on — the contact's, or their guardian's if the contact is a minor. -
conversation_idinteger | null required — The thread it was booked from, when it was booked by text. -
starts_atstring, format date-time required -
ends_atstring, format date-time required -
local_labelstring required — The start rendered in the account's timezone. -
appointment_typestring required -
statestring ("booked", "reminded", "attended", "no_show", "canceled", "rescheduled") required — `booked` and `reminded` are the live states — the ones that hold an instant against someone else booking it. `attended`, `canceled` and `rescheduled` are ter... -
attendance_sourceany required -
attended_atstring | null, format date-time required -
no_show_contested_atstring | null, format date-time required — When the contact replied that they did attend this no-show. The state stays `no_show`; resolve it with `attend`. -
canceled_atstring | null, format date-time required -
cancel_reasonstring | null required -
locationstring | null required -
meeting_kindstring ("in_person", "at_customer", "virtual") required -
join_urlstring | null required — Flywheel join page for the family. Blank until a virtual room is minted. -
host_join_urlstring | null required — Flywheel join page that enters as host. Blank until a virtual room is minted. -
video_host_joined_atstring | null, format date-time required — First time a host entered the Whereby room. Not attendance. -
video_guest_joined_atstring | null, format date-time required — First time a visitor entered the Whereby room. Not attendance. -
video_session_started_atstring | null, format date-time required — Whereby room.session.started — at least two people in the room. -
video_session_ended_atstring | null, format date-time required -
video_presence_labelstring | null required — Short room-presence summary for the dashboard. A join is never a charge. -
notesstring | null required -
value_centsinteger | null required — What you said this booking is worth, in the currency's minor unit. Yours, for reporting; Flywheel never sets it or acts on it (D487). Carried to a reschedule... -
value_currencystring | null required — ISO 4217, uppercase. Present exactly when `value_cents` is. -
external_refstring | null required — Your identifier for this row, echoed back. Not unique, not a list filter, and not an idempotency key (those are interaction captures). A reschedule is a new ... -
rescheduled_from_idinteger | null required — The appointment this row replaced. Null on a first booking. Walk it to reconstruct which times the lesson held, rather than guessing from timestamps (D357). -
created_atstring, format date-time required -
review_requestobject | null required — The public-review ask attendance created, when one exists.
-
-
-
GET
/appointments/{id}Fetch an appointmentNot nested under an account: the appointment already names its account, so the key's authority is checked against that one rather than one the caller nominates.
Response 200
-
appointmentobject-
idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required — Same derivation as `Contact.name`. -
contact_phonestring | null required — The number this appointment would be reminded on — the contact's, or their guardian's if the contact is a minor. -
conversation_idinteger | null required — The thread it was booked from, when it was booked by text. -
starts_atstring, format date-time required -
ends_atstring, format date-time required -
local_labelstring required — The start rendered in the account's timezone. -
appointment_typestring required -
statestring ("booked", "reminded", "attended", "no_show", "canceled", "rescheduled") required — `booked` and `reminded` are the live states — the ones that hold an instant against someone else booking it. `attended`, `canceled` and `rescheduled` are ter... -
attendance_sourceany required -
attended_atstring | null, format date-time required -
no_show_contested_atstring | null, format date-time required — When the contact replied that they did attend this no-show. The state stays `no_show`; resolve it with `attend`. -
canceled_atstring | null, format date-time required -
cancel_reasonstring | null required -
locationstring | null required -
meeting_kindstring ("in_person", "at_customer", "virtual") required -
join_urlstring | null required — Flywheel join page for the family. Blank until a virtual room is minted. -
host_join_urlstring | null required — Flywheel join page that enters as host. Blank until a virtual room is minted. -
video_host_joined_atstring | null, format date-time required — First time a host entered the Whereby room. Not attendance. -
video_guest_joined_atstring | null, format date-time required — First time a visitor entered the Whereby room. Not attendance. -
video_session_started_atstring | null, format date-time required — Whereby room.session.started — at least two people in the room. -
video_session_ended_atstring | null, format date-time required -
video_presence_labelstring | null required — Short room-presence summary for the dashboard. A join is never a charge. -
notesstring | null required -
value_centsinteger | null required — What you said this booking is worth, in the currency's minor unit. Yours, for reporting; Flywheel never sets it or acts on it (D487). Carried to a reschedule... -
value_currencystring | null required — ISO 4217, uppercase. Present exactly when `value_cents` is. -
external_refstring | null required — Your identifier for this row, echoed back. Not unique, not a list filter, and not an idempotency key (those are interaction captures). A reschedule is a new ... -
rescheduled_from_idinteger | null required — The appointment this row replaced. Null on a first booking. Walk it to reconstruct which times the lesson held, rather than guessing from timestamps (D357). -
created_atstring, format date-time required -
review_requestobject | null required — The public-review ask attendance created, when one exists.
-
-
-
PATCH
/appointments/{id}Record what an appointment is worthSets or clears `value_cents` and `value_currency`, in any state — the value is often known only after the visit. These are the only editable fields: time, contact, and state each have their own operation. The value is yours, for reporting. Flywheel sums it into the contact's `insights` and does nothing else with it: it is never defaulted, never derived, and never triggers a charge (D487). Send both fields, or neither; send both as `null` to clear.
Request body
-
appointmentobject required-
value_centsinteger | null — Minor units. `null` clears it. -
value_currencystring | null — ISO 4217. `null` clears it.
-
Response 200
-
appointmentobject-
idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required — Same derivation as `Contact.name`. -
contact_phonestring | null required — The number this appointment would be reminded on — the contact's, or their guardian's if the contact is a minor. -
conversation_idinteger | null required — The thread it was booked from, when it was booked by text. -
starts_atstring, format date-time required -
ends_atstring, format date-time required -
local_labelstring required — The start rendered in the account's timezone. -
appointment_typestring required -
statestring ("booked", "reminded", "attended", "no_show", "canceled", "rescheduled") required — `booked` and `reminded` are the live states — the ones that hold an instant against someone else booking it. `attended`, `canceled` and `rescheduled` are ter... -
attendance_sourceany required -
attended_atstring | null, format date-time required -
no_show_contested_atstring | null, format date-time required — When the contact replied that they did attend this no-show. The state stays `no_show`; resolve it with `attend`. -
canceled_atstring | null, format date-time required -
cancel_reasonstring | null required -
locationstring | null required -
meeting_kindstring ("in_person", "at_customer", "virtual") required -
join_urlstring | null required — Flywheel join page for the family. Blank until a virtual room is minted. -
host_join_urlstring | null required — Flywheel join page that enters as host. Blank until a virtual room is minted. -
video_host_joined_atstring | null, format date-time required — First time a host entered the Whereby room. Not attendance. -
video_guest_joined_atstring | null, format date-time required — First time a visitor entered the Whereby room. Not attendance. -
video_session_started_atstring | null, format date-time required — Whereby room.session.started — at least two people in the room. -
video_session_ended_atstring | null, format date-time required -
video_presence_labelstring | null required — Short room-presence summary for the dashboard. A join is never a charge. -
notesstring | null required -
value_centsinteger | null required — What you said this booking is worth, in the currency's minor unit. Yours, for reporting; Flywheel never sets it or acts on it (D487). Carried to a reschedule... -
value_currencystring | null required — ISO 4217, uppercase. Present exactly when `value_cents` is. -
external_refstring | null required — Your identifier for this row, echoed back. Not unique, not a list filter, and not an idempotency key (those are interaction captures). A reschedule is a new ... -
rescheduled_from_idinteger | null required — The appointment this row replaced. Null on a first booking. Walk it to reconstruct which times the lesson held, rather than guessing from timestamps (D357). -
created_atstring, format date-time required -
review_requestobject | null required — The public-review ask attendance created, when one exists.
-
-
-
POST
/appointments/{id}/attendRecord that they turned upEmits `appointment.attended`, and is the only thing that creates a review request. It takes no rating and no opinion of how the appointment went, and it never will. An attendance signal that could carry one would be a review-gating mechanism, because the ask is triggered from here. Flywheel records attendance and never bills it. Whether an attended appointment is worth money is the subscriber's business, which is what lets a subscriber bill on attended intros without Fly...
Request body
-
sourcestring ("confirmed", "deemed", "manual")How attendance was decided. `deemed` means nobody said otherwise before the silence window closed, and a subscriber billing on attendance needs to tell that apart from someone actively confirming.
Response 200
-
appointmentobject-
idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required — Same derivation as `Contact.name`. -
contact_phonestring | null required — The number this appointment would be reminded on — the contact's, or their guardian's if the contact is a minor. -
conversation_idinteger | null required — The thread it was booked from, when it was booked by text. -
starts_atstring, format date-time required -
ends_atstring, format date-time required -
local_labelstring required — The start rendered in the account's timezone. -
appointment_typestring required -
statestring ("booked", "reminded", "attended", "no_show", "canceled", "rescheduled") required — `booked` and `reminded` are the live states — the ones that hold an instant against someone else booking it. `attended`, `canceled` and `rescheduled` are ter... -
attendance_sourceany required -
attended_atstring | null, format date-time required -
no_show_contested_atstring | null, format date-time required — When the contact replied that they did attend this no-show. The state stays `no_show`; resolve it with `attend`. -
canceled_atstring | null, format date-time required -
cancel_reasonstring | null required -
locationstring | null required -
meeting_kindstring ("in_person", "at_customer", "virtual") required -
join_urlstring | null required — Flywheel join page for the family. Blank until a virtual room is minted. -
host_join_urlstring | null required — Flywheel join page that enters as host. Blank until a virtual room is minted. -
video_host_joined_atstring | null, format date-time required — First time a host entered the Whereby room. Not attendance. -
video_guest_joined_atstring | null, format date-time required — First time a visitor entered the Whereby room. Not attendance. -
video_session_started_atstring | null, format date-time required — Whereby room.session.started — at least two people in the room. -
video_session_ended_atstring | null, format date-time required -
video_presence_labelstring | null required — Short room-presence summary for the dashboard. A join is never a charge. -
notesstring | null required -
value_centsinteger | null required — What you said this booking is worth, in the currency's minor unit. Yours, for reporting; Flywheel never sets it or acts on it (D487). Carried to a reschedule... -
value_currencystring | null required — ISO 4217, uppercase. Present exactly when `value_cents` is. -
external_refstring | null required — Your identifier for this row, echoed back. Not unique, not a list filter, and not an idempotency key (those are interaction captures). A reschedule is a new ... -
rescheduled_from_idinteger | null required — The appointment this row replaced. Null on a first booking. Walk it to reconstruct which times the lesson held, rather than guessing from timestamps (D357). -
created_atstring, format date-time required -
review_requestobject | null required — The public-review ask attendance created, when one exists.
-
-
-
POST
/appointments/{id}/no-showRecord that they did not turn upEmits `appointment.no_show`. Reversible to `attended` — a no-show recorded in error is a correction, not a dead end — but nothing else.
No request body.
Response 200
-
appointmentobject-
idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required — Same derivation as `Contact.name`. -
contact_phonestring | null required — The number this appointment would be reminded on — the contact's, or their guardian's if the contact is a minor. -
conversation_idinteger | null required — The thread it was booked from, when it was booked by text. -
starts_atstring, format date-time required -
ends_atstring, format date-time required -
local_labelstring required — The start rendered in the account's timezone. -
appointment_typestring required -
statestring ("booked", "reminded", "attended", "no_show", "canceled", "rescheduled") required — `booked` and `reminded` are the live states — the ones that hold an instant against someone else booking it. `attended`, `canceled` and `rescheduled` are ter... -
attendance_sourceany required -
attended_atstring | null, format date-time required -
no_show_contested_atstring | null, format date-time required — When the contact replied that they did attend this no-show. The state stays `no_show`; resolve it with `attend`. -
canceled_atstring | null, format date-time required -
cancel_reasonstring | null required -
locationstring | null required -
meeting_kindstring ("in_person", "at_customer", "virtual") required -
join_urlstring | null required — Flywheel join page for the family. Blank until a virtual room is minted. -
host_join_urlstring | null required — Flywheel join page that enters as host. Blank until a virtual room is minted. -
video_host_joined_atstring | null, format date-time required — First time a host entered the Whereby room. Not attendance. -
video_guest_joined_atstring | null, format date-time required — First time a visitor entered the Whereby room. Not attendance. -
video_session_started_atstring | null, format date-time required — Whereby room.session.started — at least two people in the room. -
video_session_ended_atstring | null, format date-time required -
video_presence_labelstring | null required — Short room-presence summary for the dashboard. A join is never a charge. -
notesstring | null required -
value_centsinteger | null required — What you said this booking is worth, in the currency's minor unit. Yours, for reporting; Flywheel never sets it or acts on it (D487). Carried to a reschedule... -
value_currencystring | null required — ISO 4217, uppercase. Present exactly when `value_cents` is. -
external_refstring | null required — Your identifier for this row, echoed back. Not unique, not a list filter, and not an idempotency key (those are interaction captures). A reschedule is a new ... -
rescheduled_from_idinteger | null required — The appointment this row replaced. Null on a first booking. Walk it to reconstruct which times the lesson held, rather than guessing from timestamps (D357). -
created_atstring, format date-time required -
review_requestobject | null required — The public-review ask attendance created, when one exists.
-
-
-
POST
/appointments/{id}/cancelCancel an appointmentEmits `appointment.canceled` and frees the instant for someone else. Terminal: a canceled appointment has no transitions out, so re-booking means creating a new one.
Request body
-
reasonstringFree text, stored verbatim.
Response 200
-
appointmentobject-
idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required — Same derivation as `Contact.name`. -
contact_phonestring | null required — The number this appointment would be reminded on — the contact's, or their guardian's if the contact is a minor. -
conversation_idinteger | null required — The thread it was booked from, when it was booked by text. -
starts_atstring, format date-time required -
ends_atstring, format date-time required -
local_labelstring required — The start rendered in the account's timezone. -
appointment_typestring required -
statestring ("booked", "reminded", "attended", "no_show", "canceled", "rescheduled") required — `booked` and `reminded` are the live states — the ones that hold an instant against someone else booking it. `attended`, `canceled` and `rescheduled` are ter... -
attendance_sourceany required -
attended_atstring | null, format date-time required -
no_show_contested_atstring | null, format date-time required — When the contact replied that they did attend this no-show. The state stays `no_show`; resolve it with `attend`. -
canceled_atstring | null, format date-time required -
cancel_reasonstring | null required -
locationstring | null required -
meeting_kindstring ("in_person", "at_customer", "virtual") required -
join_urlstring | null required — Flywheel join page for the family. Blank until a virtual room is minted. -
host_join_urlstring | null required — Flywheel join page that enters as host. Blank until a virtual room is minted. -
video_host_joined_atstring | null, format date-time required — First time a host entered the Whereby room. Not attendance. -
video_guest_joined_atstring | null, format date-time required — First time a visitor entered the Whereby room. Not attendance. -
video_session_started_atstring | null, format date-time required — Whereby room.session.started — at least two people in the room. -
video_session_ended_atstring | null, format date-time required -
video_presence_labelstring | null required — Short room-presence summary for the dashboard. A join is never a charge. -
notesstring | null required -
value_centsinteger | null required — What you said this booking is worth, in the currency's minor unit. Yours, for reporting; Flywheel never sets it or acts on it (D487). Carried to a reschedule... -
value_currencystring | null required — ISO 4217, uppercase. Present exactly when `value_cents` is. -
external_refstring | null required — Your identifier for this row, echoed back. Not unique, not a list filter, and not an idempotency key (those are interaction captures). A reschedule is a new ... -
rescheduled_from_idinteger | null required — The appointment this row replaced. Null on a first booking. Walk it to reconstruct which times the lesson held, rather than guessing from timestamps (D357). -
created_atstring, format date-time required -
review_requestobject | null required — The public-review ask attendance created, when one exists.
-
-
-
POST
/appointments/{id}/rescheduleMove an appointment to a new timeBooks the new time and retires the old appointment in one transaction, then emits `appointment.rescheduled`. Use this rather than creating a new appointment and cancelling the old one: as two calls there is no way to guarantee both happen, and the appointment left behind stays live — it goes on collecting reminders, and on an account with `deemed_attendance_after_hours` set it is eventually recorded attended. If the new time is taken the original is left exactly as it was,...
Request body
-
appointmentobject required-
starts_atstring, format date-time required — The new start. Duration, location, contact, and type carry over from the original rather than being re-derived from availability. `external_ref` does not — i...
-
Response 200
-
appointmentobject-
idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required — Same derivation as `Contact.name`. -
contact_phonestring | null required — The number this appointment would be reminded on — the contact's, or their guardian's if the contact is a minor. -
conversation_idinteger | null required — The thread it was booked from, when it was booked by text. -
starts_atstring, format date-time required -
ends_atstring, format date-time required -
local_labelstring required — The start rendered in the account's timezone. -
appointment_typestring required -
statestring ("booked", "reminded", "attended", "no_show", "canceled", "rescheduled") required — `booked` and `reminded` are the live states — the ones that hold an instant against someone else booking it. `attended`, `canceled` and `rescheduled` are ter... -
attendance_sourceany required -
attended_atstring | null, format date-time required -
no_show_contested_atstring | null, format date-time required — When the contact replied that they did attend this no-show. The state stays `no_show`; resolve it with `attend`. -
canceled_atstring | null, format date-time required -
cancel_reasonstring | null required -
locationstring | null required -
meeting_kindstring ("in_person", "at_customer", "virtual") required -
join_urlstring | null required — Flywheel join page for the family. Blank until a virtual room is minted. -
host_join_urlstring | null required — Flywheel join page that enters as host. Blank until a virtual room is minted. -
video_host_joined_atstring | null, format date-time required — First time a host entered the Whereby room. Not attendance. -
video_guest_joined_atstring | null, format date-time required — First time a visitor entered the Whereby room. Not attendance. -
video_session_started_atstring | null, format date-time required — Whereby room.session.started — at least two people in the room. -
video_session_ended_atstring | null, format date-time required -
video_presence_labelstring | null required — Short room-presence summary for the dashboard. A join is never a charge. -
notesstring | null required -
value_centsinteger | null required — What you said this booking is worth, in the currency's minor unit. Yours, for reporting; Flywheel never sets it or acts on it (D487). Carried to a reschedule... -
value_currencystring | null required — ISO 4217, uppercase. Present exactly when `value_cents` is. -
external_refstring | null required — Your identifier for this row, echoed back. Not unique, not a list filter, and not an idempotency key (those are interaction captures). A reschedule is a new ... -
rescheduled_from_idinteger | null required — The appointment this row replaced. Null on a first booking. Walk it to reconstruct which times the lesson held, rather than guessing from timestamps (D357). -
created_atstring, format date-time required -
review_requestobject | null required — The public-review ask attendance created, when one exists.
-
-
previousobject-
idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required — Same derivation as `Contact.name`. -
contact_phonestring | null required — The number this appointment would be reminded on — the contact's, or their guardian's if the contact is a minor. -
conversation_idinteger | null required — The thread it was booked from, when it was booked by text. -
starts_atstring, format date-time required -
ends_atstring, format date-time required -
local_labelstring required — The start rendered in the account's timezone. -
appointment_typestring required -
statestring ("booked", "reminded", "attended", "no_show", "canceled", "rescheduled") required — `booked` and `reminded` are the live states — the ones that hold an instant against someone else booking it. `attended`, `canceled` and `rescheduled` are ter... -
attendance_sourceany required -
attended_atstring | null, format date-time required -
no_show_contested_atstring | null, format date-time required — When the contact replied that they did attend this no-show. The state stays `no_show`; resolve it with `attend`. -
canceled_atstring | null, format date-time required -
cancel_reasonstring | null required -
locationstring | null required -
meeting_kindstring ("in_person", "at_customer", "virtual") required -
join_urlstring | null required — Flywheel join page for the family. Blank until a virtual room is minted. -
host_join_urlstring | null required — Flywheel join page that enters as host. Blank until a virtual room is minted. -
video_host_joined_atstring | null, format date-time required — First time a host entered the Whereby room. Not attendance. -
video_guest_joined_atstring | null, format date-time required — First time a visitor entered the Whereby room. Not attendance. -
video_session_started_atstring | null, format date-time required — Whereby room.session.started — at least two people in the room. -
video_session_ended_atstring | null, format date-time required -
video_presence_labelstring | null required — Short room-presence summary for the dashboard. A join is never a charge. -
notesstring | null required -
value_centsinteger | null required — What you said this booking is worth, in the currency's minor unit. Yours, for reporting; Flywheel never sets it or acts on it (D487). Carried to a reschedule... -
value_currencystring | null required — ISO 4217, uppercase. Present exactly when `value_cents` is. -
external_refstring | null required — Your identifier for this row, echoed back. Not unique, not a list filter, and not an idempotency key (those are interaction captures). A reschedule is a new ... -
rescheduled_from_idinteger | null required — The appointment this row replaced. Null on a first booking. Walk it to reconstruct which times the lesson held, rather than guessing from timestamps (D357). -
created_atstring, format date-time required -
review_requestobject | null required — The public-review ask attendance created, when one exists.
-
-
Reviews
Public-review asks (created by attendance) and first-party testimonials (coach-triggered quotes for the subscriber's own site).
-
GET
/accounts/{account_id}/review-requestsList review asks**There is no create operation, and there will not be one.** A review request exists because an appointment was attended, and for no other reason. An endpoint that let a caller decide who gets asked is the mechanism for review gating — asking the customers you expect to be happy — whatever the intent behind adding it, so the capability is absent rather than policed. What follows from that, structurally rather than by convention: everyone eligible gets the same public-revi...
Parameters
-
statestring ("pending", "asked", "reminded", "completed", "declined", "retired", "unreachable")There is no transition back to `asked`, which is what caps a request at one ask plus one reminder. `retired` is the 90-day family window closing over an unanswered request. `unreachable` means we could not text them (no number, or STOP); it is listed, not silent, and does not count as having asked.
-
openstring ("true")`true` returns only requests still in `pending`, `asked`, or `reminded`.
-
appointment_idinteger -
received_sincestring, format date-timeISO 8601. Returns review requests whose review was RECORDED at or after this instant, inclusive. Windows on `received_at`, not request `created_at` — the row is born at attendance and completed days later. When either received bound is present the list is ordered by `received_at` ascending (then `id`) so a newly matching row lands at the end of the walk (D470). Without either bound the list is newest-created first. A value that cannot be parsed is refused with `validation_failed` rather than ignored.
-
received_beforestring, format date-timeISO 8601. Returns review requests whose review was recorded before this instant, exclusive. Adjacent `[received_since, received_before)` slices therefore partition (D362). Either received bound orders the list by `received_at` ascending. Refused, not ignored, when the value cannot be parsed.
-
pageinteger -
per_pageinteger
Response 200
-
review_requestsarray of object-
idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required — Same derivation as `Contact.name`. -
appointment_idinteger | null required — The attended appointment that caused this. At most one request per appointment, enforced by a unique index. -
statestring ("pending", "asked", "reminded", "completed", "declined", "retired", "unreachable") required — There is no transition back to `asked`, which is what caps a request at one ask plus one reminder. `retired` is the 90-day family window closing over an unan... -
platformstring required — Where they are sent to write it. -
review_urlstring | null required — Copied from the account at creation. The write-a-review URL the ask will send, never one Flywheel hosts. -
ratinginteger | null required — Written when a review is recorded and never read back. Nothing that decides who gets asked can see it, which is what makes gating impossible rather than mere... -
bodystring | null required -
asked_atstring | null, format date-time required -
reminded_atstring | null, format date-time required — The one reminder. There is no second. -
completed_atstring | null, format date-time required -
received_atstring | null, format date-time required — When the review was recorded. Same instant as `completed_at` on `complete`. The event clock for `review.received` (D448). -
created_atstring, format date-time required
-
-
-
POST
/review-requests/{id}/sendSend the queued ask nowAccelerates an existing `pending` ask. Does not create a request and does not skip anyone else. The delayed first send is the same row — this just sends it.
No request body.
Response 200
-
review_requestobjectOne review ask. Created by attendance and by nothing else — there is no operation that makes one.
-
idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required — Same derivation as `Contact.name`. -
appointment_idinteger | null required — The attended appointment that caused this. At most one request per appointment, enforced by a unique index. -
statestring ("pending", "asked", "reminded", "completed", "declined", "retired", "unreachable") required — There is no transition back to `asked`, which is what caps a request at one ask plus one reminder. `retired` is the 90-day family window closing over an unan... -
platformstring required — Where they are sent to write it. -
review_urlstring | null required — Copied from the account at creation. The write-a-review URL the ask will send, never one Flywheel hosts. -
ratinginteger | null required — Written when a review is recorded and never read back. Nothing that decides who gets asked can see it, which is what makes gating impossible rather than mere... -
bodystring | null required -
asked_atstring | null, format date-time required -
reminded_atstring | null, format date-time required — The one reminder. There is no second. -
completed_atstring | null, format date-time required -
received_atstring | null, format date-time required — When the review was recorded. Same instant as `completed_at` on `complete`. The event clock for `review.received` (D448). -
created_atstring, format date-time required
-
-
-
POST
/review-requests/{id}/completeRecord that a review arrivedEmits `review.received`. Reachable from `pending` as well as from `asked` and `reminded`: a review can arrive before we ask, and recording it is what stops the ask going out afterwards. `rating` is written here and never read anywhere that decides whether to ask. Sending it does not and cannot influence who is asked next.
Request body
-
ratinginteger -
bodystring -
sourcestringHow we learned the review arrived. `google_live` means a dashboard Places name-match; it is never a stored Google review body.
Response 200
-
review_requestobjectOne review ask. Created by attendance and by nothing else — there is no operation that makes one.
-
idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required — Same derivation as `Contact.name`. -
appointment_idinteger | null required — The attended appointment that caused this. At most one request per appointment, enforced by a unique index. -
statestring ("pending", "asked", "reminded", "completed", "declined", "retired", "unreachable") required — There is no transition back to `asked`, which is what caps a request at one ask plus one reminder. `retired` is the 90-day family window closing over an unan... -
platformstring required — Where they are sent to write it. -
review_urlstring | null required — Copied from the account at creation. The write-a-review URL the ask will send, never one Flywheel hosts. -
ratinginteger | null required — Written when a review is recorded and never read back. Nothing that decides who gets asked can see it, which is what makes gating impossible rather than mere... -
bodystring | null required -
asked_atstring | null, format date-time required -
reminded_atstring | null, format date-time required — The one reminder. There is no second. -
completed_atstring | null, format date-time required -
received_atstring | null, format date-time required — When the review was recorded. Same instant as `completed_at` on `complete`. The event clock for `review.received` (D448). -
created_atstring, format date-time required
-
-
-
POST
/review-requests/{id}/declineRecord that they will not be leaving oneTerminal. The 90-day family retirement window counts a decline exactly like a completed review: someone who said no is left alone just as long as someone who wrote one.
No request body.
Response 200
-
review_requestobjectOne review ask. Created by attendance and by nothing else — there is no operation that makes one.
-
idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required — Same derivation as `Contact.name`. -
appointment_idinteger | null required — The attended appointment that caused this. At most one request per appointment, enforced by a unique index. -
statestring ("pending", "asked", "reminded", "completed", "declined", "retired", "unreachable") required — There is no transition back to `asked`, which is what caps a request at one ask plus one reminder. `retired` is the 90-day family window closing over an unan... -
platformstring required — Where they are sent to write it. -
review_urlstring | null required — Copied from the account at creation. The write-a-review URL the ask will send, never one Flywheel hosts. -
ratinginteger | null required — Written when a review is recorded and never read back. Nothing that decides who gets asked can see it, which is what makes gating impossible rather than mere... -
bodystring | null required -
asked_atstring | null, format date-time required -
reminded_atstring | null, format date-time required — The one reminder. There is no second. -
completed_atstring | null, format date-time required -
received_atstring | null, format date-time required — When the review was recorded. Same instant as `completed_at` on `complete`. The event clock for `review.received` (D448). -
created_atstring, format date-time required
-
-
-
GET
/accounts/{account_id}/testimonialsList first-party quotesParameters
-
contact_idstring -
consent_statestring ("pending", "granted", "declined") -
granted_sincestring, format date-timeISO 8601. Returns testimonials whose YES was recorded at or after this instant, inclusive. Windows on `granted_at`, not `created_at` or `asked_at`. When either granted bound is present the list is ordered by `granted_at` ascending (then `id`) (D470). Without either bound the list is newest-created first. A value that cannot be parsed is refused with `validation_failed` rather than ignored.
-
granted_beforestring, format date-timeISO 8601. Returns testimonials whose YES was recorded before this instant, exclusive. Adjacent `[granted_since, granted_before)` slices therefore partition (D362). Either granted bound orders the list by `granted_at` ascending. Refused, not ignored, when the value cannot be parsed.
-
pageinteger -
per_pageinteger
Response 200
-
testimonialsarray of object-
idinteger required -
contact_idstring required -
contact_namestring required -
appointment_idinteger | null required -
quotestring required -
reviewer_namestring | null required -
consent_statestring ("pending", "granted", "declined") required -
asked_atstring | null, format date-time required -
granted_atstring | null, format date-time required -
declined_atstring | null, format date-time required -
created_atstring, format date-time required
-
-
-
POST
/accounts/{account_id}/testimonialsAsk to publish a quote on the subscriber's siteCoach-triggered. Not a Google review ask and not a way to pick who gets a public-review link. Sends `review.testimonial_ask`; YES grants consent and emits `testimonial.granted`.
Request body
-
testimonialobject required-
contact_idstring — Contact slug. -
quotestring -
reviewer_namestring -
appointment_idinteger
-
Response 201
-
testimonialobjectA first-party quote awaiting or granted site-publish consent.
-
idinteger required -
contact_idstring required -
contact_namestring required -
appointment_idinteger | null required -
quotestring required -
reviewer_namestring | null required -
consent_statestring ("pending", "granted", "declined") required -
asked_atstring | null, format date-time required -
granted_atstring | null, format date-time required -
declined_atstring | null, format date-time required -
created_atstring, format date-time required
-
-
Re-engagement
Follow-ups Flywheel raises when a contact's booking history says they need attention — a first visit with nothing after it, a customer past their usual rhythm, a cancellation with no rebook — and the per-segment opt-in to sending them automatically.
-
GET
/accounts/{account_id}/follow-upsList re-engagement follow-ups, oldest first**There is no create operation.** Flywheel raises a follow-up when a contact's booking history puts them in a segment worth chasing: `first_time` (one visit, nothing booked after it), `lapsed` (past twice their usual gap between visits, or past the account's `lapsed_after_days` when they have no usual gap yet), or `canceled` (latest appointment canceled, nothing rebooked). No-shows are not a segment — no-show recovery already follows up. At most one follow-up per contact,...
Parameters
-
statestring ("open", "queued", "sent", "dismissed", "resolved")`open` waits for a decision. `queued` was sent into quiet hours or a pause and goes out when it can. `sent`, `dismissed`, and `resolved` are terminal; `resolved` means the contact booked again or left the segment before anyone followed up.
-
segmentstring ("first_time", "lapsed", "canceled")Why a contact was raised. Each has its own template, `reengage.<segment>`.
-
contact_idstringLimits to one contact, by contact id (slug).
-
pageinteger -
per_pageinteger
Response 200
-
follow_upsarray of object-
idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required — Same derivation as `Contact.name`. -
segmentstring ("first_time", "lapsed", "canceled") required — Why a contact was raised. Each has its own template, `reengage.<segment>`. -
statestring ("open", "queued", "sent", "dismissed", "resolved") required — `open` waits for a decision. `queued` was sent into quiet hours or a pause and goes out when it can. `sent`, `dismissed`, and `resolved` are terminal; `resol... -
template_keystring required — The template a send uses. -
trigger_appointment_idinteger required — The appointment the segment was judged from — the last visit, or the canceled one. -
sent_viastring | null ("manual", "auto", nil) required — `manual` when someone pressed send, `auto` when the account's rule sent it. -
last_errorstring | null required — Why the last attempt did not send: the Sender's reason (`no_consent`, `opted_out`, `no_phone`, …), `delivery_failed`, or `approved_copy_changed` when auto-se... -
send_attemptsinteger required -
insightsobject required — Their booking history, summarised. Derived from appointments; never written directly. A contact nobody has booked reads as `lead` with an empty history. -
queued_atstring | null, format date-time required -
sent_atstring | null, format date-time required -
dismissed_atstring | null, format date-time required -
resolved_atstring | null, format date-time required -
created_atstring, format date-time required
-
-
-
POST
/follow-ups/{id}/sendSend the follow-up's templated messageSends the segment's template (`reengage.first_time`, `reengage.lapsed`, `reengage.canceled`) through the same guards as every automated message: opt-out, SMS consent (these are promotional), the human-takeover pause, and recipient-local quiet hours. A send held by quiet hours or a pause is `queued` and goes out when it can. If they have booked since, it is `resolved` instead of sent.
No request body.
Response 200
-
follow_upobjectA prompt to get back in touch with one contact. Raised by Flywheel from their history; there is no operation that creates one.
-
idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required — Same derivation as `Contact.name`. -
segmentstring ("first_time", "lapsed", "canceled") required — Why a contact was raised. Each has its own template, `reengage.<segment>`. -
statestring ("open", "queued", "sent", "dismissed", "resolved") required — `open` waits for a decision. `queued` was sent into quiet hours or a pause and goes out when it can. `sent`, `dismissed`, and `resolved` are terminal; `resol... -
template_keystring required — The template a send uses. -
trigger_appointment_idinteger required — The appointment the segment was judged from — the last visit, or the canceled one. -
sent_viastring | null ("manual", "auto", nil) required — `manual` when someone pressed send, `auto` when the account's rule sent it. -
last_errorstring | null required — Why the last attempt did not send: the Sender's reason (`no_consent`, `opted_out`, `no_phone`, …), `delivery_failed`, or `approved_copy_changed` when auto-se... -
send_attemptsinteger required -
insightsobject required — Their booking history, summarised. Derived from appointments; never written directly. A contact nobody has booked reads as `lead` with an empty history. -
queued_atstring | null, format date-time required -
sent_atstring | null, format date-time required -
dismissed_atstring | null, format date-time required -
resolved_atstring | null, format date-time required -
created_atstring, format date-time required
-
-
-
POST
/follow-ups/{id}/dismissDecide not to follow upTerminal. The same lapse or cancellation is never raised again; a later one is.
No request body.
Response 200
-
follow_upobjectA prompt to get back in touch with one contact. Raised by Flywheel from their history; there is no operation that creates one.
-
idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required — Same derivation as `Contact.name`. -
segmentstring ("first_time", "lapsed", "canceled") required — Why a contact was raised. Each has its own template, `reengage.<segment>`. -
statestring ("open", "queued", "sent", "dismissed", "resolved") required — `open` waits for a decision. `queued` was sent into quiet hours or a pause and goes out when it can. `sent`, `dismissed`, and `resolved` are terminal; `resol... -
template_keystring required — The template a send uses. -
trigger_appointment_idinteger required — The appointment the segment was judged from — the last visit, or the canceled one. -
sent_viastring | null ("manual", "auto", nil) required — `manual` when someone pressed send, `auto` when the account's rule sent it. -
last_errorstring | null required — Why the last attempt did not send: the Sender's reason (`no_consent`, `opted_out`, `no_phone`, …), `delivery_failed`, or `approved_copy_changed` when auto-se... -
send_attemptsinteger required -
insightsobject required — Their booking history, summarised. Derived from appointments; never written directly. A contact nobody has booked reads as `lead` with an empty history. -
queued_atstring | null, format date-time required -
sent_atstring | null, format date-time required -
dismissed_atstring | null, format date-time required -
resolved_atstring | null, format date-time required -
created_atstring, format date-time required
-
-
-
GET
/accounts/{account_id}/reengagement-rulesList the per-segment automatic-send settingsAlways one row per segment. A segment nobody has touched is off.
Response 200
-
reengagement_rulesarray of object-
segmentstring ("first_time", "lapsed", "canceled") required — Why a contact was raised. Each has its own template, `reengage.<segment>`. -
template_keystring required -
auto_sendboolean required -
current_bodystring | null required — The template as it reads now, placeholders included. Slot times are filled in at send time. -
approved_bodystring | null required — The copy that was approved, exactly. -
approved_atstring | null, format date-time required -
approved_copy_currentboolean required — The approved copy still matches the template. -
auto_send_readyboolean required — `auto_send` is on and the approved copy is current — new follow-ups will send without anyone pressing send.
-
-
-
PATCH
/accounts/{account_id}/reengagement-rules/{id}Turn automatic follow-ups on or off, or approve the copyAutomatic sends need both `auto_send: true` and an approval. `approve: true` records the segment's template exactly as it reads now. If the template changes afterwards, automatic sends stop and new follow-ups wait in the list (with `last_error: approved_copy_changed`) until the new copy is approved.
Request body
-
reengagement_ruleobject required-
auto_sendboolean -
approveboolean — `true` approves the template as it reads right now.
-
Response 200
-
reengagement_ruleobjectWhether one segment's follow-ups send by themselves. Off until both switched on and approved.
-
segmentstring ("first_time", "lapsed", "canceled") required — Why a contact was raised. Each has its own template, `reengage.<segment>`. -
template_keystring required -
auto_sendboolean required -
current_bodystring | null required — The template as it reads now, placeholders included. Slot times are filled in at send time. -
approved_bodystring | null required — The copy that was approved, exactly. -
approved_atstring | null, format date-time required -
approved_copy_currentboolean required — The approved copy still matches the template. -
auto_send_readyboolean required — `auto_send` is on and the approved copy is current — new follow-ups will send without anyone pressing send.
-
-
Content
Interaction captures, draft suggestions packaged from them, and consent checking before publish.
-
GET
/accounts/{account_id}/interaction-capturesList interaction captures, newest firstRaw notes about what happened with a contact — the input to later content drafts. Filter with `contact_id` (slug) or `appointment_id`.
Parameters
-
contact_idstringContact slug.
-
appointment_idintegerAppointment id.
-
pageinteger -
per_pageinteger
Response 200
-
interaction_capturesarray of object-
idinteger required -
contact_idstring required — Contact slug. -
appointment_idinteger | null required — Optional appointment this capture is about. -
bodystring required — Freeform notes. Flywheel does not interpret them. -
media_refsarray of string required — Opaque refs the subscriber owns (URLs, storage keys). Flywheel does not fetch or host them. -
external_refstring | null required — Your own idempotency key. Unique per account when set. -
sourcestring | null required — Opaque label from you (`recap`, `manual`, …). Not interpreted. -
occurred_atstring, format date-time required — When the interaction happened. Defaults to creation time. -
created_atstring, format date-time required -
updated_atstring, format date-time required
-
-
-
POST
/accounts/{account_id}/interaction-capturesStore what happened with a contactVertical-agnostic on purpose. A plumber's job note and a music teacher's lesson recap are the same shape: freeform `body`, optional `media_refs` the subscriber owns, optional link to an appointment. Flywheel does not interpret the text and does not host the media. `external_ref` is your idempotency key — unique per account when set. Consent for publishing is still on the contact; run `checkContent` before you publish anything that names them.
Request body
-
interaction_captureobject required-
contact_idstring — Contact slug. Required on create; ignored on update. -
appointment_idinteger | null — Optional. Must belong to the same account and contact. -
bodystring -
media_refsarray of string -
external_refstring -
sourcestring -
occurred_atstring, format date-time
-
Response 201
-
interaction_captureobjectWhat happened with a contact — freeform notes and optional media refs for later content drafts. Vertical-agnostic.
-
idinteger required -
contact_idstring required — Contact slug. -
appointment_idinteger | null required — Optional appointment this capture is about. -
bodystring required — Freeform notes. Flywheel does not interpret them. -
media_refsarray of string required — Opaque refs the subscriber owns (URLs, storage keys). Flywheel does not fetch or host them. -
external_refstring | null required — Your own idempotency key. Unique per account when set. -
sourcestring | null required — Opaque label from you (`recap`, `manual`, …). Not interpreted. -
occurred_atstring, format date-time required — When the interaction happened. Defaults to creation time. -
created_atstring, format date-time required -
updated_atstring, format date-time required
-
-
-
GET
/accounts/{account_id}/interaction-captures/{id}Fetch an interaction captureResponse 200
-
interaction_captureobjectWhat happened with a contact — freeform notes and optional media refs for later content drafts. Vertical-agnostic.
-
idinteger required -
contact_idstring required — Contact slug. -
appointment_idinteger | null required — Optional appointment this capture is about. -
bodystring required — Freeform notes. Flywheel does not interpret them. -
media_refsarray of string required — Opaque refs the subscriber owns (URLs, storage keys). Flywheel does not fetch or host them. -
external_refstring | null required — Your own idempotency key. Unique per account when set. -
sourcestring | null required — Opaque label from you (`recap`, `manual`, …). Not interpreted. -
occurred_atstring, format date-time required — When the interaction happened. Defaults to creation time. -
created_atstring, format date-time required -
updated_atstring, format date-time required
-
-
-
PATCH
/accounts/{account_id}/interaction-captures/{id}Update an interaction captureRequest body
-
interaction_captureobject required-
contact_idstring — Contact slug. Required on create; ignored on update. -
appointment_idinteger | null — Optional. Must belong to the same account and contact. -
bodystring -
media_refsarray of string -
external_refstring -
sourcestring -
occurred_atstring, format date-time
-
Response 200
-
interaction_captureobjectWhat happened with a contact — freeform notes and optional media refs for later content drafts. Vertical-agnostic.
-
idinteger required -
contact_idstring required — Contact slug. -
appointment_idinteger | null required — Optional appointment this capture is about. -
bodystring required — Freeform notes. Flywheel does not interpret them. -
media_refsarray of string required — Opaque refs the subscriber owns (URLs, storage keys). Flywheel does not fetch or host them. -
external_refstring | null required — Your own idempotency key. Unique per account when set. -
sourcestring | null required — Opaque label from you (`recap`, `manual`, …). Not interpreted. -
occurred_atstring, format date-time required — When the interaction happened. Defaults to creation time. -
created_atstring, format date-time required -
updated_atstring, format date-time required
-
-
-
DELETE
/accounts/{account_id}/interaction-captures/{id}Delete an interaction captureResponse 204
No JSON body (or empty success).
-
GET
/accounts/{account_id}/draft-suggestionsList draft suggestions, newest firstParameters
-
contact_idstringContact slug.
-
interaction_capture_idintegerInteraction capture id.
-
testimonial_idintegerGranted-quote id this draft was assembled from.
-
pageinteger -
per_pageinteger
Response 200
-
draft_suggestionsarray of object-
idinteger required -
interaction_capture_idinteger | null required — Set when assembled from a job note. Null when the source is a quote. -
testimonial_idinteger | null required — Set when assembled from a granted quote. Null when the source is a capture. -
contact_idstring required — Contact slug. -
contact_namestring required — Display name so a list does not need a second request. -
titlestring required -
bodystring required -
statusstring ("ready", "edited") required — `ready` from the assembler; `edited` after a subscriber PATCH. -
media_refsarray of string required — Copied from the capture (or last edit). Opaque; Flywheel does not host them. -
allowedboolean required — True only when `violations` is empty at last check. -
violationsarray of object required — Snapshot from ConsentGuard. A blocked draft is still stored so you can edit it. -
source_kindstring ("job note", "customer quote") required — What the piece was written from. -
source_textstring | null required — The source material verbatim, so an editor can judge the piece against what it claims to be grounded in. Null if the source row was deleted. -
created_atstring, format date-time required -
updated_atstring, format date-time required
-
-
-
POST
/accounts/{account_id}/draft-suggestionsWrite a content piece from a capture or a granted quoteWrites a short post — title and body in the business's voice — from the source material. Pass exactly one of `interaction_capture_id` or `testimonial_id` (granted quotes only). The writing model is grounded (only facts the source states; a URL the source never wrote kills the output) and the subject's consent tier travels into the prompt; without a model the source text is packaged mechanically instead, so a draft always comes back (D483). Consent is always checked on the ...
Request body
-
draft_suggestionobject required-
interaction_capture_idinteger — Create-only. Exactly one of this or `testimonial_id`. Ignored on update. -
testimonial_idinteger — Create-only. Granted quotes only. Exactly one of this or `interaction_capture_id`. Ignored on update. -
titlestring — On update: replaces the assembled title and re-checks consent. -
bodystring — On update: replaces the assembled body and re-checks consent. -
media_refsarray of string — On update: replaces media refs; presence of any ref is treated as has_media for consent. -
identifying_termsarray of string — Location/school/neighbourhood terms for the re-identification guard. Not stored.
-
Response 201
-
draft_suggestionobjectA content piece written from a capture or granted quote and consent-checked. Not a publish.
-
idinteger required -
interaction_capture_idinteger | null required — Set when assembled from a job note. Null when the source is a quote. -
testimonial_idinteger | null required — Set when assembled from a granted quote. Null when the source is a capture. -
contact_idstring required — Contact slug. -
contact_namestring required — Display name so a list does not need a second request. -
titlestring required -
bodystring required -
statusstring ("ready", "edited") required — `ready` from the assembler; `edited` after a subscriber PATCH. -
media_refsarray of string required — Copied from the capture (or last edit). Opaque; Flywheel does not host them. -
allowedboolean required — True only when `violations` is empty at last check. -
violationsarray of object required — Snapshot from ConsentGuard. A blocked draft is still stored so you can edit it. -
source_kindstring ("job note", "customer quote") required — What the piece was written from. -
source_textstring | null required — The source material verbatim, so an editor can judge the piece against what it claims to be grounded in. Null if the source row was deleted. -
created_atstring, format date-time required -
updated_atstring, format date-time required
-
-
-
POST
/accounts/{account_id}/draft-suggestions/{id}/rewriteWrite a fresh piece from the same source materialReplaces the draft's title and body with a newly written piece from its original capture or quote — same grounding and consent rules as create. Status returns to `ready` and consent is re-checked. Refused (422, `rewrite_failed`) when the writing model is unavailable; the existing text is left untouched.
No request body.
Response 200
-
draft_suggestionobjectA content piece written from a capture or granted quote and consent-checked. Not a publish.
-
idinteger required -
interaction_capture_idinteger | null required — Set when assembled from a job note. Null when the source is a quote. -
testimonial_idinteger | null required — Set when assembled from a granted quote. Null when the source is a capture. -
contact_idstring required — Contact slug. -
contact_namestring required — Display name so a list does not need a second request. -
titlestring required -
bodystring required -
statusstring ("ready", "edited") required — `ready` from the assembler; `edited` after a subscriber PATCH. -
media_refsarray of string required — Copied from the capture (or last edit). Opaque; Flywheel does not host them. -
allowedboolean required — True only when `violations` is empty at last check. -
violationsarray of object required — Snapshot from ConsentGuard. A blocked draft is still stored so you can edit it. -
source_kindstring ("job note", "customer quote") required — What the piece was written from. -
source_textstring | null required — The source material verbatim, so an editor can judge the piece against what it claims to be grounded in. Null if the source row was deleted. -
created_atstring, format date-time required -
updated_atstring, format date-time required
-
-
-
GET
/accounts/{account_id}/draft-suggestions/{id}Fetch a draft suggestionResponse 200
-
draft_suggestionobjectA content piece written from a capture or granted quote and consent-checked. Not a publish.
-
idinteger required -
interaction_capture_idinteger | null required — Set when assembled from a job note. Null when the source is a quote. -
testimonial_idinteger | null required — Set when assembled from a granted quote. Null when the source is a capture. -
contact_idstring required — Contact slug. -
contact_namestring required — Display name so a list does not need a second request. -
titlestring required -
bodystring required -
statusstring ("ready", "edited") required — `ready` from the assembler; `edited` after a subscriber PATCH. -
media_refsarray of string required — Copied from the capture (or last edit). Opaque; Flywheel does not host them. -
allowedboolean required — True only when `violations` is empty at last check. -
violationsarray of object required — Snapshot from ConsentGuard. A blocked draft is still stored so you can edit it. -
source_kindstring ("job note", "customer quote") required — What the piece was written from. -
source_textstring | null required — The source material verbatim, so an editor can judge the piece against what it claims to be grounded in. Null if the source row was deleted. -
created_atstring, format date-time required -
updated_atstring, format date-time required
-
-
-
PATCH
/accounts/{account_id}/draft-suggestions/{id}Edit a draft suggestion and re-check consentRe-runs the consent guard on the new title/body. Status becomes `edited`.
Request body
-
draft_suggestionobject required-
interaction_capture_idinteger — Create-only. Exactly one of this or `testimonial_id`. Ignored on update. -
testimonial_idinteger — Create-only. Granted quotes only. Exactly one of this or `interaction_capture_id`. Ignored on update. -
titlestring — On update: replaces the assembled title and re-checks consent. -
bodystring — On update: replaces the assembled body and re-checks consent. -
media_refsarray of string — On update: replaces media refs; presence of any ref is treated as has_media for consent. -
identifying_termsarray of string — Location/school/neighbourhood terms for the re-identification guard. Not stored.
-
Response 200
-
draft_suggestionobjectA content piece written from a capture or granted quote and consent-checked. Not a publish.
-
idinteger required -
interaction_capture_idinteger | null required — Set when assembled from a job note. Null when the source is a quote. -
testimonial_idinteger | null required — Set when assembled from a granted quote. Null when the source is a capture. -
contact_idstring required — Contact slug. -
contact_namestring required — Display name so a list does not need a second request. -
titlestring required -
bodystring required -
statusstring ("ready", "edited") required — `ready` from the assembler; `edited` after a subscriber PATCH. -
media_refsarray of string required — Copied from the capture (or last edit). Opaque; Flywheel does not host them. -
allowedboolean required — True only when `violations` is empty at last check. -
violationsarray of object required — Snapshot from ConsentGuard. A blocked draft is still stored so you can edit it. -
source_kindstring ("job note", "customer quote") required — What the piece was written from. -
source_textstring | null required — The source material verbatim, so an editor can judge the piece against what it claims to be grounded in. Null if the source row was deleted. -
created_atstring, format date-time required -
updated_atstring, format date-time required
-
-
-
DELETE
/accounts/{account_id}/draft-suggestions/{id}Delete a draft suggestionResponse 204
No JSON body (or empty success).
-
POST
/accounts/{account_id}/content-checkCheck a draft against its subjects' consentA POST because you submit a draft, not because anything is stored — **nothing is stored**. No row is written, no consent state changes, and calling this twice is identical to calling it once. Subjects are named by contact slug, and the consent tier comes from Flywheel's record of the person rather than from whatever the caller believes it to be. That is the point: a subscriber cannot talk itself into publishing by passing an optimistic tier. A slug that does not resolve i...
Request body
-
titlestring -
bodystring -
contact_idsarray of stringSlugs of the contacts the draft references. An unknown slug is a 404, not a dropped subject.
-
has_mediabooleanTrue when the draft carries a photo or video of the subjects. Requires `photo_video` consent from each of them.
-
identifying_termsarray of stringLocation, school, or neighbourhood terms that should count toward the re-identification limit if they appear in the text. Flywheel cannot know yours, so it will not find them unless you name them.
-
max_consentobjectContact slug to a tier ceiling for this one check. The stricter of your ceiling and the tier on file wins, so this can only ever remove permission — there is no way to assert consent you do not have. Use it when you know something we do not yet, such as a withdrawal that has not reached us.
Response 200
-
allowedboolean requiredTrue only when `violations` is empty.
-
violationsarray of object required-
subject_idstring | null required — Slug of the contact this violation is about. -
typestring ("opted_out", "name_beyond_tier", "media_beyond_tier", "re_identification") required — Branch on this. `message` is for a human reviewing the draft. -
messagestring required
-
-
Learning
Escalate-then-human-reply episodes. Capture is automatic; applying a schema change is not.
-
GET
/accounts/{account_id}/learning-episodesList learning episodes, newest firstParameters
-
statusstring ("unreviewed", "individual", "accepted", "rejected")unreviewed, individual, accepted, or rejected.
-
conversation_idintegerInteger conversation id.
-
pageinteger -
per_pageinteger
Response 200
-
learning_episodesarray of object-
idinteger required -
conversation_idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required -
escalate_reasonstring required -
flagged_atstring, format date-time required -
inbound_bodiesarray of string required — The unclear inbound streak that raised the flag. -
flow_keystring | null required — Flow key at escalate. Null when the thread had no ladder. -
step_positioninteger | null required -
step_template_keystring | null required -
step_expectsstring | null required -
step_writes_tostring | null required -
step_choicesarray of object required — Snapshot of the step's choices. Empty when the rung was not a choice. -
human_bodystring required — What the owner sent. -
statusstring ("unreviewed", "individual", "accepted", "rejected") required -
proposalobject required — Suggested or edited schema diff. Applied only on accept. -
individual_fieldstring | null required — Set when triaged individual. -
individual_valuestring | null required -
reviewed_atstring | null, format date-time required -
created_atstring, format date-time required -
updated_atstring, format date-time required
-
-
-
GET
/accounts/{account_id}/learning-episodes/{id}Fetch a learning episodeResponse 200
-
learning_episodeobjectA needs_human escalate followed by a human reply. Unreviewed until the owner triages.
-
idinteger required -
conversation_idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required -
escalate_reasonstring required -
flagged_atstring, format date-time required -
inbound_bodiesarray of string required — The unclear inbound streak that raised the flag. -
flow_keystring | null required — Flow key at escalate. Null when the thread had no ladder. -
step_positioninteger | null required -
step_template_keystring | null required -
step_expectsstring | null required -
step_writes_tostring | null required -
step_choicesarray of object required — Snapshot of the step's choices. Empty when the rung was not a choice. -
human_bodystring required — What the owner sent. -
statusstring ("unreviewed", "individual", "accepted", "rejected") required -
proposalobject required — Suggested or edited schema diff. Applied only on accept. -
individual_fieldstring | null required — Set when triaged individual. -
individual_valuestring | null required -
reviewed_atstring | null, format date-time required -
created_atstring, format date-time required -
updated_atstring, format date-time required
-
-
-
PATCH
/accounts/{account_id}/learning-episodes/{id}Edit the stored proposal without applying itRequest body
-
learning_episodeobject requiredProposal edits. Accept merges these over the stored proposal, then applies.
-
proposalobject
-
Response 200
-
learning_episodeobjectA needs_human escalate followed by a human reply. Unreviewed until the owner triages.
-
idinteger required -
conversation_idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required -
escalate_reasonstring required -
flagged_atstring, format date-time required -
inbound_bodiesarray of string required — The unclear inbound streak that raised the flag. -
flow_keystring | null required — Flow key at escalate. Null when the thread had no ladder. -
step_positioninteger | null required -
step_template_keystring | null required -
step_expectsstring | null required -
step_writes_tostring | null required -
step_choicesarray of object required — Snapshot of the step's choices. Empty when the rung was not a choice. -
human_bodystring required — What the owner sent. -
statusstring ("unreviewed", "individual", "accepted", "rejected") required -
proposalobject required — Suggested or edited schema diff. Applied only on accept. -
individual_fieldstring | null required — Set when triaged individual. -
individual_valuestring | null required -
reviewed_atstring | null, format date-time required -
created_atstring, format date-time required -
updated_atstring, format date-time required
-
-
-
POST
/accounts/{account_id}/learning-episodes/{id}/individualStore the lesson on this contact onlyWrites a custom field on the contact. Does not change the account's flow, templates, or any other lead (D420).
Request body
-
learning_episodeobject required-
fieldstring required — custom_fields key. Letters, numbers, underscore. -
valuestring
-
Response 200
-
learning_episodeobjectA needs_human escalate followed by a human reply. Unreviewed until the owner triages.
-
idinteger required -
conversation_idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required -
escalate_reasonstring required -
flagged_atstring, format date-time required -
inbound_bodiesarray of string required — The unclear inbound streak that raised the flag. -
flow_keystring | null required — Flow key at escalate. Null when the thread had no ladder. -
step_positioninteger | null required -
step_template_keystring | null required -
step_expectsstring | null required -
step_writes_tostring | null required -
step_choicesarray of object required — Snapshot of the step's choices. Empty when the rung was not a choice. -
human_bodystring required — What the owner sent. -
statusstring ("unreviewed", "individual", "accepted", "rejected") required -
proposalobject required — Suggested or edited schema diff. Applied only on accept. -
individual_fieldstring | null required — Set when triaged individual. -
individual_valuestring | null required -
reviewed_atstring | null, format date-time required -
created_atstring, format date-time required -
updated_atstring, format date-time required
-
-
-
POST
/accounts/{account_id}/learning-episodes/{id}/acceptApply the proposal to this account's flow or templatesForks a shipped default into the account if needed, applies the synonym / template tweak / new rung, and publishes. Never writes a global or vertical seed (D420).
Request body
-
learning_episodeobject requiredProposal edits. Accept merges these over the stored proposal, then applies.
-
proposalobject
-
Response 200
-
learning_episodeobjectA needs_human escalate followed by a human reply. Unreviewed until the owner triages.
-
idinteger required -
conversation_idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required -
escalate_reasonstring required -
flagged_atstring, format date-time required -
inbound_bodiesarray of string required — The unclear inbound streak that raised the flag. -
flow_keystring | null required — Flow key at escalate. Null when the thread had no ladder. -
step_positioninteger | null required -
step_template_keystring | null required -
step_expectsstring | null required -
step_writes_tostring | null required -
step_choicesarray of object required — Snapshot of the step's choices. Empty when the rung was not a choice. -
human_bodystring required — What the owner sent. -
statusstring ("unreviewed", "individual", "accepted", "rejected") required -
proposalobject required — Suggested or edited schema diff. Applied only on accept. -
individual_fieldstring | null required — Set when triaged individual. -
individual_valuestring | null required -
reviewed_atstring | null, format date-time required -
created_atstring, format date-time required -
updated_atstring, format date-time required
-
-
-
POST
/accounts/{account_id}/learning-episodes/{id}/rejectDiscard the episode without changing the flowNo request body.
Response 200
-
learning_episodeobjectA needs_human escalate followed by a human reply. Unreviewed until the owner triages.
-
idinteger required -
conversation_idinteger required -
contact_idstring required — Contact slug. -
contact_namestring required -
escalate_reasonstring required -
flagged_atstring, format date-time required -
inbound_bodiesarray of string required — The unclear inbound streak that raised the flag. -
flow_keystring | null required — Flow key at escalate. Null when the thread had no ladder. -
step_positioninteger | null required -
step_template_keystring | null required -
step_expectsstring | null required -
step_writes_tostring | null required -
step_choicesarray of object required — Snapshot of the step's choices. Empty when the rung was not a choice. -
human_bodystring required — What the owner sent. -
statusstring ("unreviewed", "individual", "accepted", "rejected") required -
proposalobject required — Suggested or edited schema diff. Applied only on accept. -
individual_fieldstring | null required — Set when triaged individual. -
individual_valuestring | null required -
reviewed_atstring | null, format date-time required -
created_atstring, format date-time required -
updated_atstring, format date-time required
-
-
Webhooks
Where an account wants to be told about things. Delivery is live: persisted attempts, signed payloads, bounded retries.
-
GET
/accounts/{account_id}/webhook-endpointsList registered endpointsResponse 200
-
webhook_endpointsarray of object-
idinteger required -
urlstring, format uri required -
eventsarray of string ("contact.created", "contact.qualified", "contact.opted_out", "conversation.needs_human", "message.received", "message.failed", "message.answered", "appointment.booked", …) required — Empty means every event except `message.received`. Name that event to receive inbound bodies. Every value is one of the `WebhookEvent` enum — an unrecognised... -
activeboolean required — Disabled automatically after 20 consecutive delivery failures. We email the account when that happens; call `enableWebhookEndpoint` to resume. -
consecutive_failuresinteger required -
disabled_atstring | null, format date-time required — When delivery was switched off. `active` says that it stopped; this says since when, which is how far back you need to reconcile. -
last_delivered_atstring | null, format date-time required -
created_atstring, format date-time required
-
-
-
POST
/accounts/{account_id}/webhook-endpointsRegister an endpointMust be `https`. The signing secret is returned once, here, and is unrecoverable afterward — a lost secret means rotating the endpoint. Payloads are signed HMAC-SHA256 with it. An empty `events` array subscribes to every event except `message.received`. Name that event to receive inbound bodies. Delivery is live. Each attempt POSTs the envelope with `X-Flywheel-Signature` (hex HMAC-SHA256 of the raw body), `X-Flywheel-Event`, and `X-Flywheel-Delivery`. Treat `X-Flywheel-...
Request body
-
webhook_endpointobject required-
urlstring, format uri — Must be https. -
eventsarray of string ("contact.created", "contact.qualified", "contact.opted_out", "conversation.needs_human", "message.received", "message.failed", "message.answered", "appointment.booked", …) — Empty subscribes to everything. Validated on write: an unrecognised event name is rejected with `validation_failed` rather than accepted into an endpoint tha...
-
Response 201
-
webhook_endpointobjectCreate payload. Same fields as WebhookEndpoint, plus the one-time `secret` (D278).
-
idinteger required -
urlstring, format uri required -
eventsarray of string ("contact.created", "contact.qualified", "contact.opted_out", "conversation.needs_human", "message.received", "message.failed", "message.answered", "appointment.booked", …) required — Empty means every event except `message.received`. Name that event to receive inbound bodies. Every value is one of the `WebhookEvent` enum — an unrecognised... -
activeboolean required — Disabled automatically after 20 consecutive delivery failures. We email the account when that happens; call `enableWebhookEndpoint` to resume. -
consecutive_failuresinteger required -
disabled_atstring | null, format date-time required — When delivery was switched off. `active` says that it stopped; this says since when, which is how far back you need to reconcile. -
last_delivered_atstring | null, format date-time required -
created_atstring, format date-time required -
secretstring required — Returned once, at creation. HMAC-SHA256 signing key. Unrecoverable afterward.
-
-
-
PATCH
/accounts/{account_id}/webhook-endpoints/{id}Change what an endpoint hears aboutUse this to subscribe to an event published after you registered. The signing secret is neither returned nor rotated — recreating the endpoint would mint a new one and every delivery still in flight against the old secret would fail verification.
Request body
-
webhook_endpointobject required-
urlstring, format uri -
eventsarray of string ("contact.created", "contact.qualified", "contact.opted_out", "conversation.needs_human", "message.received", "message.failed", "message.answered", "appointment.booked", …) — Replaces the current list. An empty array means every event except `message.received`.
-
Response 200
-
webhook_endpointobject-
idinteger required -
urlstring, format uri required -
eventsarray of string ("contact.created", "contact.qualified", "contact.opted_out", "conversation.needs_human", "message.received", "message.failed", "message.answered", "appointment.booked", …) required — Empty means every event except `message.received`. Name that event to receive inbound bodies. Every value is one of the `WebhookEvent` enum — an unrecognised... -
activeboolean required — Disabled automatically after 20 consecutive delivery failures. We email the account when that happens; call `enableWebhookEndpoint` to resume. -
consecutive_failuresinteger required -
disabled_atstring | null, format date-time required — When delivery was switched off. `active` says that it stopped; this says since when, which is how far back you need to reconcile. -
last_delivered_atstring | null, format date-time required -
created_atstring, format date-time required
-
-
-
DELETE
/accounts/{account_id}/webhook-endpoints/{id}Remove an endpointResponse 204
No JSON body (or empty success).
-
POST
/accounts/{account_id}/webhook-endpoints/{id}/enableResume delivery after repeated failures switched it offTurns the endpoint back on and clears `consecutive_failures` in the same operation. Those two have to move together: an endpoint re-enabled while still holding 20 failures is switched off again by the very next one, and you would see a single event arrive and then silence. Idempotent, and safe on an endpoint that was never disabled — call it while recovering without first checking whether you need to. It does NOT replay what you missed. Flushing a backlog at a receiver t...
No request body.
Response 200
-
webhook_endpointobject-
idinteger required -
urlstring, format uri required -
eventsarray of string ("contact.created", "contact.qualified", "contact.opted_out", "conversation.needs_human", "message.received", "message.failed", "message.answered", "appointment.booked", …) required — Empty means every event except `message.received`. Name that event to receive inbound bodies. Every value is one of the `WebhookEvent` enum — an unrecognised... -
activeboolean required — Disabled automatically after 20 consecutive delivery failures. We email the account when that happens; call `enableWebhookEndpoint` to resume. -
consecutive_failuresinteger required -
disabled_atstring | null, format date-time required — When delivery was switched off. `active` says that it stopped; this says since when, which is how far back you need to reconcile. -
last_delivered_atstring | null, format date-time required -
created_atstring, format date-time required
-
-
-
GET
/accounts/{account_id}/webhook-deliveriesList delivery attempts for this accountThe attempt log: whether we reached your receiver, how many times we tried, the HTTP status and the error. This is what `enableWebhookEndpoint` tells you to read first. `payload` is never on these rows. Event bodies — including `message.received` — stay on the delivery row in Flywheel and expire on their own clocks. Read current state (contacts, appointments, reviews) when you need the thing the event described. Newest first, tiebroken on `id`. An unrecognised `state` is...
Parameters
-
statestring ("pending", "delivered", "failed", "dead")Limits to one attempt state. — `pending` has not been POSTed yet. `failed` will be retried. `dead` exhausted the backoff. `delivered` reached the receiver.
-
webhook_endpoint_idintegerLimits to one endpoint of this account. An id that is not yours is `not_found`, not an empty list.
-
pageinteger -
per_pageinteger
Response 200
-
webhook_deliveriesarray of object-
idinteger required -
webhook_endpoint_idinteger required -
eventstring ("contact.created", "contact.qualified", "contact.opted_out", "conversation.needs_human", "message.received", "message.failed", "message.answered", "appointment.booked", …) required — Every event Flywheel emits. Read from the emitter itself, so this list cannot drift from what actually fires. `message.failed` is a terminal outbound transpo... -
statestring ("pending", "delivered", "failed", "dead") required — `pending` has not been POSTed yet. `failed` will be retried. `dead` exhausted the backoff. `delivered` reached the receiver. -
attemptsinteger required -
response_statusinteger | null required — HTTP status from the receiver. Null if we have not POSTed yet, or the attempt never got a status. -
last_errorstring | null required — Why the last attempt failed. Null on a clean delivery or a row that has not been tried. -
next_attempt_atstring | null, format date-time required — When we will try again. Null once the row is `dead` or `delivered`. -
delivered_atstring | null, format date-time required -
created_atstring, format date-time required -
updated_atstring, format date-time required
-
-
Building a flow
The usual path is one call: post the data points you need in plain words and get back a
reviewable draft — question copy, answer shapes, and storage are written for you. A data
point can carry a plain-word rule: an answer can route to its own ending (“if
commercial, send them to…”), a question can apply only to some leads
(“only ask this if…”), and a rule about what an answer means
(“if it's a large breed”) compiles to a judged route_when condition
evaluated against the captured answer at reply time. Rules that could not compile come back
as warnings, never silently dropped. Try the draft, then publish.
Prefer talking it through? The same authoring is available as a conversation:
POST /accounts/{account_id}/setup-chat/messages sends one owner turn to an
assistant that builds, rewords, and publishes through exactly these endpoints — each reply
names the actions the server actually executed. GET reads the transcript and
where the draft stands; DELETE resets the conversation without touching the
flow it built. Flywheel's own dashboard authors flows through this chat.
{
"setup": {
"data_points": [
"dog breed",
{ "need": "job type",
"rule": "if it's commercial, send them to https://acme.com/commercial" }
],
"close_style": "sms_slots"
}
}
Under the hood a flow is a list of steps, and the step endpoints stay available for
fine-grained edits to the draft. Each step sends a template, expects an answer of a given shape,
writes the captured value somewhere on the contact, and decides where to go next.
choices is an ordered array because texting “2” must select the second
option; a JSON object would not preserve that order and the wrong answer would be captured.
{
"step": {
"position": 1,
"template_key": "intake.problem",
"expects": "choice",
"writes_to": "custom_fields.issue",
"choices": [
{ "value": "leak", "synonyms": ["leaking", "slab leak", "burst", "water everywhere"] },
{ "value": "clog", "synonyms": ["clogged", "backed up", "won't drain"] },
{ "value": "install", "synonyms": ["installation", "new water heater", "replace"] }
],
"branch_on": { "leak": 2, "clog": 3, "install": 3 },
"default_next_position": 4
}
}
Webhooks
Delivery is live
Register an HTTPS endpoint, keep the signing secret returned once at creation, and verify HMAC-SHA256 on each delivery. Endpoints are disabled after 20 consecutive failures so an outage on your side does not build a backlog we later flush at you.
Flywheel pushes outward and never calls back into a subscriber synchronously. Putting someone else's uptime on the critical path of a sub-60-second reply is the one thing that architecture is designed to prevent.
Reading this as an agent?
Prefer /llms.txt and /openapi.json. This HTML page mirrors the OpenAPI shapes for humans; the JSON is the contract. Signup needs no credentials, so you can go from reading to a working account without a human involved.