# Vesturium Cloud External API und Webhooks

Technische Referenz für den Vesturium Cloud API-Zugriff, API-Token-Scopes, Cursor-Pagination, Fehlerformate und signierte Webhook-Events.

## Überblick

Die External API ist tenant-scoped und für Reporting, Dashboards, CRM-Integrationen und die Pflege von Objekten gedacht. Reservierungen, Benutzer, KPIs und Kunden sind nur lesbar; CRM-Leads können zusätzlich angelegt und aktualisiert werden. Objekte, Einheiten, Stellplätze, Bilder, Dokumente und Checklisten lassen sich lesen und mit den Schreib-Scopes auch anlegen und pflegen — siehe Objekte schreiben. API-Tokens werden im Admin-Bereich unter API Access erstellt und auf Scopes begrenzt.

Lesend gilt ein Token für einen Mandanten, nie für einen Benutzer: `/leads` und `/customers` liefern alle Leads und Kunden des Mandanten. Das dreistufige Sichtbarkeitsmodell des internen CRM (eigene Datensätze, Team, alle) gilt in der External API nicht. Schreibend handelt ein Token dagegen mit den Rechten der Person, die ihn erstellt hat — siehe Objekte schreiben.

| Bereich           | Wert                                         |
| ----------------- | -------------------------------------------- |
| Base URL          | `https://api.vesturium.cloud/external/v1`    |
| Base path         | `/external/v1`                               |
| Authentifizierung | `Authorization: Bearer <external_api_token>` |
| Pagination        | Cursor                                       |
| Webhook-Signatur  | HMAC-SHA256                                  |

## Authentifizierung

Jede External-API-Anfrage benötigt einen externen Bearer-Token. Tokens beginnen mit `bv_live_` und werden nach dem Erstellen nur einmal im Admin-UI angezeigt.

```http
Authorization: Bearer <external_api_token>
```

Unterstützte Scopes:

| Scope               | Zugriff                                                                                        |
| ------------------- | ---------------------------------------------------------------------------------------------- |
| `reservations:read` | Reservierungslisten und Reservierungsdetails                                                   |
| `users:read`        | Benutzerlisten und Benutzerdetails                                                             |
| `kpis:read`         | KPI-Endpunkt                                                                                   |
| `leads:read`        | CRM-Leads lesen und auflisten                                                                  |
| `leads:write`       | CRM-Leads anlegen und aktualisieren                                                            |
| `customers:read`    | CRM-Kunden lesen und auflisten                                                                 |
| `projects:read`     | Objekte mit Einheiten und Dokument-Metadaten, Einheitendetail                                  |
| `documents:read`    | Dokumentdetail mit Download-Link und Text — braucht `projects:read`, um Dokument-IDs zu finden |
| `projects:write`    | Objekte anlegen und ändern, online stellen, Mikrolage aktualisieren                            |
| `properties:write`  | Einheiten anlegen (bis zu 50 je Anfrage), ändern, Status und Sichtbarkeit setzen, löschen; Stellplätze pflegen |
| `media:write`       | Bilder und Dokumente hochladen, beschriften, sortieren und löschen                             |
| `checklist:write`   | Freigabe-Checkliste lesen und Punkte setzen                                                    |
| `purchase_prices:read`  | Einkaufspreis und Provisionssatz lesen — nur, wenn die erstellende Person „Alle Provisionen anzeigen“ hat |
| `purchase_prices:write` | Einkaufspreis und Provisionssatz schreiben — dieselbe Bedingung                             |

> Scopes werden beim Erstellen eines Tokens festgelegt. Eine Rotation übernimmt die bestehenden Scopes unverändert — Zugriff auf eine neue Ressource erfordert daher einen neu erstellten Token. Wer rotiert, wird zur erstellenden Person des Tokens; Schreib-Scopes handeln danach mit deren Rechten.
>
> Schon `projects:read` zeigt die Metadaten (Titel und Dateinamen) von Dokumenten, die in Vesturium Cloud als intern markiert sind; `documents:read` gibt zusätzlich deren Download-Link und OCR-Text frei. Keiner der beiden Scopes filtert sie heraus.

## Pagination und Fehler

Listen-Endpunkte verwenden Cursor-Pagination. Sende `limit` mit einem Wert von `1` bis `500`. Nutze `pagination.next_cursor` aus der vorherigen Antwort als nächsten `cursor`-Wert. Ein `null`-Cursor bedeutet, dass die Seite vollständig ist.

JSON-Fehler verwenden ein stabiles Format mit `code`, `message` und optionalen `details`.

| Status | Code               | Bedeutung                                                                                                         |
| ------ | ------------------ | ----------------------------------------------------------------------------------------------------------------- |
| `400`  | `invalid_filter`   | Ein Query-Parameter wird nicht unterstützt oder ist fehlerhaft.                                                   |
| `400`  | `invalid_body`     | Der Request-Body ist ungültig: unbekannter Schlüssel, fehlendes Pflichtfeld oder unbekannter Enum-Wert.           |
| `400`  | `invalid_assignee` | `assigned_agent_id` ist kein aktiver Benutzer des Mandanten zum Token.                                            |
| `401`  | `unauthorized`     | Bearer-Token fehlt, ist fehlerhaft, ungültig, abgelaufen oder widerrufen.                                         |
| `403`  | `forbidden`        | Der Token ist gültig, enthält aber nicht den erforderlichen Scope, oder der API-Zugang des Mandanten ist inaktiv. |
| `404`  | `not_found`        | Der Datensatz existiert im Mandanten nicht oder hat den adressierten Typ nicht mehr.                              |
| `409`  | `duplicate_email`  | Im Mandanten existiert bereits eine Person mit dieser E-Mail. Der bestehende Datensatz steht in `details`.        |
| `429`  | `rate_limited`     | Der Token hat das External-API-Rate-Limit überschritten.                                                          |

Zusätzlich bei Schreibzugriffen auf Objekte (siehe Objekte schreiben):

| Status | Code                       | Bedeutung                                                                                                   |
| ------ | -------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `400`  | `invalid_request`          | Vesturium Cloud lehnt den Vorgang fachlich ab; der Grund steht in `message`.                                     |
| `402`  | `confirm_overage_required` | Der Vorgang überschreitet das im Tarif enthaltene Kontingent; mit `confirm_overage=true` wiederholen, um die Mehrkosten anzunehmen. |
| `403`  | `not_owned`                | Das Objekt oder die Einheit ist über das Objektnetzwerk freigegeben und gehört dem Mandanten nicht; schreiben geht nur am eigenen Bestand. |
| `403`  | `token_owner_inactive`     | Die Person, die den Token erstellt hat, ist inaktiv oder gehört nicht mehr zum Mandanten. Den Token als aktive Person rotieren. |
| `409`  | `publication_blocked`      | Offene Checklisten-Punkte verhindern das Online-Stellen; die Punkte stehen in `details.blockers`.            |
| `409`  | `conflict`                 | Der Vorgang widerspricht dem aktuellen Zustand, z. B. Löschen einer Einheit mit Reservierung.               |
| `502`  | `upstream_error`           | Der Vorgang konnte nicht abgeschlossen werden; später erneut versuchen.                                     |
| `503`  | `service_unavailable`      | Vesturium Cloud ist kurz ausgelastet; später erneut versuchen.                                                   |

Ein nicht schreibbares Feld hat keinen eigenen Code: `email` beim Update und `status: converted` gehören schlicht nicht zum akzeptierten Schema, beide kommen deshalb als `invalid_body` zurück, mit dem betroffenen Schlüssel in `details.fields`. Der einzige Schreibfehler außerhalb der Body-Validierung ist `invalid_assignee`.

Beispiel-Fehlerantwort:

```json
{
  "error": {
    "code": "invalid_filter",
    "message": "Unsupported date_field",
    "details": { "parameter": "date_field" }
  }
}
```

Ein ungültiger Body benennt die betroffenen Felder. Das folgende Beispiel ist die Antwort auf ein `PATCH /leads/{lead_id}`, das `email` mitgeschickt hat:

```json
{
  "error": {
    "code": "invalid_body",
    "message": "Request body validation failed",
    "details": {
      "fields": [
        { "field": "body.email", "message": "Extra inputs are not permitted" }
      ]
    }
  }
}
```

Eine doppelte E-Mail liefert ID und Typ des bestehenden Datensatzes zurück, damit ein wiederholter `POST /leads` einen Wiederanlauf hat und nicht im Konflikt hängen bleibt:

```json
{
  "error": {
    "code": "duplicate_email",
    "message": "A person with this email already exists",
    "details": { "person_id": "…", "type": "customer" }
  }
}
```

## Endpunkte

### GET /reservations

Erforderlicher Scope: `reservations:read`

Gibt tenant-scoped Reservierungen zurück. Ohne `view` wird die vollständige Reservierungsressource geliefert. Mit `view` kann eine flache Notartermin- oder Provisionsprojektion angefordert werden.

