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.
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:readzeigt die Metadaten (Titel und Dateinamen) von Dokumenten, die in Vesturium Cloud als intern markiert sind;documents:readgibt 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:
{
"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.
| 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. |
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
| 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. |
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. |
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.
| Parameter | Typ | Beschreibung |
|---|---|---|
user_id | UUID | Erforderliche 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.
| 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. |
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.
| 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. |
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.
| Parameter | Typ | Beschreibung |
|---|---|---|
lead_id | UUID | Erforderliche Lead-ID im Pfad. |
lang | en | de | Anzeigesprache 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.
| 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. |
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" }
emailist nach dem Anlegen nicht mehr änderbar — sie ist der Dedup-Anker. Der Update-Body kennt gar keinen Schlüsselemail; wird er trotzdem gesendet, folgt400 invalid_bodymitbody.emailindetails.fields.statuskann nicht aufconvertedgesetzt 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 mit400 invalid_bodyabgelehnt.- Jeder andere Pipeline-Status wird akzeptiert, es gibt keinen Übergangsgraphen.
contacted,qualifying,qualified,invited,nurturingundunresponsivesind nur über diese API erreichbar; die CRM-Oberfläche kann einen Lead aus ihnen nicht wieder herausbewegen. - Jeder Schreibzugriff hebt
updated_atan, damit ein geänderter Lead zuverlässig im nächstenupdated_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. |
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.
| Parameter | Typ | Beschreibung |
|---|---|---|
customer_id | UUID | Erforderliche Kunden-ID im Pfad. |
lang | en | de | Anzeigesprache 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.
| 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. |
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_maxundprice_per_sqm_min/price_per_sqm_max: berechnet, nur lesbar.micro_location: die Mikrolage, nur lesbar.visibility:offline,pre_saleoderonline.images: Objektbilder mitid,url,category,title,display_order.parking_spaces: der Stellplatz-Pool des Objekts mitid,identifier,parking_type,assigned_unit_id(fest zugeordnet) odernull(optional),sale_price,monthly_rent,monthly_cost_deductible,monthly_cost_non_deductible,display_order.commission_ratesowiedeveloper_purchase_priceje Stellplatz nur mitpurchase_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.
| Parameter | Typ | Beschreibung |
|---|---|---|
document_id | UUID | Erforderliche Dokument-ID im Pfad. |
lang | en | de | Anzeigesprache 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:readgibt damit auch deren Download-Link und OCR-Text frei. download_urlist eine Stunde gültig; für einen frischen Link das Dokument erneut abfragen. Sie istnull, wenn kein Link signiert werden kann. Selten liefert ein Link kurz nach einem KI-Import404, bevor die Datei an ihrem Platz liegt.textist 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 isttextnull: Die Texterkennung des Bauträgers gibt die API nie heraus; erscheinen kann nur Text, den das eigene Konto bereits hat lesen lassen (Assistent).uploaded_byist die Person, die die Datei hochgeladen hat; bei synchronisierten Dateien (Investagon, TeamProQ) die Person, die den Sync ausgelöst hat.nullbei 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.
404bei 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 liefern400 invalid_body. - Antworten: Anlegen und Ändern antworten mit dem Objekt bzw. der Einheit in der Lese-Form (
langwie 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_numberohne das Präfix „WE“ schicken; Vesturium Cloud zeigt es selbst davor.- Neue Einheiten stehen auf dem Status
available(frei). apartment_priceundkey_box_descriptionlassen sich erst nach dem Anlegen perPATCH /units/{unit_id}setzen. Einapartment_pricebestimmt dannpurchase_priceneu (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:
POST /uploadsmitentity_type(projectoderunit),entity_id,kind(imagesoderdocuments) und bis zu 50files(filename,content_type,sizein Bytes). Die Antwort (201) enthältsession_id,expires_atund je Dateistaged_id,upload_urlundrequired_headers.- Jede Datei per
PUTan ihreupload_urlschicken, mit genau denrequired_headers. POST /uploads/{session_id}/finalizemititems: je Dateistaged_id,category(bei Bildern die Bildkategorie, z. B.exterior,interior,floor_plan; bei Dokumenten der Dokumenttyp-Code, z. B.exposeoderdeclaration_of_division), optionaltitle,description,display_order,is_internal. Die Antwort nennt je Datei die neueid,kind(imageoderdocument) undcategory.
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
statuseiner Einheit: er ist immer aktuell, genau wie bei eigenen Einheiten. Derstatuseines 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_countzä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 sienull— die freigegebenen Einheiten selbst werden trotzdem geliefert. - Bauträger-Kontakt:
bautraegerist 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_byistnullbei Dokumenten des Bauträgers, ebensotext, sofern das eigene Konto die Datei nicht schon hat lesen lassen — die Texterkennung des Bauträgers gibt die API nie heraus.parking_spaces[].idistnullbei freigegebenen Einheiten — Snapshots tragen keine Stellplatz-ID. - Filter:
zip_code,cityundqinGET /projectsgreifen 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_atder Einheit und des Objekts,updated_sinceerfasst 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_priceist 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_priceist der Übergabepreis der Freigabe (odernull),commission_rateder eigene Provisionssatz des Mandanten. Freigegebene Objekte tragen wederprice_min/price_maxnochparking_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:
| 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:
<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:
- Raw request body vor dem JSON-Parsing puffern.
X-Vesturium-Signatureper HMAC-SHA256 prüfen.- Timestamps außerhalb von 5 Minuten ablehnen.
X-Vesturium-Delivery-Idfür Idempotenz speichern.- 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:
{
"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
idim 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_statusoder den MyVesto-Sync-Zeitstempel —, bewegtupdated_atmöglicherweise nicht und kann deshalb von einemupdated_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_sincegelöscht; gleiche Löschungen über einen periodischen Vollabgleich ab. Dokument-Uploads bewegen keinupdated_at— nutze die Markerdocuments_updated_atunddocument_count. Die Marker beachten dieselbendocument_type/file_type/is_internal-Filter, ein gefilterter Abgleich konvergiert also ebenfalls. Ein Dokument-Upload bewegt das eigeneupdated_atdes Objekts ebenfalls nicht, einupdated_since-Abgleich sieht es also nie — lies für Dokumentänderungen die vollständige Liste (ohneupdated_since), und nutzeupdated_sincenur für die Objekt- und Einheitendaten selbst.updated_atsind Transaktions-Startzeiten: poll mit 15 Minuten Überlappung und dedupliziere überid. - 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.