{"openapi":"3.1.0","info":{"title":"Flywheel API","version":"1.0.0","summary":"Answer inbound leads in under 60 seconds and qualify them by text.","description":"Flywheel is lead response and customer management for local service\nbusinesses. Every capability the product has is an endpoint in this\ndocument — there is no private surface, and the company that built it\nuses the same API a stranger does.\n\n## Getting started\n\n`POST /accounts` requires no credentials. It returns the new account\nand its first API key; the key token is returned exactly once and\ncannot be read back. Authenticate every other request with\n`Authorization: Bearer \u003ctoken\u003e`.\n\n## Identifiers\n\nAccounts and contacts are addressed by an opaque URL-safe slug, never\nby a numeric database id. Conversations, messages, flows, steps,\ntemplates, webhook endpoints, appointments, availability rules and\nexceptions, and review requests use integer ids.\n\n## Errors\n\nEvery error response is `{\"error\": {\"code\", \"message\", \"details\"}}`\nwith a stable machine-readable `code`. Do not parse `message`.\n\nBranch on `code`, not on the status class. Booking in particular\nreturns three different failures that call for three different\nbehaviours: `409 slot_taken` is worth retrying with a fresh slot,\nwhile `422 too_soon` and `422 in_the_past` will fail identically\nforever with the same `starts_at`.\n\n## Completeness\n\nThere is no capability sitting behind this document. Signup,\nprovisioning a sending number, configuration, the conversation\nengine, scheduling and booking, attendance, review collection,\ncontent consent checking, and outbound webhook delivery are all\noperations below. If something is absent here, it does not exist.\n\nThat is enforced rather than promised: the spec that publishes this\ndocument fails if any API v1 route is not described here, so a\ncapability cannot ship quietly.\n","license":{"name":"Proprietary","identifier":"LicenseRef-Proprietary"}},"servers":[{"url":"https://use-flywheel.com/api/v1","description":"Flywheel API v1"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Health","description":"Liveness. Unauthenticated."},{"name":"Sessions","description":"Trading a dashboard login for a short-lived API key. Exists so a human-facing client needs no privileged route in."},{"name":"Accounts","description":"The tenant: a business using Flywheel. Signup is unauthenticated."},{"name":"Templates","description":"Message copy. Account rows override shipped defaults by key."},{"name":"Flows","description":"The qualification conversation, defined as data rather than code."},{"name":"Contacts","description":"People the account is talking to, across the whole pipeline."},{"name":"Conversations","description":"Threads, messages, and human takeover."},{"name":"Scheduling","description":"When an account will take appointments, and the open times that follow from it. Slots are computed, never stored."},{"name":"Appointments","description":"Booked times, attendance, and cancellation. Flywheel records attendance and never bills it."},{"name":"Reviews","description":"Review asks. Created by attendance and nothing else — there is no way to choose who gets asked."},{"name":"Content","description":"Consent checking for subscriber-authored content. Reads nothing, writes nothing."},{"name":"Webhooks","description":"Where an account wants to be told about things. Delivery is live: persisted attempts, signed payloads, bounded retries."}],"paths":{"/health":{"get":{"tags":["Health"],"operationId":"getHealth","summary":"Liveness probe","description":"Verifies the Flywheel database connection specifically, not the host process.","security":[],"responses":{"200":{"description":"Service is up","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","examples":["ok"]},"service":{"type":"string","examples":["flywheel"]},"api_version":{"type":"string","examples":["v1"]},"time":{"type":"string","format":"date-time"}}}}}},"503":{"description":"Flywheel's database is unreachable","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","examples":["error"]},"service":{"type":"string","examples":["flywheel"]}}}}}}}}},"/sessions":{"post":{"tags":["Sessions"],"operationId":"createSession","summary":"Exchange an email and password for an API key","description":"Unauthenticated. Returns a short-lived `session` key for the account\nwhose `login_email` and `password` were sent, together with the\naccount itself.\n\nThis exists so that a human-facing client has no privileged route in.\nFlywheel's own subscriber dashboard signs in through this endpoint and\nthen makes ordinary bearer-token calls for everything else, which means\nanyone building their own UI has exactly the same capability — there is\nno in-process shortcut being kept back.\n\nA wrong password and an unknown email are the same `401` with the same\nmessage, deliberately. Distinguishing them would turn this into a way\nto ask which businesses have accounts.\n\n`api_key.token` is present only in this response. The key expires on\nits own and is revoked by `DELETE /sessions/current`; it is not a\nsubstitute for a long-lived account key, which is what a server\nintegration should hold.\n","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["login_email","password"],"properties":{"login_email":{"type":"string","format":"email"},"password":{"type":"string","format":"password"}}}}}},"responses":{"201":{"description":"Signed in","content":{"application/json":{"schema":{"type":"object","properties":{"account":{"$ref":"#/components/schemas/Account"},"api_key":{"$ref":"#/components/schemas/ApiKeyWithToken"}}}}}},"401":{"description":"`invalid_credentials` — no account matches that email and password, or the account has no dashboard login set.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"`validation_failed` — email or password missing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/sessions/current":{"delete":{"tags":["Sessions"],"operationId":"destroySession","summary":"Revoke the key presented on this request","description":"Signs out by revoking the credential rather than by forgetting it. A\nkey left live after sign-out is a credential nobody is holding and\nnobody can withdraw.\n\n`current` rather than an id: the only session a caller may destroy is\nthe one it is presenting. Any key may be used here, so an integration\ncan retire its own credential without an administrative endpoint\nexisting that could retire someone else's.\n","responses":{"204":{"description":"Revoked"},"401":{"description":"No valid key presented","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/accounts":{"post":{"tags":["Accounts"],"operationId":"createAccount","summary":"Sign up","description":"Unauthenticated. Send a partner key in `Authorization` to create the\naccount as operated by that partner; omit it and the account stands\nalone. Same endpoint either way — a partner receives no scope a\nstranger could not obtain.\n\n`api_key.token` is present only in this response.\n","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["account"],"properties":{"account":{"allOf":[{"$ref":"#/components/schemas/AccountInput"}],"required":["name","time_zone"]}}}}}},"responses":{"201":{"description":"Account created","content":{"application/json":{"schema":{"type":"object","properties":{"account":{"$ref":"#/components/schemas/Account"},"api_key":{"$ref":"#/components/schemas/ApiKeyWithToken"}}}}}},"422":{"description":"Validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/accounts/{account_id}":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}}],"get":{"tags":["Accounts"],"operationId":"getAccount","summary":"Fetch an account","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"account":{"$ref":"#/components/schemas/Account"}}}}}},"403":{"description":"This key has no authority over that account","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such account","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Accounts"],"operationId":"updateAccount","summary":"Update an account","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["account"],"properties":{"account":{"$ref":"#/components/schemas/AccountInput"}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"account":{"$ref":"#/components/schemas/Account"}}}}}},"422":{"description":"Validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/accounts/{account_id}/pause":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}}],"post":{"tags":["Accounts"],"operationId":"pauseAccount","summary":"Pause an account","description":"State pushed onto the account rather than queried at send time, so pausing cannot slow the reply path.","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"account":{"$ref":"#/components/schemas/Account"}}}}}}}}},"/accounts/{account_id}/resume":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}}],"post":{"tags":["Accounts"],"operationId":"resumeAccount","summary":"Resume a paused account","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"account":{"$ref":"#/components/schemas/Account"}}}}}}}}},"/accounts/{account_id}/phone-number":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}}],"get":{"tags":["Accounts"],"operationId":"getPhoneNumber","summary":"Fetch the account's sending number","description":"A singular resource: an account has exactly one sending number in v1.","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"phone_number":{"$ref":"#/components/schemas/PhoneNumber"}}}}}},"404":{"description":"`no_phone_number` — none provisioned yet. POST to this path to get one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Accounts"],"operationId":"provisionPhoneNumber","summary":"Provision a sending number","description":"The last step of unattended onboarding, and the one that makes an\naccount able to send.\n\nIdempotent: called again it returns the existing number with `200`\nrather than buying a second one. A number carries a monthly cost\nand a retried HTTP request is a certainty, not an edge case, so\nthe safe behaviour is the default one.\n\nA failure to find inventory is `503`, not `422`. The request was\nvalid and there is nothing the caller can phrase differently —\nonly another area code, or waiting.\n","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"area_code":{"type":"string","description":"Preferred area code. Omit to take whatever is available.","examples":["512"]},"allow_toll_free":{"type":"boolean","default":true,"description":"Whether a toll-free number is acceptable when no local one is available."}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"phone_number":{"$ref":"#/components/schemas/PhoneNumber"}}}}}},"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"phone_number":{"$ref":"#/components/schemas/PhoneNumber"}}}}}},"503":{"description":"`no_inventory` — no number available on those terms. Retry with a different `area_code` or later.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/accounts/{account_id}/message-templates":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}}],"get":{"tags":["Templates"],"operationId":"listMessageTemplates","summary":"List effective templates","description":"Returns what will actually be used: the account's own rows plus every shipped default it has not overridden. `scope` says which is which.","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message_templates":{"type":"array","items":{"$ref":"#/components/schemas/MessageTemplate"}}}}}}}}},"post":{"tags":["Templates"],"operationId":"createMessageTemplate","summary":"Override a template","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["message_template"],"properties":{"message_template":{"allOf":[{"$ref":"#/components/schemas/MessageTemplateInput"}],"required":["key","body"]}}}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message_template":{"$ref":"#/components/schemas/MessageTemplate"}}}}}},"422":{"description":"Validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/accounts/{account_id}/message-templates/{id}":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"Template id","schema":{"type":"integer"}}],"patch":{"tags":["Templates"],"operationId":"updateMessageTemplate","summary":"Update a template override","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["message_template"],"properties":{"message_template":{"$ref":"#/components/schemas/MessageTemplateInput"}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message_template":{"$ref":"#/components/schemas/MessageTemplate"}}}}}}}},"delete":{"tags":["Templates"],"operationId":"deleteMessageTemplate","summary":"Drop an override and fall back to the default","responses":{"204":{"description":"Deleted"}}}},"/accounts/{account_id}/flows":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}}],"get":{"tags":["Flows"],"operationId":"listFlows","summary":"List effective flows, with steps","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"flows":{"type":"array","items":{"$ref":"#/components/schemas/Flow"}}}}}}}}},"post":{"tags":["Flows"],"operationId":"createFlow","summary":"Create a flow","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["flow"],"properties":{"flow":{"allOf":[{"$ref":"#/components/schemas/FlowInput"}],"required":["key"]}}}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"flow":{"$ref":"#/components/schemas/Flow"}}}}}}}}},"/accounts/{account_id}/flows/{id}":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"Flow id","schema":{"type":"integer"}}],"patch":{"tags":["Flows"],"operationId":"updateFlow","summary":"Update a flow","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["flow"],"properties":{"flow":{"$ref":"#/components/schemas/FlowInput"}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"flow":{"$ref":"#/components/schemas/Flow"}}}}}}}},"delete":{"tags":["Flows"],"operationId":"deleteFlow","summary":"Delete a flow","responses":{"204":{"description":"Deleted"}}}},"/accounts/{account_id}/flows/{id}/fork":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"Id of a shipped default flow","schema":{"type":"integer"}}],"post":{"tags":["Flows"],"operationId":"forkFlow","summary":"Copy a shipped default into this account","description":"Makes an editable copy of a default flow, steps included, and returns it.\n\nThe step endpoints only reach flows the account owns, so this is how a\ndefault's ladder becomes changeable at all — the alternative is\nrecreating it step by step, which is many round trips to reach a copy\nthe server can make in one, and every intermediate state is a live flow\nthat is half written.\n\nIdempotent on the flow's key: forking twice returns the existing copy\nrather than leaving the account with two flows answering to one name.\nOnly a default this account would fall back to can be forked.\n","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"flow":{"$ref":"#/components/schemas/Flow"}}}}}},"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"flow":{"$ref":"#/components/schemas/Flow"}}}}}},"404":{"description":"No default flow with that id is available to this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/accounts/{account_id}/flows/{flow_id}/steps":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}},{"name":"flow_id","in":"path","required":true,"description":"Flow id","schema":{"type":"integer"}}],"get":{"tags":["Flows"],"operationId":"listFlowSteps","summary":"List the steps of a flow","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"steps":{"type":"array","items":{"$ref":"#/components/schemas/FlowStep"}}}}}}}}},"post":{"tags":["Flows"],"operationId":"createFlowStep","summary":"Add a step","description":"`choices` is an ordered array. Order is what a numbered SMS reply refers to, so it is preserved exactly as sent.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["step"],"properties":{"step":{"allOf":[{"$ref":"#/components/schemas/FlowStepInput"}],"required":["position"]}}}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"step":{"$ref":"#/components/schemas/FlowStep"}}}}}}}}},"/accounts/{account_id}/flows/{flow_id}/steps/{id}":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}},{"name":"flow_id","in":"path","required":true,"description":"Flow id","schema":{"type":"integer"}},{"name":"id","in":"path","required":true,"description":"Step id","schema":{"type":"integer"}}],"patch":{"tags":["Flows"],"operationId":"updateFlowStep","summary":"Update a step","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["step"],"properties":{"step":{"$ref":"#/components/schemas/FlowStepInput"}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"step":{"$ref":"#/components/schemas/FlowStep"}}}}}}}},"delete":{"tags":["Flows"],"operationId":"deleteFlowStep","summary":"Delete a step","responses":{"204":{"description":"Deleted"}}}},"/accounts/{account_id}/contacts":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}}],"get":{"tags":["Contacts"],"operationId":"listContacts","summary":"List contacts","parameters":[{"name":"pipeline_state","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PipelineState"}},{"name":"external_ref","in":"query","required":false,"description":"Your own identifier for this person, if you set one.","schema":{"type":"string"}},{"name":"open","in":"query","required":false,"description":"`true` excludes contacts in `won` or `lost`.","schema":{"type":"string","enum":["true"]}},{"name":"created_since","in":"query","required":false,"description":"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.","schema":{"type":"string","format":"date-time"}},{"name":"updated_since","in":"query","required":false,"description":"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. Note that results are ordered by creation, so a contact updated during your pagination can enter the filtered set at a position you have already passed; a sweep that runs on a schedule picks it up next time.","schema":{"type":"string","format":"date-time"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1}},{"name":"per_page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200,"default":50}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"contacts":{"type":"array","items":{"$ref":"#/components/schemas/Contact"}}}}}}},"422":{"description":"`validation_failed` — `created_since` or `updated_since` was not a valid ISO 8601 timestamp. Refused rather than ignored, so a broken filter cannot look like an empty result.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Contacts"],"operationId":"createContact","summary":"Add a lead","description":"This is the money path. With `start_conversation: true` the first\noutbound reply is enqueued on the high-priority lane immediately;\nit is dispatched asynchronously so your response time never depends\non the carrier's.\n\nConsent is recorded, never assumed. Set `sms_consented: true`\nON the contact to assert it, and it lands as a defensible\ntimestamp. It sits with `content_consent`, the other tier,\nbecause both are facts about the person rather than instructions\nabout the request.\n\nFor a minor, set `guardian_phone` (or link a `guardian`): the\nguardian becomes the message recipient, structurally.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["contact"],"properties":{"contact":{"$ref":"#/components/schemas/ContactInput"},"start_conversation":{"type":"boolean","description":"Enqueue the first reply on the high-priority lane."}}}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"contact":{"$ref":"#/components/schemas/Contact"}}}}}},"422":{"description":"Validation failed — a contact must have a phone, an email, or a guardian","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/accounts/{account_id}/contacts/{id}":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"Contact slug","schema":{"type":"string"}}],"get":{"tags":["Contacts"],"operationId":"getContact","summary":"Fetch a contact","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"contact":{"$ref":"#/components/schemas/Contact"}}}}}}}},"patch":{"tags":["Contacts"],"operationId":"updateContact","summary":"Update a contact","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["contact"],"properties":{"contact":{"$ref":"#/components/schemas/ContactInput"}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"contact":{"$ref":"#/components/schemas/Contact"}}}}}}}}},"/accounts/{account_id}/conversations":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}}],"get":{"tags":["Conversations"],"operationId":"listConversations","summary":"List conversations","parameters":[{"name":"needs_human","in":"query","required":false,"description":"`true` returns only threads the bot has escalated.","schema":{"type":"string","enum":["true"]}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1}},{"name":"per_page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200,"default":50}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"conversations":{"type":"array","items":{"$ref":"#/components/schemas/Conversation"}}}}}}}}},"post":{"tags":["Conversations"],"operationId":"createConversation","summary":"Open a thread with a contact without sending anything","description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["conversation"],"properties":{"conversation":{"allOf":[{"$ref":"#/components/schemas/ConversationInput"}],"required":["contact_id"]}}}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"conversation":{"$ref":"#/components/schemas/Conversation"}}}}}},"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"conversation":{"$ref":"#/components/schemas/Conversation"}}}}}}}}},"/accounts/{account_id}/conversations/{id}":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"Conversation id","schema":{"type":"integer"}}],"get":{"tags":["Conversations"],"operationId":"getConversation","summary":"Fetch a conversation with its full message history","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"conversation":{"$ref":"#/components/schemas/ConversationWithMessages"}}}}}}}}},"/conversations/{conversation_id}/messages":{"parameters":[{"name":"conversation_id","in":"path","required":true,"description":"Conversation id","schema":{"type":"integer"}}],"get":{"tags":["Conversations"],"operationId":"listMessages","summary":"List the messages in a thread, oldest first","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"messages":{"type":"array","items":{"$ref":"#/components/schemas/Message"}}}}}}}}},"post":{"tags":["Conversations"],"operationId":"sendMessage","summary":"Take over the thread","description":"A human sending into the thread. This pauses that thread's\nautomation for 24 hours as a side effect — the bot must not talk\nover a person, and making it automatic means nobody can forget.\n\nIt skips template lookup, flow logic, and quiet hours. It does not\nskip opt-out or suppression: \"a person typed it\" is not a defence\nagainst texting someone who replied STOP.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["message"],"properties":{"message":{"type":"object","required":["body"],"properties":{"body":{"type":"string"}}}}}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"$ref":"#/components/schemas/Message"}}}}}},"422":{"description":"`send_failed` — the reason is in `error.details.reason`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/conversations/{conversation_id}/sends":{"parameters":[{"name":"conversation_id","in":"path","required":true,"description":"Conversation id","schema":{"type":"integer"}}],"post":{"tags":["Conversations"],"operationId":"sendFromTemplate","summary":"Send one of your own templates","description":"Your automation sending a message, as opposed to a person typing\none. Use this rather than `sendMessage` for anything a machine\ndecided to send.\n\nEverything your automation could get wrong on its own is applied\nhere: quiet hours in the recipient's timezone, opt-outs,\nsuppression, and not sending the same template twice to the same\nperson for the same occurrence. A message held for quiet hours\nanswers `202` with status `requeued` and goes out when the window\nopens — it is not lost, and you should not retry it.\n\nUnlike `sendMessage`, this does NOT pause the thread's automation,\nbecause nobody has taken over.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["send"],"properties":{"send":{"type":"object","required":["template_key"],"properties":{"template_key":{"type":"string","example":"intro.followup_day4"},"occurrence":{"type":"integer","default":0,"description":"Distinguishes repeats of the same template to the same person."},"variables":{"type":"object","additionalProperties":{"type":"string"},"description":"Values for the template's {{placeholders}}."}}}}}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"send":{"$ref":"#/components/schemas/Send"}}}}}},"202":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"send":{"$ref":"#/components/schemas/Send"}}}}}},"422":{"description":"`send_refused` — the reason is in `error.details.reason`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/conversations/{id}/resume":{"parameters":[{"name":"id","in":"path","required":true,"description":"Conversation id","schema":{"type":"integer"}}],"post":{"tags":["Conversations"],"operationId":"resumeConversation","summary":"End a takeover early","description":"Clears the 24-hour pause and the needs-human flag, so automation picks the thread back up now.","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"conversation":{"$ref":"#/components/schemas/Conversation"}}}}}}}}},"/accounts/{account_id}/availability-rules":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}}],"get":{"tags":["Scheduling"],"operationId":"listAvailabilityRules","summary":"List the recurring weekly windows","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"availability_rules":{"type":"array","items":{"$ref":"#/components/schemas/AvailabilityRule"}}}}}}}}},"post":{"tags":["Scheduling"],"operationId":"createAvailabilityRule","summary":"Add a weekly window","description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["availability_rule"],"properties":{"availability_rule":{"allOf":[{"$ref":"#/components/schemas/AvailabilityRuleInput"}],"required":["weekday","start_minute","end_minute"]}}}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"availability_rule":{"$ref":"#/components/schemas/AvailabilityRule"}}}}}},"422":{"description":"Validation failed — `weekday` outside 0–6, or `end_minute` not after `start_minute`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/accounts/{account_id}/availability-rules/{id}":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"Availability rule id","schema":{"type":"integer"}}],"patch":{"tags":["Scheduling"],"operationId":"updateAvailabilityRule","summary":"Update a weekly window","description":"Takes effect on the next slot computation. Appointments already booked into a window you narrow are not disturbed — they are rows, not slots.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["availability_rule"],"properties":{"availability_rule":{"$ref":"#/components/schemas/AvailabilityRuleInput"}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"availability_rule":{"$ref":"#/components/schemas/AvailabilityRule"}}}}}},"422":{"description":"Validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Scheduling"],"operationId":"deleteAvailabilityRule","summary":"Remove a weekly window","responses":{"204":{"description":"Deleted"}}}},"/accounts/{account_id}/availability-exceptions":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}}],"get":{"tags":["Scheduling"],"operationId":"listAvailabilityExceptions","summary":"List the days the rules do not apply","parameters":[{"name":"upcoming","in":"query","required":false,"description":"`true` excludes exceptions whose date has passed.","schema":{"type":"string","enum":["true"]}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"availability_exceptions":{"type":"array","items":{"$ref":"#/components/schemas/AvailabilityException"}}}}}}}}},"post":{"tags":["Scheduling"],"operationId":"createAvailabilityException","summary":"Block a day, or part of one","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["availability_exception"],"properties":{"availability_exception":{"allOf":[{"$ref":"#/components/schemas/AvailabilityExceptionInput"}],"required":["date"]}}}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"availability_exception":{"$ref":"#/components/schemas/AvailabilityException"}}}}}},"422":{"description":"Validation failed — a partial exception needs both `start_minute` and `end_minute`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/accounts/{account_id}/availability-exceptions/{id}":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"Availability exception id","schema":{"type":"integer"}}],"delete":{"tags":["Scheduling"],"operationId":"deleteAvailabilityException","summary":"Unblock a day","responses":{"204":{"description":"Deleted"}}}},"/accounts/{account_id}/slots":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}}],"get":{"tags":["Scheduling"],"operationId":"listSlots","summary":"Compute open times","description":"Read-only, and there is deliberately no operation that creates a\nslot. A slot is not a record; it is derived at the moment you ask,\nfrom availability rules minus exceptions, minus live appointments,\nminus the account's minimum notice. Storing slots means eventually\nserving one that stopped being true, and that is how a\ndouble-booking happens.\n\nWhich also means a slot is an offer, not a hold. Nothing is\nreserved until an appointment exists. Book one by passing its\n`starts_at` back to `POST /accounts/{account_id}/appointments`,\nand expect `409 slot_taken` if someone else got there first.\n\nReturned soonest first, and rendered in the account's timezone\nalongside the instant, so a caller showing a time to a human does\nnot have to reimplement the account's timezone rules to get it.\n","parameters":[{"name":"appointment_type","in":"query","required":false,"description":"Matches the `appointment_type` on the account's availability rules. Opaque to Flywheel.","schema":{"type":"string","default":"intro"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":50,"default":10}},{"name":"horizon_days","in":"query","required":false,"description":"How far ahead to look. Values above 60 are clamped to 60.","schema":{"type":"integer","minimum":1,"maximum":60,"default":14}}],"responses":{"200":{"description":"Open times, soonest first. An account with no active rules returns an empty array rather than an error.","content":{"application/json":{"schema":{"type":"object","properties":{"slots":{"type":"array","items":{"$ref":"#/components/schemas/Slot"}},"time_zone":{"type":"string","description":"The account's IANA zone — the one every `local_*` field is rendered in."}}}}}}}}},"/accounts/{account_id}/appointments":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}}],"get":{"tags":["Appointments"],"operationId":"listAppointments","summary":"List appointments, soonest first","parameters":[{"name":"state","in":"query","required":false,"schema":{"$ref":"#/components/schemas/AppointmentState"}},{"name":"upcoming","in":"query","required":false,"description":"`true` returns only live appointments still in the future.","schema":{"type":"string","enum":["true"]}},{"name":"past","in":"query","required":false,"description":"`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.","schema":{"type":"string","enum":["true"]}},{"name":"contact_id","in":"query","required":false,"description":"Limits to one contact, by contact id.","schema":{"type":"string"}},{"name":"attended_since","in":"query","required":false,"description":"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. 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.","schema":{"type":"string","format":"date-time"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1}},{"name":"per_page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200,"default":50}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"appointments":{"type":"array","items":{"$ref":"#/components/schemas/Appointment"}}}}}}},"422":{"description":"`validation_failed` — `attended_since` was not a valid ISO 8601 timestamp. Refused rather than ignored, so a broken filter cannot look like an empty result.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Appointments"],"operationId":"createAppointment","summary":"Book a time","description":"Pass a `starts_at` you got from\n`GET /accounts/{account_id}/slots`. The race between being offered\na time and confirming it cannot be closed in application code, so\na unique index decides it and the loser is told to pick again\nrather than being silently double-booked.\n\nThe three failures are distinguishable on purpose, because they\ncall for different behaviour:\n\n- **409 `slot_taken`** — someone else took that instant between\n  the offer and this request. The request itself was valid.\n  Re-fetch slots and retry with a different time. This is the only\n  one worth retrying.\n- **422 `too_soon`** — inside the account's\n  `minimum_notice_minutes`. Retrying the same `starts_at` fails\n  identically until the window moves past it.\n- **422 `in_the_past`** — `starts_at` has already passed. Never\n  retryable.\n\nAn agent that retries blindly on 422 loops forever, so branch on\n`error.code` rather than on the status alone.\n\nOn success the contact advances to `scheduled`, reminders are\nscheduled, and `appointment.booked` is emitted.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["appointment"],"properties":{"appointment":{"allOf":[{"$ref":"#/components/schemas/AppointmentInput"}],"required":["contact_id","starts_at"]}}}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"appointment":{"$ref":"#/components/schemas/Appointment"}}}}}},"404":{"description":"No contact with that slug in this account","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"`slot_taken` — lost the race for that instant. Re-fetch slots and pick another. Retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"`too_soon` or `in_the_past`. Not retryable with the same `starts_at`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/appointments/{id}":{"parameters":[{"name":"id","in":"path","required":true,"description":"Appointment id","schema":{"type":"integer"}}],"get":{"tags":["Appointments"],"operationId":"getAppointment","summary":"Fetch an appointment","description":"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.","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"appointment":{"$ref":"#/components/schemas/Appointment"}}}}}},"403":{"description":"This key has no authority over that appointment's account","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such appointment","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/appointments/{id}/attend":{"parameters":[{"name":"id","in":"path","required":true,"description":"Appointment id","schema":{"type":"integer"}}],"post":{"tags":["Appointments"],"operationId":"attendAppointment","summary":"Record that they turned up","description":"Emits `appointment.attended`, and is the only thing that creates a\nreview request.\n\nIt takes no rating and no opinion of how the appointment went, and\nit never will. An attendance signal that could carry one would be\na review-gating mechanism, because the ask is triggered from here.\n\nFlywheel records attendance and never bills it. Whether an\nattended appointment is worth money is the subscriber's business,\nwhich is what lets a subscriber bill on attended intros without\nFlywheel knowing what one costs.\n","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"source":{"$ref":"#/components/schemas/AttendanceSource","default":"manual"}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"appointment":{"$ref":"#/components/schemas/Appointment"}}}}}},"409":{"description":"`conflict` — the appointment's current state has no transition to `attended`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"`invalid_source` — `source` is not one of the recorded attendance sources","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/appointments/{id}/no-show":{"parameters":[{"name":"id","in":"path","required":true,"description":"Appointment id","schema":{"type":"integer"}}],"post":{"tags":["Appointments"],"operationId":"noShowAppointment","summary":"Record that they did not turn up","description":"Emits `appointment.no_show`. Reversible to `attended` — a no-show recorded in error is a correction, not a dead end — but nothing else.","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"appointment":{"$ref":"#/components/schemas/Appointment"}}}}}},"409":{"description":"`conflict` — the appointment's current state has no transition to `no_show`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/appointments/{id}/cancel":{"parameters":[{"name":"id","in":"path","required":true,"description":"Appointment id","schema":{"type":"integer"}}],"post":{"tags":["Appointments"],"operationId":"cancelAppointment","summary":"Cancel an appointment","description":"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.","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string","description":"Free text, stored verbatim."}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"appointment":{"$ref":"#/components/schemas/Appointment"}}}}}},"409":{"description":"`conflict` — the appointment is already in a terminal state","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/appointments/{id}/reschedule":{"parameters":[{"name":"id","in":"path","required":true,"description":"Appointment id","schema":{"type":"integer"}}],"post":{"tags":["Appointments"],"operationId":"rescheduleAppointment","summary":"Move an appointment to a new time","description":"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, so a `409` means the contact still holds the time they had.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["appointment"],"properties":{"appointment":{"type":"object","required":["starts_at"],"properties":{"starts_at":{"type":"string","format":"date-time","description":"The new start. Duration carries over from the original rather than being re-derived from availability."}}}}}}}},"responses":{"200":{"description":"Moved. `appointment` is the new booking; `previous` is the original, now `rescheduled`.","content":{"application/json":{"schema":{"type":"object","properties":{"appointment":{"$ref":"#/components/schemas/Appointment"},"previous":{"$ref":"#/components/schemas/Appointment"}}}}}},"409":{"description":"`slot_taken` — the new time was booked by someone else. The original is untouched.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"`not_live` — the appointment is already terminal. Also `in_the_past` and `too_soon` for the new time.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/accounts/{account_id}/review-requests":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}}],"get":{"tags":["Reviews"],"operationId":"listReviewRequests","summary":"List review asks","description":"**There is no create operation, and there will not be one.** A\nreview request exists because an appointment was attended, and for\nno other reason. An endpoint that let a caller decide who gets\nasked is the mechanism for review gating — asking the customers\nyou expect to be happy — whatever the intent behind adding it, so\nthe capability is absent rather than policed.\n\nWhat follows from that, structurally rather than by convention:\nthe ask is uniform, it is capped at one ask plus one reminder per\nappointment, and once a household has been asked the whole family\nis left alone for 90 days.\n\n`rating` is recorded and never read back. Nothing in the\neligibility path can see it.\n","parameters":[{"name":"state","in":"query","required":false,"schema":{"$ref":"#/components/schemas/ReviewRequestState"}},{"name":"open","in":"query","required":false,"description":"`true` returns only requests still in `pending`, `asked`, or `reminded`.","schema":{"type":"string","enum":["true"]}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1}},{"name":"per_page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200,"default":50}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"review_requests":{"type":"array","items":{"$ref":"#/components/schemas/ReviewRequest"}}}}}}}}}},"/review-requests/{id}/complete":{"parameters":[{"name":"id","in":"path","required":true,"description":"Review request id","schema":{"type":"integer"}}],"post":{"tags":["Reviews"],"operationId":"completeReviewRequest","summary":"Record that a review arrived","description":"Emits `review.received`. Reachable from `pending` as well as from\n`asked` and `reminded`: a review can arrive before we ask, and\nrecording it is what stops the ask going out afterwards.\n\n`rating` is written here and never read anywhere that decides\nwhether to ask. Sending it does not and cannot influence who is\nasked next.\n","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"rating":{"type":"integer","minimum":1,"maximum":5},"body":{"type":"string"}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"review_request":{"$ref":"#/components/schemas/ReviewRequest"}}}}}},"409":{"description":"`conflict` — already completed, declined, or retired","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Validation failed — `rating` must be an integer 1–5","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/review-requests/{id}/decline":{"parameters":[{"name":"id","in":"path","required":true,"description":"Review request id","schema":{"type":"integer"}}],"post":{"tags":["Reviews"],"operationId":"declineReviewRequest","summary":"Record that they will not be leaving one","description":"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.","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"review_request":{"$ref":"#/components/schemas/ReviewRequest"}}}}}},"409":{"description":"`conflict` — already completed, declined, or retired","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/accounts/{account_id}/content-check":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}}],"post":{"tags":["Content"],"operationId":"checkContent","summary":"Check a draft against its subjects' consent","description":"A POST because you submit a draft, not because anything is\nstored — **nothing is stored**. No row is written, no consent\nstate changes, and calling this twice is identical to calling it\nonce.\n\nSubjects are named by contact slug, and the consent tier comes\nfrom Flywheel's record of the person rather than from whatever the\ncaller believes it to be. That is the point: a subscriber cannot\ntalk itself into publishing by passing an optimistic tier. A slug\nthat does not resolve is a 404, never a silently dropped subject,\nbecause dropping it would turn a typo into permission to publish.\n\nTwo guards run:\n\n- **Tier** — a name or a photo may not exceed the subject's\n  consent tier. `none` means opted out of published content\n  entirely: no reference at all.\n- **Re-identification** — even at `anonymous`, a draft may not\n  combine more than two identifying specifics. Age, grade, and\n  achievement are detected from the text; location, school, or\n  neighbourhood terms have to be supplied in\n  `identifying_terms`, because Flywheel cannot know yours. Three\n  specifics single someone out in a small town with no name ever\n  appearing.\n\nMeant to run at draft time **and** at publish time. The consent\ntier can change between the two, and only the check at publish\ntime is the one that was true.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContentCheckInput"}}}},"responses":{"200":{"description":"Checked. Nothing was stored, including the draft. `allowed: false` is an answer, not an error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContentCheckResult"}}}},"404":{"description":"A slug in `contact_ids` is not a contact of this account","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/accounts/{account_id}/webhook-endpoints":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}}],"get":{"tags":["Webhooks"],"operationId":"listWebhookEndpoints","summary":"List registered endpoints","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"webhook_endpoints":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEndpoint"}}}}}}}}},"post":{"tags":["Webhooks"],"operationId":"createWebhookEndpoint","summary":"Register an endpoint","description":"Must be `https`. The signing secret is returned once, here, and is\nunrecoverable afterward — a lost secret means rotating the endpoint.\nPayloads are signed HMAC-SHA256 with it.\n\nAn empty `events` array subscribes to everything.\n\nDelivery is live. Each attempt POSTs the envelope with\n`X-Flywheel-Signature` (hex HMAC-SHA256 of the raw body),\n`X-Flywheel-Event`, and `X-Flywheel-Delivery`. Treat\n`X-Flywheel-Delivery` as the idempotency key: a retry repeats it,\nso it is how you tell a retry from a second event.\n\nEvery attempt is a persisted row rather than a fire-and-forget\nrequest, so \"did you try?\" has an answer. Failures retry on a\nfixed backoff (10 seconds, 30 seconds, 2 minutes, 5 minutes, 15 minutes) and then go `dead` instead\nof retrying forever. 20\nconsecutive failures deactivates the endpoint, so a receiver that\nstays down does not accumulate a backlog we later flush at it all\nat once.\n\nWe will never do that quietly. Deactivation emails the address on\nyour account and stamps `disabled_at`, and delivery resumes when\nyou call `enableWebhookEndpoint` — which also clears the failure\ncount, so one further hiccup will not switch it straight back off.\nEvents dropped in the meantime are not replayed; read the state\nyou care about instead.\n\nFlywheel pushes outward and never calls back into a subscriber, so\nconsistency across the boundary is eventual by design — these\nretries are what the two sides reconcile through.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["webhook_endpoint"],"properties":{"webhook_endpoint":{"allOf":[{"$ref":"#/components/schemas/WebhookEndpointInput"}],"required":["url"]}}}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"webhook_endpoint":{"$ref":"#/components/schemas/WebhookEndpointWithSecret"}}}}}},"422":{"description":"Validation failed — `url` must be https","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/accounts/{account_id}/webhook-endpoints/{id}":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"Endpoint id","schema":{"type":"integer"}}],"patch":{"tags":["Webhooks"],"operationId":"updateWebhookEndpoint","summary":"Change what an endpoint hears about","description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["webhook_endpoint"],"properties":{"webhook_endpoint":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"type":"string","enum":["contact.created","contact.qualified","contact.opted_out","conversation.needs_human","message.received","appointment.booked","appointment.attended","appointment.no_show","appointment.canceled","appointment.rescheduled","review.received"]},"description":"Replaces the current list. An empty array means every event."}}}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"webhook_endpoint":{"$ref":"#/components/schemas/WebhookEndpoint"}}}}}},"422":{"description":"`validation_failed` — an unrecognised event name","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Webhooks"],"operationId":"deleteWebhookEndpoint","summary":"Remove an endpoint","responses":{"204":{"description":"Deleted"}}}},"/accounts/{account_id}/webhook-endpoints/{id}/enable":{"parameters":[{"name":"account_id","in":"path","required":true,"description":"Account slug. Not a numeric id — an integer would expose row counts and invite guessing.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"Endpoint id","schema":{"type":"integer"}}],"post":{"tags":["Webhooks"],"operationId":"enableWebhookEndpoint","summary":"Resume delivery after repeated failures switched it off","description":"Turns the endpoint back on and clears `consecutive_failures` in the\nsame operation. Those two have to move together: an endpoint\nre-enabled while still holding\n20 failures is switched\noff again by the very next one, and you would see a single event\narrive and then silence.\n\nIdempotent, and safe on an endpoint that was never disabled — call\nit while recovering without first checking whether you need to.\n\nIt does NOT replay what you missed. Flushing a backlog at a\nreceiver that has just come back up is what the failure limit\nexists to prevent, so catch up by reading contacts, appointments\nand reviews instead.\n","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"webhook_endpoint":{"$ref":"#/components/schemas/WebhookEndpoint"}}}}}}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"`Authorization: Bearer \u003ctoken\u003e`.\n\nAn **account key** is scoped to one account — what a direct\nsubscriber gets. A **partner key** may act on the accounts it\nprovisioned — what an agency or reseller gets. A partner key gets no\nscope a stranger could not obtain.\n"}},"schemas":{"PipelineState":{"type":"string","description":"One generic ladder for every vertical. Verticals differ in what the stages mean, not in their shape.","enum":["new","engaged","qualified","scheduled","showed","won","lost"]},"ContentConsent":{"type":"string","description":"Ordered most to least restrictive. `anonymous` is the notice-only default; naming or picturing someone requires an actively recorded decision.","enum":["none","anonymous","first_name","photo_video"]},"AppointmentState":{"type":"string","description":"`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.","enum":["booked","reminded","attended","no_show","canceled","rescheduled"]},"AttendanceSource":{"type":"string","description":"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.","enum":["confirmed","deemed","manual"]},"ReviewRequestState":{"type":"string","description":"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.","enum":["pending","asked","reminded","completed","declined","retired"]},"WebhookEvent":{"type":"string","description":"Every event Flywheel emits. Read from the emitter itself, so this list cannot drift from what actually fires.","enum":["contact.created","contact.qualified","contact.opted_out","conversation.needs_human","message.received","appointment.booked","appointment.attended","appointment.no_show","appointment.canceled","appointment.rescheduled","review.received"]},"Error":{"type":"object","description":"Every failure. Branch on `code`; `message` is for humans and may change.","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["unauthorized","invalid_credentials","forbidden","not_found","validation_failed","bad_request","conflict","send_failed","send_refused","slot_taken","too_soon","in_the_past","not_live","invalid_source","no_phone_number","no_inventory","rate_limited"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":true}},"required":["code","message"]}}},"Account":{"type":"object","description":"The tenant, and the voice. Messages in an operated account still speak as the account, never as its operator.","properties":{"id":{"type":"string","description":"Opaque slug."},"name":{"type":"string"},"display_name":{"type":["string","null"]},"contact_first_name":{"type":["string","null"]},"contact_email":{"type":["string","null"]},"contact_phone":{"type":["string","null"],"description":"E.164. Where account-level notices go."},"website_url":{"type":["string","null"]},"brand_color":{"type":["string","null"]},"time_zone":{"type":"string","description":"IANA name, e.g. America/Chicago."},"phone_number":{"type":["string","null"],"description":"E.164 sending number."},"vertical":{"type":["string","null"],"description":"Drives which shipped defaults this account falls back to."},"status":{"type":"string","enum":["active","paused","canceled"]},"serves_minors":{"type":"boolean","description":"Defaults to true: a business that does not declare gets the protective posture."},"default_content_consent":{"$ref":"#/components/schemas/ContentConsent"},"quiet_hours":{"type":"object","description":"Minutes past midnight, evaluated in the RECIPIENT's timezone. A blocked message is requeued to the next open window, not dropped.","properties":{"start_minute":{"type":"integer","minimum":0,"maximum":1439},"end_minute":{"type":"integer","minimum":0,"maximum":1439}}},"minimum_notice_minutes":{"type":["integer","null"]},"deemed_attendance_after_hours":{"type":["integer","null"],"description":"When set, an appointment left unactioned this many hours past its end is recorded attended with `attendance_source: deemed`. Null means never."},"review_ask_after_attendances":{"type":"integer","description":"How many attended appointments a family needs before a review is asked for. 1 (the default) asks after the first."},"review_url":{"type":["string","null"],"description":"Where a review ask sends people. The account's own URL, never one Flywheel hosts. Blank means no review is ever asked for."},"external_ref":{"type":["string","null"],"description":"Your own identifier. Opaque to Flywheel, never constrained."},"login_email":{"type":["string","null"],"description":"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 entirely through the API."},"calendar_url":{"type":"string","format":"uri","description":"Subscribable ICS feed of this account's appointments, keyed by an unguessable token. Read-only and unauthenticated by design, because calendar apps poll it without credentials — treat the URL itself as the secret. Minted the first time the account is serialized."},"operated_by":{"type":["string","null"],"description":"Slug of the operating account, if any."},"created_at":{"type":"string","format":"date-time"}}},"AccountInput":{"type":"object","properties":{"name":{"type":"string"},"display_name":{"type":"string"},"contact_first_name":{"type":"string"},"contact_email":{"type":"string"},"contact_phone":{"type":"string","description":"E.164. Where account-level notices go."},"website_url":{"type":"string"},"time_zone":{"type":"string","examples":["America/Chicago"]},"vertical":{"type":"string","examples":["plumbing"]},"external_ref":{"type":"string"},"brand_color":{"type":"string"},"login_email":{"type":"string","format":"email","description":"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 succeed, and is rejected."},"password":{"type":"string","format":"password","minLength":10,"description":"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-provisioned one."},"serves_minors":{"type":"boolean"},"default_content_consent":{"$ref":"#/components/schemas/ContentConsent"},"quiet_hours_start_minute":{"type":"integer","minimum":0,"maximum":1439},"quiet_hours_end_minute":{"type":"integer","minimum":0,"maximum":1439},"minimum_notice_minutes":{"type":"integer"},"deemed_attendance_after_hours":{"type":["integer","null"],"description":"Hours an appointment may sit unactioned past its end before it is recorded attended with `attendance_source: deemed`. Null (the default) means never. Only opt in if you honor the other half of that bargain with your customer — deciding something happened because nobody said otherwise is a claim they should be able to contest."},"review_ask_after_attendances":{"type":"integer","minimum":1,"maximum":10,"description":"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 is what you want when the first appointment is usually the only one. Raise it when the first appointment is a trial and the relationship is the product — 5 is roughly \"once there is something to review\". Capped at 10 deliberately: every family who stops before the threshold is never asked, and stopping early correlates with being unhappy, so a high enough number is review gating in disguise while still looking like asks are on. The ask itself never sees a rating and never will."},"review_url":{"type":"string","format":"uri","description":"Set this before you expect review asks — without it every ask is skipped."}}},"Contact":{"type":"object","properties":{"id":{"type":"string","description":"Opaque slug."},"name":{"type":"string","description":"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 addressable. Derived — set `first_name` and `last_name` to change it."},"first_name":{"type":["string","null"]},"last_name":{"type":["string","null"]},"phone":{"type":["string","null"]},"email":{"type":["string","null"]},"time_zone":{"type":["string","null"]},"age":{"type":["integer","null"]},"age_bucket":{"type":["string","null"],"enum":["under_13","13_to_17","18_plus",null]},"minor":{"type":"boolean","description":"A minor is never the message recipient; their guardian is."},"guardian_id":{"type":["string","null"]},"guardian_name":{"type":["string","null"]},"guardian_phone":{"type":["string","null"]},"pipeline_state":{"$ref":"#/components/schemas/PipelineState"},"lost_reason":{"type":["string","null"]},"source":{"type":["string","null"]},"sms_consented":{"type":"boolean"},"content_consent":{"$ref":"#/components/schemas/ContentConsent"},"content_consent_at":{"type":["string","null"],"format":"date-time","description":"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 simply the account default — which is the answer you need if you are ever asked to justify publishing someone's name or photo. Every change writes an immutable consent event alongside this timestamp."},"custom_fields":{"type":"object","additionalProperties":true,"description":"Whatever your flow steps write. This is where domain-specific answers land, which is why Flywheel needs no domain knowledge."},"external_ref":{"type":["string","null"]},"last_inbound_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"}}},"ConversationInput":{"type":"object","properties":{"contact_id":{"type":"string","description":"Contact id (slug) to open the thread with."}},"required":["contact_id"]},"ContactInput":{"type":"object","description":"Must be reachable: a phone, an email, or a guardian phone.","properties":{"first_name":{"type":"string"},"last_name":{"type":"string"},"phone":{"type":"string","description":"E.164."},"email":{"type":"string"},"time_zone":{"type":"string","description":"Used for quiet hours. Theirs, not yours."},"age":{"type":"integer"},"guardian_name":{"type":"string"},"guardian_phone":{"type":"string"},"source":{"type":"string"},"external_ref":{"type":"string"},"content_consent":{"$ref":"#/components/schemas/ContentConsent"},"content_consent_reason":{"type":"string","description":"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 grounds, not only when and by whom. Send it whenever a person decided this rather than a form capturing it."},"sms_consented":{"type":"boolean","description":"Assert that this person consented to SMS. Recorded as a timestamp, and it is what promotional sending is gated on — a contact without it can be created and then never marketed to."},"custom_fields":{"type":"object","additionalProperties":true}}},"Conversation":{"type":"object","properties":{"id":{"type":"integer"},"contact_id":{"type":"string","description":"Contact slug."},"contact_name":{"type":"string","description":"Same derivation as `Contact.name`. Here so that rendering a list of threads is one request rather than one per thread."},"channel_number":{"type":["string","null"]},"flow_key":{"type":["string","null"]},"automation_paused_until":{"type":["string","null"],"format":"date-time","description":"Set 24 hours out whenever a human sends into the thread."},"automation_paused":{"type":"boolean","description":"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 subtly wrong."},"needs_human":{"type":"boolean"},"needs_human_at":{"type":["string","null"],"format":"date-time"},"needs_human_reason":{"type":["string","null"],"enum":["unparsed_reply","repeated_reschedule",null],"description":"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 3 or more times."},"message_count":{"type":"integer"},"last_message_at":{"type":["string","null"],"format":"date-time"},"last_message_direction":{"type":["string","null"],"enum":["inbound","outbound",null]},"last_message_preview":{"type":["string","null"],"description":"First 140 characters of the most recent message."},"created_at":{"type":"string","format":"date-time"}}},"ConversationWithMessages":{"allOf":[{"$ref":"#/components/schemas/Conversation"},{"type":"object","properties":{"messages":{"type":"array","items":{"$ref":"#/components/schemas/Message"}}}}]},"Message":{"type":"object","properties":{"id":{"type":"integer"},"direction":{"type":"string","enum":["inbound","outbound"]},"channel":{"type":"string","enum":["sms"]},"from":{"type":["string","null"]},"to":{"type":["string","null"]},"body":{"type":["string","null"]},"template_key":{"type":["string","null"]},"status":{"type":["string","null"]},"classified_intent":{"type":["string","null"],"description":"What the rule-based classifier made of an inbound reply."},"sent_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"}}},"Send":{"type":"object","description":"The outcome of asking us to send a template. `requeued` means held for quiet hours and already scheduled — do not retry it.","properties":{"status":{"type":"string","enum":["sent","requeued"]},"message":{"$ref":"#/components/schemas/Message"},"scheduled_send_id":{"type":["integer","null"]}}},"MessageTemplate":{"type":"object","properties":{"id":{"type":"integer"},"key":{"type":"string"},"vertical":{"type":["string","null"]},"channel":{"type":"string","enum":["sms"],"description":"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":{"type":"string","description":"Supports {{variable}} interpolation."},"delay_minutes":{"type":["integer","null"],"description":"For `nurture.` keys, this is the rung's position in the ladder."},"guards":{"type":"object","additionalProperties":true,"description":"Halt conditions declared next to the copy they protect: `halt_if_replied`, `halt_if_pipeline_state_in`, `require_pipeline_state_in`."},"promotional":{"type":"boolean","description":"Promotional copy requires recorded consent and is never sent without it."},"transactional":{"type":"boolean","description":"Transactional copy survives a human takeover and quiet hours. Kept separate from `promotional` on purpose — a qualification question is neither."},"active":{"type":"boolean"},"scope":{"type":"string","enum":["default","account"]}}},"MessageTemplateInput":{"type":"object","properties":{"key":{"type":"string"},"vertical":{"type":"string"},"channel":{"type":"string","enum":["sms"]},"body":{"type":"string"},"delay_minutes":{"type":"integer"},"active":{"type":"boolean"},"promotional":{"type":"boolean"},"transactional":{"type":"boolean"},"guards":{"type":"object","additionalProperties":true}}},"Flow":{"type":"object","properties":{"id":{"type":"integer"},"key":{"type":"string"},"vertical":{"type":["string","null"]},"name":{"type":["string","null"]},"active":{"type":"boolean"},"scope":{"type":"string","enum":["default","account"]},"steps":{"type":"array","items":{"$ref":"#/components/schemas/FlowStep"}}}},"FlowInput":{"type":"object","properties":{"key":{"type":"string"},"vertical":{"type":"string"},"name":{"type":"string"},"active":{"type":"boolean"}}},"FlowStep":{"type":"object","properties":{"id":{"type":"integer"},"position":{"type":"integer"},"template_key":{"type":["string","null"]},"expects":{"type":["string","null"],"enum":["choice","number","boolean","freeform","confirm","name",null]},"writes_to":{"type":["string","null"],"description":"Contact field the captured answer lands in, e.g. `age` or `custom_fields.issue`."},"choices":{"$ref":"#/components/schemas/FlowStepChoices"},"branch_on":{"type":"object","additionalProperties":{"type":"integer"},"description":"Captured value to next position."},"default_next_position":{"type":["integer","null"],"description":"Where to go when no branch matches. Lets branches rejoin somewhere other than the next row."},"on_confirm_action":{"type":["string","null"]},"terminal":{"type":"boolean"}}},"FlowStepChoices":{"type":"array","description":"ORDERED. Position is what a numbered SMS reply refers to — texting '2' selects the second entry — so this is an array, never an object.","items":{"type":"object","required":["value"],"properties":{"value":{"type":"string","description":"The canonical value written to `writes_to`."},"synonyms":{"type":"array","items":{"type":"string"},"description":"How real people say it. This is where domain vocabulary lives, which is why the engine needs none."}}},"examples":[[{"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"]}]]},"FlowStepInput":{"type":"object","properties":{"position":{"type":"integer"},"template_key":{"type":"string"},"expects":{"type":"string","enum":["choice","number","boolean","freeform","confirm","name"]},"writes_to":{"type":"string"},"choices":{"$ref":"#/components/schemas/FlowStepChoices"},"branch_on":{"type":"object","additionalProperties":{"type":"integer"}},"default_next_position":{"type":"integer"},"on_confirm_action":{"type":"string"},"terminal":{"type":"boolean"}}},"PhoneNumber":{"type":"object","description":"The account's one sending number. `area_code` is present only in the response to the request that provisioned it.","properties":{"number":{"type":"string","description":"E.164."},"provider":{"type":"string"},"area_code":{"type":"string"},"sms":{"type":"boolean"},"voice":{"type":"boolean"}}},"AvailabilityRule":{"type":"object","description":"A recurring weekly window when the account will take appointments.","properties":{"id":{"type":"integer"},"weekday":{"type":"integer","minimum":0,"maximum":6,"description":"0 is Sunday, matching Ruby's `Date#wday`."},"start_minute":{"type":"integer","minimum":0,"maximum":1440,"description":"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 window by an hour across a DST transition."},"end_minute":{"type":"integer","minimum":0,"maximum":1440,"description":"Exclusive, and must be after `start_minute`."},"appointment_type":{"type":"string","description":"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":{"type":"integer","minimum":1,"description":"The window is divided into back-to-back slots of this length. A remainder shorter than one slot is not offered."},"active":{"type":"boolean","description":"Inactive rules produce no slots. Appointments already booked inside them are untouched."}}},"AvailabilityRuleInput":{"type":"object","properties":{"weekday":{"type":"integer","minimum":0,"maximum":6},"start_minute":{"type":"integer","minimum":0,"maximum":1440,"examples":[540]},"end_minute":{"type":"integer","minimum":0,"maximum":1440,"examples":[1020]},"appointment_type":{"type":"string","default":"intro"},"slot_duration_minutes":{"type":"integer","minimum":1,"default":30},"active":{"type":"boolean","default":true}}},"AvailabilityException":{"type":"object","description":"A day, or part of one, when the recurring rules do not apply.","properties":{"id":{"type":"integer"},"date":{"type":"string","format":"date","description":"The account's local date."},"all_day":{"type":"boolean"},"start_minute":{"type":["integer","null"],"description":"Present only for a partial block. Minutes from local midnight."},"end_minute":{"type":["integer","null"]},"reason":{"type":["string","null"],"description":"Free text, for the account's own benefit. Never sent to anyone."}}},"AvailabilityExceptionInput":{"type":"object","description":"Defaults to blocking the whole day. Set `all_day: false` and both minutes to block part of one.","properties":{"date":{"type":"string","format":"date"},"all_day":{"type":"boolean","default":true},"start_minute":{"type":"integer","minimum":0,"maximum":1440},"end_minute":{"type":"integer","minimum":0,"maximum":1440},"reason":{"type":"string"}}},"Slot":{"type":"object","description":"An open time as of the moment you asked. Not a hold — nothing is reserved until an appointment exists, so treat it as an offer that can be lost.","properties":{"starts_at":{"type":"string","format":"date-time","description":"Pass this back as an appointment's `starts_at` to book it."},"ends_at":{"type":"string","format":"date-time"},"local_date":{"type":"string","format":"date","description":"In the account's timezone."},"local_time":{"type":"string","examples":["3:30 PM"]},"label":{"type":"string","description":"Ready to show a human, rendered in the account's timezone.","examples":["Tuesday, March 4 at 3:30 PM"]}}},"Appointment":{"type":"object","properties":{"id":{"type":"integer"},"contact_id":{"type":"string","description":"Contact slug."},"contact_name":{"type":"string","description":"Same derivation as `Contact.name`."},"contact_phone":{"type":["string","null"],"description":"The number this appointment would be reminded on — the contact's, or their guardian's if the contact is a minor."},"conversation_id":{"type":["integer","null"],"description":"The thread it was booked from, when it was booked by text."},"starts_at":{"type":"string","format":"date-time"},"ends_at":{"type":"string","format":"date-time"},"local_label":{"type":"string","description":"The start rendered in the account's timezone.","examples":["Tuesday, March 4 at 3:30 PM"]},"appointment_type":{"type":"string"},"state":{"$ref":"#/components/schemas/AppointmentState"},"attendance_source":{"anyOf":[{"$ref":"#/components/schemas/AttendanceSource"},{"type":"null"}]},"attended_at":{"type":["string","null"],"format":"date-time"},"canceled_at":{"type":["string","null"],"format":"date-time"},"cancel_reason":{"type":["string","null"]},"location":{"type":["string","null"]},"notes":{"type":["string","null"]},"external_ref":{"type":["string","null"],"description":"Your own identifier, echoed back."},"created_at":{"type":"string","format":"date-time"}}},"AppointmentInput":{"type":"object","properties":{"contact_id":{"type":"string","description":"Contact SLUG, not an integer — same identifier the contact endpoints use."},"starts_at":{"type":"string","format":"date-time","description":"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 mean to."},"appointment_type":{"type":"string","default":"intro"},"duration_minutes":{"type":"integer","minimum":1,"description":"Defaults to the matching availability rule's `slot_duration_minutes`, or 30 if there is no rule."},"location":{"type":"string"},"external_ref":{"type":"string"}}},"ReviewRequest":{"type":"object","description":"One review ask. Created by attendance and by nothing else — there is no operation that makes one.","properties":{"id":{"type":"integer"},"contact_id":{"type":"string","description":"Contact slug."},"contact_name":{"type":"string","description":"Same derivation as `Contact.name`."},"appointment_id":{"type":["integer","null"],"description":"The attended appointment that caused this. At most one request per appointment, enforced by a unique index."},"state":{"$ref":"#/components/schemas/ReviewRequestState"},"platform":{"type":"string","description":"Where they are sent to write it."},"review_url":{"type":["string","null"],"description":"Copied from the account at creation. The account's own URL, never one Flywheel hosts."},"rating":{"type":["integer","null"],"minimum":1,"maximum":5,"description":"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 merely forbidden."},"body":{"type":["string","null"]},"asked_at":{"type":["string","null"],"format":"date-time"},"reminded_at":{"type":["string","null"],"format":"date-time","description":"The one reminder. There is no second."},"completed_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"}}},"ContentCheckInput":{"type":"object","description":"Flat rather than wrapped in a resource key, because no resource is created.","properties":{"title":{"type":"string"},"body":{"type":"string"},"contact_ids":{"type":"array","items":{"type":"string"},"description":"Slugs of the contacts the draft references. An unknown slug is a 404, not a dropped subject."},"has_media":{"type":"boolean","description":"True when the draft carries a photo or video of the subjects. Requires `photo_video` consent from each of them."},"identifying_terms":{"type":"array","items":{"type":"string"},"description":"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":{"type":"object","additionalProperties":{"type":"string","enum":["none","anonymous","first_name","photo_video"]},"description":"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."}}},"ContentCheckResult":{"type":"object","description":"The answer only. Nothing was stored, and no consent state moved.","properties":{"allowed":{"type":"boolean","description":"True only when `violations` is empty."},"violations":{"type":"array","items":{"$ref":"#/components/schemas/ContentViolation"}}}},"ContentViolation":{"type":"object","properties":{"subject_id":{"type":["string","null"],"description":"Slug of the contact this violation is about."},"type":{"type":"string","description":"Branch on this. `message` is for a human reviewing the draft.","enum":["opted_out","name_beyond_tier","media_beyond_tier","re_identification"]},"message":{"type":"string"}}},"WebhookEndpoint":{"type":"object","properties":{"id":{"type":"integer"},"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEvent"},"description":"Empty means all events. Every value is one of the `WebhookEvent` enum — an unrecognised name is rejected at registration rather than stored."},"active":{"type":"boolean","description":"Disabled automatically after 20 consecutive delivery failures. We email the account when that happens; call `enableWebhookEndpoint` to resume."},"consecutive_failures":{"type":"integer"},"disabled_at":{"type":["string","null"],"format":"date-time","description":"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":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"}}},"WebhookEndpointWithSecret":{"allOf":[{"$ref":"#/components/schemas/WebhookEndpoint"},{"type":"object","properties":{"secret":{"type":"string","description":"Returned once, at creation. HMAC-SHA256 signing key. Unrecoverable afterward."}}}]},"WebhookEndpointInput":{"type":"object","properties":{"url":{"type":"string","format":"uri","description":"Must be https."},"events":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEvent"},"description":"Empty subscribes to everything. Validated on write: an unrecognised event name is rejected with `validation_failed` rather than accepted into an endpoint that would then never fire."}}},"ApiKeyWithToken":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":["string","null"]},"kind":{"type":"string","enum":["account","partner","session"]},"last_four":{"type":["string","null"]},"last_used_at":{"type":["string","null"],"format":"date-time"},"expires_at":{"type":["string","null"],"format":"date-time","description":"When this key stops working. Null means it does not expire on its own; only `session` keys carry one."},"created_at":{"type":"string","format":"date-time"},"token":{"type":"string","description":"Shown exactly once. Store it now."}}}}}}