| Parameter                    | Typ                                                                                            | Beschreibung                                                                                                       |
| ---------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `view`                       | `notary \| commission`                                                                         | Optionale Reservierungsprojektion. Ohne `view` wird die vollständige Ressource zurückgegeben.                      |
| `lang`                       | `en \| de`                                                                                     | Anzeigesprache für Statuslabels. Standard ist `en`.                                                                |
| `status`                     | `string`                                                                                       | Kommagetrennte Statuscodes wie `reserved`, `cancelled`, `notary_appointment` oder Legacy-Integer/deutsche Aliasse. |
| `date_from` / `date_to`      | ISO-Datum oder Datetime                                                                        | Inklusiver Datumsbereich für das ausgewählte `date_field`.                                                         |
| `date_field`                 | `created_at \| updated_at \| reservation_fee_paid_at \| notary_appointment_at \| cancelled_at` | Datumsspalte für `date_from` und `date_to`. Standard ist `updated_at`.                                             |
| `advisor_id` / `property_id` | UUID                                                                                           | Optionale Filter für Reservierungsinhaber oder Objekt.                                                             |
| `updated_since`              | ISO datetime                                                                                   | Inkrementeller Sync-Filter gegen `updated_at`.                                                                     |
| `limit` / `cursor`           | number / string                                                                                | Cursor-Pagination. `limit` akzeptiert `1` bis `500`.                                                               |

```http
GET /reservations?lang=de&status=reserved&date_field=created_at&date_from=2026-06-01T00:00:00Z&date_to=2026-06-30T23:59:59Z&limit=100
```

```json
{
  "items": [
    {
      "id": "33333333-3333-3333-3333-333333333333",
      "status": { "code": "reserved", "label": "Reserviert" },
      "buyer": {
        "name": "Erika Muster",
        "email": "buyer@example.com",
        "salutation": "mrs",
        "address": {
          "street": "Lindenweg",
          "house_number": "7a",
          "postal_code": "80331",
          "city": "Muenchen",
          "country": null
        }
      },
      "co_buyer": null,
      "property": {
        "id": "22222222-2222-2222-2222-222222222222",
        "address": "München - WE1",
        "unit_number": "WE1",
        "project_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
        "project_name": "Schwabinger Bach"
      },
      "price": {
        "purchase_price": "445000.00",
        "original_purchase_price": "445000.00",
        "adjustment_amount": "0.00",
        "currency": "EUR"
      },
      "advisor": {
        "id": "11111111-1111-1111-1111-111111111111",
        "name": "Ada Advisor",
        "email": "advisor@example.com"
      },
      "notary": { "appointment_at": null },
      "property_management": {
        "name": "Hausverwaltung Schmidt",
        "address": "Hausverwaltung Schmidt, Leopoldstr. 10, 80802 Muenchen"
      },
      "commission": { "total_payouts": "15000.00", "currency": "EUR" },
      "created_at": "2026-06-15T10:00:00Z",
      "updated_at": "2026-06-15T10:00:00Z"
    }
  ],
  "pagination": { "limit": 100, "next_cursor": null }
}
```

`property_management` ist die der Reservierung zugewiesene Sondereigentumsverwaltung (SEV) mit Name und einzeiliger Adresse, oder `null`, wenn keine zugewiesen ist. Das Feld steht nur in der vollständigen Ressource, nicht in den Ansichten `notary` und `commission`.

`buyer` ist der Hauptantragsteller, wie er an der Reservierung erfasst wurde: `name`, `email`, `salutation` (`mr`, `mrs`, `diverse` oder `null`; `diverse` umfasst auch Käufer, die keine Anrede angegeben haben — also neutral anschreiben) und die Postanschrift `address`. Die Anschrift ist die zu diesem Kauf erfasste, nicht der aktuelle CRM-Stand — den trägt die Kunden-Ressource. Die Reservierung erfasst kein Land, `buyer.address.country` ist daher immer `null`; `address` ist `null`, wenn kein Teil davon erfasst wurde. Bei einem gemeinsamen Kauf trägt `co_buyer` den Mitantragsteller in derselben Form (seine `email` kann `null` sein); bei einem einzelnen Käufer ist es `null`. Beide sind Eigentümer. Weitere persönliche Daten der Käufer (Geburtsdatum, Steuer-ID, Güterstand …) sind nicht Teil dieser API.

#### Reservierungsstatus

| Code                 | Deutsches Label         | Bedeutung                                                                          |
| -------------------- | ----------------------- | ---------------------------------------------------------------------------------- |
| `awaiting_signature` | Wartet auf Unterschrift | Kaufinteresse bekundet; Reservierungsvereinbarung noch nicht unterschrieben.       |
| `requested`          | Angefragt               | Reservierung angefragt.                                                            |
| `reserved`           | Reserviert              | Einheit für den Käufer reserviert.                                                 |
| `notary_preparation` | Notarvorbereitung       | Kaufvertrag in Vorbereitung.                                                       |
| `notary_appointment` | Notartermin             | Notartermin geplant; noch nicht beurkundet.                                        |
| `aftersale`          | Aftersale               | **Kaufvertrag beurkundet.** Der Käufer ist (künftiger) Eigentümer.                 |
| `completed`          | Abgeschlossen           | Verkauf vollständig abgeschlossen.                                                 |
| `legacy_completed`   | Legacy abgeschlossen    | Abgeschlossener Verkauf aus einem früheren Datenmodell; wie `completed` behandeln. |
| `cancelled`          | Abgebrochen             | Reservierung abgebrochen.                                                          |
| `available`          | Frei                    | Keine aktive Reservierung.                                                         |

Als Käufer gilt, wer `aftersale` erreicht hat: `status=aftersale,completed,legacy_completed` liefert alle verkauften Einheiten. Der übliche Ablauf ist `requested` → `reserved` → `notary_preparation` → `notary_appointment` → `aftersale` → `completed`; jeder Status vor `aftersale` kann in `cancelled` enden. Auch `aftersale` und `completed` sind keine Einbahnstraße: eine Reservierung in `aftersale` kann auf `notary_appointment` zurückgesetzt (eine Korrektur — `notary.deed_date` ist dann wieder `null`) oder abgebrochen werden, und ein abgeschlossener Verkauf kann rückabgewickelt werden und steht dann auf `cancelled`. Maßgeblich ist immer der aktuelle `status`; bei jeder Änderung die Eigentümerschaft neu bewerten. Eine abgebrochene Reservierung ist nicht mehr aktiv und fällt aus `GET /reservations` heraus — auch aus einem `updated_since`-Abruf —, während `GET /reservations/{reservation_id}` sie weiter liefert. Diese Änderungen bemerkst du über das Webhook-Ereignis `reservation.status_changed` oder, indem du die als verkauft geführten Reservierungen per ID neu liest.

`notary.deed_date` ist das Beurkundungsdatum: der Kalendertag des Notartermins in dessen Zeitzone (`Europe/Berlin`, wenn `notary.timezone` `null` ist), sobald die Reservierung `aftersale`, `completed` oder `legacy_completed` erreicht hat. Davor ist es `null`, weil sich ein Termin noch verschieben kann. `null` ist es auch bei Verkäufen ohne erfassten Notartermin, etwa importierten Altverkäufen. Den Nutzen-Lasten-Übergang erfasst Vesturium Cloud nicht; er wird im Kaufvertrag vereinbart und ist nicht Teil dieser API.

### GET /reservations/{reservation_id}

Erforderlicher Scope: `reservations:read`

Gibt eine tenant-scoped Reservierung nach ID zurück. `view` und `lang` funktionieren wie beim Listen-Endpunkt.

| Parameter        | Typ                    | Beschreibung                                                                                  |
| ---------------- | ---------------------- | --------------------------------------------------------------------------------------------- |
| `reservation_id` | UUID                   | Erforderliche Reservierungs-ID im Pfad.                                                       |
| `view`           | `notary \| commission` | Optionale Reservierungsprojektion. Ohne `view` wird die vollständige Ressource zurückgegeben. |
| `lang`           | `en \| de`             | Anzeigesprache für Statuslabels. Standard ist `en`.                                           |

```http
GET /reservations/33333333-3333-3333-3333-333333333333?lang=de
```

### GET /users

Erforderlicher Scope: `users:read`

Gibt Tenant-Benutzer zurück, mit denen Advisor-IDs aus Reservierungsdaten aufgelöst werden können.

| Parameter          | Typ               | Beschreibung                                                 |
| ------------------ | ----------------- | ------------------------------------------------------------ |
| `role`             | `advisor`         | Optionaler Filter nur für Advisor.                           |
| `has_reservations` | boolean           | Wenn true, werden nur Benutzer mit Reservierungen geliefert. |
| `is_active`        | boolean oder null | Standard ist true.                                           |
| `updated_since`    | ISO datetime      | Inkrementeller Sync-Filter gegen `updated_at`.               |
| `limit` / `cursor` | number / string   | Cursor-Pagination. `limit` akzeptiert `1` bis `500`.         |

```http
GET /users?role=advisor&has_reservations=true&limit=100
```

```json
{
  "items": [
    {
      "id": "11111111-1111-1111-1111-111111111111",
      "name": "Ada Advisor",
      "email": "advisor@example.com",
      "is_active": true,
      "provision_percentage": "30.00",
      "created_at": "2026-01-10T09:00:00Z",
      "updated_at": "2026-06-15T10:00:00Z"
    }
  ],
  "pagination": { "limit": 100, "next_cursor": null }
}
```

### GET /users/{user_id}

Erforderlicher Scope: `users:read`

Gibt einen Tenant-Benutzer nach ID zurück.

| Parameter | Typ  | Beschreibung                       |
| --------- | ---- | ---------------------------------- |
| `user_id` | UUID | Erforderliche Benutzer-ID im Pfad. |

```http
GET /users/11111111-1111-1111-1111-111111111111
```

### GET /kpis

Erforderlicher Scope: `kpis:read`

Gibt Tenant-KPI-Metriken über denselben Aggregationsservice wie das Sales-Dashboard zurück. Ohne `view` werden alle unterstützten Insight-Gruppen geliefert.

