Skip to content
Flywheel

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.

1. Create an account
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}}'
2. Add a lead and start the conversation
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 except GET /health, POST /accounts, and POST /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, not 404.
Pagination
page (from 1) and per_page (default 50, maximum 200) on list endpoints. Ordered newest first unless noted on the operation.
Errors
Always {"error": {"code", "message", "details"}}. Branch on code; treat message as human-facing and liable to change.
unauthorized forbidden not_found validation_failed bad_request conflict send_failed slot_taken too_soon in_the_past

Endpoints

Request and response fields below come from openapi.json. Prefer that file if you are a machine.

Health

Liveness. Unauthenticated.

  • GET /health Liveness probe

    No authentication required

    Verifies the Flywheel database connection specifically, not the host process.

    Response 200

    • status string
    • service string
    • api_version string
    • sms_mode string ("live", "sandbox")

      This process. Whether it would text real people.

    • worker_sms_mode string ("live", "sandbox", "unknown")

      The worker that drains StartJob. Conformance requires both sandbox (D249).

    • time string, 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 /sessions Exchange an email and password for an API key

    No 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_email string, format email required
    • password string, format password required

    Response 201

    • account object

      The tenant, and the voice. Messages in an operated account still speak as the account, never as its operator.

      • id string required — Opaque slug.
      • name string required
      • display_name string | null required
      • contact_first_name string | null required
      • contact_email string | 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_phone string | null required — E.164. Where account-level SMS notices go, including the needs_human owner pull-in (D419). Required on standalone signup.
      • website_url string | null required
      • brand_color string | null required
      • time_zone string required — IANA name, e.g. America/Chicago.
      • phone_number string | null required — E.164 sending number.
      • vertical string | null required — Drives which shipped defaults this account falls back to.
      • status string ("active", "paused", "canceled") required
      • serves_minors boolean required — Defaults to true: a business that does not declare gets the protective posture.
      • default_content_consent string ("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_hours object required — Minutes past midnight, evaluated in the RECIPIENT's timezone. A blocked message is requeued to the next open window, not dropped.
      • minimum_notice_minutes integer | 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_hours integer | 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_days integer 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_attendances integer required — How many attended appointments a family needs before a review is asked for. 1 (the default) asks after the first.
      • review_ask_retirement_days integer required — Days a family is left alone after a review ask. Default 90. The visit-number ceiling scales with this number.
      • review_url string | 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_id string | 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_location string | null required — Shop address stamped on in_person bookings and included in the confirmation. Unused when meeting_kind is at_customer or virtual.
      • booking_url string | null required — Public page for picking a time. Used when close_style is booking_link.
      • close_style string ("handoff", "sms_slots", "booking_link") required — How a ready lead books. handoff is the default. Changing this rewrites only the qualify close.
      • meeting_kind string ("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_ref string | null required — Your own identifier. Unique across Flywheel when set — two accounts cannot share one.
      • login_email string | 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_reachable boolean 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_alerts boolean 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_coverage string ("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_url string, 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_by string | null required — Slug of the operating account, if any.
      • created_at string, format date-time required
    • api_key object
      • id integer required
      • name string | null required
      • kind string ("account", "partner", "session") required
      • last_four string | null required
      • last_used_at string | null, format date-time required
      • expires_at string | 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_at string, format date-time required
      • token string required — Shown exactly once. Store it now.
  • DELETE /sessions/current Revoke the key presented on this request

    Signs 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 /accounts Sign up

    No 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

    • account object required
      • name string
      • display_name string
      • contact_first_name string
      • contact_email string — Required on standalone signup (D419). Partner-operated create may omit. Where webhook-disable and dead-delivery notices go.
      • contact_phone string — Required on standalone signup (D419). E.164. Where account-level SMS notices go, including the needs_human owner pull-in.
      • owner_sms_alerts boolean — 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_url string
      • time_zone string
      • vertical string
      • external_ref string
      • brand_color string
      • login_email string, 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...
      • password string, 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_minors boolean
      • default_content_consent string ("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_hours object — 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_minutes integer — Minutes before a slot that it may be offered. Defaults to 60. Raise it if the business needs more runway.
      • lapsed_after_days integer — Days after a last visit that a contact with no usual gap between visits yet counts as `lapsed`. Default 60.
      • deemed_attendance_after_hours integer | 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_attendances integer — 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_days integer — 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_url string — 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_id string — 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_location string
      • booking_url string, format uri
      • close_style string ("handoff", "sms_slots", "booking_link")
      • meeting_kind string ("in_person", "at_customer", "virtual")

    Response 201

    • account object

      The tenant, and the voice. Messages in an operated account still speak as the account, never as its operator.

      • id string required — Opaque slug.
      • name string required
      • display_name string | null required
      • contact_first_name string | null required
      • contact_email string | 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_phone string | null required — E.164. Where account-level SMS notices go, including the needs_human owner pull-in (D419). Required on standalone signup.
      • website_url string | null required
      • brand_color string | null required
      • time_zone string required — IANA name, e.g. America/Chicago.
      • phone_number string | null required — E.164 sending number.
      • vertical string | null required — Drives which shipped defaults this account falls back to.
      • status string ("active", "paused", "canceled") required
      • serves_minors boolean required — Defaults to true: a business that does not declare gets the protective posture.
      • default_content_consent string ("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_hours object required — Minutes past midnight, evaluated in the RECIPIENT's timezone. A blocked message is requeued to the next open window, not dropped.
      • minimum_notice_minutes integer | 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_hours integer | 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_days integer 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_attendances integer required — How many attended appointments a family needs before a review is asked for. 1 (the default) asks after the first.
      • review_ask_retirement_days integer required — Days a family is left alone after a review ask. Default 90. The visit-number ceiling scales with this number.
      • review_url string | 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_id string | 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_location string | null required — Shop address stamped on in_person bookings and included in the confirmation. Unused when meeting_kind is at_customer or virtual.
      • booking_url string | null required — Public page for picking a time. Used when close_style is booking_link.
      • close_style string ("handoff", "sms_slots", "booking_link") required — How a ready lead books. handoff is the default. Changing this rewrites only the qualify close.
      • meeting_kind string ("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_ref string | null required — Your own identifier. Unique across Flywheel when set — two accounts cannot share one.
      • login_email string | 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_reachable boolean 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_alerts boolean 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_coverage string ("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_url string, 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_by string | null required — Slug of the operating account, if any.
      • created_at string, format date-time required
    • api_key object
      • id integer required
      • name string | null required
      • kind string ("account", "partner", "session") required
      • last_four string | null required
      • last_used_at string | null, format date-time required
      • expires_at string | 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_at string, format date-time required
      • token string required — Shown exactly once. Store it now.
  • GET /accounts/{account_id} Fetch an account

    Response 200

    • account object

      The tenant, and the voice. Messages in an operated account still speak as the account, never as its operator.

      • id string required — Opaque slug.
      • name string required
      • display_name string | null required
      • contact_first_name string | null required
      • contact_email string | 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_phone string | null required — E.164. Where account-level SMS notices go, including the needs_human owner pull-in (D419). Required on standalone signup.
      • website_url string | null required
      • brand_color string | null required
      • time_zone string required — IANA name, e.g. America/Chicago.
      • phone_number string | null required — E.164 sending number.
      • vertical string | null required — Drives which shipped defaults this account falls back to.
      • status string ("active", "paused", "canceled") required
      • serves_minors boolean required — Defaults to true: a business that does not declare gets the protective posture.
      • default_content_consent string ("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_hours object required — Minutes past midnight, evaluated in the RECIPIENT's timezone. A blocked message is requeued to the next open window, not dropped.
      • minimum_notice_minutes integer | 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_hours integer | 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_days integer 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_attendances integer required — How many attended appointments a family needs before a review is asked for. 1 (the default) asks after the first.
      • review_ask_retirement_days integer required — Days a family is left alone after a review ask. Default 90. The visit-number ceiling scales with this number.
      • review_url string | 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_id string | 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_location string | null required — Shop address stamped on in_person bookings and included in the confirmation. Unused when meeting_kind is at_customer or virtual.
      • booking_url string | null required — Public page for picking a time. Used when close_style is booking_link.
      • close_style string ("handoff", "sms_slots", "booking_link") required — How a ready lead books. handoff is the default. Changing this rewrites only the qualify close.
      • meeting_kind string ("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_ref string | null required — Your own identifier. Unique across Flywheel when set — two accounts cannot share one.
      • login_email string | 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_reachable boolean 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_alerts boolean 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_coverage string ("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_url string, 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_by string | null required — Slug of the operating account, if any.
      • created_at string, format date-time required
  • PATCH /accounts/{account_id} Update an account

    Request body

    • account object required
      • name string
      • display_name string
      • contact_first_name string
      • contact_email string — Required on standalone signup (D419). Partner-operated create may omit. Where webhook-disable and dead-delivery notices go.
      • contact_phone string — Required on standalone signup (D419). E.164. Where account-level SMS notices go, including the needs_human owner pull-in.
      • owner_sms_alerts boolean — 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_url string
      • time_zone string
      • vertical string
      • external_ref string
      • brand_color string
      • login_email string, 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...
      • password string, 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_minors boolean
      • default_content_consent string ("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_hours object — 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_minutes integer — Minutes before a slot that it may be offered. Defaults to 60. Raise it if the business needs more runway.
      • lapsed_after_days integer — Days after a last visit that a contact with no usual gap between visits yet counts as `lapsed`. Default 60.
      • deemed_attendance_after_hours integer | 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_attendances integer — 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_days integer — 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_url string — 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_id string — 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_location string
      • booking_url string, format uri
      • close_style string ("handoff", "sms_slots", "booking_link")
      • meeting_kind string ("in_person", "at_customer", "virtual")

    Response 200

    • account object

      The tenant, and the voice. Messages in an operated account still speak as the account, never as its operator.

      • id string required — Opaque slug.
      • name string required
      • display_name string | null required
      • contact_first_name string | null required
      • contact_email string | 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_phone string | null required — E.164. Where account-level SMS notices go, including the needs_human owner pull-in (D419). Required on standalone signup.
      • website_url string | null required
      • brand_color string | null required
      • time_zone string required — IANA name, e.g. America/Chicago.
      • phone_number string | null required — E.164 sending number.
      • vertical string | null required — Drives which shipped defaults this account falls back to.
      • status string ("active", "paused", "canceled") required
      • serves_minors boolean required — Defaults to true: a business that does not declare gets the protective posture.
      • default_content_consent string ("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_hours object required — Minutes past midnight, evaluated in the RECIPIENT's timezone. A blocked message is requeued to the next open window, not dropped.
      • minimum_notice_minutes integer | 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_hours integer | 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_days integer 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_attendances integer required — How many attended appointments a family needs before a review is asked for. 1 (the default) asks after the first.
      • review_ask_retirement_days integer required — Days a family is left alone after a review ask. Default 90. The visit-number ceiling scales with this number.
      • review_url string | 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_id string | 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_location string | null required — Shop address stamped on in_person bookings and included in the confirmation. Unused when meeting_kind is at_customer or virtual.
      • booking_url string | null required — Public page for picking a time. Used when close_style is booking_link.
      • close_style string ("handoff", "sms_slots", "booking_link") required — How a ready lead books. handoff is the default. Changing this rewrites only the qualify close.
      • meeting_kind string ("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_ref string | null required — Your own identifier. Unique across Flywheel when set — two accounts cannot share one.
      • login_email string | 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_reachable boolean 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_alerts boolean 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_coverage string ("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_url string, 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_by string | null required — Slug of the operating account, if any.
      • created_at string, format date-time required
  • POST /accounts/{account_id}/pause Pause an account

    State pushed onto the account rather than queried at send time, so pausing cannot slow the reply path.

    No request body.

    Response 200

    • account object

      The tenant, and the voice. Messages in an operated account still speak as the account, never as its operator.

      • id string required — Opaque slug.
      • name string required
      • display_name string | null required
      • contact_first_name string | null required
      • contact_email string | 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_phone string | null required — E.164. Where account-level SMS notices go, including the needs_human owner pull-in (D419). Required on standalone signup.
      • website_url string | null required
      • brand_color string | null required
      • time_zone string required — IANA name, e.g. America/Chicago.
      • phone_number string | null required — E.164 sending number.
      • vertical string | null required — Drives which shipped defaults this account falls back to.
      • status string ("active", "paused", "canceled") required
      • serves_minors boolean required — Defaults to true: a business that does not declare gets the protective posture.
      • default_content_consent string ("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_hours object required — Minutes past midnight, evaluated in the RECIPIENT's timezone. A blocked message is requeued to the next open window, not dropped.
      • minimum_notice_minutes integer | 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_hours integer | 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_days integer 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_attendances integer required — How many attended appointments a family needs before a review is asked for. 1 (the default) asks after the first.
      • review_ask_retirement_days integer required — Days a family is left alone after a review ask. Default 90. The visit-number ceiling scales with this number.
      • review_url string | 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_id string | 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_location string | null required — Shop address stamped on in_person bookings and included in the confirmation. Unused when meeting_kind is at_customer or virtual.
      • booking_url string | null required — Public page for picking a time. Used when close_style is booking_link.
      • close_style string ("handoff", "sms_slots", "booking_link") required — How a ready lead books. handoff is the default. Changing this rewrites only the qualify close.
      • meeting_kind string ("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_ref string | null required — Your own identifier. Unique across Flywheel when set — two accounts cannot share one.
      • login_email string | 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_reachable boolean 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_alerts boolean 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_coverage string ("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_url string, 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_by string | null required — Slug of the operating account, if any.
      • created_at string, format date-time required
  • POST /accounts/{account_id}/resume Resume a paused account

    No request body.

    Response 200

    • account object

      The tenant, and the voice. Messages in an operated account still speak as the account, never as its operator.

      • id string required — Opaque slug.
      • name string required
      • display_name string | null required
      • contact_first_name string | null required
      • contact_email string | 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_phone string | null required — E.164. Where account-level SMS notices go, including the needs_human owner pull-in (D419). Required on standalone signup.
      • website_url string | null required
      • brand_color string | null required
      • time_zone string required — IANA name, e.g. America/Chicago.
      • phone_number string | null required — E.164 sending number.
      • vertical string | null required — Drives which shipped defaults this account falls back to.
      • status string ("active", "paused", "canceled") required
      • serves_minors boolean required — Defaults to true: a business that does not declare gets the protective posture.
      • default_content_consent string ("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_hours object required — Minutes past midnight, evaluated in the RECIPIENT's timezone. A blocked message is requeued to the next open window, not dropped.
      • minimum_notice_minutes integer | 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_hours integer | 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_days integer 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_attendances integer required — How many attended appointments a family needs before a review is asked for. 1 (the default) asks after the first.
      • review_ask_retirement_days integer required — Days a family is left alone after a review ask. Default 90. The visit-number ceiling scales with this number.
      • review_url string | 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_id string | 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_location string | null required — Shop address stamped on in_person bookings and included in the confirmation. Unused when meeting_kind is at_customer or virtual.
      • booking_url string | null required — Public page for picking a time. Used when close_style is booking_link.
      • close_style string ("handoff", "sms_slots", "booking_link") required — How a ready lead books. handoff is the default. Changing this rewrites only the qualify close.
      • meeting_kind string ("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_ref string | null required — Your own identifier. Unique across Flywheel when set — two accounts cannot share one.
      • login_email string | 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_reachable boolean 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_alerts boolean 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_coverage string ("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_url string, 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_by string | null required — Slug of the operating account, if any.
      • created_at string, format date-time required
  • GET /accounts/{account_id}/phone-number Fetch the account's sending number

    A singular resource: an account has exactly one sending number in v1.

    Response 200

    • phone_number object

      The account's one sending number. `area_code` is present only in the response to the request that provisioned it.

      • number string required — E.164.
      • provider string required
      • area_code string
      • sms boolean required
      • voice boolean required
      • sandbox boolean 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-number Provision a sending number

    The 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_code string

      Local 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_number object

      The account's one sending number. `area_code` is present only in the response to the request that provisioned it.

      • number string required — E.164.
      • provider string required
      • area_code string
      • sms boolean required
      • voice boolean required
      • sandbox boolean 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-templates List effective templates

    Returns 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_templates array of object
      • id integer required
      • key string required
      • vertical string | null required
      • channel string ("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.
      • body string required — Supports {{variable}} interpolation.
      • delay_minutes integer | null required — For `nurture.` keys, this is the rung's position in the ladder.
      • guards object required — Halt conditions declared next to the copy they protect: `halt_if_replied`, `halt_if_pipeline_state_in`, `require_pipeline_state_in`.
      • promotional boolean required — Promotional copy requires recorded consent and is never sent without it.
      • transactional boolean required — Transactional copy survives a human takeover and quiet hours. Kept separate from `promotional` on purpose — a qualification question is neither.
      • expects_answer string | 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...
      • active boolean required
      • scope string ("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-templates Override a template

    Request body

    • message_template object required
      • key string
      • vertical string
      • channel string ("sms")
      • body string
      • delay_minutes integer
      • active boolean
      • promotional boolean
      • transactional boolean
      • expects_answer string | null ("boolean", nil)
      • guards object

    Response 201

    • message_template object
      • id integer required
      • key string required
      • vertical string | null required
      • channel string ("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.
      • body string required — Supports {{variable}} interpolation.
      • delay_minutes integer | null required — For `nurture.` keys, this is the rung's position in the ladder.
      • guards object required — Halt conditions declared next to the copy they protect: `halt_if_replied`, `halt_if_pipeline_state_in`, `require_pipeline_state_in`.
      • promotional boolean required — Promotional copy requires recorded consent and is never sent without it.
      • transactional boolean required — Transactional copy survives a human takeover and quiet hours. Kept separate from `promotional` on purpose — a qualification question is neither.
      • expects_answer string | 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...
      • active boolean required
      • scope string ("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-overrides Report HELP overrides that dropped a carrier promise

    Account-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_overrides array of object
      • account_id string required
      • account_name string required
      • status string ("incomplete", "inactive") required
      • detail string required
      • body string required
  • PATCH /accounts/{account_id}/message-templates/{id} Update a template override

    Only `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_template object required
      • key string
      • vertical string
      • channel string ("sms")
      • body string
      • delay_minutes integer
      • active boolean
      • promotional boolean
      • transactional boolean
      • expects_answer string | null ("boolean", nil)
      • guards object

    Response 200

    • message_template object
      • id integer required
      • key string required
      • vertical string | null required
      • channel string ("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.
      • body string required — Supports {{variable}} interpolation.
      • delay_minutes integer | null required — For `nurture.` keys, this is the rung's position in the ladder.
      • guards object required — Halt conditions declared next to the copy they protect: `halt_if_replied`, `halt_if_pipeline_state_in`, `require_pipeline_state_in`.
      • promotional boolean required — Promotional copy requires recorded consent and is never sent without it.
      • transactional boolean required — Transactional copy survives a human takeover and quiet hours. Kept separate from `promotional` on purpose — a qualification question is neither.
      • expects_answer string | 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...
      • active boolean required
      • scope string ("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 default

    Only `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-setup Compile plain-word data points into a qualify draft

    One 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

    • setup object required
      • data_points array of any — What you need to know about a lead, in plain words, in the order you want it asked.
      • close_style string ("handoff", "sms_slots", "booking_link") — How a ready lead books. Optional; updates the account setting before the draft is built.

    Response 201

    • flow object
      • id integer required
      • key string required
      • vertical string | null required
      • name string | null required
      • active boolean required
      • published boolean required — Live ladders are `true`. Drafts are editable; only published account flows answer real leads via lookup.
      • scope string ("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_source array 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...
      • steps array of object required
    • warnings array of string

      Rules that could not be applied, in plain words. Empty when every rule compiled.

  • GET /accounts/{account_id}/setup-chat Read the setup-chat conversation and where the draft stands

    The 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_chat object

      One persistent conversation per account with the setup assistant — the conversational authoring surface for the intake (only the most recent 40 turns are kept).

      • transcript array of object required
      • flow any required — Where the intake stands: the open qualify draft when one exists, else the published version, else null.
  • DELETE /accounts/{account_id}/setup-chat Reset the conversation

    Clears 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/messages Say one thing to the setup assistant

    One 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

    • message object required
      • body string — What the owner says, in plain words.

    Response 201

    • setup_chat object

      One persistent conversation per account with the setup assistant — the conversational authoring surface for the intake (only the most recent 40 turns are kept).

      • transcript array of object required
      • flow any required — Where the intake stands: the open qualify draft when one exists, else the published version, else null.
    • reply object
      • say string
      • actions array 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}/flows List flows, with steps

    The 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

    • catalog string ("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

    • flows array of object
      • id integer required
      • key string required
      • vertical string | null required
      • name string | null required
      • active boolean required
      • published boolean required — Live ladders are `true`. Drafts are editable; only published account flows answer real leads via lookup.
      • scope string ("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_source array 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...
      • steps array of object required
  • POST /accounts/{account_id}/flows Create a draft flow

    New flows are unpublished. Live leads keep using the published ladder (or the shipped default) until you publish.

    Request body

    • flow object required
      • key string
      • vertical string
      • name string
      • active boolean

    Response 201

    • flow object
      • id integer required
      • key string required
      • vertical string | null required
      • name string | null required
      • active boolean required
      • published boolean required — Live ladders are `true`. Drafts are editable; only published account flows answer real leads via lookup.
      • scope string ("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_source array 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...
      • steps array of object required
  • PATCH /accounts/{account_id}/flows/{id} Update a flow

    Only `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

    • flow object required
      • key string
      • vertical string
      • name string
      • active boolean

    Response 200

    • flow object
      • id integer required
      • key string required
      • vertical string | null required
      • name string | null required
      • active boolean required
      • published boolean required — Live ladders are `true`. Drafts are editable; only published account flows answer real leads via lookup.
      • scope string ("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_source array 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...
      • steps array of object required
  • DELETE /accounts/{account_id}/flows/{id} Delete a flow

    Only `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}/fork Copy a shipped default into this account as a draft

    Makes 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

    • flow object
      • id integer required
      • key string required
      • vertical string | null required
      • name string | null required
      • active boolean required
      • published boolean required — Live ladders are `true`. Drafts are editable; only published account flows answer real leads via lookup.
      • scope string ("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_source array 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...
      • steps array of object required
  • POST /accounts/{account_id}/flows/{id}/edit Open an editable draft of a published account flow

    Published 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

    • flow object
      • id integer required
      • key string required
      • vertical string | null required
      • name string | null required
      • active boolean required
      • published boolean required — Live ladders are `true`. Drafts are editable; only published account flows answer real leads via lookup.
      • scope string ("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_source array 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...
      • steps array of object required
  • POST /accounts/{account_id}/flows/{id}/use Make a shipped flow the live conversation

    Sets 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

    • flow object
      • id integer required
      • key string required
      • vertical string | null required
      • name string | null required
      • active boolean required
      • published boolean required — Live ladders are `true`. Drafts are editable; only published account flows answer real leads via lookup.
      • scope string ("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_source array 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...
      • steps array of object required
  • POST /accounts/{account_id}/flows/{id}/publish Publish a draft flow

    Promotes 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

    • flow object
      • id integer required
      • key string required
      • vertical string | null required
      • name string | null required
      • active boolean required
      • published boolean required — Live ladders are `true`. Drafts are editable; only published account flows answer real leads via lookup.
      • scope string ("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_source array 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...
      • steps array of object required
  • POST /accounts/{account_id}/flows/{flow_id}/simulations Start a try-it conversation against a flow

    Runs 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

    • simulation object
      • id integer required — Conversation id — use with advanceFlowSimulation.
      • flow_id integer required
      • contact_id string required
      • outcome string required — FlowRunner outcome for the last turn (e.g. started, advanced, reasked).
      • delivery object | 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.
      • messages array of object required
  • POST /accounts/{account_id}/simulations/{id}/messages Send a simulated lead reply

    Advances the pinned flow with the given SMS body. Same classifier and Sender path as production, null gateway only.

    Request body

    • body string required

      What the lead typed.

    Response 200

    • simulation object
      • id integer required — Conversation id — use with advanceFlowSimulation.
      • flow_id integer required
      • contact_id string required
      • outcome string required — FlowRunner outcome for the last turn (e.g. started, advanced, reasked).
      • delivery object | 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.
      • messages array of object required
  • GET /accounts/{account_id}/flows/{flow_id}/steps List the steps of a flow

    Response 200

    • steps array of object
      • id integer required
      • position integer required
      • template_key string | null required
      • expects string | null ("choice", "number", "boolean", "freeform", "confirm", "name", nil) required
      • writes_to string | null required — Contact field the captured answer lands in, e.g. `age` or `custom_fields.issue`.
      • choices array 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_on object required — Captured value to next position.
      • route_when object | 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_position integer | null required — Where to go when no branch matches. Lets branches rejoin somewhere other than the next row.
      • on_confirm_action string | null required
      • terminal boolean required
      • handoff boolean 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}/steps Add 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

    • step object required
      • position integer
      • template_key string
      • expects string ("choice", "number", "boolean", "freeform", "confirm", "name")
      • writes_to string
      • choices array 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_on object
      • default_next_position integer
      • on_confirm_action string
      • terminal boolean

    Response 201

    • step object
      • id integer required
      • position integer required
      • template_key string | null required
      • expects string | null ("choice", "number", "boolean", "freeform", "confirm", "name", nil) required
      • writes_to string | null required — Contact field the captured answer lands in, e.g. `age` or `custom_fields.issue`.
      • choices array 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_on object required — Captured value to next position.
      • route_when object | 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_position integer | null required — Where to go when no branch matches. Lets branches rejoin somewhere other than the next row.
      • on_confirm_action string | null required
      • terminal boolean required
      • handoff boolean 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 step

    Only steps on `scope=account` flows. A shipped default's id answers 422 `not_account_owned`.

    Request body

    • step object required
      • position integer
      • template_key string
      • expects string ("choice", "number", "boolean", "freeform", "confirm", "name")
      • writes_to string
      • choices array 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_on object
      • default_next_position integer
      • on_confirm_action string
      • terminal boolean

    Response 200

    • step object
      • id integer required
      • position integer required
      • template_key string | null required
      • expects string | null ("choice", "number", "boolean", "freeform", "confirm", "name", nil) required
      • writes_to string | null required — Contact field the captured answer lands in, e.g. `age` or `custom_fields.issue`.
      • choices array 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_on object required — Captured value to next position.
      • route_when object | 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_position integer | null required — Where to go when no branch matches. Lets branches rejoin somewhere other than the next row.
      • on_confirm_action string | null required
      • terminal boolean required
      • handoff boolean 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 step

    Response 204

    No JSON body (or empty success).

  • GET /accounts/{account_id}/faqs List the account's FAQ answers, plus the suggested questions not yet answered

    An 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

    • faqs array of object
      • id integer required
      • topic string | null required — Set for the suggested question set (`price`, `hours`, `location`, `service_area`, `cancellation`); null for questions the owner wrote themselves.
      • question string required — What a lead would ask. Used to match inbound texts.
      • answer string required — Sent verbatim as the reply, in the owner's own words.
    • suggested array of object

      Shipped question prompts this account has not answered yet, in display order.

      • topic string
      • question string
    • asked array of object

      Recent lead questions no FAQ answered, newest first, deduped by wording. Each was deferred to a person.

      • question string
      • asked_at string, format date-time
  • POST /accounts/{account_id}/faqs Answer a suggested question, or add your own

    Set `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

    • faq object required
      • topic string | null ("price", "hours", "location", "service_area", "cancellation", nil) — Answer a suggested question by topic, or omit to write your own.
      • question string — Required when `topic` is omitted.
      • answer string

    Response 201

    • faq object

      One 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.

      • id integer required
      • topic string | null required — Set for the suggested question set (`price`, `hours`, `location`, `service_area`, `cancellation`); null for questions the owner wrote themselves.
      • question string required — What a lead would ask. Used to match inbound texts.
      • answer string required — Sent verbatim as the reply, in the owner's own words.
  • POST /accounts/{account_id}/faqs/import Draft FAQ answers from a website or pasted text

    Reads 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

    • import object

      Give one of the two. `text` wins when both are present.

      • url string — A public page. A bare domain is fetched over https.
      • text string — Any pasted text — a price sheet, an about page, a brochure.

    Response 200

    • proposals array of object
      • topic string | null
      • question string
      • answer string
  • PATCH /accounts/{account_id}/faqs/{id} Change an FAQ's answer or wording

    Request body

    • faq object required
      • topic string | null ("price", "hours", "location", "service_area", "cancellation", nil) — Answer a suggested question by topic, or omit to write your own.
      • question string — Required when `topic` is omitted.
      • answer string

    Response 200

    • faq object

      One 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.

      • id integer required
      • topic string | null required — Set for the suggested question set (`price`, `hours`, `location`, `service_area`, `cancellation`); null for questions the owner wrote themselves.
      • question string required — What a lead would ask. Used to match inbound texts.
      • answer string required — Sent verbatim as the reply, in the owner's own words.
  • DELETE /accounts/{account_id}/faqs/{id} Remove an FAQ

    Leads 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}/contacts List contacts

    Parameters

    • pipeline_state string ("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_ref string

      Your identifier for this person. Unique per account when set.

    • phone string

      Exact 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.

    • open string ("true")

      `true` excludes contacts in `won` or `lost`.

    • retention_status string ("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_since string, format date-time

      ISO 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_before string, format date-time

      ISO 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_since string, format date-time

      ISO 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_before string, format date-time

      ISO 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.

    • page integer
    • per_page integer

    Response 200

    • contacts array of object
      • id string required — Opaque slug.
      • name string 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_name string | null required
      • last_name string | null required
      • phone string | 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.
      • email string | null required
      • address string | null required — Where to meet them when the account's meeting_kind is at_customer. Stamped onto a booking at book time.
      • time_zone string | null required
      • age integer | null required
      • age_bucket string | null ("under_13", "13_to_17", "18_plus", nil) required
      • minor boolean required — A minor is never the message recipient; their guardian is.
      • guardian_id string | null required
      • guardian_name string | null required
      • guardian_phone string | null required
      • pipeline_state string ("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_reason string | null required
      • source string | null required
      • sms_consented boolean required
      • content_consent string ("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_at string | 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_fields object required — Whatever your flow steps write. This is where domain-specific answers land, which is why Flywheel needs no domain knowledge.
      • external_ref string | null required — Your identifier for this person. Unique per account when set.
      • last_inbound_at string | null, format date-time required
      • created_at string, format date-time required
      • insights object 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}/contacts Add a lead

    This 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

    • contact object required

      Must 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_name string
      • last_name string
      • phone string — E.164. Unique per account when set.
      • email string
      • address string — Where to meet them. Used when meeting_kind is at_customer; an explicit appointment location still wins.
      • time_zone string — Used for quiet hours. Theirs, not yours.
      • age integer
      • age_bucket string ("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_name string
      • guardian_phone string
      • source string
      • external_ref string — 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_consent string ("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_reason string — 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_consented boolean — 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_fields object
    • start_conversation boolean

      Enqueue the first reply on the high-priority lane. Email-only contacts still open a thread, flagged `unreachable` — Flywheel does not send email.

    Response 200

    • contact object
      • id string required — Opaque slug.
      • name string 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_name string | null required
      • last_name string | null required
      • phone string | 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.
      • email string | null required
      • address string | null required — Where to meet them when the account's meeting_kind is at_customer. Stamped onto a booking at book time.
      • time_zone string | null required
      • age integer | null required
      • age_bucket string | null ("under_13", "13_to_17", "18_plus", nil) required
      • minor boolean required — A minor is never the message recipient; their guardian is.
      • guardian_id string | null required
      • guardian_name string | null required
      • guardian_phone string | null required
      • pipeline_state string ("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_reason string | null required
      • source string | null required
      • sms_consented boolean required
      • content_consent string ("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_at string | 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_fields object required — Whatever your flow steps write. This is where domain-specific answers land, which is why Flywheel needs no domain knowledge.
      • external_ref string | null required — Your identifier for this person. Unique per account when set.
      • last_inbound_at string | null, format date-time required
      • created_at string, format date-time required
      • insights object 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 contact

    Response 200

    • contact object
      • id string required — Opaque slug.
      • name string 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_name string | null required
      • last_name string | null required
      • phone string | 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.
      • email string | null required
      • address string | null required — Where to meet them when the account's meeting_kind is at_customer. Stamped onto a booking at book time.
      • time_zone string | null required
      • age integer | null required
      • age_bucket string | null ("under_13", "13_to_17", "18_plus", nil) required
      • minor boolean required — A minor is never the message recipient; their guardian is.
      • guardian_id string | null required
      • guardian_name string | null required
      • guardian_phone string | null required
      • pipeline_state string ("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_reason string | null required
      • source string | null required
      • sms_consented boolean required
      • content_consent string ("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_at string | 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_fields object required — Whatever your flow steps write. This is where domain-specific answers land, which is why Flywheel needs no domain knowledge.
      • external_ref string | null required — Your identifier for this person. Unique per account when set.
      • last_inbound_at string | null, format date-time required
      • created_at string, format date-time required
      • insights object 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 contact

    Request body

    • contact object required

      Must 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_name string
      • last_name string
      • phone string — E.164. Unique per account when set.
      • email string
      • address string — Where to meet them. Used when meeting_kind is at_customer; an explicit appointment location still wins.
      • time_zone string — Used for quiet hours. Theirs, not yours.
      • age integer
      • age_bucket string ("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_name string
      • guardian_phone string
      • source string
      • external_ref string — 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_consent string ("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_reason string — 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_consented boolean — 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_fields object

    Response 200

    • contact object
      • id string required — Opaque slug.
      • name string 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_name string | null required
      • last_name string | null required
      • phone string | 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.
      • email string | null required
      • address string | null required — Where to meet them when the account's meeting_kind is at_customer. Stamped onto a booking at book time.
      • time_zone string | null required
      • age integer | null required
      • age_bucket string | null ("under_13", "13_to_17", "18_plus", nil) required
      • minor boolean required — A minor is never the message recipient; their guardian is.
      • guardian_id string | null required
      • guardian_name string | null required
      • guardian_phone string | null required
      • pipeline_state string ("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_reason string | null required
      • source string | null required
      • sms_consented boolean required
      • content_consent string ("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_at string | 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_fields object required — Whatever your flow steps write. This is where domain-specific answers land, which is why Flywheel needs no domain knowledge.
      • external_ref string | null required — Your identifier for this person. Unique per account when set.
      • last_inbound_at string | null, format date-time required
      • created_at string, format date-time required
      • insights object 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}/conversations List conversations

    Summaries 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_human string ("true")

      `true` returns only threads the bot has escalated.

    • page integer
    • per_page integer

    Response 200

    • conversations array of object
      • id integer required
      • contact_id string required — Contact slug.
      • contact_name string required — Same derivation as `Contact.name`. Here so that rendering a list of threads is one request rather than one per thread.
      • channel_number string | null required
      • flow_key string | null required
      • automation_paused_until string | null, format date-time required — Set 24 hours out whenever a human sends into the thread.
      • automation_paused boolean 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_human boolean required
      • needs_human_at string | null, format date-time required
      • needs_human_reason string | 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_count integer required
      • last_message_at string | null, format date-time required
      • last_message_direction string | null ("inbound", "outbound", nil) required
      • last_message_preview string | null required — First 140 characters of the most recent message.
      • created_at string, format date-time required
  • POST /accounts/{account_id}/conversations Open a thread with a contact without sending anything

    Use 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

    • conversation object required
      • contact_id string required — Contact id (slug) to open the thread with.

    Response 200

    • conversation object
      • id integer required
      • contact_id string required — Contact slug.
      • contact_name string required — Same derivation as `Contact.name`. Here so that rendering a list of threads is one request rather than one per thread.
      • channel_number string | null required
      • flow_key string | null required
      • automation_paused_until string | null, format date-time required — Set 24 hours out whenever a human sends into the thread.
      • automation_paused boolean 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_human boolean required
      • needs_human_at string | null, format date-time required
      • needs_human_reason string | 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_count integer required
      • last_message_at string | null, format date-time required
      • last_message_direction string | null ("inbound", "outbound", nil) required
      • last_message_preview string | null required — First 140 characters of the most recent message.
      • created_at string, format date-time required
  • GET /accounts/{account_id}/conversations/{id} Fetch a conversation with its full message history

    Always includes the transcript. There is no `include` query parameter; the list endpoint never embeds messages (D277).

    Response 200

    • conversation object

      Show payload. Same fields as Conversation, plus `messages`. List and create omit that key (D277).

      • id integer required
      • contact_id string required — Contact slug.
      • contact_name string required — Same derivation as `Contact.name`. Here so that rendering a list of threads is one request rather than one per thread.
      • channel_number string | null required
      • flow_key string | null required
      • automation_paused_until string | null, format date-time required — Set 24 hours out whenever a human sends into the thread.
      • automation_paused boolean 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_human boolean required
      • needs_human_at string | null, format date-time required
      • needs_human_reason string | 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_count integer required
      • last_message_at string | null, format date-time required
      • last_message_direction string | null ("inbound", "outbound", nil) required
      • last_message_preview string | null required — First 140 characters of the most recent message.
      • created_at string, format date-time required
      • messages array of object required
  • GET /conversations/{conversation_id}/messages List the messages in a thread, oldest first

    Response 200

    • messages array of object
      • id integer required
      • direction string ("inbound", "outbound") required
      • channel string ("sms") required
      • from string | null required
      • to string | null required
      • body string | null required
      • template_key string | null required
      • status string | null required
      • classified_intent string | null required — What the rule-based classifier made of an inbound reply.
      • sent_at string | null, format date-time required
      • created_at string, format date-time required
  • POST /conversations/{conversation_id}/messages Take over the thread

    A 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

    • message object required
      • body string required
      • hand_back boolean — Send without pausing automation on this thread.

    Response 201

    • message object
      • id integer required
      • direction string ("inbound", "outbound") required
      • channel string ("sms") required
      • from string | null required
      • to string | null required
      • body string | null required
      • template_key string | null required
      • status string | null required
      • classified_intent string | null required — What the rule-based classifier made of an inbound reply.
      • sent_at string | null, format date-time required
      • created_at string, format date-time required
  • POST /conversations/{conversation_id}/sends Send one of your own templates

    Your 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

    • send object required
      • template_key string required
      • occurrence integer — Distinguishes repeats of the same template to the same person.
      • variables object — Values for the template's {{placeholders}}.

    Response 201

    • send object

      The outcome of asking us to send a template. `requeued` means held for quiet hours and already scheduled — do not retry it.

      • status string ("sent", "requeued") required
      • message any required
      • scheduled_send_id integer | null required
  • POST /conversations/{id}/resume End a takeover early

    Clears the 24-hour pause and the needs-human flag, so automation picks the thread back up now.

    No request body.

    Response 200

    • conversation object
      • id integer required
      • contact_id string required — Contact slug.
      • contact_name string required — Same derivation as `Contact.name`. Here so that rendering a list of threads is one request rather than one per thread.
      • channel_number string | null required
      • flow_key string | null required
      • automation_paused_until string | null, format date-time required — Set 24 hours out whenever a human sends into the thread.
      • automation_paused boolean 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_human boolean required
      • needs_human_at string | null, format date-time required
      • needs_human_reason string | 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_count integer required
      • last_message_at string | null, format date-time required
      • last_message_direction string | null ("inbound", "outbound", nil) required
      • last_message_preview string | null required — First 140 characters of the most recent message.
      • created_at string, 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-rules List the recurring weekly windows

    Response 200

    • availability_rules array of object
      • id integer required
      • weekday integer required — 0 is Sunday, matching Ruby's `Date#wday`.
      • start_minute integer 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_minute integer required — Exclusive, and must be after `start_minute`.
      • appointment_type string 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_minutes integer required — The window is divided into back-to-back slots of this length. A remainder shorter than one slot is not offered.
      • active boolean required — Inactive rules produce no slots. Appointments already booked inside them are untouched.
  • POST /accounts/{account_id}/availability-rules Add a weekly window

    A 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_rule object required
      • weekday integer
      • start_minute integer
      • end_minute integer
      • appointment_type string
      • slot_duration_minutes integer
      • active boolean

    Response 201

    • availability_rule object

      A recurring weekly window when the account will take appointments.

      • id integer required
      • weekday integer required — 0 is Sunday, matching Ruby's `Date#wday`.
      • start_minute integer 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_minute integer required — Exclusive, and must be after `start_minute`.
      • appointment_type string 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_minutes integer required — The window is divided into back-to-back slots of this length. A remainder shorter than one slot is not offered.
      • active boolean required — Inactive rules produce no slots. Appointments already booked inside them are untouched.
  • PATCH /accounts/{account_id}/availability-rules/{id} Update a weekly window

    Takes 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_rule object required
      • weekday integer
      • start_minute integer
      • end_minute integer
      • appointment_type string
      • slot_duration_minutes integer
      • active boolean

    Response 200

    • availability_rule object

      A recurring weekly window when the account will take appointments.

      • id integer required
      • weekday integer required — 0 is Sunday, matching Ruby's `Date#wday`.
      • start_minute integer 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_minute integer required — Exclusive, and must be after `start_minute`.
      • appointment_type string 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_minutes integer required — The window is divided into back-to-back slots of this length. A remainder shorter than one slot is not offered.
      • active boolean required — Inactive rules produce no slots. Appointments already booked inside them are untouched.
  • DELETE /accounts/{account_id}/availability-rules/{id} Remove a weekly window

    Response 204

    No JSON body (or empty success).

  • GET /accounts/{account_id}/availability-exceptions List the days the rules do not apply

    Parameters

    • upcoming string ("true")

      `true` excludes exceptions whose date has passed.

    Response 200

    • availability_exceptions array of object
      • id integer required
      • date string, format date required — The account's local date.
      • all_day boolean required
      • start_minute integer | null required — Present only for a partial block. Minutes from local midnight.
      • end_minute integer | null required
      • reason string | null required — Free text, for the account's own benefit. Never sent to anyone.
  • POST /accounts/{account_id}/availability-exceptions Block a day, or part of one

    Request body

    • availability_exception object required

      Defaults to blocking the whole day. Set `all_day: false` and both minutes to block part of one.

      • date string, format date
      • all_day boolean
      • start_minute integer
      • end_minute integer
      • reason string

    Response 201

    • availability_exception object

      A day, or part of one, when the recurring rules do not apply.

      • id integer required
      • date string, format date required — The account's local date.
      • all_day boolean required
      • start_minute integer | null required — Present only for a partial block. Minutes from local midnight.
      • end_minute integer | null required
      • reason string | null required — Free text, for the account's own benefit. Never sent to anyone.
  • DELETE /accounts/{account_id}/availability-exceptions/{id} Unblock a day

    Response 204

    No JSON body (or empty success).

  • GET /accounts/{account_id}/slots Compute open times

    Read-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_type string

      Matches the `appointment_type` on the account's availability rules. Opaque to Flywheel.

    • limit integer
    • horizon_days integer

      How far ahead to look. Values above 60 are clamped to 60.

    Response 200

    • slots array of object
      • starts_at string, format date-time required — Pass this back as an appointment's `starts_at` to book it.
      • ends_at string, format date-time required
      • local_date string, format date required — In the account's timezone.
      • local_time string required
      • label string required — Ready to show a human, rendered in the account's timezone.
    • time_zone string

      The 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}/appointments List appointments, soonest first

    Parameters

    • state string ("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.

    • upcoming string ("true")

      `true` returns only live appointments still in the future.

    • past string ("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_id string

      Limits to one contact, by contact id.

    • attended_since string, format date-time

      ISO 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.

    • page integer
    • per_page integer

    Response 200

    • appointments array of object
      • id integer required
      • contact_id string required — Contact slug.
      • contact_name string required — Same derivation as `Contact.name`.
      • contact_phone string | null required — The number this appointment would be reminded on — the contact's, or their guardian's if the contact is a minor.
      • conversation_id integer | null required — The thread it was booked from, when it was booked by text.
      • starts_at string, format date-time required
      • ends_at string, format date-time required
      • local_label string required — The start rendered in the account's timezone.
      • appointment_type string required
      • state string ("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_source any required
      • attended_at string | null, format date-time required
      • no_show_contested_at string | 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_at string | null, format date-time required
      • cancel_reason string | null required
      • location string | null required
      • meeting_kind string ("in_person", "at_customer", "virtual") required
      • join_url string | null required — Flywheel join page for the family. Blank until a virtual room is minted.
      • host_join_url string | null required — Flywheel join page that enters as host. Blank until a virtual room is minted.
      • video_host_joined_at string | null, format date-time required — First time a host entered the Whereby room. Not attendance.
      • video_guest_joined_at string | null, format date-time required — First time a visitor entered the Whereby room. Not attendance.
      • video_session_started_at string | null, format date-time required — Whereby room.session.started — at least two people in the room.
      • video_session_ended_at string | null, format date-time required
      • video_presence_label string | null required — Short room-presence summary for the dashboard. A join is never a charge.
      • notes string | null required
      • value_cents integer | 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_currency string | null required — ISO 4217, uppercase. Present exactly when `value_cents` is.
      • external_ref string | 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_id integer | 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_at string, format date-time required
      • review_request object | null required — The public-review ask attendance created, when one exists.
  • POST /accounts/{account_id}/appointments Book a time

    Pass 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

    • appointment object required
      • contact_id string — Contact SLUG, not an integer — same identifier the contact endpoints use.
      • starts_at string, 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_type string
      • duration_minutes integer — Defaults to the matching availability rule's `slot_duration_minutes`, or 30 if there is no rule.
      • location string
      • meeting_kind string ("in_person", "at_customer", "virtual") — Defaults to the account's meeting_kind.
      • external_ref string — Your identifier for this row. Echoed on the appointment that was created, not on a later reschedule successor.
      • value_cents integer — Optional. What this booking is worth to you, in the currency's minor unit. Send with `value_currency`.
      • value_currency string — ISO 4217, e.g. USD. Stored uppercase.

    Response 201

    • appointment object
      • id integer required
      • contact_id string required — Contact slug.
      • contact_name string required — Same derivation as `Contact.name`.
      • contact_phone string | null required — The number this appointment would be reminded on — the contact's, or their guardian's if the contact is a minor.
      • conversation_id integer | null required — The thread it was booked from, when it was booked by text.
      • starts_at string, format date-time required
      • ends_at string, format date-time required
      • local_label string required — The start rendered in the account's timezone.
      • appointment_type string required
      • state string ("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_source any required
      • attended_at string | null, format date-time required
      • no_show_contested_at string | 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_at string | null, format date-time required
      • cancel_reason string | null required
      • location string | null required
      • meeting_kind string ("in_person", "at_customer", "virtual") required
      • join_url string | null required — Flywheel join page for the family. Blank until a virtual room is minted.
      • host_join_url string | null required — Flywheel join page that enters as host. Blank until a virtual room is minted.
      • video_host_joined_at string | null, format date-time required — First time a host entered the Whereby room. Not attendance.
      • video_guest_joined_at string | null, format date-time required — First time a visitor entered the Whereby room. Not attendance.
      • video_session_started_at string | null, format date-time required — Whereby room.session.started — at least two people in the room.
      • video_session_ended_at string | null, format date-time required
      • video_presence_label string | null required — Short room-presence summary for the dashboard. A join is never a charge.
      • notes string | null required
      • value_cents integer | 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_currency string | null required — ISO 4217, uppercase. Present exactly when `value_cents` is.
      • external_ref string | 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_id integer | 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_at string, format date-time required
      • review_request object | null required — The public-review ask attendance created, when one exists.
  • GET /appointments/{id} Fetch an appointment

    Not 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

    • appointment object
      • id integer required
      • contact_id string required — Contact slug.
      • contact_name string required — Same derivation as `Contact.name`.
      • contact_phone string | null required — The number this appointment would be reminded on — the contact's, or their guardian's if the contact is a minor.
      • conversation_id integer | null required — The thread it was booked from, when it was booked by text.
      • starts_at string, format date-time required
      • ends_at string, format date-time required
      • local_label string required — The start rendered in the account's timezone.
      • appointment_type string required
      • state string ("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_source any required
      • attended_at string | null, format date-time required
      • no_show_contested_at string | 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_at string | null, format date-time required
      • cancel_reason string | null required
      • location string | null required
      • meeting_kind string ("in_person", "at_customer", "virtual") required
      • join_url string | null required — Flywheel join page for the family. Blank until a virtual room is minted.
      • host_join_url string | null required — Flywheel join page that enters as host. Blank until a virtual room is minted.
      • video_host_joined_at string | null, format date-time required — First time a host entered the Whereby room. Not attendance.
      • video_guest_joined_at string | null, format date-time required — First time a visitor entered the Whereby room. Not attendance.
      • video_session_started_at string | null, format date-time required — Whereby room.session.started — at least two people in the room.
      • video_session_ended_at string | null, format date-time required
      • video_presence_label string | null required — Short room-presence summary for the dashboard. A join is never a charge.
      • notes string | null required
      • value_cents integer | 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_currency string | null required — ISO 4217, uppercase. Present exactly when `value_cents` is.
      • external_ref string | 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_id integer | 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_at string, format date-time required
      • review_request object | null required — The public-review ask attendance created, when one exists.
  • PATCH /appointments/{id} Record what an appointment is worth

    Sets 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

    • appointment object required
      • value_cents integer | null — Minor units. `null` clears it.
      • value_currency string | null — ISO 4217. `null` clears it.

    Response 200

    • appointment object
      • id integer required
      • contact_id string required — Contact slug.
      • contact_name string required — Same derivation as `Contact.name`.
      • contact_phone string | null required — The number this appointment would be reminded on — the contact's, or their guardian's if the contact is a minor.
      • conversation_id integer | null required — The thread it was booked from, when it was booked by text.
      • starts_at string, format date-time required
      • ends_at string, format date-time required
      • local_label string required — The start rendered in the account's timezone.
      • appointment_type string required
      • state string ("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_source any required
      • attended_at string | null, format date-time required
      • no_show_contested_at string | 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_at string | null, format date-time required
      • cancel_reason string | null required
      • location string | null required
      • meeting_kind string ("in_person", "at_customer", "virtual") required
      • join_url string | null required — Flywheel join page for the family. Blank until a virtual room is minted.
      • host_join_url string | null required — Flywheel join page that enters as host. Blank until a virtual room is minted.
      • video_host_joined_at string | null, format date-time required — First time a host entered the Whereby room. Not attendance.
      • video_guest_joined_at string | null, format date-time required — First time a visitor entered the Whereby room. Not attendance.
      • video_session_started_at string | null, format date-time required — Whereby room.session.started — at least two people in the room.
      • video_session_ended_at string | null, format date-time required
      • video_presence_label string | null required — Short room-presence summary for the dashboard. A join is never a charge.
      • notes string | null required
      • value_cents integer | 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_currency string | null required — ISO 4217, uppercase. Present exactly when `value_cents` is.
      • external_ref string | 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_id integer | 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_at string, format date-time required
      • review_request object | null required — The public-review ask attendance created, when one exists.
  • POST /appointments/{id}/attend Record that they turned up

    Emits `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

    • source string ("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

    • appointment object
      • id integer required
      • contact_id string required — Contact slug.
      • contact_name string required — Same derivation as `Contact.name`.
      • contact_phone string | null required — The number this appointment would be reminded on — the contact's, or their guardian's if the contact is a minor.
      • conversation_id integer | null required — The thread it was booked from, when it was booked by text.
      • starts_at string, format date-time required
      • ends_at string, format date-time required
      • local_label string required — The start rendered in the account's timezone.
      • appointment_type string required
      • state string ("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_source any required
      • attended_at string | null, format date-time required
      • no_show_contested_at string | 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_at string | null, format date-time required
      • cancel_reason string | null required
      • location string | null required
      • meeting_kind string ("in_person", "at_customer", "virtual") required
      • join_url string | null required — Flywheel join page for the family. Blank until a virtual room is minted.
      • host_join_url string | null required — Flywheel join page that enters as host. Blank until a virtual room is minted.
      • video_host_joined_at string | null, format date-time required — First time a host entered the Whereby room. Not attendance.
      • video_guest_joined_at string | null, format date-time required — First time a visitor entered the Whereby room. Not attendance.
      • video_session_started_at string | null, format date-time required — Whereby room.session.started — at least two people in the room.
      • video_session_ended_at string | null, format date-time required
      • video_presence_label string | null required — Short room-presence summary for the dashboard. A join is never a charge.
      • notes string | null required
      • value_cents integer | 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_currency string | null required — ISO 4217, uppercase. Present exactly when `value_cents` is.
      • external_ref string | 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_id integer | 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_at string, format date-time required
      • review_request object | null required — The public-review ask attendance created, when one exists.
  • POST /appointments/{id}/no-show Record that they did not turn up

    Emits `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

    • appointment object
      • id integer required
      • contact_id string required — Contact slug.
      • contact_name string required — Same derivation as `Contact.name`.
      • contact_phone string | null required — The number this appointment would be reminded on — the contact's, or their guardian's if the contact is a minor.
      • conversation_id integer | null required — The thread it was booked from, when it was booked by text.
      • starts_at string, format date-time required
      • ends_at string, format date-time required
      • local_label string required — The start rendered in the account's timezone.
      • appointment_type string required
      • state string ("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_source any required
      • attended_at string | null, format date-time required
      • no_show_contested_at string | 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_at string | null, format date-time required
      • cancel_reason string | null required
      • location string | null required
      • meeting_kind string ("in_person", "at_customer", "virtual") required
      • join_url string | null required — Flywheel join page for the family. Blank until a virtual room is minted.
      • host_join_url string | null required — Flywheel join page that enters as host. Blank until a virtual room is minted.
      • video_host_joined_at string | null, format date-time required — First time a host entered the Whereby room. Not attendance.
      • video_guest_joined_at string | null, format date-time required — First time a visitor entered the Whereby room. Not attendance.
      • video_session_started_at string | null, format date-time required — Whereby room.session.started — at least two people in the room.
      • video_session_ended_at string | null, format date-time required
      • video_presence_label string | null required — Short room-presence summary for the dashboard. A join is never a charge.
      • notes string | null required
      • value_cents integer | 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_currency string | null required — ISO 4217, uppercase. Present exactly when `value_cents` is.
      • external_ref string | 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_id integer | 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_at string, format date-time required
      • review_request object | null required — The public-review ask attendance created, when one exists.
  • POST /appointments/{id}/cancel Cancel an appointment

    Emits `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

    • reason string

      Free text, stored verbatim.

    Response 200

    • appointment object
      • id integer required
      • contact_id string required — Contact slug.
      • contact_name string required — Same derivation as `Contact.name`.
      • contact_phone string | null required — The number this appointment would be reminded on — the contact's, or their guardian's if the contact is a minor.
      • conversation_id integer | null required — The thread it was booked from, when it was booked by text.
      • starts_at string, format date-time required
      • ends_at string, format date-time required
      • local_label string required — The start rendered in the account's timezone.
      • appointment_type string required
      • state string ("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_source any required
      • attended_at string | null, format date-time required
      • no_show_contested_at string | 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_at string | null, format date-time required
      • cancel_reason string | null required
      • location string | null required
      • meeting_kind string ("in_person", "at_customer", "virtual") required
      • join_url string | null required — Flywheel join page for the family. Blank until a virtual room is minted.
      • host_join_url string | null required — Flywheel join page that enters as host. Blank until a virtual room is minted.
      • video_host_joined_at string | null, format date-time required — First time a host entered the Whereby room. Not attendance.
      • video_guest_joined_at string | null, format date-time required — First time a visitor entered the Whereby room. Not attendance.
      • video_session_started_at string | null, format date-time required — Whereby room.session.started — at least two people in the room.
      • video_session_ended_at string | null, format date-time required
      • video_presence_label string | null required — Short room-presence summary for the dashboard. A join is never a charge.
      • notes string | null required
      • value_cents integer | 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_currency string | null required — ISO 4217, uppercase. Present exactly when `value_cents` is.
      • external_ref string | 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_id integer | 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_at string, format date-time required
      • review_request object | null required — The public-review ask attendance created, when one exists.
  • POST /appointments/{id}/reschedule Move an appointment to a new time

    Books 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

    • appointment object required
      • starts_at string, 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

    • appointment object
      • id integer required
      • contact_id string required — Contact slug.
      • contact_name string required — Same derivation as `Contact.name`.
      • contact_phone string | null required — The number this appointment would be reminded on — the contact's, or their guardian's if the contact is a minor.
      • conversation_id integer | null required — The thread it was booked from, when it was booked by text.
      • starts_at string, format date-time required
      • ends_at string, format date-time required
      • local_label string required — The start rendered in the account's timezone.
      • appointment_type string required
      • state string ("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_source any required
      • attended_at string | null, format date-time required
      • no_show_contested_at string | 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_at string | null, format date-time required
      • cancel_reason string | null required
      • location string | null required
      • meeting_kind string ("in_person", "at_customer", "virtual") required
      • join_url string | null required — Flywheel join page for the family. Blank until a virtual room is minted.
      • host_join_url string | null required — Flywheel join page that enters as host. Blank until a virtual room is minted.
      • video_host_joined_at string | null, format date-time required — First time a host entered the Whereby room. Not attendance.
      • video_guest_joined_at string | null, format date-time required — First time a visitor entered the Whereby room. Not attendance.
      • video_session_started_at string | null, format date-time required — Whereby room.session.started — at least two people in the room.
      • video_session_ended_at string | null, format date-time required
      • video_presence_label string | null required — Short room-presence summary for the dashboard. A join is never a charge.
      • notes string | null required
      • value_cents integer | 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_currency string | null required — ISO 4217, uppercase. Present exactly when `value_cents` is.
      • external_ref string | 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_id integer | 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_at string, format date-time required
      • review_request object | null required — The public-review ask attendance created, when one exists.
    • previous object
      • id integer required
      • contact_id string required — Contact slug.
      • contact_name string required — Same derivation as `Contact.name`.
      • contact_phone string | null required — The number this appointment would be reminded on — the contact's, or their guardian's if the contact is a minor.
      • conversation_id integer | null required — The thread it was booked from, when it was booked by text.
      • starts_at string, format date-time required
      • ends_at string, format date-time required
      • local_label string required — The start rendered in the account's timezone.
      • appointment_type string required
      • state string ("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_source any required
      • attended_at string | null, format date-time required
      • no_show_contested_at string | 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_at string | null, format date-time required
      • cancel_reason string | null required
      • location string | null required
      • meeting_kind string ("in_person", "at_customer", "virtual") required
      • join_url string | null required — Flywheel join page for the family. Blank until a virtual room is minted.
      • host_join_url string | null required — Flywheel join page that enters as host. Blank until a virtual room is minted.
      • video_host_joined_at string | null, format date-time required — First time a host entered the Whereby room. Not attendance.
      • video_guest_joined_at string | null, format date-time required — First time a visitor entered the Whereby room. Not attendance.
      • video_session_started_at string | null, format date-time required — Whereby room.session.started — at least two people in the room.
      • video_session_ended_at string | null, format date-time required
      • video_presence_label string | null required — Short room-presence summary for the dashboard. A join is never a charge.
      • notes string | null required
      • value_cents integer | 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_currency string | null required — ISO 4217, uppercase. Present exactly when `value_cents` is.
      • external_ref string | 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_id integer | 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_at string, format date-time required
      • review_request object | 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-requests List 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

    • state string ("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.

    • open string ("true")

      `true` returns only requests still in `pending`, `asked`, or `reminded`.

    • appointment_id integer
    • received_since string, format date-time

      ISO 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_before string, format date-time

      ISO 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.

    • page integer
    • per_page integer

    Response 200

    • review_requests array of object
      • id integer required
      • contact_id string required — Contact slug.
      • contact_name string required — Same derivation as `Contact.name`.
      • appointment_id integer | null required — The attended appointment that caused this. At most one request per appointment, enforced by a unique index.
      • state string ("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...
      • platform string required — Where they are sent to write it.
      • review_url string | null required — Copied from the account at creation. The write-a-review URL the ask will send, never one Flywheel hosts.
      • rating integer | 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...
      • body string | null required
      • asked_at string | null, format date-time required
      • reminded_at string | null, format date-time required — The one reminder. There is no second.
      • completed_at string | null, format date-time required
      • received_at string | 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_at string, format date-time required
  • POST /review-requests/{id}/send Send the queued ask now

    Accelerates 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_request object

      One review ask. Created by attendance and by nothing else — there is no operation that makes one.

      • id integer required
      • contact_id string required — Contact slug.
      • contact_name string required — Same derivation as `Contact.name`.
      • appointment_id integer | null required — The attended appointment that caused this. At most one request per appointment, enforced by a unique index.
      • state string ("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...
      • platform string required — Where they are sent to write it.
      • review_url string | null required — Copied from the account at creation. The write-a-review URL the ask will send, never one Flywheel hosts.
      • rating integer | 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...
      • body string | null required
      • asked_at string | null, format date-time required
      • reminded_at string | null, format date-time required — The one reminder. There is no second.
      • completed_at string | null, format date-time required
      • received_at string | 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_at string, format date-time required
  • POST /review-requests/{id}/complete Record that a review arrived

    Emits `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

    • rating integer
    • body string
    • source string

      How we learned the review arrived. `google_live` means a dashboard Places name-match; it is never a stored Google review body.

    Response 200

    • review_request object

      One review ask. Created by attendance and by nothing else — there is no operation that makes one.

      • id integer required
      • contact_id string required — Contact slug.
      • contact_name string required — Same derivation as `Contact.name`.
      • appointment_id integer | null required — The attended appointment that caused this. At most one request per appointment, enforced by a unique index.
      • state string ("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...
      • platform string required — Where they are sent to write it.
      • review_url string | null required — Copied from the account at creation. The write-a-review URL the ask will send, never one Flywheel hosts.
      • rating integer | 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...
      • body string | null required
      • asked_at string | null, format date-time required
      • reminded_at string | null, format date-time required — The one reminder. There is no second.
      • completed_at string | null, format date-time required
      • received_at string | 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_at string, format date-time required
  • POST /review-requests/{id}/decline Record that they will not be leaving one

    Terminal. 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_request object

      One review ask. Created by attendance and by nothing else — there is no operation that makes one.

      • id integer required
      • contact_id string required — Contact slug.
      • contact_name string required — Same derivation as `Contact.name`.
      • appointment_id integer | null required — The attended appointment that caused this. At most one request per appointment, enforced by a unique index.
      • state string ("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...
      • platform string required — Where they are sent to write it.
      • review_url string | null required — Copied from the account at creation. The write-a-review URL the ask will send, never one Flywheel hosts.
      • rating integer | 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...
      • body string | null required
      • asked_at string | null, format date-time required
      • reminded_at string | null, format date-time required — The one reminder. There is no second.
      • completed_at string | null, format date-time required
      • received_at string | 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_at string, format date-time required
  • GET /accounts/{account_id}/testimonials List first-party quotes

    Parameters

    • contact_id string
    • consent_state string ("pending", "granted", "declined")
    • granted_since string, format date-time

      ISO 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_before string, format date-time

      ISO 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.

    • page integer
    • per_page integer

    Response 200

    • testimonials array of object
      • id integer required
      • contact_id string required
      • contact_name string required
      • appointment_id integer | null required
      • quote string required
      • reviewer_name string | null required
      • consent_state string ("pending", "granted", "declined") required
      • asked_at string | null, format date-time required
      • granted_at string | null, format date-time required
      • declined_at string | null, format date-time required
      • created_at string, format date-time required
  • POST /accounts/{account_id}/testimonials Ask to publish a quote on the subscriber's site

    Coach-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

    • testimonial object required
      • contact_id string — Contact slug.
      • quote string
      • reviewer_name string
      • appointment_id integer

    Response 201

    • testimonial object

      A first-party quote awaiting or granted site-publish consent.

      • id integer required
      • contact_id string required
      • contact_name string required
      • appointment_id integer | null required
      • quote string required
      • reviewer_name string | null required
      • consent_state string ("pending", "granted", "declined") required
      • asked_at string | null, format date-time required
      • granted_at string | null, format date-time required
      • declined_at string | null, format date-time required
      • created_at string, 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-ups List 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

    • state string ("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.

    • segment string ("first_time", "lapsed", "canceled")

      Why a contact was raised. Each has its own template, `reengage.<segment>`.

    • contact_id string

      Limits to one contact, by contact id (slug).

    • page integer
    • per_page integer

    Response 200

    • follow_ups array of object
      • id integer required
      • contact_id string required — Contact slug.
      • contact_name string required — Same derivation as `Contact.name`.
      • segment string ("first_time", "lapsed", "canceled") required — Why a contact was raised. Each has its own template, `reengage.<segment>`.
      • state string ("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_key string required — The template a send uses.
      • trigger_appointment_id integer required — The appointment the segment was judged from — the last visit, or the canceled one.
      • sent_via string | null ("manual", "auto", nil) required — `manual` when someone pressed send, `auto` when the account's rule sent it.
      • last_error string | 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_attempts integer required
      • insights object required — Their booking history, summarised. Derived from appointments; never written directly. A contact nobody has booked reads as `lead` with an empty history.
      • queued_at string | null, format date-time required
      • sent_at string | null, format date-time required
      • dismissed_at string | null, format date-time required
      • resolved_at string | null, format date-time required
      • created_at string, format date-time required
  • POST /follow-ups/{id}/send Send the follow-up's templated message

    Sends 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_up object

      A prompt to get back in touch with one contact. Raised by Flywheel from their history; there is no operation that creates one.

      • id integer required
      • contact_id string required — Contact slug.
      • contact_name string required — Same derivation as `Contact.name`.
      • segment string ("first_time", "lapsed", "canceled") required — Why a contact was raised. Each has its own template, `reengage.<segment>`.
      • state string ("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_key string required — The template a send uses.
      • trigger_appointment_id integer required — The appointment the segment was judged from — the last visit, or the canceled one.
      • sent_via string | null ("manual", "auto", nil) required — `manual` when someone pressed send, `auto` when the account's rule sent it.
      • last_error string | 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_attempts integer required
      • insights object required — Their booking history, summarised. Derived from appointments; never written directly. A contact nobody has booked reads as `lead` with an empty history.
      • queued_at string | null, format date-time required
      • sent_at string | null, format date-time required
      • dismissed_at string | null, format date-time required
      • resolved_at string | null, format date-time required
      • created_at string, format date-time required
  • POST /follow-ups/{id}/dismiss Decide not to follow up

    Terminal. The same lapse or cancellation is never raised again; a later one is.

    No request body.

    Response 200

    • follow_up object

      A prompt to get back in touch with one contact. Raised by Flywheel from their history; there is no operation that creates one.

      • id integer required
      • contact_id string required — Contact slug.
      • contact_name string required — Same derivation as `Contact.name`.
      • segment string ("first_time", "lapsed", "canceled") required — Why a contact was raised. Each has its own template, `reengage.<segment>`.
      • state string ("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_key string required — The template a send uses.
      • trigger_appointment_id integer required — The appointment the segment was judged from — the last visit, or the canceled one.
      • sent_via string | null ("manual", "auto", nil) required — `manual` when someone pressed send, `auto` when the account's rule sent it.
      • last_error string | 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_attempts integer required
      • insights object required — Their booking history, summarised. Derived from appointments; never written directly. A contact nobody has booked reads as `lead` with an empty history.
      • queued_at string | null, format date-time required
      • sent_at string | null, format date-time required
      • dismissed_at string | null, format date-time required
      • resolved_at string | null, format date-time required
      • created_at string, format date-time required
  • GET /accounts/{account_id}/reengagement-rules List the per-segment automatic-send settings

    Always one row per segment. A segment nobody has touched is off.

    Response 200

    • reengagement_rules array of object
      • segment string ("first_time", "lapsed", "canceled") required — Why a contact was raised. Each has its own template, `reengage.<segment>`.
      • template_key string required
      • auto_send boolean required
      • current_body string | null required — The template as it reads now, placeholders included. Slot times are filled in at send time.
      • approved_body string | null required — The copy that was approved, exactly.
      • approved_at string | null, format date-time required
      • approved_copy_current boolean required — The approved copy still matches the template.
      • auto_send_ready boolean 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 copy

    Automatic 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_rule object required
      • auto_send boolean
      • approve boolean — `true` approves the template as it reads right now.

    Response 200

    • reengagement_rule object

      Whether one segment's follow-ups send by themselves. Off until both switched on and approved.

      • segment string ("first_time", "lapsed", "canceled") required — Why a contact was raised. Each has its own template, `reengage.<segment>`.
      • template_key string required
      • auto_send boolean required
      • current_body string | null required — The template as it reads now, placeholders included. Slot times are filled in at send time.
      • approved_body string | null required — The copy that was approved, exactly.
      • approved_at string | null, format date-time required
      • approved_copy_current boolean required — The approved copy still matches the template.
      • auto_send_ready boolean 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-captures List interaction captures, newest first

    Raw notes about what happened with a contact — the input to later content drafts. Filter with `contact_id` (slug) or `appointment_id`.

    Parameters

    • contact_id string

      Contact slug.

    • appointment_id integer

      Appointment id.

    • page integer
    • per_page integer

    Response 200

    • interaction_captures array of object
      • id integer required
      • contact_id string required — Contact slug.
      • appointment_id integer | null required — Optional appointment this capture is about.
      • body string required — Freeform notes. Flywheel does not interpret them.
      • media_refs array of string required — Opaque refs the subscriber owns (URLs, storage keys). Flywheel does not fetch or host them.
      • external_ref string | null required — Your own idempotency key. Unique per account when set.
      • source string | null required — Opaque label from you (`recap`, `manual`, …). Not interpreted.
      • occurred_at string, format date-time required — When the interaction happened. Defaults to creation time.
      • created_at string, format date-time required
      • updated_at string, format date-time required
  • POST /accounts/{account_id}/interaction-captures Store what happened with a contact

    Vertical-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_capture object required
      • contact_id string — Contact slug. Required on create; ignored on update.
      • appointment_id integer | null — Optional. Must belong to the same account and contact.
      • body string
      • media_refs array of string
      • external_ref string
      • source string
      • occurred_at string, format date-time

    Response 201

    • interaction_capture object

      What happened with a contact — freeform notes and optional media refs for later content drafts. Vertical-agnostic.

      • id integer required
      • contact_id string required — Contact slug.
      • appointment_id integer | null required — Optional appointment this capture is about.
      • body string required — Freeform notes. Flywheel does not interpret them.
      • media_refs array of string required — Opaque refs the subscriber owns (URLs, storage keys). Flywheel does not fetch or host them.
      • external_ref string | null required — Your own idempotency key. Unique per account when set.
      • source string | null required — Opaque label from you (`recap`, `manual`, …). Not interpreted.
      • occurred_at string, format date-time required — When the interaction happened. Defaults to creation time.
      • created_at string, format date-time required
      • updated_at string, format date-time required
  • GET /accounts/{account_id}/interaction-captures/{id} Fetch an interaction capture

    Response 200

    • interaction_capture object

      What happened with a contact — freeform notes and optional media refs for later content drafts. Vertical-agnostic.

      • id integer required
      • contact_id string required — Contact slug.
      • appointment_id integer | null required — Optional appointment this capture is about.
      • body string required — Freeform notes. Flywheel does not interpret them.
      • media_refs array of string required — Opaque refs the subscriber owns (URLs, storage keys). Flywheel does not fetch or host them.
      • external_ref string | null required — Your own idempotency key. Unique per account when set.
      • source string | null required — Opaque label from you (`recap`, `manual`, …). Not interpreted.
      • occurred_at string, format date-time required — When the interaction happened. Defaults to creation time.
      • created_at string, format date-time required
      • updated_at string, format date-time required
  • PATCH /accounts/{account_id}/interaction-captures/{id} Update an interaction capture

    Request body

    • interaction_capture object required
      • contact_id string — Contact slug. Required on create; ignored on update.
      • appointment_id integer | null — Optional. Must belong to the same account and contact.
      • body string
      • media_refs array of string
      • external_ref string
      • source string
      • occurred_at string, format date-time

    Response 200

    • interaction_capture object

      What happened with a contact — freeform notes and optional media refs for later content drafts. Vertical-agnostic.

      • id integer required
      • contact_id string required — Contact slug.
      • appointment_id integer | null required — Optional appointment this capture is about.
      • body string required — Freeform notes. Flywheel does not interpret them.
      • media_refs array of string required — Opaque refs the subscriber owns (URLs, storage keys). Flywheel does not fetch or host them.
      • external_ref string | null required — Your own idempotency key. Unique per account when set.
      • source string | null required — Opaque label from you (`recap`, `manual`, …). Not interpreted.
      • occurred_at string, format date-time required — When the interaction happened. Defaults to creation time.
      • created_at string, format date-time required
      • updated_at string, format date-time required
  • DELETE /accounts/{account_id}/interaction-captures/{id} Delete an interaction capture

    Response 204

    No JSON body (or empty success).

  • GET /accounts/{account_id}/draft-suggestions List draft suggestions, newest first

    Parameters

    • contact_id string

      Contact slug.

    • interaction_capture_id integer

      Interaction capture id.

    • testimonial_id integer

      Granted-quote id this draft was assembled from.

    • page integer
    • per_page integer

    Response 200

    • draft_suggestions array of object
      • id integer required
      • interaction_capture_id integer | null required — Set when assembled from a job note. Null when the source is a quote.
      • testimonial_id integer | null required — Set when assembled from a granted quote. Null when the source is a capture.
      • contact_id string required — Contact slug.
      • contact_name string required — Display name so a list does not need a second request.
      • title string required
      • body string required
      • status string ("ready", "edited") required — `ready` from the assembler; `edited` after a subscriber PATCH.
      • media_refs array of string required — Copied from the capture (or last edit). Opaque; Flywheel does not host them.
      • allowed boolean required — True only when `violations` is empty at last check.
      • violations array of object required — Snapshot from ConsentGuard. A blocked draft is still stored so you can edit it.
      • source_kind string ("job note", "customer quote") required — What the piece was written from.
      • source_text string | 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_at string, format date-time required
      • updated_at string, format date-time required
  • POST /accounts/{account_id}/draft-suggestions Write a content piece from a capture or a granted quote

    Writes 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_suggestion object required
      • interaction_capture_id integer — Create-only. Exactly one of this or `testimonial_id`. Ignored on update.
      • testimonial_id integer — Create-only. Granted quotes only. Exactly one of this or `interaction_capture_id`. Ignored on update.
      • title string — On update: replaces the assembled title and re-checks consent.
      • body string — On update: replaces the assembled body and re-checks consent.
      • media_refs array of string — On update: replaces media refs; presence of any ref is treated as has_media for consent.
      • identifying_terms array of string — Location/school/neighbourhood terms for the re-identification guard. Not stored.

    Response 201

    • draft_suggestion object

      A content piece written from a capture or granted quote and consent-checked. Not a publish.

      • id integer required
      • interaction_capture_id integer | null required — Set when assembled from a job note. Null when the source is a quote.
      • testimonial_id integer | null required — Set when assembled from a granted quote. Null when the source is a capture.
      • contact_id string required — Contact slug.
      • contact_name string required — Display name so a list does not need a second request.
      • title string required
      • body string required
      • status string ("ready", "edited") required — `ready` from the assembler; `edited` after a subscriber PATCH.
      • media_refs array of string required — Copied from the capture (or last edit). Opaque; Flywheel does not host them.
      • allowed boolean required — True only when `violations` is empty at last check.
      • violations array of object required — Snapshot from ConsentGuard. A blocked draft is still stored so you can edit it.
      • source_kind string ("job note", "customer quote") required — What the piece was written from.
      • source_text string | 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_at string, format date-time required
      • updated_at string, format date-time required
  • POST /accounts/{account_id}/draft-suggestions/{id}/rewrite Write a fresh piece from the same source material

    Replaces 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_suggestion object

      A content piece written from a capture or granted quote and consent-checked. Not a publish.

      • id integer required
      • interaction_capture_id integer | null required — Set when assembled from a job note. Null when the source is a quote.
      • testimonial_id integer | null required — Set when assembled from a granted quote. Null when the source is a capture.
      • contact_id string required — Contact slug.
      • contact_name string required — Display name so a list does not need a second request.
      • title string required
      • body string required
      • status string ("ready", "edited") required — `ready` from the assembler; `edited` after a subscriber PATCH.
      • media_refs array of string required — Copied from the capture (or last edit). Opaque; Flywheel does not host them.
      • allowed boolean required — True only when `violations` is empty at last check.
      • violations array of object required — Snapshot from ConsentGuard. A blocked draft is still stored so you can edit it.
      • source_kind string ("job note", "customer quote") required — What the piece was written from.
      • source_text string | 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_at string, format date-time required
      • updated_at string, format date-time required
  • GET /accounts/{account_id}/draft-suggestions/{id} Fetch a draft suggestion

    Response 200

    • draft_suggestion object

      A content piece written from a capture or granted quote and consent-checked. Not a publish.

      • id integer required
      • interaction_capture_id integer | null required — Set when assembled from a job note. Null when the source is a quote.
      • testimonial_id integer | null required — Set when assembled from a granted quote. Null when the source is a capture.
      • contact_id string required — Contact slug.
      • contact_name string required — Display name so a list does not need a second request.
      • title string required
      • body string required
      • status string ("ready", "edited") required — `ready` from the assembler; `edited` after a subscriber PATCH.
      • media_refs array of string required — Copied from the capture (or last edit). Opaque; Flywheel does not host them.
      • allowed boolean required — True only when `violations` is empty at last check.
      • violations array of object required — Snapshot from ConsentGuard. A blocked draft is still stored so you can edit it.
      • source_kind string ("job note", "customer quote") required — What the piece was written from.
      • source_text string | 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_at string, format date-time required
      • updated_at string, format date-time required
  • PATCH /accounts/{account_id}/draft-suggestions/{id} Edit a draft suggestion and re-check consent

    Re-runs the consent guard on the new title/body. Status becomes `edited`.

    Request body

    • draft_suggestion object required
      • interaction_capture_id integer — Create-only. Exactly one of this or `testimonial_id`. Ignored on update.
      • testimonial_id integer — Create-only. Granted quotes only. Exactly one of this or `interaction_capture_id`. Ignored on update.
      • title string — On update: replaces the assembled title and re-checks consent.
      • body string — On update: replaces the assembled body and re-checks consent.
      • media_refs array of string — On update: replaces media refs; presence of any ref is treated as has_media for consent.
      • identifying_terms array of string — Location/school/neighbourhood terms for the re-identification guard. Not stored.

    Response 200

    • draft_suggestion object

      A content piece written from a capture or granted quote and consent-checked. Not a publish.

      • id integer required
      • interaction_capture_id integer | null required — Set when assembled from a job note. Null when the source is a quote.
      • testimonial_id integer | null required — Set when assembled from a granted quote. Null when the source is a capture.
      • contact_id string required — Contact slug.
      • contact_name string required — Display name so a list does not need a second request.
      • title string required
      • body string required
      • status string ("ready", "edited") required — `ready` from the assembler; `edited` after a subscriber PATCH.
      • media_refs array of string required — Copied from the capture (or last edit). Opaque; Flywheel does not host them.
      • allowed boolean required — True only when `violations` is empty at last check.
      • violations array of object required — Snapshot from ConsentGuard. A blocked draft is still stored so you can edit it.
      • source_kind string ("job note", "customer quote") required — What the piece was written from.
      • source_text string | 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_at string, format date-time required
      • updated_at string, format date-time required
  • DELETE /accounts/{account_id}/draft-suggestions/{id} Delete a draft suggestion

    Response 204

    No JSON body (or empty success).

  • POST /accounts/{account_id}/content-check Check a draft against its subjects' consent

    A 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

    • title string
    • body string
    • contact_ids array of string

      Slugs of the contacts the draft references. An unknown slug is a 404, not a dropped subject.

    • has_media boolean

      True when the draft carries a photo or video of the subjects. Requires `photo_video` consent from each of them.

    • identifying_terms array of string

      Location, 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_consent object

      Contact 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

    • allowed boolean required

      True only when `violations` is empty.

    • violations array of object required
      • subject_id string | null required — Slug of the contact this violation is about.
      • type string ("opted_out", "name_beyond_tier", "media_beyond_tier", "re_identification") required — Branch on this. `message` is for a human reviewing the draft.
      • message string required

Learning

Escalate-then-human-reply episodes. Capture is automatic; applying a schema change is not.

  • GET /accounts/{account_id}/learning-episodes List learning episodes, newest first

    Parameters

    • status string ("unreviewed", "individual", "accepted", "rejected")

      unreviewed, individual, accepted, or rejected.

    • conversation_id integer

      Integer conversation id.

    • page integer
    • per_page integer

    Response 200

    • learning_episodes array of object
      • id integer required
      • conversation_id integer required
      • contact_id string required — Contact slug.
      • contact_name string required
      • escalate_reason string required
      • flagged_at string, format date-time required
      • inbound_bodies array of string required — The unclear inbound streak that raised the flag.
      • flow_key string | null required — Flow key at escalate. Null when the thread had no ladder.
      • step_position integer | null required
      • step_template_key string | null required
      • step_expects string | null required
      • step_writes_to string | null required
      • step_choices array of object required — Snapshot of the step's choices. Empty when the rung was not a choice.
      • human_body string required — What the owner sent.
      • status string ("unreviewed", "individual", "accepted", "rejected") required
      • proposal object required — Suggested or edited schema diff. Applied only on accept.
      • individual_field string | null required — Set when triaged individual.
      • individual_value string | null required
      • reviewed_at string | null, format date-time required
      • created_at string, format date-time required
      • updated_at string, format date-time required
  • GET /accounts/{account_id}/learning-episodes/{id} Fetch a learning episode

    Response 200

    • learning_episode object

      A needs_human escalate followed by a human reply. Unreviewed until the owner triages.

      • id integer required
      • conversation_id integer required
      • contact_id string required — Contact slug.
      • contact_name string required
      • escalate_reason string required
      • flagged_at string, format date-time required
      • inbound_bodies array of string required — The unclear inbound streak that raised the flag.
      • flow_key string | null required — Flow key at escalate. Null when the thread had no ladder.
      • step_position integer | null required
      • step_template_key string | null required
      • step_expects string | null required
      • step_writes_to string | null required
      • step_choices array of object required — Snapshot of the step's choices. Empty when the rung was not a choice.
      • human_body string required — What the owner sent.
      • status string ("unreviewed", "individual", "accepted", "rejected") required
      • proposal object required — Suggested or edited schema diff. Applied only on accept.
      • individual_field string | null required — Set when triaged individual.
      • individual_value string | null required
      • reviewed_at string | null, format date-time required
      • created_at string, format date-time required
      • updated_at string, format date-time required
  • PATCH /accounts/{account_id}/learning-episodes/{id} Edit the stored proposal without applying it

    Request body

    • learning_episode object required

      Proposal edits. Accept merges these over the stored proposal, then applies.

      • proposal object

    Response 200

    • learning_episode object

      A needs_human escalate followed by a human reply. Unreviewed until the owner triages.

      • id integer required
      • conversation_id integer required
      • contact_id string required — Contact slug.
      • contact_name string required
      • escalate_reason string required
      • flagged_at string, format date-time required
      • inbound_bodies array of string required — The unclear inbound streak that raised the flag.
      • flow_key string | null required — Flow key at escalate. Null when the thread had no ladder.
      • step_position integer | null required
      • step_template_key string | null required
      • step_expects string | null required
      • step_writes_to string | null required
      • step_choices array of object required — Snapshot of the step's choices. Empty when the rung was not a choice.
      • human_body string required — What the owner sent.
      • status string ("unreviewed", "individual", "accepted", "rejected") required
      • proposal object required — Suggested or edited schema diff. Applied only on accept.
      • individual_field string | null required — Set when triaged individual.
      • individual_value string | null required
      • reviewed_at string | null, format date-time required
      • created_at string, format date-time required
      • updated_at string, format date-time required
  • POST /accounts/{account_id}/learning-episodes/{id}/individual Store the lesson on this contact only

    Writes a custom field on the contact. Does not change the account's flow, templates, or any other lead (D420).

    Request body

    • learning_episode object required
      • field string required — custom_fields key. Letters, numbers, underscore.
      • value string

    Response 200

    • learning_episode object

      A needs_human escalate followed by a human reply. Unreviewed until the owner triages.

      • id integer required
      • conversation_id integer required
      • contact_id string required — Contact slug.
      • contact_name string required
      • escalate_reason string required
      • flagged_at string, format date-time required
      • inbound_bodies array of string required — The unclear inbound streak that raised the flag.
      • flow_key string | null required — Flow key at escalate. Null when the thread had no ladder.
      • step_position integer | null required
      • step_template_key string | null required
      • step_expects string | null required
      • step_writes_to string | null required
      • step_choices array of object required — Snapshot of the step's choices. Empty when the rung was not a choice.
      • human_body string required — What the owner sent.
      • status string ("unreviewed", "individual", "accepted", "rejected") required
      • proposal object required — Suggested or edited schema diff. Applied only on accept.
      • individual_field string | null required — Set when triaged individual.
      • individual_value string | null required
      • reviewed_at string | null, format date-time required
      • created_at string, format date-time required
      • updated_at string, format date-time required
  • POST /accounts/{account_id}/learning-episodes/{id}/accept Apply the proposal to this account's flow or templates

    Forks 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_episode object required

      Proposal edits. Accept merges these over the stored proposal, then applies.

      • proposal object

    Response 200

    • learning_episode object

      A needs_human escalate followed by a human reply. Unreviewed until the owner triages.

      • id integer required
      • conversation_id integer required
      • contact_id string required — Contact slug.
      • contact_name string required
      • escalate_reason string required
      • flagged_at string, format date-time required
      • inbound_bodies array of string required — The unclear inbound streak that raised the flag.
      • flow_key string | null required — Flow key at escalate. Null when the thread had no ladder.
      • step_position integer | null required
      • step_template_key string | null required
      • step_expects string | null required
      • step_writes_to string | null required
      • step_choices array of object required — Snapshot of the step's choices. Empty when the rung was not a choice.
      • human_body string required — What the owner sent.
      • status string ("unreviewed", "individual", "accepted", "rejected") required
      • proposal object required — Suggested or edited schema diff. Applied only on accept.
      • individual_field string | null required — Set when triaged individual.
      • individual_value string | null required
      • reviewed_at string | null, format date-time required
      • created_at string, format date-time required
      • updated_at string, format date-time required
  • POST /accounts/{account_id}/learning-episodes/{id}/reject Discard the episode without changing the flow

    No request body.

    Response 200

    • learning_episode object

      A needs_human escalate followed by a human reply. Unreviewed until the owner triages.

      • id integer required
      • conversation_id integer required
      • contact_id string required — Contact slug.
      • contact_name string required
      • escalate_reason string required
      • flagged_at string, format date-time required
      • inbound_bodies array of string required — The unclear inbound streak that raised the flag.
      • flow_key string | null required — Flow key at escalate. Null when the thread had no ladder.
      • step_position integer | null required
      • step_template_key string | null required
      • step_expects string | null required
      • step_writes_to string | null required
      • step_choices array of object required — Snapshot of the step's choices. Empty when the rung was not a choice.
      • human_body string required — What the owner sent.
      • status string ("unreviewed", "individual", "accepted", "rejected") required
      • proposal object required — Suggested or edited schema diff. Applied only on accept.
      • individual_field string | null required — Set when triaged individual.
      • individual_value string | null required
      • reviewed_at string | null, format date-time required
      • created_at string, format date-time required
      • updated_at string, 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-endpoints List registered endpoints

    Response 200

    • webhook_endpoints array of object
      • id integer required
      • url string, format uri required
      • events array 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...
      • active boolean required — Disabled automatically after 20 consecutive delivery failures. We email the account when that happens; call `enableWebhookEndpoint` to resume.
      • consecutive_failures integer required
      • disabled_at string | 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_at string | null, format date-time required
      • created_at string, format date-time required
  • POST /accounts/{account_id}/webhook-endpoints Register an endpoint

    Must 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_endpoint object required
      • url string, format uri — Must be https.
      • events array 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_endpoint object

      Create payload. Same fields as WebhookEndpoint, plus the one-time `secret` (D278).

      • id integer required
      • url string, format uri required
      • events array 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...
      • active boolean required — Disabled automatically after 20 consecutive delivery failures. We email the account when that happens; call `enableWebhookEndpoint` to resume.
      • consecutive_failures integer required
      • disabled_at string | 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_at string | null, format date-time required
      • created_at string, format date-time required
      • secret string required — Returned once, at creation. HMAC-SHA256 signing key. Unrecoverable afterward.
  • PATCH /accounts/{account_id}/webhook-endpoints/{id} Change what an endpoint hears about

    Use 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_endpoint object required
      • url string, format uri
      • events array 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_endpoint object
      • id integer required
      • url string, format uri required
      • events array 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...
      • active boolean required — Disabled automatically after 20 consecutive delivery failures. We email the account when that happens; call `enableWebhookEndpoint` to resume.
      • consecutive_failures integer required
      • disabled_at string | 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_at string | null, format date-time required
      • created_at string, format date-time required
  • DELETE /accounts/{account_id}/webhook-endpoints/{id} Remove an endpoint

    Response 204

    No JSON body (or empty success).

  • POST /accounts/{account_id}/webhook-endpoints/{id}/enable Resume delivery after repeated failures switched it off

    Turns 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_endpoint object
      • id integer required
      • url string, format uri required
      • events array 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...
      • active boolean required — Disabled automatically after 20 consecutive delivery failures. We email the account when that happens; call `enableWebhookEndpoint` to resume.
      • consecutive_failures integer required
      • disabled_at string | 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_at string | null, format date-time required
      • created_at string, format date-time required
  • GET /accounts/{account_id}/webhook-deliveries List delivery attempts for this account

    The 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

    • state string ("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_id integer

      Limits to one endpoint of this account. An id that is not yours is `not_found`, not an empty list.

    • page integer
    • per_page integer

    Response 200

    • webhook_deliveries array of object
      • id integer required
      • webhook_endpoint_id integer required
      • event string ("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...
      • state string ("pending", "delivered", "failed", "dead") required — `pending` has not been POSTed yet. `failed` will be retried. `dead` exhausted the backoff. `delivered` reached the receiver.
      • attempts integer required
      • response_status integer | null required — HTTP status from the receiver. Null if we have not POSTed yet, or the attempt never got a status.
      • last_error string | null required — Why the last attempt failed. Null on a clean delivery or a row that has not been tried.
      • next_attempt_at string | null, format date-time required — When we will try again. Null once the row is `dead` or `delivered`.
      • delivered_at string | null, format date-time required
      • created_at string, format date-time required
      • updated_at string, 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.

POST /accounts/{account_id}/flow-setup
{
  "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.

POST /accounts/{account_id}/flows/{flow_id}/steps
{
  "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.