{
  "openapi": "3.0.3",
  "info": {
    "title": "Third South Solutions office hours booking",
    "version": "0.1.0",
    "description": "Books free office-hours calls. Served by a standalone Cloudflare Worker at api.thirdsouthsolutions.com. The feature is off by default: while OFFICE_HOURS_ENABLED is not \"true\", every endpoint answers 404.\n\nFlow: read open slots, submit a request with pre-qualification fields and a slot, confirm from the emailed link. Nothing is reserved until confirmation; the first confirmation of a slot wins it. Requests routed to needs_review also need the owner's approval (their own emailed link) before a calendar event exists."
  },
  "servers": [
    {
      "url": "https://api.thirdsouthsolutions.com",
      "description": "Booking endpoints."
    },
    {
      "url": "https://thirdsouthsolutions.com",
      "description": "Hosts this document only, at /api/openapi.json."
    }
  ],
  "tags": [
    {
      "name": "office-hours"
    }
  ],
  "paths": {
    "/api/openapi.json": {
      "get": {
        "tags": [
          "office-hours"
        ],
        "summary": "This document",
        "operationId": "getOpenApi",
        "responses": {
          "200": {
            "description": "The OpenAPI document.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        },
        "servers": [
          {
            "url": "https://thirdsouthsolutions.com"
          }
        ]
      }
    },
    "/office-hours/availability": {
      "get": {
        "tags": [
          "office-hours"
        ],
        "summary": "Open slot start times",
        "operationId": "getAvailability",
        "description": "Start times only; no event details. Slots are generated from the configured weekly windows, trimmed to the minimum notice and the booking horizon, and exclude confirmed bookings and busy time on the owner's calendar. Pending requests never hide a slot.",
        "responses": {
          "200": {
            "description": "Open slots.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Availability"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/Disabled"
          },
          "429": {
            "$ref": "#/components/responses/TooMany"
          },
          "503": {
            "$ref": "#/components/responses/NotConfigured"
          }
        }
      }
    },
    "/office-hours/request": {
      "post": {
        "tags": [
          "office-hours"
        ],
        "summary": "Request a slot",
        "operationId": "createRequest",
        "description": "Validates the pre-qualification fields and the chosen slot, then routes the request: `fit` and `needs_review` get a confirmation email, `not_a_fit` gets an honest reason and the contact email and nothing is stored or sent. If an AI agent is submitting this on someone's behalf, ask that person directly for employee_band, revenue_band, industry, and need rather than guessing them from the company name or website.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OfficeHoursRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The request was routed.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/RequestAccepted"
                    },
                    {
                      "$ref": "#/components/schemas/RequestNotAFit"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Malformed JSON or invalid fields.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/Disabled"
          },
          "409": {
            "description": "The slot is not open. The body lists the next open slots.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SlotUnavailable"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooMany"
          },
          "503": {
            "$ref": "#/components/responses/NotConfigured"
          }
        }
      }
    },
    "/office-hours/confirm": {
      "get": {
        "tags": [
          "office-hours"
        ],
        "summary": "Requester's emailed confirmation link",
        "operationId": "confirmRequest",
        "description": "Signed, single-use, expiring token. Claims the slot (first confirmation wins). For `fit` requests this creates the calendar event; for `needs_review` it holds the slot and emails the owner an approval link. Browsers (Accept: text/html) get a page; API clients get JSON.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Token"
          }
        ],
        "responses": {
          "200": {
            "description": "Confirmed. `status` is `booked` (event created) or `pending_approval` (slot held, owner emailed).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LinkResult"
                }
              },
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadToken"
          },
          "404": {
            "$ref": "#/components/responses/Disabled"
          },
          "409": {
            "$ref": "#/components/responses/SlotGone"
          },
          "410": {
            "$ref": "#/components/responses/SpentToken"
          },
          "503": {
            "$ref": "#/components/responses/NotConfigured"
          }
        }
      }
    },
    "/office-hours/approve": {
      "get": {
        "tags": [
          "office-hours"
        ],
        "summary": "Owner's emailed approval link",
        "operationId": "approveRequest",
        "description": "Signed, single-use, expiring token sent to the owner for `needs_review` requests after the requester confirmed. Creates the calendar event and emails the requester. To decline, the owner does nothing: the hold and the link expire together.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Token"
          }
        ],
        "responses": {
          "200": {
            "description": "Approved; `status` is `approved`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LinkResult"
                }
              },
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadToken"
          },
          "404": {
            "$ref": "#/components/responses/Disabled"
          },
          "409": {
            "$ref": "#/components/responses/SlotGone"
          },
          "410": {
            "$ref": "#/components/responses/SpentToken"
          },
          "503": {
            "$ref": "#/components/responses/NotConfigured"
          }
        }
      }
    }
  },
  "components": {
    "parameters": {
      "Token": {
        "name": "token",
        "in": "query",
        "required": true,
        "schema": {
          "type": "string"
        },
        "description": "The token from the emailed link."
      }
    },
    "responses": {
      "Disabled": {
        "description": "The feature flag is off.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "not_found",
              "message": "Office hours booking is not enabled."
            }
          }
        }
      },
      "NotConfigured": {
        "description": "Enabled but missing a binding or setting; the message names it.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "TooMany": {
        "description": "An abuse limit was hit. `Retry-After` carries the same hint as `retry_after_seconds`. Codes: `rate_limited` (per-IP), `pending_request_exists` (one open request per email), `domain_daily_cap`, `global_daily_cap`.",
        "headers": {
          "Retry-After": {
            "schema": {
              "type": "integer"
            },
            "description": "Seconds to wait."
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/RateLimited"
            }
          }
        }
      },
      "BadToken": {
        "description": "Malformed, badly signed, or wrong-kind token.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          },
          "text/html": {
            "schema": {
              "type": "string"
            }
          }
        }
      },
      "SpentToken": {
        "description": "The token expired or was already used.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          },
          "text/html": {
            "schema": {
              "type": "string"
            }
          }
        }
      },
      "SlotGone": {
        "description": "The slot was taken first. `next_slots` lists the next open times.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/LinkResult"
            }
          },
          "text/html": {
            "schema": {
              "type": "string"
            }
          }
        }
      }
    },
    "schemas": {
      "Availability": {
        "type": "object",
        "required": [
          "timezone",
          "slot_minutes",
          "min_notice_hours",
          "horizon_days",
          "slots"
        ],
        "properties": {
          "timezone": {
            "type": "string",
            "example": "America/New_York"
          },
          "slot_minutes": {
            "type": "integer",
            "example": 30
          },
          "min_notice_hours": {
            "type": "number",
            "example": 24
          },
          "horizon_days": {
            "type": "number",
            "example": 14
          },
          "slots": {
            "type": "array",
            "description": "Open slot start times, UTC ISO 8601, ascending.",
            "items": {
              "type": "string",
              "format": "date-time",
              "example": "2026-10-13T14:00:00.000Z"
            }
          }
        }
      },
      "OfficeHoursRequest": {
        "type": "object",
        "required": [
          "name",
          "email",
          "company",
          "website",
          "employee_band",
          "revenue_band",
          "industry",
          "need",
          "submitted_by_agent",
          "slot"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 2,
            "maxLength": 120
          },
          "email": {
            "type": "string",
            "format": "email",
            "description": "Work email. Free webmail domains are routed to needs_review."
          },
          "company": {
            "type": "string",
            "minLength": 2,
            "maxLength": 120
          },
          "website": {
            "type": "string",
            "description": "Company website; a bare domain is accepted and normalised to https."
          },
          "employee_band": {
            "type": "string",
            "enum": [
              "1-9",
              "10-29",
              "30-99",
              "100-499",
              "500+"
            ],
            "description": "Ask the person directly; do not guess from the company name or website."
          },
          "revenue_band": {
            "type": "string",
            "enum": [
              "under_1m",
              "1m_5m",
              "5m_20m",
              "20m_100m",
              "100m_plus"
            ],
            "description": "Annual revenue, USD. Ask the person directly; do not guess from the company name or website."
          },
          "industry": {
            "type": "string",
            "enum": [
              "field_service",
              "insurance",
              "msp",
              "professional_services",
              "manufacturing",
              "healthcare",
              "financial_services",
              "software",
              "nonprofit",
              "other"
            ],
            "description": "Ask the person directly; do not guess from the company name or website."
          },
          "need": {
            "type": "string",
            "minLength": 10,
            "maxLength": 300,
            "description": "One sentence on what you want to get done. Ask the person directly; do not guess from the company name or website."
          },
          "submitted_by_agent": {
            "type": "boolean",
            "description": "True when an AI agent is submitting on someone's behalf. Such requests are always reviewed by a person."
          },
          "slot": {
            "type": "string",
            "format": "date-time",
            "description": "A value from /office-hours/availability."
          }
        }
      },
      "RequestAccepted": {
        "type": "object",
        "required": [
          "status",
          "message",
          "request_id"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "fit",
              "needs_review"
            ]
          },
          "message": {
            "type": "string",
            "description": "Plain-language next step."
          },
          "request_id": {
            "type": "string"
          }
        }
      },
      "RequestNotAFit": {
        "type": "object",
        "required": [
          "status",
          "message",
          "contact"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "not_a_fit"
            ]
          },
          "message": {
            "type": "string",
            "description": "An honest reason and where to write instead."
          },
          "contact": {
            "type": "string",
            "format": "email"
          },
          "reasons": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "SlotUnavailable": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Error"
          },
          {
            "type": "object",
            "properties": {
              "next_slots": {
                "type": "array",
                "items": {
                  "type": "string",
                  "format": "date-time"
                }
              },
              "timezone": {
                "type": "string"
              }
            }
          }
        ]
      },
      "LinkResult": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "booked",
              "pending_approval",
              "approved",
              "slot_gone"
            ]
          },
          "slot": {
            "type": "string",
            "format": "date-time"
          },
          "meet_link": {
            "type": "string",
            "format": "uri"
          },
          "message": {
            "type": "string"
          },
          "next_slots": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "date-time"
            }
          },
          "timezone": {
            "type": "string"
          }
        }
      },
      "ValidationError": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Error"
          },
          {
            "type": "object",
            "properties": {
              "errors": {
                "type": "array",
                "items": {
                  "type": "object",
                  "required": [
                    "field",
                    "message"
                  ],
                  "properties": {
                    "field": {
                      "type": "string"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        ]
      },
      "RateLimited": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Error"
          },
          {
            "type": "object",
            "required": [
              "retry_after_seconds"
            ],
            "properties": {
              "retry_after_seconds": {
                "type": "integer"
              }
            }
          }
        ]
      },
      "Error": {
        "type": "object",
        "required": [
          "error",
          "message"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "Machine-readable code."
          },
          "message": {
            "type": "string",
            "description": "Plain-language explanation."
          }
        }
      }
    }
  }
}