| Parameter               | Typ                                                                                                                     | Beschreibung                                                                                |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `view`                  | `trends \| funnel \| leaderboard \| pipeline`                                                                           | Optionale KPI-Gruppe. Ohne `view` werden alle Gruppen mit Antwortwert `view=all` geliefert. |
| `period`                | `last_7_days \| last_30_days \| last_90_days \| current_month \| current_quarter \| current_year \| all_time \| custom` | Reporting-Zeitraum. Standard ist `current_month`.                                           |
| `date_from` / `date_to` | ISO-Datum                                                                                                               | Erforderlich bei `period=custom`.                                                           |
| `granularity`           | `daily \| weekly \| monthly`                                                                                            | Optional für Trends. Standard ist `daily`.                                                  |
| `advisor_id`            | UUID                                                                                                                    | Optionaler Benutzerfilter.                                                                  |
| `limit`                 | number                                                                                                                  | Leaderboard-Limit. Akzeptiert `1` bis `500`.                                                |

```http
GET /kpis?period=current_month
```

```json
{
  "view": "all",
  "period": "current_month",
  "period_start": "2026-06-01",
  "period_end": "2026-06-30",
  "currency": "EUR",
  "metrics": {
    "trends": { "points": [] },
    "funnel": { "stages": [] },
    "leaderboard": { "items": [] },
    "pipeline": { "stages": [] }
  }
}
```

### GET /leads

Erforderlicher Scope: `leads:read`

Gibt die CRM-Leads des Mandanten zurück. Ein Token sieht alle Leads des Mandanten; das dreistufige Sichtbarkeitsmodell des internen CRM gilt hier nicht.

Ein Lead, der zum Kunden konvertiert, verlässt diese Sammlung: Er verschwindet aus `/leads`, `GET /leads/{lead_id}` liefert danach `404`, und die Person erscheint stattdessen unter `/customers`. Dieselbe Änderung wird über `lead.converted` aktiv zugestellt.

| Parameter           | Typ                                        | Beschreibung                                                                                     |
| ------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| `status`            | Lead-Statuscode                            | Filter auf den Pipeline-Status, etwa `new`, `contacted`, `qualifying`, `qualified`, `invited`.   |
| `source`            | Lead-Quellcode                             | Filter auf die Herkunft: `website`, `campaign`, `trade_fair`, `referral`, `manual_entry`, `api`. |
| `assigned_agent_id` | UUID                                       | Optionaler Filter auf den zugewiesenen Betreuer.                                                 |
| `invite_status`     | `pending \| used \| registered \| expired` | Filter auf den Status der verknüpften MyVesto-Einladung.                                         |
| `updated_since`     | ISO datetime                               | Inkrementeller Sync-Filter gegen `updated_at`.                                                   |
| `lang`              | `en \| de`                                 | Anzeigesprache für Status- und Quellenlabels. Standard ist `en`.                                 |
| `limit` / `cursor`  | number / string                            | Cursor-Pagination. `limit` akzeptiert `1` bis `500`, Standard ist `100`.                         |

```http
GET /leads?status=qualified&lang=de&limit=100
```

```json
{
  "items": [
    {
      "id": "44444444-4444-4444-4444-444444444444",
      "first_name": "Max",
      "last_name": "Muster",
      "email": "lead@example.com",
      "phone": "+49 30 1234567",
      "status": { "code": "qualified", "label": "Qualifiziert" },
      "source": { "code": "api", "label": "API" },
      "assigned_agent": {
        "id": "11111111-1111-1111-1111-111111111111",
        "name": "Ada Advisor",
        "email": "advisor@example.com"
      },
      "notes": null,
      "myvesto_invite": {
        "invited": true,
        "status": "pending",
        "invited_at": "2026-06-14T08:00:00Z",
        "expires_at": "2026-06-28T08:00:00Z"
      },
      "created_at": "2026-06-14T08:00:00Z",
      "updated_at": "2026-06-16T09:30:00Z"
    }
  ],
  "pagination": { "limit": 100, "next_cursor": null }
}
```

`myvesto_invite` beschreibt ausschließlich den Einladungsstatus und enthält weder Invite-Token noch Invite-URL. Ein `registered`-Flag gibt es hier nicht: Die MyVesto-Registrierung ist die Konvertierung vom Lead zum Kunden, eine registrierte Person wird deshalb über `/customers` beantwortet.

### GET /leads/{lead_id}

Erforderlicher Scope: `leads:read`

Gibt einen Lead des Mandanten zurück.

| Parameter | Typ        | Beschreibung                                                     |
| --------- | ---------- | ---------------------------------------------------------------- |
| `lead_id` | UUID       | Erforderliche Lead-ID im Pfad.                                   |
| `lang`    | `en \| de` | Anzeigesprache für Status- und Quellenlabels. Standard ist `en`. |

```http
GET /leads/44444444-4444-4444-4444-444444444444?lang=de
```

Liefert `404`, wenn die ID unbekannt ist, zu einem anderen Mandanten gehört oder eine bereits zum Kunden konvertierte Person benennt.

### POST /leads

Erforderlicher Scope: `leads:write`

Legt einen Lead an und antwortet mit `201` und derselben Ressource wie `GET /leads/{lead_id}`. Enum-Werte werden in beliebiger Groß-/Kleinschreibung akzeptiert, `api` und `API` sind beide gültig.

| Feld                | Typ              | Beschreibung                                                                              |
| ------------------- | ---------------- | ----------------------------------------------------------------------------------------- |
| `first_name`        | string           | Pflichtfeld.                                                                              |
| `last_name`         | string           | Pflichtfeld.                                                                              |
| `email`             | string           | Pflichtfeld. Darf im Mandanten weder bei einem Lead noch bei einem Kunden existieren.     |
| `phone`             | string oder null | Optional.                                                                                 |
| `notes`             | string oder null | Optional.                                                                                 |
| `source`            | Lead-Quelle      | Optional. Standard ist `api`.                                                             |
| `status`            | Lead-Status      | Optional. Standard ist `new`. `converted` wird nicht akzeptiert.                          |
| `assigned_agent_id` | UUID oder null   | Optional. Muss ein aktiver Benutzer des Mandanten sein. Ohne Angabe bleibt der Lead frei. |
| `lang`              | Query-Parameter  | Anzeigesprache der Labels in der Antwort. Standard ist `en`.                              |

```http
POST /leads
Content-Type: application/json
```

```json
{
  "first_name": "Erika",
  "last_name": "Musterfrau",
  "email": "erika@example.de",
  "phone": "+49 30 1234567",
  "source": "api",
  "status": "new"
}
```

Unbekannte Schlüssel werden mit `400 invalid_body` abgelehnt statt ignoriert — Score, Temperatur, Budget und Kaufzeitraum eines Leads gehören nicht zu diesem Vertrag. Eine bereits vergebene E-Mail liefert `409 duplicate_email`, ein unbekannter oder inaktiver Betreuer `400 invalid_assignee`.

Ein erfolgreicher Create sendet `lead.created`. In der Vesturium-Cloud-Oberfläche angelegte Leads senden es ebenfalls. Ein Abonnent sieht damit jeden Lead des Mandanten, nicht nur die selbst angelegten.

Ein Lead ohne Zuweisung ist für Betreuer mit eingeschränkter CRM-Sichtbarkeit unsichtbar. Setze `assigned_agent_id`, wenn der Mandant mit eingeschränkter Sichtbarkeit arbeitet.

### PATCH /leads/{lead_id}

Erforderlicher Scope: `leads:write`

Aktualisiert die gesendeten Felder und gibt den vollständigen Lead zurück. Änderbar sind `first_name`, `last_name`, `phone`, `notes`, `status`, `source` und `assigned_agent_id`. Nicht gesendete Schlüssel bleiben unverändert; ein explizites `null` bei `assigned_agent_id` hebt die Zuweisung auf.

```http
PATCH /leads/44444444-4444-4444-4444-444444444444
Content-Type: application/json
```

```json
{ "status": "qualified" }
```

- `email` ist nach dem Anlegen nicht mehr änderbar — sie ist der Dedup-Anker. Der Update-Body kennt gar keinen Schlüssel `email`; wird er trotzdem gesendet, folgt `400 invalid_body` mit `body.email` in `details.fields`.
- `status` kann nicht auf `converted` gesetzt werden. Die Konvertierung ist eine strukturelle Operation (Kundendatensatz plus Typwechsel der Person) und kein Statuswert; der Wert gehört nicht zur akzeptierten Menge und wird mit `400 invalid_body` abgelehnt.
- Jeder andere Pipeline-Status wird akzeptiert, es gibt keinen Übergangsgraphen. `contacted`, `qualifying`, `qualified`, `invited`, `nurturing` und `unresponsive` sind nur über diese API erreichbar; die CRM-Oberfläche kann einen Lead aus ihnen nicht wieder herausbewegen.
- Jeder Schreibzugriff hebt `updated_at` an, damit ein geänderter Lead zuverlässig im nächsten `updated_since`-Poll erscheint.
- Ein tatsächlicher Statuswechsel sendet `lead.status_changed`. Ein PATCH ohne Statuswechsel sendet nichts.
- Liefert `404`, wenn die ID unbekannt ist, zu einem anderen Mandanten gehört oder eine konvertierte Person benennt.

### GET /customers

Erforderlicher Scope: `customers:read`

Gibt die CRM-Kunden des Mandanten zurück, ausschließlich lesend. Hier landet eine Person nach der Konvertierung vom Lead, und hier wird beantwortet, ob sie in MyVesto registriert ist.

