API-Dokumentation

Vesturium Cloud External API und Webhooks

Technische Referenz für die Vesturium Cloud External API, Token-Scopes, Pagination, Fehlerformate und signierte Webhook-Events.

API-Dokumentation herunterladen

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.

BereichWert
Base URLhttps://api.vesturium.cloud/external/v1
Base path/external/v1
AuthentifizierungAuthorization: Bearer <external_api_token>
PaginationCursor
Webhook-SignaturHMAC-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.

Authorization: Bearer <external_api_token>

Unterstützte Scopes:

ScopeZugriff
reservations:readReservierungslisten und Reservierungsdetails
users:readBenutzerlisten und Benutzerdetails
kpis:readKPI-Endpunkt
leads:readCRM-Leads lesen und auflisten
leads:writeCRM-Leads anlegen und aktualisieren
customers:readCRM-Kunden lesen und auflisten
projects:readObjekte mit Einheiten und Dokument-Metadaten, Einheitendetail
documents:readDokumentdetail mit Download-Link und Text — braucht projects:read, um Dokument-IDs zu finden
projects:writeObjekte anlegen und ändern, online stellen, Mikrolage aktualisieren
properties:writeEinheiten anlegen (bis zu 50 je Anfrage), ändern, Status und Sichtbarkeit setzen, löschen; Stellplätze pflegen
media:writeBilder und Dokumente hochladen, beschriften, sortieren und löschen
checklist:writeFreigabe-Checkliste lesen und Punkte setzen
purchase_prices:readEinkaufspreis und Provisionssatz lesen — nur, wenn die erstellende Person „Alle Provisionen anzeigen“ hat
purchase_prices:writeEinkaufspreis 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.

StatusCodeBedeutung
400invalid_filterEin Query-Parameter wird nicht unterstützt oder ist fehlerhaft.
400invalid_bodyDer Request-Body ist ungültig: unbekannter Schlüssel, fehlendes Pflichtfeld oder unbekannter Enum-Wert.
400invalid_assigneeassigned_agent_id ist kein aktiver Benutzer des Mandanten zum Token.
401unauthorizedBearer-Token fehlt, ist fehlerhaft, ungültig, abgelaufen oder widerrufen.
403forbiddenDer Token ist gültig, enthält aber nicht den erforderlichen Scope, oder der API-Zugang des Mandanten ist inaktiv.
404not_foundDer Datensatz existiert im Mandanten nicht oder hat den adressierten Typ nicht mehr.
409duplicate_emailIm Mandanten existiert bereits eine Person mit dieser E-Mail. Der bestehende Datensatz steht in details.
429rate_limitedDer Token hat das External-API-Rate-Limit überschritten.

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

StatusCodeBedeutung
400invalid_requestVesturium Cloud lehnt den Vorgang fachlich ab; der Grund steht in message.
402confirm_overage_requiredDer Vorgang überschreitet das im Tarif enthaltene Kontingent; mit confirm_overage=true wiederholen, um die Mehrkosten anzunehmen.
403not_ownedDas Objekt oder die Einheit ist über das Objektnetzwerk freigegeben und gehört dem Mandanten nicht; schreiben geht nur am eigenen Bestand.
403token_owner_inactiveDie Person, die den Token erstellt hat, ist inaktiv oder gehört nicht mehr zum Mandanten. Den Token als aktive Person rotieren.
409publication_blockedOffene Checklisten-Punkte verhindern das Online-Stellen; die Punkte stehen in details.blockers.
409conflictDer Vorgang widerspricht dem aktuellen Zustand, z. B. Löschen einer Einheit mit Reservierung.
502upstream_errorDer Vorgang konnte nicht abgeschlossen werden; später erneut versuchen.
503service_unavailableVesturium 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:

{
  "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:

{
  "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:

{
  "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.

ParameterTypBeschreibung
viewnotary | commissionOptionale Reservierungsprojektion. Ohne view wird die vollständige Ressource zurückgegeben.
langen | deAnzeigesprache für Statuslabels. Standard ist en.
statusstringKommagetrennte Statuscodes wie reserved, cancelled, notary_appointment oder Legacy-Integer/deutsche Aliasse.
date_from / date_toISO-Datum oder DatetimeInklusiver Datumsbereich für das ausgewählte date_field.
date_fieldcreated_at | updated_at | reservation_fee_paid_at | notary_appointment_at | cancelled_atDatumsspalte für date_from und date_to. Standard ist updated_at.
advisor_id / property_idUUIDOptionale Filter für Reservierungsinhaber oder Objekt.
updated_sinceISO datetimeInkrementeller Sync-Filter gegen updated_at.
limit / cursornumber / stringCursor-Pagination. limit akzeptiert 1 bis 500.
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
{
  "items": [
    {
      "id": "33333333-3333-3333-3333-333333333333",
      "status": { "code": "reserved", "label": "Reserviert" },
      "buyer": {
        "name": "Erika Muster",
        "email": "[email protected]",
        "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": "[email protected]"
      },
      "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

CodeDeutsches LabelBedeutung
awaiting_signatureWartet auf UnterschriftKaufinteresse bekundet; Reservierungsvereinbarung noch nicht unterschrieben.
requestedAngefragtReservierung angefragt.
reservedReserviertEinheit für den Käufer reserviert.
notary_preparationNotarvorbereitungKaufvertrag in Vorbereitung.
notary_appointmentNotarterminNotartermin geplant; noch nicht beurkundet.
aftersaleAftersaleKaufvertrag beurkundet. Der Käufer ist (künftiger) Eigentümer.
completedAbgeschlossenVerkauf vollständig abgeschlossen.
legacy_completedLegacy abgeschlossenAbgeschlossener Verkauf aus einem früheren Datenmodell; wie completed behandeln.
cancelledAbgebrochenReservierung abgebrochen.
availableFreiKeine 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.

ParameterTypBeschreibung
reservation_idUUIDErforderliche Reservierungs-ID im Pfad.
viewnotary | commissionOptionale Reservierungsprojektion. Ohne view wird die vollständige Ressource zurückgegeben.
langen | deAnzeigesprache für Statuslabels. Standard ist en.
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.

ParameterTypBeschreibung
roleadvisorOptionaler Filter nur für Advisor.
has_reservationsbooleanWenn true, werden nur Benutzer mit Reservierungen geliefert.
is_activeboolean oder nullStandard ist true.
updated_sinceISO datetimeInkrementeller Sync-Filter gegen updated_at.
limit / cursornumber / stringCursor-Pagination. limit akzeptiert 1 bis 500.
GET /users?role=advisor&has_reservations=true&limit=100
{
  "items": [
    {
      "id": "11111111-1111-1111-1111-111111111111",
      "name": "Ada Advisor",
      "email": "[email protected]",
      "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.

ParameterTypBeschreibung
user_idUUIDErforderliche Benutzer-ID im Pfad.
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.

ParameterTypBeschreibung
viewtrends | funnel | leaderboard | pipelineOptionale KPI-Gruppe. Ohne view werden alle Gruppen mit Antwortwert view=all geliefert.
periodlast_7_days | last_30_days | last_90_days | current_month | current_quarter | current_year | all_time | customReporting-Zeitraum. Standard ist current_month.
date_from / date_toISO-DatumErforderlich bei period=custom.
granularitydaily | weekly | monthlyOptional für Trends. Standard ist daily.
advisor_idUUIDOptionaler Benutzerfilter.
limitnumberLeaderboard-Limit. Akzeptiert 1 bis 500.
GET /kpis?period=current_month
{
  "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.

ParameterTypBeschreibung
statusLead-StatuscodeFilter auf den Pipeline-Status, etwa new, contacted, qualifying, qualified, invited.
sourceLead-QuellcodeFilter auf die Herkunft: website, campaign, trade_fair, referral, manual_entry, api.
assigned_agent_idUUIDOptionaler Filter auf den zugewiesenen Betreuer.
invite_statuspending | used | registered | expiredFilter auf den Status der verknüpften MyVesto-Einladung.
updated_sinceISO datetimeInkrementeller Sync-Filter gegen updated_at.
langen | deAnzeigesprache für Status- und Quellenlabels. Standard ist en.
limit / cursornumber / stringCursor-Pagination. limit akzeptiert 1 bis 500, Standard ist 100.
GET /leads?status=qualified&lang=de&limit=100
{
  "items": [
    {
      "id": "44444444-4444-4444-4444-444444444444",
      "first_name": "Max",
      "last_name": "Muster",
      "email": "[email protected]",
      "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": "[email protected]"
      },
      "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.

ParameterTypBeschreibung
lead_idUUIDErforderliche Lead-ID im Pfad.
langen | deAnzeigesprache für Status- und Quellenlabels. Standard ist en.
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.

FeldTypBeschreibung
first_namestringPflichtfeld.
last_namestringPflichtfeld.
emailstringPflichtfeld. Darf im Mandanten weder bei einem Lead noch bei einem Kunden existieren.
phonestring oder nullOptional.
notesstring oder nullOptional.
sourceLead-QuelleOptional. Standard ist api.
statusLead-StatusOptional. Standard ist new. converted wird nicht akzeptiert.
assigned_agent_idUUID oder nullOptional. Muss ein aktiver Benutzer des Mandanten sein. Ohne Angabe bleibt der Lead frei.
langQuery-ParameterAnzeigesprache der Labels in der Antwort. Standard ist en.
POST /leads
Content-Type: application/json
{
  "first_name": "Erika",
  "last_name": "Musterfrau",
  "email": "[email protected]",
  "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.

PATCH /leads/44444444-4444-4444-4444-444444444444
Content-Type: application/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.

ParameterTypBeschreibung
lifecycle_statusLifecycle-StatuscodeFilter auf registered, active, purchaser, completed, inactive, churned.
assigned_agent_idUUIDOptionaler Filter auf den zugewiesenen Betreuer.
myvesto_registeredbooleantrue liefert nur Kunden mit MyVesto-Konto.
updated_sinceISO datetimeInkrementeller Sync-Filter gegen updated_at.
langen | deAnzeigesprache für Lifecycle-Labels. Standard ist en.
limit / cursornumber / stringCursor-Pagination. limit akzeptiert 1 bis 500, Standard ist 100.
GET /customers?myvesto_registered=true&limit=100
{
  "items": [
    {
      "id": "66666666-6666-6666-6666-666666666666",
      "first_name": "Erika",
      "last_name": "Musterfrau",
      "email": "[email protected]",
      "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": "[email protected]"
      },
      "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.

ParameterTypBeschreibung
customer_idUUIDErforderliche Kunden-ID im Pfad.
langen | deAnzeigesprache für Lifecycle-Labels. Standard ist en.
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.

ParameterTypBeschreibung
zip_codelistExakte Postleitzahlen, kommagetrennt oder wiederholt.
citystringStadt, exakter Vergleich ohne Groß-/Kleinschreibung.
qstringTeilstring von Objektname oder Straße.
updated_sinceISO datetimeInkrementeller Sync gegen updated_at.
document_typelistSchränkt die eingebetteten documents ein, z. B. hoa_minutes,resolution_collection. Entfernt nie Objekte.
file_typelistSchränkt die eingebetteten documents nach Endung ein: pdf, jpg (inkl. .jpeg), png, …
is_internalbooleanSchränkt die eingebetteten documents auf interne (true) oder teilbare (false) Dateien ein. Interne Dokumente sind standardmäßig enthalten.
langen | deAnzeigesprache der Labels. Standard en.
limit / cursornumber / stringCursor-Pagination. Limit 1 bis 500, Standard 100. Sortiert nach updated_at absteigend.
GET /projects?zip_code=10115&document_type=hoa_minutes&lang=de
{
  "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.

{
  "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.

ParameterTypBeschreibung
document_idUUIDErforderliche Dokument-ID im Pfad.
langen | deAnzeigesprache des Dokumenttyp-Labels. Standard en.
{
  "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": "[email protected]" },
  "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.

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:

{
  "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.

EndpunktScopeWirkung
POST /projects/{project_id}/parking-spacesproperties:writeStellplatz 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:writeMitgeschickte Felder ändern.
DELETE /projects/{project_id}/parking-spaces/{space_id}properties:writeStellplatz 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

EndpunktWirkung
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/orderReihenfolge 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}:

FeldTypAnlegenHinweis
namestringPflicht
streetstringPflicht
house_numberstringPflicht
citystringPflicht
districtstringoptional
statestringPflicht
countrystringoptional
zip_codestringPflicht
latitudenumberoptional
longitudenumberoptional
construction_yearintegeroptional
completion_datedateoptional
renovation_yearintegeroptional
renovation_measure_amountdecimal stringoptional
renovation_measure_completion_datedateoptional
total_floorsintegeroptional
total_unitsintegeroptional
building_typeCode: residential, commercial, mixedoptional
project_typeCode: new_build, kfw_renovation, existing_renovation, existing_propertyoptional
has_elevatorbooleanoptional
has_parkingbooleanoptional
has_basementbooleanoptional
has_gardenbooleanoptional
energy_certificate_typeCode: consumption, demandoptional
energy_consumptionnumberoptional
energy_classCode: a_plus, a, b, c, d, e, f, g, hoptional
heating_typeCode: gas_central, oil_central, district_central, heat_pump_central, pellet_central, electric_central, gas_floor, electric_floor, solar_thermal, central, floor, other, unknownoptional
primary_energy_consumptionnumberoptional
heating_building_yearintegeroptional
backyard_developmentbooleanoptional
sev_takeover_one_yearbooleanoptional
renovationsstringoptional
modernizationslist (string)optional
move_in_ready_datedateoptional
mabv_rateslist (MaBV-Rate)optional
descriptionstringoptional
amenitieslist (string)optional
bautraeger_idUUIDoptional
video_youtube_urlstringoptional
expose_template_idUUIDoptional
default_image_set_idUUID—nur PATCH
commission_ratenumberoptionalpurchase_prices:*
price_adjustment_enabledbooleanoptional

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

FeldTypAnlegenHinweis
unit_numberstringPflicht
property_typeCode: apartment, studio, penthouse, maisonette, house, co_living, denkmal, commercial, parking, storage, otherPflicht
property_usageCode: wg_apartment, condominium, micro_apartment, commercial_other, global_objectoptional
size_sqmnumberPflicht
roomsnumberPflicht
bathroomsintegeroptional
floorstringoptional
balconyCode: balcony, loggia, terrace, roof_terrace, conservatoryoptional
has_cellarbooleanoptional
object_share_ownernumberoptional
rental_typeCode: existing_tenant, rental_pool, operator, normal_rentaloptional
has_sevbooleanoptional
has_initial_letting_guaranteebooleanoptional
purchase_pricedecimal stringPflicht
apartment_pricedecimal string—nur PATCH
furniture_pricedecimal stringoptional
monthly_rentdecimal stringPflicht
management_feedecimal stringoptional
hoa_costsdecimal stringoptional
operation_cost_tenantdecimal stringoptional
maintenance_reservedecimal stringoptional
maintenance_reserve_balancedecimal stringoptional
work_contract_amountdecimal stringoptional
land_valuedecimal stringoptional
afa_typeCode: linear, degressiveoptional
depreciation_rate_building_manualdecimal stringoptional
depreciation_special_7bbooleanoptional
renovation_costsdecimal stringoptional
renovation_tax_treatmentCode: maintenance_expense, capitalized, redevelopment_area_7h, heritage_7ioptional
renovation_depreciation_rate_manualdecimal stringoptional
completion_date_overridedateoptional
move_in_ready_date_overridedateoptional
mabv_rates_overridelist (MaBV-Rate)optional
takeover_special_charges_monthsintegeroptional
takeover_special_charges_amountdecimal stringoptional
modernizationslist (string)optional
notary_override_percentagedecimal stringoptional
kfw_enabledbooleanoptional
kfw_programstringoptional
kfw_program_idUUIDoptional
kfw_repayment_bonusdecimal stringoptional
kfw_wpbbooleanoptional
kfw_baubegleitung_enabledbooleanoptional
external_idstringoptionalnur beim Anlegen
key_box_descriptionstring—nur PATCH
developer_purchase_pricedecimal stringoptionalpurchase_prices:*
commission_rate_overridedecimal stringoptionalpurchase_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:

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:

HeaderBeschreibung
Content-TypeJSON Content-Type.
User-AgentVesturium-Webhooks/1.0.
X-Vesturium-EventEvent-Name.
X-Vesturium-Delivery-IdEindeutige Delivery-ID für Idempotenz.
X-Vesturium-TimestampUnix-Timestamp für die Signatur.
X-Vesturium-SignatureHMAC-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:

<timestamp>.<raw_json_body>

Signaturformat:

v1=<hex_digest>

Timestamps älter als 5 Minuten sollten abgelehnt werden.

Testvektor:

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

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

Beispiel-Payload:

{
  "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:

{
  "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": "[email protected]",
    "source": "api",
    "status": "new",
    "assigned_agent_id": null
  }
}
{
  "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
  }
}
{
  "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.