{
  "openapi": "3.1.0",
  "info": {
    "title": "Henry Koch Immobilien: public API",
    "version": "1.0.0",
    "summary": "Offers and contact details of kochs-immobilien.de, and a way to send a message.",
    "description": "Read the current real-estate offers and the office's contact details of kochs-immobilien.de, and send a contact message or an enquiry about an offer on a person's behalf.\n\nField names are English, the content is German, exactly as the website shows it. No authentication, no cookies. Errors are `application/problem+json` (RFC 9457); the `type` of each problem is a URI below `https://kochs-immobilien.de/api#problem-`.\n\nMessages sent through this API are checked for spam before anyone reads them, and the number of messages accepted per day is limited. A `202` answer means the message was received, nothing more: it says nothing about the outcome of that check.\n\nSending a message is for a person who asked for it. Do not send messages the person has not seen and approved.",
    "contact": {
      "name": "Henry Koch Immobilien",
      "email": "info@kochs-immobilien.de",
      "url": "https://kochs-immobilien.de/kontakt"
    }
  },
  "servers": [
    {
      "url": "https://kochs-immobilien.de/api/v1",
      "description": "Production"
    }
  ],
  "externalDocs": {
    "description": "Developer documentation",
    "url": "https://kochs-immobilien.de/api"
  },
  "security": [],
  "tags": [
    {
      "name": "Offers",
      "description": "The current offers for rent and sale."
    },
    {
      "name": "Contact",
      "description": "The office's contact details."
    },
    {
      "name": "Messages",
      "description": "Send a message to the office."
    }
  ],
  "paths": {
    "/offers": {
      "get": {
        "operationId": "list_offers",
        "summary": "List the current offers",
        "description": "The current offers (not withdrawn, with their exposé), newest first: the same set as `/llms.txt` and the RSS feed. `type` and `category` filter the list; an empty or missing parameter does not filter.",
        "tags": [
          "Offers"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Type"
          },
          {
            "$ref": "#/components/parameters/Category"
          }
        ],
        "responses": {
          "200": {
            "description": "The offers; an empty list if none matches.",
            "headers": {
              "Access-Control-Allow-Origin": {
                "description": "`*`: a page on any origin may read this answer.",
                "schema": {
                  "type": "string",
                  "const": "*"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OfferList"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/offers/{id}": {
      "get": {
        "operationId": "get_offer",
        "summary": "Get one offer",
        "description": "One current offer with what its exposé page shows: the facts in the order of the details table, the texts, the energy certificate's values and the photos. An unknown or withdrawn offer answers 404.",
        "tags": [
          "Offers"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/OfferId"
          }
        ],
        "responses": {
          "200": {
            "description": "The offer.",
            "headers": {
              "Access-Control-Allow-Origin": {
                "description": "`*`: a page on any origin may read this answer.",
                "schema": {
                  "type": "string",
                  "const": "*"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OfferDetail"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/OfferNotFound"
          }
        }
      }
    },
    "/contact": {
      "get": {
        "operationId": "get_contact",
        "summary": "Get the contact details",
        "description": "The office's name, contact person, address, phone number, e-mail address and website as the contact page shows them.",
        "tags": [
          "Contact"
        ],
        "responses": {
          "200": {
            "description": "The contact details.",
            "headers": {
              "Access-Control-Allow-Origin": {
                "description": "`*`: a page on any origin may read this answer.",
                "schema": {
                  "type": "string",
                  "const": "*"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Contact"
                }
              }
            }
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          }
        }
      }
    },
    "/contact-messages": {
      "post": {
        "operationId": "send_contact_message",
        "summary": "Send a contact message",
        "description": "Sends a message to the office, like the contact form of the website but without the bot check. The same fields are required: `name`, `email` (a plain address) and `message`; `telephone` is optional. HTML tags are removed from the message. The body is a JSON object of at most 16 KiB with no other fields.\n\nPer sender (network and e-mail address) the same limits apply as on the website, and the site accepts a limited number of messages per day through the API and the MCP server together.",
        "tags": [
          "Messages"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContactMessage"
              },
              "example": {
                "name": "Erika Muster",
                "email": "erika@example.com",
                "telephone": "05021 123456",
                "message": "Guten Tag, ich habe eine Frage zur Hausverwaltung. Bitte rufen Sie mich an."
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "The message was received. The answer is the same whatever the spam check decides later: it contains no id and no status of the message.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Received"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "413": {
            "$ref": "#/components/responses/PayloadTooLarge"
          },
          "415": {
            "$ref": "#/components/responses/UnsupportedMediaType"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/offers/{id}/enquiries": {
      "post": {
        "operationId": "send_enquiry",
        "summary": "Send an enquiry about an offer",
        "description": "Sends an enquiry about a current offer, like the enquiry form of its exposé but without the bot check. Required: `name`, `street`, `place` (postal code and town), `email` (must contain `@`) and `message`; `telephone` is optional. The offer comes from the path; an unknown, withdrawn or non-numeric id answers 404 before anything is checked.\n\nThe limits are those of the contact message; per offer a sender may enquire only a few times a day (the number is a setting of the site).",
        "tags": [
          "Messages"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/OfferId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EnquiryMessage"
              },
              "example": {
                "name": "Erika Muster",
                "street": "Hauptstr. 1",
                "place": "31582 Nienburg",
                "telephone": "05021 123456",
                "email": "erika@example.com",
                "message": "Ist die Wohnung noch frei? Ich würde sie gern besichtigen."
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "The message was received. The answer is the same whatever the spam check decides later: it contains no id and no status of the message.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Received"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/OfferNotFound"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "413": {
            "$ref": "#/components/responses/PayloadTooLarge"
          },
          "415": {
            "$ref": "#/components/responses/UnsupportedMediaType"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/openapi.json": {
      "get": {
        "operationId": "openapi",
        "summary": "This document",
        "description": "The OpenAPI 3.1 description of the API.",
        "responses": {
          "200": {
            "description": "The OpenAPI document.",
            "headers": {
              "Access-Control-Allow-Origin": {
                "description": "`*`: a page on any origin may read this answer.",
                "schema": {
                  "type": "string",
                  "const": "*"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Price": {
        "type": "object",
        "description": "The offer's price line as the site shows it.",
        "required": [
          "label",
          "value"
        ],
        "properties": {
          "label": {
            "type": "string",
            "description": "What the price is: Kaufpreis, Monatsmiete, Miete or Kaltmiete.",
            "examples": [
              "Monatsmiete"
            ]
          },
          "value": {
            "type": "string",
            "description": "The amount and anything the site adds, in German notation.",
            "examples": [
              "875,00 € plus : EBK 40,00 €"
            ]
          }
        }
      },
      "OfferSummary": {
        "type": "object",
        "description": "One offer of the list.",
        "required": [
          "id",
          "title",
          "type",
          "category",
          "place",
          "url",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "integer",
            "minimum": 1,
            "description": "The offer number (Objekt-Nr.).",
            "examples": [
              22754
            ]
          },
          "title": {
            "type": "string",
            "description": "The offer's title.",
            "examples": [
              "gepflegte, moderne DHH nähe Schulzentrum mit Garten, Garage, Geräteraum, Carport"
            ]
          },
          "type": {
            "type": "string",
            "enum": [
              "verkauf",
              "vermietung"
            ],
            "description": "For sale or for rent. Empty only for an offer the site knows from its start page alone."
          },
          "category": {
            "type": "string",
            "description": "The site's category slug.",
            "examples": [
              "wohnobjekte"
            ]
          },
          "place": {
            "type": "string",
            "description": "The town.",
            "examples": [
              "Heemsen"
            ]
          },
          "price": {
            "$ref": "#/components/schemas/Price"
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "The canonical URL of the exposé page."
          },
          "image": {
            "type": "string",
            "format": "uri",
            "description": "The cover photo as a JPEG of 640 px width (smaller photos at their own width). Missing if the offer has no photo."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the offer last changed, UTC."
          }
        }
      },
      "OfferList": {
        "type": "object",
        "required": [
          "offers"
        ],
        "properties": {
          "offers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OfferSummary"
            }
          }
        }
      },
      "Fact": {
        "type": "object",
        "required": [
          "label",
          "value"
        ],
        "description": "One row of a table of the exposé.",
        "properties": {
          "label": {
            "type": "string",
            "description": "The row's label, as the exposé shows it.",
            "examples": [
              "Wohnfläche"
            ]
          },
          "value": {
            "type": "string",
            "description": "The row's value.",
            "examples": [
              "125 m²"
            ]
          }
        }
      },
      "Text": {
        "type": "object",
        "required": [
          "heading",
          "text"
        ],
        "description": "One text section of the exposé; line breaks separate lines, blank lines paragraphs.",
        "properties": {
          "heading": {
            "type": "string",
            "description": "The section's heading.",
            "examples": [
              "Objektbeschreibung"
            ]
          },
          "text": {
            "type": "string",
            "description": "The section's text."
          }
        }
      },
      "Photo": {
        "type": "object",
        "required": [
          "url",
          "width"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "description": "A JPEG of the site's image route, 1280 px wide (smaller photos at their own width)."
          },
          "width": {
            "type": "integer",
            "minimum": 1,
            "description": "The width of that JPEG in pixels."
          }
        }
      },
      "OfferDetail": {
        "description": "One offer with what its exposé page shows.",
        "allOf": [
          {
            "$ref": "#/components/schemas/OfferSummary"
          },
          {
            "type": "object",
            "required": [
              "facts",
              "texts",
              "photos",
              "enquiry"
            ],
            "properties": {
              "facts": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/Fact"
                },
                "description": "The details table in page order: Objekt-Nr., Ort, the price, Wohnfläche, Zimmer, Grundstück, then the further details. The energy certificate's rows are in `energy`."
              },
              "texts": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/Text"
                },
                "description": "The text sections in page order: Objektbeschreibung, Lagebeschreibung, Hinweis (the price note), Weitere Angaben, Einheiten; sections the offer lacks are left out."
              },
              "energy": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/Fact"
                },
                "description": "The values of the energy certificate. Missing if the offer has none."
              },
              "photos": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/Photo"
                },
                "description": "The photos in gallery order (the floor plans only if the offer has no photo)."
              },
              "enquiry": {
                "type": "boolean",
                "description": "Whether the offer takes enquiries through `POST /offers/{id}/enquiries`."
              }
            }
          }
        ]
      },
      "Contact": {
        "type": "object",
        "description": "The office as the contact page shows it.",
        "required": [
          "name",
          "person",
          "street",
          "postal_code",
          "city",
          "phone",
          "email",
          "website"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "",
            "examples": [
              "Henry Koch Immobilien + Hausverwaltung + Finanzierung"
            ]
          },
          "person": {
            "type": "string",
            "description": "The contact person.",
            "examples": [
              "Henry Koch"
            ]
          },
          "street": {
            "type": "string",
            "description": "",
            "examples": [
              "Lichtenmoorstr. 5"
            ]
          },
          "postal_code": {
            "type": "string",
            "description": "",
            "examples": [
              "31622"
            ]
          },
          "city": {
            "type": "string",
            "description": "",
            "examples": [
              "Heemsen"
            ]
          },
          "phone": {
            "type": "string",
            "description": "As the site shows it.",
            "examples": [
              "0 50 24 - 88 330"
            ]
          },
          "email": {
            "type": "string",
            "description": "",
            "examples": [
              "info@kochs-immobilien.de"
            ]
          },
          "website": {
            "type": "string",
            "format": "uri",
            "examples": [
              "https://kochs-immobilien.de"
            ]
          }
        }
      },
      "ContactMessage": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "name",
          "email",
          "message"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "The sender's name."
          },
          "email": {
            "type": "string",
            "description": "The sender's e-mail address, a plain address."
          },
          "telephone": {
            "type": "string",
            "description": "The sender's telephone number. Optional."
          },
          "message": {
            "type": "string",
            "description": "The message. HTML tags are removed."
          }
        }
      },
      "EnquiryMessage": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "name",
          "street",
          "place",
          "email",
          "message"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "The sender's name."
          },
          "street": {
            "type": "string",
            "description": "The sender's street and house number."
          },
          "place": {
            "type": "string",
            "description": "The sender's postal code and town."
          },
          "telephone": {
            "type": "string",
            "description": "The sender's telephone number. Optional."
          },
          "email": {
            "type": "string",
            "description": "The sender's e-mail address; must contain `@`."
          },
          "message": {
            "type": "string",
            "description": "The enquiry."
          }
        }
      },
      "Received": {
        "type": "object",
        "required": [
          "status",
          "message"
        ],
        "description": "The answer to a received message: always the same.",
        "properties": {
          "status": {
            "type": "string",
            "const": "received"
          },
          "message": {
            "type": "string",
            "description": "A German thank-you.",
            "examples": [
              "Vielen Dank für Ihre Nachricht. Wir melden uns bei Ihnen."
            ]
          }
        }
      },
      "Problem": {
        "type": "object",
        "description": "A problem (RFC 9457). The German `detail` is meant to be read by the person who asked.",
        "required": [
          "type",
          "title",
          "status",
          "detail"
        ],
        "properties": {
          "type": {
            "type": "string",
            "format": "uri",
            "description": "One of the problem types below `https://kochs-immobilien.de/api#problem-`: validation, offer-not-found, limited, daily-cap, bad-request, unsupported-media-type, payload-too-large, internal, not-found, method-not-allowed."
          },
          "title": {
            "type": "string",
            "description": "A short summary of the problem type."
          },
          "status": {
            "type": "integer",
            "description": "The HTTP status."
          },
          "detail": {
            "type": "string",
            "description": "What happened, in German."
          },
          "errors": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FieldError"
            },
            "description": "The fields that failed validation (type `validation`)."
          }
        },
        "examples": [
          {
            "type": "https://kochs-immobilien.de/api#problem-not-found",
            "title": "Nicht gefunden",
            "status": 404,
            "detail": "Diese Adresse gibt es in der API nicht. Eine Übersicht steht unter /api/v1."
          }
        ]
      },
      "FieldError": {
        "type": "object",
        "required": [
          "field",
          "message"
        ],
        "properties": {
          "field": {
            "type": "string",
            "enum": [
              "name",
              "street",
              "place",
              "telephone",
              "email",
              "message"
            ],
            "description": "The field of the request body."
          },
          "message": {
            "type": "string",
            "description": "What is wrong, in German, as the website's form says it."
          }
        }
      }
    },
    "parameters": {
      "OfferId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "The offer number.",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 999999999
        },
        "example": 22754
      },
      "Type": {
        "name": "type",
        "in": "query",
        "description": "Only offers for sale or only for rent.",
        "schema": {
          "type": "string",
          "enum": [
            "verkauf",
            "vermietung"
          ]
        }
      },
      "Category": {
        "name": "category",
        "in": "query",
        "description": "Only offers of this category (the site's category slug).",
        "schema": {
          "type": "string",
          "enum": [
            "einfamilienhaeuser",
            "eigentumswohnungen",
            "gewerbeobjekte",
            "grundstuecke",
            "mehrfamilienhaeuser",
            "resthoefe",
            "wohnobjekte"
          ]
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "The request is malformed: an unknown filter value, a body that is not a JSON object, an unknown field. Problem type `bad-request`.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            },
            "example": {
              "type": "https://kochs-immobilien.de/api#problem-bad-request",
              "title": "Ungültige Anfrage",
              "status": 400,
              "detail": "Ungültiger Wert für den Parameter type. Erlaubt: verkauf, vermietung."
            }
          }
        }
      },
      "OfferNotFound": {
        "description": "No current offer has this number: it is unknown, withdrawn or not a number. Problem type `offer-not-found`.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            },
            "example": {
              "type": "https://kochs-immobilien.de/api#problem-offer-not-found",
              "title": "Angebot nicht gefunden",
              "status": 404,
              "detail": "Zu dieser Nummer gibt es kein aktuelles Angebot. Es kann inzwischen vergeben oder zurückgezogen sein."
            }
          }
        }
      },
      "PayloadTooLarge": {
        "description": "The body is larger than 16 KiB. Problem type `payload-too-large`.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            },
            "example": {
              "type": "https://kochs-immobilien.de/api#problem-payload-too-large",
              "title": "Anfrage zu groß",
              "status": 413,
              "detail": "Die Anfrage darf höchstens 16 KiB groß sein."
            }
          }
        }
      },
      "UnsupportedMediaType": {
        "description": "The body is not `application/json`. Problem type `unsupported-media-type`.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            },
            "example": {
              "type": "https://kochs-immobilien.de/api#problem-unsupported-media-type",
              "title": "Format nicht unterstützt",
              "status": 415,
              "detail": "Bitte senden Sie den Inhalt als application/json."
            }
          }
        }
      },
      "ValidationFailed": {
        "description": "A field is missing or invalid; nothing was stored. Problem type `validation`.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            },
            "example": {
              "type": "https://kochs-immobilien.de/api#problem-validation",
              "title": "Ungültige Angaben",
              "status": 422,
              "detail": "Bitte geben Sie Ihre E-Mail-Adresse ein.",
              "errors": [
                {
                  "field": "email",
                  "message": "Bitte geben Sie Ihre E-Mail-Adresse ein."
                }
              ]
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "The sender sent too many messages (`limited`) or the day's limit of the site is used up (`daily-cap`). Nothing was stored. Wait for `Retry-After` seconds.",
        "headers": {
          "Retry-After": {
            "description": "Seconds to wait: an hour for `limited`, the time to the next midnight (Europe/Berlin) for `daily-cap`.",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          }
        },
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            },
            "examples": {
              "limited": {
                "summary": "Per-sender limit (limited)",
                "value": {
                  "type": "https://kochs-immobilien.de/api#problem-limited",
                  "title": "Zu viele Nachrichten",
                  "status": 429,
                  "detail": "Sie haben in kurzer Zeit bereits mehrere Anfragen gesendet. Bitte rufen Sie uns an oder versuchen Sie es später erneut."
                }
              },
              "daily-cap": {
                "summary": "Daily limit (daily-cap)",
                "value": {
                  "type": "https://kochs-immobilien.de/api#problem-daily-cap",
                  "title": "Tageslimit erreicht",
                  "status": 429,
                  "detail": "Über die API und den MCP-Server können heute keine weiteren Nachrichten angenommen werden. Bitte versuchen Sie es morgen erneut oder rufen Sie an: 0 50 24 - 88 330."
                }
              }
            }
          }
        }
      },
      "InternalError": {
        "description": "The request could not be processed. Problem type `internal`.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            },
            "example": {
              "type": "https://kochs-immobilien.de/api#problem-internal",
              "title": "Interner Fehler",
              "status": 500,
              "detail": "Die Anfrage konnte nicht verarbeitet werden. Bitte versuchen Sie es später erneut oder rufen Sie an: 0 50 24 - 88 330."
            }
          }
        }
      },
      "MethodNotAllowed": {
        "description": "The address does not take this method; `Allow` lists the ones it takes. Problem type `method-not-allowed`.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            },
            "example": {
              "type": "https://kochs-immobilien.de/api#problem-method-not-allowed",
              "title": "Methode nicht erlaubt",
              "status": 405,
              "detail": "Für diese Adresse sind nur diese Methoden erlaubt: POST."
            }
          }
        }
      }
    }
  }
}