| Parameter            | Typ                  | Beschreibung                                                                        |
| -------------------- | -------------------- | ----------------------------------------------------------------------------------- |
| `lifecycle_status`   | Lifecycle-Statuscode | Filter auf `registered`, `active`, `purchaser`, `completed`, `inactive`, `churned`. |
| `assigned_agent_id`  | UUID                 | Optionaler Filter auf den zugewiesenen Betreuer.                                    |
| `myvesto_registered` | boolean              | `true` liefert nur Kunden mit MyVesto-Konto.                                        |
| `updated_since`      | ISO datetime         | Inkrementeller Sync-Filter gegen `updated_at`.                                      |
| `lang`               | `en \| de`           | Anzeigesprache für Lifecycle-Labels. Standard ist `en`.                             |
| `limit` / `cursor`   | number / string      | Cursor-Pagination. `limit` akzeptiert `1` bis `500`, Standard ist `100`.            |

```http
GET /customers?myvesto_registered=true&limit=100
```

```json
{
  "items": [
    {
      "id": "66666666-6666-6666-6666-666666666666",
      "first_name": "Erika",
      "last_name": "Musterfrau",
      "email": "customer@example.com",
      "phone": "+49 30 1234567",
      "mobile_phone": null,
      "salutation": "mrs",
      "address": {
        "street": "Lindenweg",
        "house_number": "7a",
        "postal_code": "80331",
        "city": "Muenchen",
        "country": "Deutschland"
      },
      "lifecycle_status": { "code": "registered", "label": "Registriert" },
      "assigned_agent": {
        "id": "11111111-1111-1111-1111-111111111111",
        "name": "Ada Advisor",
        "email": "advisor@example.com"
      },
      "notes": null,
      "myvesto": {
        "registered": true,
        "registered_at": "2026-06-20T11:00:00Z",
        "converted_at": "2026-06-20T11:00:00Z",
        "invite_status": "registered",
        "last_synced_at": "2026-06-21T03:00:00Z"
      },
      "last_activity_at": "2026-06-21T09:00:00Z",
      "created_at": "2026-06-14T08:00:00Z",
      "updated_at": "2026-06-21T03:00:00Z"
    }
  ],
  "pagination": { "limit": 100, "next_cursor": null }
}
```

`myvesto.registered` leitet sich aus der MyVesto-Benutzer-ID ab, nicht aus `lifecycle_status`. `lifecycle_status` steht beim Anlegen eines Kunden per Vorgabe auf `registered` und sagt deshalb nichts über ein tatsächliches MyVesto-Konto aus — filtere über `myvesto_registered`, wenn die Registrierung gemeint ist.

Kunden tragen `salutation` (`mr`, `mrs`, `diverse` oder `null`) und die Postanschrift `address` des CRM-Datensatzes (`street`, `house_number`, `postal_code`, `city`, `country`; `null`, wenn weder Straße noch PLZ noch Ort erfasst sind — ein Land allein ist keine Anschrift). Die übrige Selbstauskunft — Geburtsdatum, Geburtsname, Geburtsort, Staatsangehörigkeit, Familienstand, Steuer-ID, Güterstand — wird aus MyVesto synchronisiert, ist aber nie Teil dieser API. Die Anschrift am `buyer` einer Reservierung ist die zu diesem Kauf erfasste und kann von dieser abweichen.

### GET /customers/{customer_id}

Erforderlicher Scope: `customers:read`

Gibt einen Kunden des Mandanten zurück.

| Parameter     | Typ        | Beschreibung                                            |
| ------------- | ---------- | ------------------------------------------------------- |
| `customer_id` | UUID       | Erforderliche Kunden-ID im Pfad.                        |
| `lang`        | `en \| de` | Anzeigesprache für Lifecycle-Labels. Standard ist `en`. |

```http
GET /customers/66666666-6666-6666-6666-666666666666?lang=de
```

Liefert `404`, wenn die ID unbekannt ist, zu einem anderen Mandanten gehört oder eine Person benennt, die noch ein Lead ist.

### GET /projects

Erforderlicher Scope: `projects:read`

Liefert die eigenen Objekte (Gebäude) des Mandanten sowie Objekte mit Einheiten, die über das Objektnetzwerk in den Mandanten freigegeben wurden, mit ihren Einheiten und den Metadaten der Objekt-Dokumente. Demo-Objekte, die beim Anlegen eines Mandanten entstehen, erscheinen nie. Wie freigegebene Objekte und Einheiten erscheinen, steht unter Freigegebene Objekte.

| Parameter          | Typ             | Beschreibung                                                                                                                                     |
| ------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `zip_code`         | list            | Exakte Postleitzahlen, kommagetrennt oder wiederholt.                                                                                            |
| `city`             | string          | Stadt, exakter Vergleich ohne Groß-/Kleinschreibung.                                                                                             |
| `q`                | string          | Teilstring von Objektname oder Straße.                                                                                                           |
| `updated_since`    | ISO datetime    | Inkrementeller Sync gegen `updated_at`.                                                                                                          |
| `document_type`    | list            | Schränkt die eingebetteten `documents` ein, z. B. `hoa_minutes,resolution_collection`. Entfernt nie Objekte.                                     |
| `file_type`        | list            | Schränkt die eingebetteten `documents` nach Endung ein: `pdf`, `jpg` (inkl. `.jpeg`), `png`, …                                                   |
| `is_internal`      | boolean         | Schränkt die eingebetteten `documents` auf interne (`true`) oder teilbare (`false`) Dateien ein. Interne Dokumente sind standardmäßig enthalten. |
| `lang`             | `en \| de`      | Anzeigesprache der Labels. Standard `en`.                                                                                                        |
| `limit` / `cursor` | number / string | Cursor-Pagination. Limit `1` bis `500`, Standard `100`. Sortiert nach `updated_at` absteigend.                                                   |

```http
GET /projects?zip_code=10115&document_type=hoa_minutes&lang=de
```

```json
{
  "items": [
    {
      "id": "77777777-7777-7777-7777-777777777777",
      "name": "Musterstraße 12",
      "street": "Musterstraße",
      "house_number": "12",
      "zip_code": "10115",
      "city": "Berlin",
      "district": "Mitte",
      "state": "Berlin",
      "country": "Deutschland",
      "construction_year": 1908,
      "renovation_year": 2024,
      "total_floors": 5,
      "project_type": { "code": "existing_renovation", "label": "Bestandssanierung" },
      "building_type": { "code": "residential", "label": "Wohnimmobilie" },
      "completion_date": "2025-03-31",
      "move_in_ready_date": "2025-04-01",
      "status": { "code": "sold", "label": "Verkauft" },
      "is_shared": false,
      "bautraeger": { "id": "88888888-8888-8888-8888-888888888888", "company_name": "Beispiel Bau GmbH" },
      "unit_count": 1,
      "units": [
        {
          "id": "99999999-9999-9999-9999-999999999999",
          "unit_number": "WE 3",
          "floor": "1. OG",
          "property_type": { "code": "apartment", "label": "Wohnung" },
          "size_sqm": 64.5,
          "rooms": 2.5,
          "status": { "code": "completed", "label": "Abgeschlossen" },
          "is_shared": false,
          "document_count": 4,
          "documents_updated_at": "2026-09-01T10:00:00Z",
          "updated_at": "2026-08-20T09:00:00Z"
        }
      ],
      "documents": [
        {
          "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
          "document_type": { "code": "hoa_minutes", "label": "WEG Protokolle" },
          "title": "Eigentümerversammlung 2025",
          "file_name": "protokoll-2025.pdf",
          "file_type": "pdf",
          "mime_type": "application/pdf",
          "file_size": 482113,
          "is_internal": false,
          "uploaded_at": "2026-06-02T08:00:00Z",
          "updated_at": "2026-06-02T08:00:00Z"
        }
      ],
      "documents_updated_at": "2026-06-02T08:00:00Z",
      "created_at": "2025-01-10T08:00:00Z",
      "updated_at": "2026-08-20T09:00:00Z"
    }
  ],
  "pagination": { "limit": 100, "next_cursor": null }
}
```

`documents` enthält nur Objekt-Dokumente; die Dokumente jeder Einheit stehen in `GET /properties/{property_id}`. Dokument-Uploads bewegen **kein** `updated_at` — nutze `documents_updated_at` am Objekt und `document_count` / `documents_updated_at` an jeder Einheit, um zu sehen, welche Einheiten neu abzufragen sind. Die Marker beachten dieselben Filter wie die eingebetteten Listen, ein gefilterter Abgleich konvergiert also ebenfalls. Ein neues Dokument bewegt das `updated_at` des Objekts nicht, ein `updated_since`-Abgleich sieht es also nie — lies die vollständige Liste (ohne `updated_since`), wenn du Dokumentänderungen sehen willst, und nutze `updated_since` nur für Änderungen an den Objekt- und Einheitendaten selbst. `unit_count` zählt die aufgeführten Einheiten. `is_shared` markiert ein Objekt mit Einheiten, die über das Objektnetzwerk in den Mandanten freigegeben wurden — siehe Freigegebene Objekte.

Neben den Feldern im Beispiel trägt jedes Objekt alle Felder, die sich auch schreiben lassen (siehe Feldreferenz), mit denselben Namen, dazu:

