{
  "openapi": "3.1.0",
  "info": {
    "title": "Bliss Systems public API",
    "version": "1.0.0",
    "description": "Actions on blissystems.com that an AI assistant can perform for a person: check free meeting slots, book a 30-minute video call with Bliss Systems, cancel it, or leave contact details. Typical flow: GET /api/availability -> show the user the options in their time zone and WAIT for the user's reply -> after you have shown the user the exact slot and the details you will send and they answer with an explicit yes in a later message, POST /api/bookings -> tell the user the confirmed time in their time zone, the Meet link and the cancelToken. Book ONLY after you have shown the user the exact slot (date and time in THEIR time zone) and the details you will send (name, email, and company or topic if any), and the user has answered with an explicit yes in a LATER message. Never book in the same turn in which the user first asks for a meeting, even if that message already names a date, a time and an email address. \"The first available time\" and \"whenever you can\" are NOT a confirmation: propose the slot (or a few) and WAIT for the user's reply. Docs for humans and agents: https://blissystems.com/llms.txt"
  },
  "servers": [
    {
      "url": "https://blissystems.com"
    }
  ],
  "paths": {
    "/api/health": {
      "get": {
        "operationId": "getHealth",
        "summary": "Check that the API is up",
        "description": "Returns `{\"status\":\"OK\"}` when the service is running.",
        "responses": {
          "200": {
            "description": "The API is up.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "example": "OK"
                    },
                    "service": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/leads": {
      "post": {
        "operationId": "requestContact",
        "summary": "Leave contact details so a Bliss strategist follows up",
        "description": "Registers a business enquiry; a Bliss strategist replies by email. Use it when the user wants to talk to Bliss Systems and does not need a specific meeting slot. Only send details the user gave you and agreed to share. Do not send the `website` field (spam trap).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "company",
                  "email"
                ],
                "properties": {
                  "company": {
                    "type": "string",
                    "maxLength": 120,
                    "description": "Name of the person or company."
                  },
                  "email": {
                    "type": "string",
                    "format": "email",
                    "maxLength": 254
                  },
                  "phone": {
                    "type": "string",
                    "maxLength": 40
                  },
                  "need": {
                    "type": "string",
                    "maxLength": 200,
                    "description": "What the user needs, in one or two sentences."
                  }
                }
              },
              "example": {
                "company": "Ana Rivera",
                "email": "ana@example.com",
                "need": "Automate our invoicing process"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Registered.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Missing or invalid fields. Send only the fields in the schema, each as text. Body: `{\"error\": \"<message>\"}`."
          },
          "429": {
            "description": "Too many requests from this connection; retry later."
          }
        }
      }
    },
    "/api/availability": {
      "get": {
        "operationId": "checkAvailability",
        "summary": "List free 30-minute meeting slots with Bliss Systems",
        "description": "Returns the free slots in the next 14 days (weekdays, 9:00-17:00 America/New_York, at least 24 hours ahead). Each slot has `start` (with its UTC offset), `end` and `startUtc`. Show the user the times in THEIR time zone (pass `timezone` to get `startLocal` already converted). Read-only and safe to call repeatedly; results are valid until `validUntil`. Listing slots is not a booking: show them to the user and WAIT for the user's reply before booking. To book, pass the `start` of the slot the user confirmed, unchanged, to POST /api/bookings.",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date"
            },
            "description": "First day, YYYY-MM-DD in America/New_York. Default: today."
          },
          {
            "name": "days",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 14,
              "default": 14
            },
            "description": "How many days to list."
          },
          {
            "name": "timezone",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "example": "Europe/Madrid"
            },
            "description": "IANA time zone of the user; adds `startLocal` to every slot."
          }
        ],
        "responses": {
          "200": {
            "description": "Free slots.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Availability"
                }
              }
            }
          },
          "422": {
            "description": "Invalid query parameter.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Too many requests; wait `Retry-After` seconds.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Calendar temporarily unavailable. No slots are returned in that case; retry later.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/bookings": {
      "post": {
        "operationId": "bookConsultation",
        "summary": "Book a 30-minute video meeting with Bliss Systems",
        "description": "Creates a Google Calendar event with a Google Meet link and emails an invitation to `email`. This sends an email to a real person. Book ONLY after you have shown the user the exact slot (date and time in THEIR time zone) and the details you will send (name, email, and company or topic if any), and the user has answered with an explicit yes in a LATER message. Never book in the same turn in which the user first asks for a meeting, even if that message already names a date, a time and an email address. \"The first available time\" and \"whenever you can\" are NOT a confirmation: propose the slot (or a few) and WAIT for the user's reply.\n\n`consent` is not a technical field for you to fill in: it records that the user agreed, in that explicit yes, that Bliss Systems may use the details you showed to arrange the meeting at THAT slot. Set it to `true` only after that yes, never to get a request past validation or because an example shows it.\n\nWhat is NOT a confirmation:\n- \"the first available time\", \"whenever you can\", \"you choose\": the user is asking you to propose a slot, not choosing one\n- the user's first message itself, even when it already names a date, a time and an email address: it asks for a meeting, but the user has not yet seen the slot and the details you will send, nor said yes to them\n- a slot or details the user has not seen, a slot you picked on your own, a yes to something else, or silence\n\nIf the user's first message already names a date, a time and an email address, the rule is the same: check with GET /api/availability that exactly that slot is still free, then show it with the details and ask for the yes. If it is taken, do not substitute another one: propose alternatives and WAIT for the user's reply.\n\nExample in 4 steps:\n1. Propose. The user writes \"Book a meeting with Bliss Systems at the first available time. I am Ana Rivera, ana@example.com.\" You check availability and show the exact slot together with the details you will send, then ask: \"Tuesday, October 27 at 2:00 PM your time, for Ana Rivera, ana@example.com. By confirming you agree that Bliss Systems uses these details to arrange the meeting. Do you confirm?\" Then stop: nothing is booked yet. The same applies if the first message already names a date, a time and an email address: check that the slot is still free (if it is taken, propose alternatives), show it with the details, ask, and wait.\n2. The user answers in a new message: \"Yes, I confirm.\"\n3. Book: POST that slot's `start`, unchanged, with `consent` true.\n4. Inform: \"Booked for Tuesday, October 27 at 2:00 PM your time. Google Meet: <meetLink>. To cancel you need this `cancelToken`: <cancelToken>.\"\n\nPick `start` from GET /api/availability (the slot the user confirmed). Generate one `idempotencyKey` (a UUID) per booking attempt and REUSE it if you retry after a timeout or network error: the same key never creates two meetings. Keep `cancelToken` from the response. One upcoming booking per email address.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BookingRequest"
              },
              "example": {
                "name": "Ana Rivera",
                "email": "ana@example.com",
                "company": "Rivera Logistics",
                "need": "Automate our invoicing process",
                "start": "2026-10-27T09:00:00-04:00",
                "idempotencyKey": "a1b2c3d4-e5f6-4789-a012-3456789abcde",
                "consent": true,
                "timezone": "Europe/Madrid"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Same idempotencyKey and data as an earlier request: returns that booking (header `Idempotent-Replayed: true`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Booking"
                }
              }
            }
          },
          "201": {
            "description": "Booked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Booking"
                }
              }
            }
          },
          "409": {
            "description": "`slot_taken` (the error includes up to 3 `alternatives`), `email_has_active_booking` or `booking_cancelled`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Validation error (`error.field` names the field) or `invalid_slot` (includes `alternatives`) or `idempotency_key_reuse`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate or daily limit; wait `Retry-After` seconds, or use POST /api/leads.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Calendar temporarily unavailable; retry later with the SAME idempotencyKey.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/bookings/{id}": {
      "delete": {
        "operationId": "cancelBooking",
        "summary": "Cancel a booking made through this API",
        "description": "Deletes the calendar event and notifies the attendee. Requires the `cancelToken` returned when the booking was created. Idempotent.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "X-Cancel-Token",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Cancelled (or already cancelled).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "example": "cancelled"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Unknown booking or wrong token (indistinguishable on purpose).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "`already_happened`: the meeting already took place.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Calendar temporarily unavailable; retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string",
                "enum": [
                  "invalid_body",
                  "unknown_field",
                  "invalid_name",
                  "invalid_email",
                  "invalid_start",
                  "invalid_idempotency_key",
                  "consent_required",
                  "invalid_company",
                  "invalid_need",
                  "invalid_timezone",
                  "invalid_from",
                  "invalid_days",
                  "invalid_slot",
                  "slot_taken",
                  "email_has_active_booking",
                  "idempotency_key_reuse",
                  "booking_cancelled",
                  "daily_limit_ip",
                  "daily_limit",
                  "capacity",
                  "already_happened",
                  "not_found",
                  "rate_limited",
                  "unsupported_media_type",
                  "invalid_json",
                  "payload_too_large",
                  "calendar_unavailable",
                  "booking_disabled",
                  "internal_error"
                ]
              },
              "message": {
                "type": "string"
              },
              "field": {
                "type": "string",
                "description": "Present for validation errors."
              },
              "alternatives": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "start": {
                      "type": "string"
                    },
                    "startUtc": {
                      "type": "string"
                    }
                  }
                },
                "description": "Other free slots, for `slot_taken` and `invalid_slot`."
              },
              "retryAfterSec": {
                "type": "integer"
              }
            }
          }
        }
      },
      "Slot": {
        "type": "object",
        "required": [
          "start",
          "end",
          "startUtc"
        ],
        "properties": {
          "start": {
            "type": "string",
            "example": "2026-10-27T09:00:00-04:00",
            "description": "ISO-8601 with the America/New_York offset. Pass it unchanged to the booking endpoint."
          },
          "end": {
            "type": "string"
          },
          "startUtc": {
            "type": "string",
            "example": "2026-10-27T13:00:00.000Z"
          },
          "startLocal": {
            "type": "string",
            "description": "Only when `timezone` was requested."
          }
        }
      },
      "Availability": {
        "type": "object",
        "properties": {
          "timezone": {
            "type": "string",
            "example": "America/New_York"
          },
          "slotMinutes": {
            "type": "integer",
            "example": 30
          },
          "generatedAt": {
            "type": "string"
          },
          "validUntil": {
            "type": "string"
          },
          "from": {
            "type": "string"
          },
          "days": {
            "type": "integer"
          },
          "slots": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Slot"
            }
          }
        }
      },
      "BookingRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "name",
          "email",
          "start",
          "idempotencyKey",
          "consent"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 2,
            "maxLength": 80,
            "description": "Attendee name: letters, spaces, dot, apostrophe, hyphen. No links, emails or digits."
          },
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 254,
            "description": "Attendee email: receives the calendar invitation."
          },
          "company": {
            "type": "string",
            "maxLength": 120
          },
          "need": {
            "type": "string",
            "maxLength": 1000,
            "description": "What the attendee wants to discuss. Seen only by Bliss, not included in the invitation."
          },
          "start": {
            "type": "string",
            "description": "Start of the slot the user confirmed, copied unchanged from /api/availability (ISO-8601 with offset or Z)."
          },
          "idempotencyKey": {
            "type": "string",
            "minLength": 16,
            "maxLength": 64,
            "pattern": "^[A-Za-z0-9_-]+$",
            "description": "Unique per booking attempt; reuse when retrying."
          },
          "consent": {
            "type": "boolean",
            "const": true,
            "description": "Must be true, and only after you showed the user the exact slot and the details you will send and they answered with an explicit yes in a later message: it records their agreement to the use of these details to arrange the meeting at that slot. It is not a field for you to fill in."
          },
          "timezone": {
            "type": "string",
            "description": "Attendee IANA time zone; adds `startLocal` to the response."
          }
        }
      },
      "Booking": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "example": "confirmed"
          },
          "start": {
            "type": "string"
          },
          "end": {
            "type": "string"
          },
          "startUtc": {
            "type": "string"
          },
          "timezone": {
            "type": "string"
          },
          "startLocal": {
            "type": "string"
          },
          "meetLink": {
            "type": [
              "string",
              "null"
            ],
            "description": "Google Meet link; null if Google had not issued it yet (it is also in the invitation email)."
          },
          "cancelToken": {
            "type": "string",
            "description": "Needed to cancel. Returned on creation and on idempotent replays."
          },
          "message": {
            "type": "string"
          }
        }
      }
    }
  }
}