- `price_min` / `price_max`: kleinster und größter Kaufpreis (`purchase_price`) der aufgeführten Einheiten.
- `size_min` / `size_max` und `price_per_sqm_min` / `price_per_sqm_max`: berechnet, nur lesbar. `micro_location`: die Mikrolage, nur lesbar.
- `visibility`: `offline`, `pre_sale` oder `online`.
- `images`: Objektbilder mit `id`, `url`, `category`, `title`, `display_order`.
- `parking_spaces`: der Stellplatz-Pool des Objekts mit `id`, `identifier`, `parking_type`, `assigned_unit_id` (fest zugeordnet) oder `null` (optional), `sale_price`, `monthly_rent`, `monthly_cost_deductible`, `monthly_cost_non_deductible`, `display_order`.
- `commission_rate` sowie `developer_purchase_price` je Stellplatz nur mit `purchase_prices:read`; sonst fehlen die Schlüssel.

Einheiten in der Liste tragen zusätzlich `purchase_price` und `visibility`.

### GET /projects/{project_id}

Erforderlicher Scope: `projects:read`

Liefert ein Objekt in derselben Form wie ein Listeneintrag und akzeptiert dieselben Parameter `document_type`, `file_type`, `is_internal` und `lang`. `404` bei unbekannter ID, Objekt eines anderen Mandanten ohne Freigabe für diesen Mandanten oder Demo-Objekt.

### GET /properties/{property_id}

Erforderlicher Scope: `projects:read`

Liefert eine Einheit mit ihren Daten und ihren eigenen Dokumenten. Die ID ist dieselbe `property_id` wie in `/reservations`, einschließlich Einheiten, die über das Objektnetzwerk in den Mandanten freigegeben wurden — siehe Freigegebene Objekte. Akzeptiert `document_type`, `file_type`, `is_internal` und `lang`. `GET /units/{unit_id}` liefert dieselbe Antwort unter dem Pfad, den auch die Schreib-Endpunkte für Einheiten verwenden.

```json
{
  "id": "99999999-9999-9999-9999-999999999999",
  "project_id": "77777777-7777-7777-7777-777777777777",
  "unit_number": "WE 3",
  "floor": "1. OG",
  "property_type": { "code": "apartment", "label": "Wohnung" },
  "property_usage": { "code": "condominium", "label": "Eigentumswohnung" },
  "size_sqm": 64.5,
  "rooms": 2.5,
  "bathrooms": 1,
  "balcony": { "code": "balcony", "label": "Balkon" },
  "has_cellar": true,
  "object_share_owner": 0.0645,
  "rental_type": { "code": "normal_rental", "label": "Normale Vermietung" },
  "has_sev": false,
  "completion_date": "2025-03-31",
  "move_in_ready_date": "2025-04-01",
  "monthly_rent": "890.00",
  "total_monthly_rent": "970.00",
  "hoa_costs": "312.50",
  "operation_cost_tenant": "180.00",
  "management_fee": "29.75",
  "maintenance_reserve": "45.00",
  "maintenance_reserve_balance": "3200.00",
  "parking_spaces": [
    { "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "identifier": "TG-15", "parking_type": { "code": "underground_parking", "label": "Tiefgarage" }, "monthly_rent": "80.00" }
  ],
  "status": { "code": "completed", "label": "Abgeschlossen" },
  "is_shared": false,
  "documents": [],
  "created_at": "2025-01-10T08:00:00Z",
  "updated_at": "2026-08-20T09:00:00Z"
}
```

`object_share_owner` ist der Miteigentumsanteil als Bruch zwischen 0 und 1 in voller Genauigkeit. `monthly_rent` ist die geplante Kaltmiete aus der Verkaufskalkulation, kein Mietvertrag; `total_monthly_rent` zählt die Mieten der Stellplätze dazu und ist die Größe, auf der die Bruttomietrendite beruht. `hoa_costs` ist der nicht umlagefähige Vermieter-Anteil am Hausgeld, nicht das ganze Hausgeld: das volle monatliche Hausgeld ist `hoa_costs` + `operation_cost_tenant` (Mieter-Umlage) + `maintenance_reserve` (monatliche Rücklagenzuführung). `maintenance_reserve_balance` ist die angesparte Rücklage. Die Stellplatzmiete steht je Stellplatz in `parking_spaces[].monthly_rent`. Geldbeträge sind Dezimal-Strings. Zentrale Mandanten-Dokumente (Muster-Kaufvertrag und ähnliche, die Vesturium Cloud an jeder Einheit zeigt) sind nicht Teil dieser API: `documents` enthält nur die Dateien, die am Objekt oder an der Einheit selbst hängen, und Zähler wie Marker folgen dem. `is_shared` markiert eine Einheit, die über das Objektnetzwerk in den Mandanten freigegeben wurde, siehe Freigegebene Objekte. `404` bei unbekannter ID, Einheit eines anderen Mandanten ohne Freigabe für diesen Mandanten, Demo-Einheit oder Einheit eines Demo-Objekts. `rental_type` ist `existing_tenant` (bei Kauf vermietet), `rental_pool` (Mietpool), `operator` (an einen Betreiber mit festem Vertrag vermietet), `normal_rental` (vom Eigentümer vermietet) oder `null`; `has_sev` gibt an, ob für die Einheit eine Sondereigentumsverwaltung vereinbart ist.

Neben den Feldern im Beispiel trägt jede Einheit alle Felder, die sich auch schreiben lassen (siehe Feldreferenz), mit denselben Namen — gelesene Werte lassen sich unverändert zurückschreiben. Dazu gehören die Verkaufspreise: `purchase_price` (Gesamtinvestition), `apartment_price` (Wohnungspreis: Gesamtinvestition abzüglich Möbel und fest zugeordneter Stellplätze) und `furniture_price`; je Stellplatz `sale_price`. Weiter `visibility` (`deactivated`, `draft`, `active`), `images` wie beim Objekt sowie berechnet und nur lesbar `price_per_sqm` und `gross_rental_yield`. `developer_purchase_price` (Einkaufspreis) und `commission_rate` (wirksamer Provisionssatz) erscheinen nur mit `purchase_prices:read`.

### GET /documents/{document_id}

Erforderlicher Scope: `documents:read` (Dokument-IDs stammen aus `projects:read`-Antworten, zum Abholen von Dateien braucht es also beide Scopes)

Liefert ein Objekt- oder Einheiten-Dokument mit Download-Link, hochladender Person und vorhandenem Text.

| Parameter     | Typ        | Beschreibung                                            |
| ------------- | ---------- | ------------------------------------------------------- |
| `document_id` | UUID       | Erforderliche Dokument-ID im Pfad.                      |
| `lang`        | `en \| de` | Anzeigesprache des Dokumenttyp-Labels. Standard `en`.   |

```json
{
  "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
  "level": "project",
  "project_id": "77777777-7777-7777-7777-777777777777",
  "property_id": null,
  "document_type": { "code": "hoa_minutes", "label": "WEG Protokolle" },
  "title": "Eigentümerversammlung 2025",
  "description": null,
  "file_name": "protokoll-2025.pdf",
  "file_type": "pdf",
  "mime_type": "application/pdf",
  "file_size": 482113,
  "is_internal": false,
  "uploaded_at": "2026-06-02T08:00:00Z",
  "updated_at": "2026-06-02T08:00:00Z",
  "uploaded_by": { "id": "11111111-1111-1111-1111-111111111111", "name": "Celina Beispiel", "email": "celina@example.com" },
  "download_url": "https://…",
  "download_url_expires_at": "2026-09-22T15:00:00Z",
  "text": "# Protokoll der Eigentümerversammlung …"
}
```

- Als intern markierte Dokumente (`is_internal: true`) werden hier wie jedes andere zurückgegeben; `documents:read` gibt damit auch deren Download-Link und OCR-Text frei.
- `download_url` ist eine Stunde gültig; für einen frischen Link das Dokument erneut abfragen. Sie ist `null`, wenn kein Link signiert werden kann. Selten liefert ein Link kurz nach einem KI-Import `404`, bevor die Datei an ihrem Platz liegt.
- `text` ist Markdown aus der Texterkennung und nur vorhanden, wo Vesturium Cloud die Datei schon gelesen hat (KI-Import oder Assistent), derzeit eine kleine Minderheit der Dokumente. Die API startet nie eine Texterkennung. Ein Parser-Update kann vorhandenen Text ausblenden, bis die Datei erneut gelesen wurde. Bei freigegebenen Dokumenten des Bauträgers ist `text` `null`: Die Texterkennung des Bauträgers gibt die API nie heraus; erscheinen kann nur Text, den das eigene Konto bereits hat lesen lassen (Assistent).
- `uploaded_by` ist die Person, die die Datei hochgeladen hat; bei synchronisierten Dateien (Investagon, TeamProQ) die Person, die den Sync ausgelöst hat. `null` bei Uploads über das Bauträger-Portal und bei gelöschten Benutzern.
- Löst auch Dokumente an einer freigegebenen Einheit oder einem freigegebenen Objekt auf — die freigegebenen Dokumente des Bauträgers und die eigenen Uploads des Mandanten daran; siehe Freigegebene Objekte für die Frage, welche Dokumente des Bauträgers sichtbar sind.
- `404` bei unbekannter ID, Dokument eines anderen Mandanten ohne Sichtbarkeit über eine Freigabe, oder Dokument eines Demo-Objekts bzw. einer Demo-Einheit.

## Objekte schreiben

Mit den Schreib-Scopes legt ein Partnersystem Objekte samt Einheiten, Stellplätzen, Bildern und Dokumenten an, pflegt sie und stellt sie online. Es gelten dieselben Regeln wie in Vesturium Cloud selbst: Pflichtfelder, Plan-Grenzen, Freigabe-Checkliste und Rechte.

### Grundlagen

- **Handelnde Person:** Ein Schreibzugriff läuft im Namen der Person, die den Token erstellt hat (bei rotierten Tokens: die Person, die zuletzt rotiert hat). Der Token kann nie mehr als sie: es gelten ihre Rechte in Vesturium Cloud, beschränkt auf die Scopes des Tokens. Änderungen erscheinen in Vesturium Cloud als Änderungen dieser Person; das Audit-Protokoll vermerkt zusätzlich den Token.
- **Sicht der Person:** Was die Person in Vesturium Cloud nicht sieht, kann der Token nicht ändern, etwa Einheiten außerhalb ihrer Objektgruppen, sofern sie nicht „Kann alle Immobilien sehen“ hat. Die lesenden Endpunkte bleiben davon unberührt und liefern weiter den ganzen Mandanten.
- **Eigener Bestand:** Geschrieben wird nur an eigenen Objekten und Einheiten; über das Objektnetzwerk freigegebene liefern `403 not_owned`.
- **Feldnamen:** Lesen und Schreiben verwenden dieselben Namen und Codes (siehe Feldreferenz). Enum-Werte werden als Code geschickt, z. B. `"rental_type": "normal_rental"`; Geldbeträge als Zahl oder Dezimal-String. Unbekannte Felder liefern `400 invalid_body`.
- **Antworten:** Anlegen und Ändern antworten mit dem Objekt bzw. der Einheit in der Lese-Form (`lang` wie beim Lesen).
- **Fehler:** wie unter Pagination und Fehler, zusätzlich die Codes für Schreibzugriffe.

### Projekte

#### POST /projects

Erforderlicher Scope: `projects:write`

Legt ein Objekt an und antwortet mit `201` und dem Objekt. Pflicht sind `name`, `street`, `house_number`, `zip_code`, `city`, `state`. Ohne `latitude` / `longitude` ermittelt Vesturium Cloud die Koordinaten aus der Adresse; gelingt das nicht, kommt `400 invalid_request` mit der Bitte, die Koordinaten mitzuschicken. Der Name muss im Mandanten eindeutig sein (`409 conflict`). Überschreitet ein weiteres Objekt das Tarif-Kontingent, kommt `402 confirm_overage_required`; mit dem Query-Parameter `confirm_overage=true` wiederholen, um die Mehrkosten anzunehmen. Eine Freigabe-Checkliste entsteht wie in Vesturium Cloud automatisch.

```http
POST /projects
Content-Type: application/json

{ "name": "Lindenstraße 12", "street": "Lindenstraße", "house_number": "12", "zip_code": "04275", "city": "Leipzig", "state": "Sachsen", "project_type": "existing_renovation" }
```

#### PATCH /projects/{project_id}

Erforderlicher Scope: `projects:write`

Ändert nur die mitgeschickten Felder.

#### PUT /projects/{project_id}/visibility

Erforderlicher Scope: `projects:write`

Body `{ "visibility": "offline" | "pre_sale" | "online" }`. Halten offene Checklisten-Punkte das Online-Stellen auf, kommt `409 publication_blocked` mit den Punkten in `details.blockers` — siehe Checkliste.

#### POST /projects/{project_id}/micro-location/refresh

Erforderlicher Scope: `projects:write`

Aktualisiert die Mikrolage aus der Adresse und antwortet mit dem Objekt.

### Einheiten

#### POST /projects/{project_id}/units

Erforderlicher Scope: `properties:write`

Legt bis zu 50 Einheiten auf einmal an: `{ "units": [ … ] }`. Pflicht je Einheit sind `unit_number`, `property_type`, `size_sqm`, `rooms`, `purchase_price` und `monthly_rent`. Jede Einheit wird für sich gespeichert; ein Fehler hält die übrigen nicht auf. Die Antwort ist immer `200`:

```json
{
  "created": 1,
  "failed": 1,
  "results": [
    { "index": 0, "status": "created", "id": "99999999-9999-9999-9999-999999999999" },
    { "index": 1, "status": "failed", "error": { "code": "invalid_body", "message": "Request body validation failed", "details": { "fields": [ { "field": "body.rooms", "message": "Input should be greater than or equal to 0" } ] } } }
  ]
}
```

- `unit_number` ohne das Präfix „WE“ schicken; Vesturium Cloud zeigt es selbst davor.
- Neue Einheiten stehen auf dem Status `available` (frei).
- `apartment_price` und `key_box_description` lassen sich erst nach dem Anlegen per `PATCH /units/{unit_id}` setzen. Ein `apartment_price` bestimmt dann `purchase_price` neu (Wohnung + Möbel + fest zugeordnete Stellplätze); beide zugleich zu schicken ist ein Fehler.

#### PATCH /units/{unit_id}

Erforderlicher Scope: `properties:write`

Ändert nur die mitgeschickten Felder und antwortet mit der Einheit.

#### PUT /units/{unit_id}/status

Erforderlicher Scope: `properties:write`

Body `{ "status": "<code>" }` mit einem Status-Code wie in `GET /units/{unit_id}`, z. B. `available` oder `reserved`. Den Status `available` setzt Vesturium Cloud nur, wenn keine Freigabe-Punkte offen sind.

#### PUT /units/{unit_id}/visibility

Erforderlicher Scope: `properties:write`

Body `{ "visibility": "deactivated" | "draft" | "active", "confirm_overage": false }`.

#### DELETE /units/{unit_id}

Erforderlicher Scope: `properties:write`

Löscht eine Einheit (`204`). Eine Einheit mit Reservierung lässt sich nicht löschen (`409 conflict`).

### Stellplätze

Stellplätze gehören zum Pool eines Objekts und erscheinen in `parking_spaces` von `GET /projects/{project_id}`. Mit `assigned_unit_id` ist ein Stellplatz einer Einheit fest zugeordnet und zählt zu deren Kaufpreis; ohne bleibt er optional im Pool.

| Endpunkt | Scope | Wirkung |
| --- | --- | --- |
| `POST /projects/{project_id}/parking-spaces` | `properties:write` | Stellplatz anlegen (`201`). Pflicht: `identifier`, `parking_type` (`parking_space`, `garage`, `duplex_garage`, `underground_parking`, `carport`, `parking_garage`). Weiter: `assigned_unit_id`, `sale_price`, `monthly_rent`, `monthly_cost_deductible`, `monthly_cost_non_deductible`, `display_order`, mit `purchase_prices:write` `developer_purchase_price`. |
| `PATCH /projects/{project_id}/parking-spaces/{space_id}` | `properties:write` | Mitgeschickte Felder ändern. |
| `DELETE /projects/{project_id}/parking-spaces/{space_id}` | `properties:write` | Stellplatz löschen (`204`). |

### Uploads

Erforderlicher Scope: `media:write`

Dateien gehen in drei Schritten direkt an den Speicher, nie durch die API:

1. `POST /uploads` mit `entity_type` (`project` oder `unit`), `entity_id`, `kind` (`images` oder `documents`) und bis zu 50 `files` (`filename`, `content_type`, `size` in Bytes). Die Antwort (`201`) enthält `session_id`, `expires_at` und je Datei `staged_id`, `upload_url` und `required_headers`.
2. Jede Datei per `PUT` an ihre `upload_url` schicken, mit genau den `required_headers`.
3. `POST /uploads/{session_id}/finalize` mit `items`: je Datei `staged_id`, `category` (bei Bildern die Bildkategorie, z. B. `exterior`, `interior`, `floor_plan`; bei Dokumenten der Dokumenttyp-Code, z. B. `expose` oder `declaration_of_division`), optional `title`, `description`, `display_order`, `is_internal`. Die Antwort nennt je Datei die neue `id`, `kind` (`image` oder `document`) und `category`.

`DELETE /uploads/{session_id}` bricht einen Upload ab (`204`). Erlaubte Dateitypen und -größen sind dieselben wie in Vesturium Cloud.

### Bilder und Dokumente

Erforderlicher Scope: `media:write`

| Endpunkt | Wirkung |
| --- | --- |
| `PATCH /projects/{project_id}/images/{image_id}` | `category`, `title`, `description`, `display_order` eines Objektbilds ändern. |
| `PATCH /units/{unit_id}/images/{image_id}` | `title`, `description`, `display_order` eines Einheitenbilds ändern. |
| `PUT /projects/{project_id}/images/order`, `PUT /units/{unit_id}/images/order` | Reihenfolge setzen: `{ "image_ids": [ … ] }` mit allen Bildern, jedes genau einmal. |
| `DELETE /projects/{project_id}/images/{image_id}`, `DELETE /units/{unit_id}/images/{image_id}` | Bild löschen (`204`). |
| `PATCH /documents/{document_id}` | `document_type` (Code) und `is_internal` eines Objekt- oder Einheiten-Dokuments ändern. |

### Checkliste

Erforderlicher Scope: `checklist:write`. Braucht im Mandanten das Modul Smarte Checklisten; ohne kommt `403 forbidden` mit `details.submodule`.

#### GET /projects/{project_id}/readiness

Liefert, was dem Online-Stellen noch im Weg steht: `stats` (`total_required`, `completed_required`, `completion_percentage`, `pending_count`, `overdue_count`, `missing_count`, `not_needed_count`), `template_name`, `checklist_deadline` und `items` mit `id`, `type`, `reference` (Feld bzw. Dokumenttyp), `unit_id`, `unit_number`, `required`, `blocks_publication`, `status` und `rejection_reason`. Als „nicht nötig“ markierte Punkte erscheinen nur mit `include_not_needed=true`.

#### PATCH /checklist-items/{item_id}

Setzt `status` (`missing`, `requested`, `pending_approval`, `rejected`, `completed`, `not_needed`, `overdue`), `is_required`, `blocks_publication` oder `rejection_reason`. Für `not_needed` ist `not_needed_reason` Pflicht.

### Feldreferenz

Objekt — dieselben Namen in `GET /projects`, `POST /projects` und `PATCH /projects/{project_id}`:

| Feld | Typ | Anlegen | Hinweis |
|---|---|---|---|
| `name` | string | Pflicht |  |
| `street` | string | Pflicht |  |
| `house_number` | string | Pflicht |  |
| `city` | string | Pflicht |  |
| `district` | string | optional |  |
| `state` | string | Pflicht |  |
| `country` | string | optional |  |
| `zip_code` | string | Pflicht |  |
| `latitude` | number | optional |  |
| `longitude` | number | optional |  |
| `construction_year` | integer | optional |  |
| `completion_date` | date | optional |  |
| `renovation_year` | integer | optional |  |
| `renovation_measure_amount` | decimal string | optional |  |
| `renovation_measure_completion_date` | date | optional |  |
| `total_floors` | integer | optional |  |
| `total_units` | integer | optional |  |
| `building_type` | Code: `residential`, `commercial`, `mixed` | optional |  |
| `project_type` | Code: `new_build`, `kfw_renovation`, `existing_renovation`, `existing_property` | optional |  |
| `has_elevator` | boolean | optional |  |
| `has_parking` | boolean | optional |  |
| `has_basement` | boolean | optional |  |
| `has_garden` | boolean | optional |  |
| `energy_certificate_type` | Code: `consumption`, `demand` | optional |  |
| `energy_consumption` | number | optional |  |
| `energy_class` | Code: `a_plus`, `a`, `b`, `c`, `d`, `e`, `f`, `g`, `h` | optional |  |
| `heating_type` | Code: `gas_central`, `oil_central`, `district_central`, `heat_pump_central`, `pellet_central`, `electric_central`, `gas_floor`, `electric_floor`, `solar_thermal`, `central`, `floor`, `other`, `unknown` | optional |  |
| `primary_energy_consumption` | number | optional |  |
| `heating_building_year` | integer | optional |  |
| `backyard_development` | boolean | optional |  |
| `sev_takeover_one_year` | boolean | optional |  |
| `renovations` | string | optional |  |
| `modernizations` | list (string) | optional |  |
| `move_in_ready_date` | date | optional |  |
| `mabv_rates` | list (MaBV-Rate) | optional |  |
| `description` | string | optional |  |
| `amenities` | list (string) | optional |  |
| `bautraeger_id` | UUID | optional |  |
| `video_youtube_url` | string | optional |  |
| `expose_template_id` | UUID | optional |  |
| `default_image_set_id` | UUID | — | nur PATCH |
| `commission_rate` | number | optional | purchase_prices:* |
| `price_adjustment_enabled` | boolean | optional |  |

Einheit — dieselben Namen in `GET /units/{unit_id}`, `POST /projects/{project_id}/units` und `PATCH /units/{unit_id}`:

| Feld | Typ | Anlegen | Hinweis |
|---|---|---|---|
| `unit_number` | string | Pflicht |  |
| `property_type` | Code: `apartment`, `studio`, `penthouse`, `maisonette`, `house`, `co_living`, `denkmal`, `commercial`, `parking`, `storage`, `other` | Pflicht |  |
| `property_usage` | Code: `wg_apartment`, `condominium`, `micro_apartment`, `commercial_other`, `global_object` | optional |  |
| `size_sqm` | number | Pflicht |  |
| `rooms` | number | Pflicht |  |
| `bathrooms` | integer | optional |  |
| `floor` | string | optional |  |
| `balcony` | Code: `balcony`, `loggia`, `terrace`, `roof_terrace`, `conservatory` | optional |  |
| `has_cellar` | boolean | optional |  |
| `object_share_owner` | number | optional |  |
| `rental_type` | Code: `existing_tenant`, `rental_pool`, `operator`, `normal_rental` | optional |  |
| `has_sev` | boolean | optional |  |
| `has_initial_letting_guarantee` | boolean | optional |  |
| `purchase_price` | decimal string | Pflicht |  |
| `apartment_price` | decimal string | — | nur PATCH |
| `furniture_price` | decimal string | optional |  |
| `monthly_rent` | decimal string | Pflicht |  |
| `management_fee` | decimal string | optional |  |
| `hoa_costs` | decimal string | optional |  |
| `operation_cost_tenant` | decimal string | optional |  |
| `maintenance_reserve` | decimal string | optional |  |
| `maintenance_reserve_balance` | decimal string | optional |  |
| `work_contract_amount` | decimal string | optional |  |
| `land_value` | decimal string | optional |  |
| `afa_type` | Code: `linear`, `degressive` | optional |  |
| `depreciation_rate_building_manual` | decimal string | optional |  |
| `depreciation_special_7b` | boolean | optional |  |
| `renovation_costs` | decimal string | optional |  |
| `renovation_tax_treatment` | Code: `maintenance_expense`, `capitalized`, `redevelopment_area_7h`, `heritage_7i` | optional |  |
| `renovation_depreciation_rate_manual` | decimal string | optional |  |
| `completion_date_override` | date | optional |  |
| `move_in_ready_date_override` | date | optional |  |
| `mabv_rates_override` | list (MaBV-Rate) | optional |  |
| `takeover_special_charges_months` | integer | optional |  |
| `takeover_special_charges_amount` | decimal string | optional |  |
| `modernizations` | list (string) | optional |  |
| `notary_override_percentage` | decimal string | optional |  |
| `kfw_enabled` | boolean | optional |  |
| `kfw_program` | string | optional |  |
| `kfw_program_id` | UUID | optional |  |
| `kfw_repayment_bonus` | decimal string | optional |  |
| `kfw_wpb` | boolean | optional |  |
| `kfw_baubegleitung_enabled` | boolean | optional |  |
| `external_id` | string | optional | nur beim Anlegen |
| `key_box_description` | string | — | nur PATCH |
| `developer_purchase_price` | decimal string | optional | purchase_prices:* |
| `commission_rate_override` | decimal string | optional | purchase_prices:* |

Felder mit `purchase_prices:*` werden ohne `purchase_prices:read` nicht ausgeliefert und ohne `purchase_prices:write` mit `403 forbidden` abgewiesen. Beide Scopes wirken nur, wenn die Person, die den Token erstellt hat, das Recht „Alle Provisionen anzeigen“ hat. Eine MaBV-Rate ist `{ "percentage": 30, "payment_date": "2027-01-15", "description": "Baubeginn" }`.

## Freigegebene Objekte

Einheiten, die über das Objektnetzwerk in den Mandanten freigegeben wurden, erscheinen zusammen mit dem eigenen Bestand: in `GET /projects`, `GET /projects/{project_id}` und `GET /properties/{property_id}`, ihre Dokumente in `GET /documents/{document_id}`. `is_shared` markiert sie mit `true`; eigene Objekte zeigen immer `false`. Eine freigegebene Einheit erscheint erst, wenn der Mandant ihre Freigabe genehmigt hat; ausstehende Freigaben bleiben unsichtbar.

- **Werte:** Einheiten- und Objektfelder sind die Werte, die der Bauträger freigegeben und der Mandant genehmigt hat — nicht die aktuellen Daten des Bauträgers. Ausnahme ist der `status` einer Einheit: er ist immer aktuell, genau wie bei eigenen Einheiten. Der `status` eines Objekts kommt wie seine übrigen Objekt-Felder aus der genehmigten Objekt-Review.
- **Gebäude:** Ein freigegebenes Objekt listet nur die Einheiten, die für diesen Mandanten freigegeben sind; `unit_count` zählt genau diese, nie das vollständige Gebäude des Bauträgers. Objekt-Felder (`name`, `city`, `project_type`, …) erscheinen, sobald der Bauträger sie freigegeben und der Mandant eine Objekt-Review genehmigt hat; bis dahin sind sie `null` — die freigegebenen Einheiten selbst werden trotzdem geliefert.
- **Bauträger-Kontakt:** `bautraeger` ist der eigene Kontakt des Mandanten für den Bauträger, nicht dessen eigener Kontakt-Datensatz.
- **Dokumente:** öffentliche Dokumente des Bauträgers, interne nur dort, wo der Bauträger sie ausdrücklich freigegeben hat, dazu die eigenen Uploads des Mandanten an der freigegebenen Einheit. `uploaded_by` ist `null` bei Dokumenten des Bauträgers, ebenso `text`, sofern das eigene Konto die Datei nicht schon hat lesen lassen — die Texterkennung des Bauträgers gibt die API nie heraus. `parking_spaces[].id` ist `null` bei freigegebenen Einheiten — Snapshots tragen keine Stellplatz-ID.
- **Filter:** `zip_code`, `city` und `q` in `GET /projects` greifen bei einem freigegebenen Objekt auf die genehmigten Werte, die auch die Antwort zeigt, nie auf die aktuellen Daten des Bauträgers. Ein freigegebenes Objekt ohne genehmigte Objekt-Review trifft keiner dieser Filter.
- **Lebenszyklus:** Eine gestoppte Freigabe bleibt sichtbar; erst eine beendete Freigabe entfernt die Einheit, und das geschieht ohne eigenes Signal — über einen Vollabgleich ausgleichen, wie bei jeder anderen Löschung.
- **Sync:** Eine neue Freigabe, ein neu genehmigter Wert oder eine Änderung an den eigenen Überschreibungen des Mandanten an einer freigegebenen Einheit bewegt das `updated_at` der Einheit und des Objekts, `updated_since` erfasst also auch neu freigegebenen Bestand und neu genehmigte Änderungen, nicht nur Bearbeitungen an den eigenen Daten.
- **Preise:** Freigegebene Einheiten zeigen die Preise des Mandanten, nie die des Bauträgers: `purchase_price` ist der Verkaufspreis aus der genehmigten Freigabe, bei einer Freigabe mit fester Provision mit dem vereinbarten Aufschlag, sonst ein eigener überschriebener Preis des Mandanten. `developer_purchase_price` ist der Übergabepreis der Freigabe (oder `null`), `commission_rate` der eigene Provisionssatz des Mandanten. Freigegebene Objekte tragen weder `price_min` / `price_max` noch `parking_spaces`.
- **Schreiben:** Freigegebene Objekte und Einheiten lassen sich über die API nicht ändern (`403 not_owned`).

## Webhooks

Outbound-Webhooks liefern Änderungen an aktive HTTPS-Endpunkte. Die fachliche Mutation wird nicht zurückgerollt, wenn eine Webhook-Zustellung fehlschlägt.

Konfigurationsendpunkte im Admin API Access:

```http
GET /api-webhooks
POST /api-webhooks
PUT /api-webhooks/{webhook_id}
POST /api-webhooks/{webhook_id}/rotate-secret
DELETE /api-webhooks/{webhook_id}
```

Delivery-Header:

| Header                     | Beschreibung                           |
| -------------------------- | -------------------------------------- |
| `Content-Type`             | JSON Content-Type.                     |
| `User-Agent`               | `Vesturium-Webhooks/1.0`.             |
| `X-Vesturium-Event`        | Event-Name.                            |
| `X-Vesturium-Delivery-Id`  | Eindeutige Delivery-ID für Idempotenz. |
| `X-Vesturium-Timestamp`    | Unix-Timestamp für die Signatur.       |
| `X-Vesturium-Signature`    | HMAC-Signatur.                         |

Dieselben vier Werte gehen zusätzlich unter den früheren Namen `X-BlackVesto-Event`, `X-BlackVesto-Delivery-Id`, `X-BlackVesto-Timestamp` und `X-BlackVesto-Signature` mit. Eine bestehende Prüfung auf die alten Namen läuft unverändert; neue Integrationen nutzen `X-Vesturium-*`.

### Signaturprüfung

Signaturen verwenden HMAC-SHA256 über Timestamp und Raw-JSON-Body:

```text
<timestamp>.<raw_json_body>
```

Signaturformat:

```text
v1=<hex_digest>
```

Timestamps älter als 5 Minuten sollten abgelehnt werden.

Testvektor:

```text
secret:    whsec_test
timestamp: 1781654400
body:      {"event":"reservation.status_changed"}
signature: v1=d7a69833f51a909cfa04b217f0660dd0943a1e5b599ea21833a04bbf8d6fba99
```

Receiver-Checkliste:

1. Raw request body vor dem JSON-Parsing puffern.
2. `X-Vesturium-Signature` per HMAC-SHA256 prüfen.
3. Timestamps außerhalb von 5 Minuten ablehnen.
4. `X-Vesturium-Delivery-Id` für Idempotenz speichern.
5. Bei erfolgreicher Annahme einen `2xx`-Status zurückgeben.

### Events

| Event                        | Beschreibung                                                                                             |
| ---------------------------- | -------------------------------------------------------------------------------------------------------- |
| `reservation.status_changed` | Wird gesendet, wenn sich ein Reservierungsstatus zwischen zwei unterschiedlichen Werten ändert.          |
| `notary_appointment.created` | Wird gesendet, wenn eine Reservierung erstmals einen Notartermin erhält.                                 |
| `notary_appointment.changed` | Wird gesendet, wenn Datetime, Zeitzone oder Notarkontakt eines bestehenden Notartermins geändert werden. |
| `lead.created`               | Wird gesendet, wenn ein Lead angelegt wird — über `POST /leads` wie auch in der Oberfläche.             |
| `lead.status_changed`        | Wird gesendet, wenn sich ein Lead-Status zwischen zwei Werten ändert — aus der API oder aus dem CRM.     |
| `lead.converted`             | Wird gesendet, wenn ein Lead tatsächlich zum Kunden wird, nie allein aufgrund eines Statuswerts.         |

Beispiel-Payload:

```json
{
  "id": "55555555-5555-5555-5555-555555555555",
  "event": "reservation.status_changed",
  "tenant_id": "99999999-9999-9999-9999-999999999999",
  "occurred_at": "2026-06-17T12:00:00Z",
  "data": {
    "reservation_id": "33333333-3333-3333-3333-333333333333",
    "property_id": "22222222-2222-2222-2222-222222222222",
    "previous_status": 5,
    "previous_status_label": "angefragt",
    "new_status": 6,
    "new_status_label": "reserviert",
    "changed_by_user_id": "11111111-1111-1111-1111-111111111111"
  }
}
```

Lead-Events nutzen denselben Envelope und liefern Status- und Quellcodes in Kleinschreibung:

```json
{
  "id": "77777777-7777-7777-7777-777777777777",
  "event": "lead.created",
  "tenant_id": "99999999-9999-9999-9999-999999999999",
  "occurred_at": "2026-06-17T12:00:00Z",
  "data": {
    "lead_id": "44444444-4444-4444-4444-444444444444",
    "email": "lead@example.com",
    "source": "api",
    "status": "new",
    "assigned_agent_id": null
  }
}
```

```json
{
  "id": "88888888-8888-8888-8888-888888888888",
  "event": "lead.status_changed",
  "tenant_id": "99999999-9999-9999-9999-999999999999",
  "occurred_at": "2026-06-17T12:05:00Z",
  "data": {
    "lead_id": "44444444-4444-4444-4444-444444444444",
    "previous_status": "new",
    "new_status": "qualified",
    "changed_by_user_id": null
  }
}
```

```json
{
  "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
  "event": "lead.converted",
  "tenant_id": "99999999-9999-9999-9999-999999999999",
  "occurred_at": "2026-06-20T11:00:00Z",
  "data": {
    "lead_id": "44444444-4444-4444-4444-444444444444",
    "person_id": "44444444-4444-4444-4444-444444444444",
    "previous_status": "invited",
    "converted_by_user_id": "11111111-1111-1111-1111-111111111111"
  }
}
```

`changed_by_user_id` und `converted_by_user_id` sind `null`, wenn die Änderung aus der External API oder aus einer MyVesto-Registrierung stammt. In beiden Fällen gibt es keinen handelnden Vesturium-Cloud-Benutzer. `lead.converted` behält die Personen-ID: Die Konvertierung ändert den Typ des Datensatzes, nicht seine ID, dieselbe ID adressiert danach den Kunden.

## Grenzen

- **Zustellung:** Webhooks werden mindestens einmal zugestellt. Die `id` im Envelope bleibt über Wiederholungen stabil, Empfänger müssen darüber deduplizieren. Es gibt keinen senderseitigen Retry und kein Backoff — eine fehlgeschlagene Zustellung wird protokolliert, nicht wiederholt.
- **Löschung:** Das Löschen eines Leads erzeugt weder ein Webhook-Event noch eine Spur in `updated_since`. Gleiche Löschungen über einen periodischen Vollabgleich ab.
- **Kunden-Sync:** Eine Änderung, die nur kundenspezifische Felder betrifft — etwa `lifecycle_status` oder den MyVesto-Sync-Zeitstempel —, bewegt `updated_at` möglicherweise nicht und kann deshalb von einem `updated_since`-Poll übersehen werden. Lesezugriffe und Konvertierungen sind davon nicht betroffen.
- **Rate-Limits:** 600 Anfragen pro Minute und 20 000 pro Tag je Token, gemeinsam für Lese- und Schreibzugriffe.
- **Schreiben:** bis zu 50 Einheiten je `POST /projects/{project_id}/units`, bis zu 50 Dateien je Upload. Dateien gehen direkt an den Speicher (`upload_url`), nie durch die API. Die Mikrolage lässt sich je Person höchstens zehnmal pro Stunde aktualisieren.
- **Objekte und Dokumente:** Objekte, Einheiten und Dokumente werden ohne Spur in `updated_since` gelöscht; gleiche Löschungen über einen periodischen Vollabgleich ab. Dokument-Uploads bewegen kein `updated_at` — nutze die Marker `documents_updated_at` und `document_count`. Die Marker beachten dieselben `document_type` / `file_type` / `is_internal`-Filter, ein gefilterter Abgleich konvergiert also ebenfalls. Ein Dokument-Upload bewegt das eigene `updated_at` des Objekts ebenfalls nicht, ein `updated_since`-Abgleich sieht es also nie — lies für Dokumentänderungen die vollständige Liste (ohne `updated_since`), und nutze `updated_since` nur für die Objekt- und Einheitendaten selbst. `updated_at` sind Transaktions-Startzeiten: poll mit 15 Minuten Überlappung und dedupliziere über `id`.
- **Datei-Downloads:** eine Anfrage je Dokument-Link. Ein erster Vollabgleich eines großen Mandanten (≈300 Objekte, ≈1.300 Einheiten, ≈10.000 Dokumente) braucht rund 12.000 Anfragen — etwa 20 Minuten bei 600/min und mehr als die Hälfte des Tagesbudgets; spätere Abgleiche holen nur geänderte Einheiten und neue Dokumente.
