# Vesturium Cloud External API and Webhooks

Technical reference for Vesturium Cloud API access, API token scopes, cursor pagination, error formats, and signed webhook events.

## Overview

The External API is tenant-scoped and intended for reporting, dashboards, CRM integrations and maintaining projects. Reservations, users, KPIs and customers are read-only; CRM leads can also be created and updated. Projects, units, parking spaces, images, documents and checklists can be read and, with the write scopes, created and maintained — see Writing objects. API tokens are created in the Admin API Access area and are constrained by scopes.

For reads, a token is scoped to a tenant, never to a user: `/leads` and `/customers` return every lead and customer of the tenant. The three-tier visibility model of the internal CRM (own records, team records, all records) does not apply to the External API. For writes, a token acts with the permissions of the person who created it — see Writing objects.

| Item            | Value                                        |
| --------------- | -------------------------------------------- |
| Base URL        | `https://api.vesturium.cloud/external/v1`    |
| Base path       | `/external/v1`                               |
| Authentication  | `Authorization: Bearer <external_api_token>` |
| Pagination      | Cursor                                       |
| Webhook signing | HMAC-SHA256                                  |

## Authentication

Every External API request requires an external bearer token. Tokens start with `bv_live_` and are only shown once after creation in the Admin UI.

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

Supported scopes:

| Scope               | Access                                                                                   |
| ------------------- | ---------------------------------------------------------------------------------------- |
| `reservations:read` | Reservation list and detail endpoints                                                    |
| `users:read`        | User list and detail endpoints                                                           |
| `kpis:read`         | KPI endpoint                                                                             |
| `leads:read`        | List and read CRM leads                                                                  |
| `leads:write`       | Create and update CRM leads                                                              |
| `customers:read`    | List and read CRM customers                                                              |
| `projects:read`     | Projects with units and document metadata, unit detail                                   |
| `documents:read`    | Document detail with download link and text — needs `projects:read` to find document ids |
| `projects:write`    | Create and update projects, publish them, refresh the micro location                     |
| `properties:write`  | Create units (up to 50 per request), update them, set status and visibility, delete; maintain parking spaces |
| `media:write`       | Upload, label, order and delete images and documents                                      |
| `checklist:write`   | Read the publication checklist and set items                                              |
| `purchase_prices:read`  | Read purchase price and commission rate — only if the token's creator has "View All Provisions" |
| `purchase_prices:write` | Write purchase price and commission rate — same condition                              |

> Scopes are fixed when a token is created. Rotating a token copies its existing scopes, so granting access to a new resource requires creating a new token. Whoever rotates becomes the token's creator; write scopes then act with that person's permissions.
>
> `projects:read` already discloses the metadata (titles and file names) of documents flagged internal in Vesturium Cloud, and `documents:read` adds their download link and OCR text. Neither scope filters them out.

## Pagination and errors

List endpoints use cursor pagination. Pass `limit` with a value from `1` to `500`. Use `pagination.next_cursor` from a previous response as the next `cursor` value. A `null` cursor means the page is complete.

JSON errors use a stable envelope with `code`, `message`, and optional `details`.

| Status | Code               | Meaning                                                                                                              |
| ------ | ------------------ | -------------------------------------------------------------------------------------------------------------------- |
| `400`  | `invalid_filter`   | A query parameter is unsupported or malformed.                                                                       |
| `400`  | `invalid_body`     | The request body failed validation: unknown key, missing required field, or unsupported enum value.                  |
| `400`  | `invalid_assignee` | `assigned_agent_id` is not an active user of the token's tenant.                                                     |
| `401`  | `unauthorized`     | Bearer token is missing, malformed, invalid, expired, or revoked.                                                    |
| `403`  | `forbidden`        | The token is valid but does not include the required scope, or the tenant's API access is inactive.                  |
| `404`  | `not_found`        | The record does not exist in the token's tenant, or no longer has the addressed type.                                |
| `409`  | `duplicate_email`  | A person with this email already exists in the tenant. The existing record is returned in `details` for idempotency. |
| `429`  | `rate_limited`     | The token exceeded the external API rate limit.                                                                      |

Additionally for writes to projects and units (see Writing objects):

| Status | Code                       | Meaning                                                                                                     |
| ------ | -------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `400`  | `invalid_request`          | Vesturium Cloud rejects the operation on business grounds; the reason is in `message`.                          |
| `402`  | `confirm_overage_required` | The operation exceeds the quota included in the plan; repeat with `confirm_overage=true` to accept the extra cost. |
| `403`  | `not_owned`                | The project or unit is shared through the object network and not owned by the tenant; writes only work on own stock. |
| `403`  | `token_owner_inactive`     | The person who created the token is inactive or no longer belongs to the tenant. Rotate the token as an active user. |
| `409`  | `publication_blocked`      | Open checklist items block publishing; they are listed in `details.blockers`.                              |
| `409`  | `conflict`                 | The operation conflicts with the current state, e.g. deleting a unit with a reservation.                   |
| `502`  | `upstream_error`           | The operation could not be completed; retry later.                                                          |
| `503`  | `service_unavailable`      | Vesturium Cloud is briefly busy; retry later.                                                                    |

A field that cannot be written has no code of its own: `email` on an update and `status: converted` are simply not part of the accepted schema, so both come back as `invalid_body` with the offending key listed in `details.fields`. The only write error raised outside body validation is `invalid_assignee`.

Example error response:

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

A body that fails validation names the offending fields. The example below is the response to a `PATCH /leads/{lead_id}` that sent `email`:

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

A duplicate email returns the id and type of the record that already holds it, so a retried `POST /leads` has a recovery path instead of looping on the conflict:

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

## Endpoints

### GET /reservations

Required scope: `reservations:read`

Returns tenant-scoped reservations. Omit `view` for the full reservation resource, or request a flat notary or commission projection.

| Parameter                    | Type                                                                                           | Description                                                                                                           |
| ---------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `view`                       | `notary \| commission`                                                                         | Optional reservation projection. Omit `view` to return the full resource.                                             |
| `lang`                       | `en \| de`                                                                                     | Display language for status labels. Defaults to `en`.                                                                 |
| `status`                     | `string`                                                                                       | Comma-separated status codes such as `reserved`, `cancelled`, `notary_appointment`, or legacy integer/German aliases. |
| `date_from` / `date_to`      | ISO date or datetime                                                                           | Inclusive date range for the selected `date_field`.                                                                   |
| `date_field`                 | `created_at \| updated_at \| reservation_fee_paid_at \| notary_appointment_at \| cancelled_at` | Date column used by `date_from` and `date_to`. Defaults to `updated_at`.                                              |
| `advisor_id` / `property_id` | UUID                                                                                           | Optional filters for the reservation owner or property.                                                               |
| `updated_since`              | ISO datetime                                                                                   | Incremental sync filter against `updated_at`.                                                                         |
| `limit` / `cursor`           | number / string                                                                                | Cursor pagination controls. Limit accepts `1` to `500`.                                                               |

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

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

`property_management` is the Sondereigentumsverwaltung (SEV) assigned to the reservation, with its name and one-line address, or `null` when none is assigned. It is present on the full resource only, not on the `notary` or `commission` views.

`buyer` is the main applicant as captured on the reservation: `name`, `email`, `salutation` (`mr`, `mrs`, `diverse` or `null`; `diverse` also covers buyers who chose no salutation, so address them neutrally) and the postal `address`. The address is the one entered on this purchase, not the current CRM record — the customer resource carries that. The reservation does not capture a country, so `buyer.address.country` is always `null`; `address` is `null` when no part of it was entered. For a joint purchase, `co_buyer` carries the co-applicant in the same shape (its `email` may be `null`); it is `null` for a single buyer. Both are owners. No other personal data of the buyers (date of birth, tax id, Güterstand …) is part of this API.

#### Reservation status

| Code                 | German label            | Meaning                                                            |
| -------------------- | ----------------------- | ------------------------------------------------------------------ |
| `awaiting_signature` | Wartet auf Unterschrift | Buyer expressed interest; reservation agreement not yet signed.    |
| `requested`          | Angefragt               | Reservation requested.                                             |
| `reserved`           | Reserviert              | Unit reserved for the buyer.                                       |
| `notary_preparation` | Notarvorbereitung       | Purchase contract being drafted.                                   |
| `notary_appointment` | Notartermin             | Notary appointment scheduled; not yet notarised.                   |
| `aftersale`          | Aftersale               | **Purchase contract notarised.** The buyer is the (future) owner.  |
| `completed`          | Abgeschlossen           | Sale fully completed.                                              |
| `legacy_completed`   | Legacy abgeschlossen    | Completed sale from an earlier data model; treat like `completed`. |
| `cancelled`          | Abgebrochen             | Reservation cancelled.                                             |
| `available`          | Frei                    | No active reservation.                                             |

A buyer counts as purchaser from `aftersale` on: filter with `status=aftersale,completed,legacy_completed` to get every sold unit. The usual order is `requested` → `reserved` → `notary_preparation` → `notary_appointment` → `aftersale` → `completed`; any status before `aftersale` can end in `cancelled`. `aftersale` and `completed` are not one-way either: an `aftersale` reservation can be set back to `notary_appointment` (a correction — `notary.deed_date` then returns to `null`) or cancelled, and a `completed` sale can be reversed (Rückabwicklung), which moves it to `cancelled`. Treat the current `status` as the truth and re-evaluate ownership whenever it changes. A cancelled reservation is no longer active and drops out of `GET /reservations` — also out of an `updated_since` poll — while `GET /reservations/{reservation_id}` still returns it. To notice these changes, subscribe to `reservation.status_changed` or re-read the reservations you hold as sold by id.

`notary.deed_date` is the notarisation date: the calendar day of the notary appointment, in the appointment's timezone (`Europe/Berlin` when `notary.timezone` is `null`), once the reservation reached `aftersale`, `completed` or `legacy_completed`. Before that it is `null`, because an appointment can still move. It is also `null` for sales without a recorded appointment, such as imported historical sales. Vesturium Cloud does not record the transfer of possession (Nutzen-Lasten-Übergang); it is agreed in the purchase contract and is not part of this API.

### GET /reservations/{reservation_id}

Required scope: `reservations:read`

Returns one tenant-scoped reservation by id with the same optional projection and `lang` options as the list endpoint.

| Parameter        | Type                   | Description                                                               |
| ---------------- | ---------------------- | ------------------------------------------------------------------------- |
| `reservation_id` | UUID                   | Required reservation id in the path.                                      |
| `view`           | `notary \| commission` | Optional reservation projection. Omit `view` to return the full resource. |
| `lang`           | `en \| de`             | Display language for status labels. Defaults to `en`.                     |

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

### GET /users

Required scope: `users:read`

Returns tenant users that can be used to resolve advisor ids from reservation records.

| Parameter          | Type            | Description                                             |
| ------------------ | --------------- | ------------------------------------------------------- |
| `role`             | `advisor`       | Optional advisor-only filter.                           |
| `has_reservations` | boolean         | When true, returns only users owning reservations.      |
| `is_active`        | boolean or null | Defaults to true.                                       |
| `updated_since`    | ISO datetime    | Incremental sync filter against `updated_at`.           |
| `limit` / `cursor` | number / string | Cursor pagination controls. Limit accepts `1` to `500`. |

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

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

### GET /users/{user_id}

Required scope: `users:read`

Returns one tenant user by id.

| Parameter | Type | Description                   |
| --------- | ---- | ----------------------------- |
| `user_id` | UUID | Required user id in the path. |

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

### GET /kpis

Required scope: `kpis:read`

Returns tenant KPI metrics using the same aggregation service as the sales dashboard. Omitting `view` returns every supported insight group.

| Parameter               | Type                                                                                                                    | Description                                                                                      |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `view`                  | `trends \| funnel \| leaderboard \| pipeline`                                                                           | Optional KPI insight group. Omit `view` to return all supported groups with response `view=all`. |
| `period`                | `last_7_days \| last_30_days \| last_90_days \| current_month \| current_quarter \| current_year \| all_time \| custom` | Reporting period. Defaults to `current_month`.                                                   |
| `date_from` / `date_to` | ISO date                                                                                                                | Required when `period=custom`.                                                                   |
| `granularity`           | `daily \| weekly \| monthly`                                                                                            | Optional for trends. Defaults to `daily`.                                                        |
| `advisor_id`            | UUID                                                                                                                    | Optional user filter.                                                                            |
| `limit`                 | number                                                                                                                  | Leaderboard limit. Accepts `1` to `500`.                                                         |

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

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

### GET /leads

Required scope: `leads:read`

Returns the tenant's CRM leads. A token grants tenant-wide lead visibility; the three-tier visibility model of the internal CRM does not apply.

A lead that converts to a customer leaves this collection: it disappears from `/leads`, `GET /leads/{lead_id}` then returns `404`, and the person appears on `/customers` instead. Subscribe to `lead.converted` to be pushed the same transition.

| Parameter           | Type                                       | Description                                                                                      |
| ------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| `status`            | lead status code                           | Filter by pipeline status, for example `new`, `contacted`, `qualifying`, `qualified`, `invited`. |
| `source`            | lead source code                           | Filter by origin: `website`, `campaign`, `trade_fair`, `referral`, `manual_entry`, `api`.        |
| `assigned_agent_id` | UUID                                       | Optional filter for the assigned agent.                                                          |
| `invite_status`     | `pending \| used \| registered \| expired` | Filter by the state of the MyVesto invite linked to the lead.                                    |
| `updated_since`     | ISO datetime                               | Incremental sync filter against `updated_at`.                                                    |
| `lang`              | `en \| de`                                 | Display language for status and source labels. Defaults to `en`.                                 |
| `limit` / `cursor`  | number / string                            | Cursor pagination controls. Limit accepts `1` to `500` and defaults to `100`.                    |

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

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

`myvesto_invite` reports invite state only and never carries the invite token or invite URL. It has no `registered` flag: MyVesto registration is the lead-to-customer conversion, so a registered person is answered by `/customers`.

### GET /leads/{lead_id}

Required scope: `leads:read`

Returns one lead of the token's tenant.

| Parameter | Type       | Description                                                      |
| --------- | ---------- | ---------------------------------------------------------------- |
| `lead_id` | UUID       | Required lead id in the path.                                    |
| `lang`    | `en \| de` | Display language for status and source labels. Defaults to `en`. |

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

Returns `404` when the id is unknown, belongs to another tenant, or names a person who has already converted to a customer.

### POST /leads

Required scope: `leads:write`

Creates a lead and responds `201` with the same resource `GET /leads/{lead_id}` returns. Enum values are accepted in either casing, so `api` and `API` are both valid.

| Field               | Type            | Description                                                                    |
| ------------------- | --------------- | ------------------------------------------------------------------------------ |
| `first_name`        | string          | Required.                                                                      |
| `last_name`         | string          | Required.                                                                      |
| `email`             | string          | Required. Must not already exist in the tenant, on a lead or on a customer.    |
| `phone`             | string or null  | Optional.                                                                      |
| `notes`             | string or null  | Optional.                                                                      |
| `source`            | lead source     | Optional. Defaults to `api`.                                                   |
| `status`            | lead status     | Optional. Defaults to `new`. `converted` is not accepted.                      |
| `assigned_agent_id` | UUID or null    | Optional. Must be an active user of the token's tenant. Unassigned by default. |
| `lang`              | query parameter | Display language of the response labels. Defaults to `en`.                     |

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

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

Unknown keys are rejected with `400 invalid_body` rather than ignored — a lead's score, temperature, budget and purchase timeline are not part of this contract. An existing email returns `409 duplicate_email`, an unknown or inactive assignee `400 invalid_assignee`.

A successful create emits `lead.created`. Leads created in the Vesturium Cloud UI emit it too, so a subscriber sees every lead in the tenant, not only the ones it created itself.

An unassigned lead is invisible to agents whose CRM visibility is restricted to their own records. Set `assigned_agent_id` when the receiving tenant works with restricted visibility.

### PATCH /leads/{lead_id}

Required scope: `leads:write`

Updates the supplied fields and returns the full lead. Mutable fields: `first_name`, `last_name`, `phone`, `notes`, `status`, `source`, `assigned_agent_id`. Keys that are not sent stay untouched; an explicit `null` on `assigned_agent_id` un-assigns the lead.

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

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

- `email` cannot be changed after creation — it is the deduplication anchor. The update body has no `email` key at all, so sending one is rejected with `400 invalid_body` and `details.fields` reports `body.email`.
- `status` cannot be set to `converted`. Conversion is a structural operation (a customer record is created and the person changes type), not a status value a partner may write; the value is not part of the accepted set and is rejected with `400 invalid_body`.
- Any other pipeline status is accepted; there is no transition graph. `contacted`, `qualifying`, `qualified`, `invited`, `nurturing` and `unresponsive` are reachable through this API only, and the CRM UI cannot move a lead back out of them.
- Every write bumps `updated_at`, so a changed lead reliably reappears in the next `updated_since` poll.
- An actual status change emits `lead.status_changed`. A PATCH that leaves the status unchanged emits nothing.
- Returns `404` when the id is unknown, belongs to another tenant, or names a converted person.

### GET /customers

Required scope: `customers:read`

Returns the tenant's CRM customers, read-only. This is where a person lands after converting from a lead, and the resource that answers whether a person is registered in MyVesto.

| Parameter            | Type                  | Description                                                                        |
| -------------------- | --------------------- | ---------------------------------------------------------------------------------- |
| `lifecycle_status`   | lifecycle status code | Filter by `registered`, `active`, `purchaser`, `completed`, `inactive`, `churned`. |
| `assigned_agent_id`  | UUID                  | Optional filter for the assigned agent.                                            |
| `myvesto_registered` | boolean               | `true` returns only customers with a MyVesto account.                              |
| `updated_since`      | ISO datetime          | Incremental sync filter against `updated_at`.                                      |
| `lang`               | `en \| de`            | Display language for lifecycle labels. Defaults to `en`.                           |
| `limit` / `cursor`   | number / string       | Cursor pagination controls. Limit accepts `1` to `500` and defaults to `100`.      |

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

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

`myvesto.registered` is derived from the MyVesto user id, not from `lifecycle_status`. `lifecycle_status` defaults to `registered` when a customer record is created and therefore says nothing about an actual MyVesto account — filter on `myvesto_registered` when you mean registration.

Customers carry `salutation` (`mr`, `mrs`, `diverse` or `null`) and the postal `address` of the CRM record (`street`, `house_number`, `postal_code`, `city`, `country`; `null` when no street, postal code or city is recorded — a country alone is not an address). The rest of the Selbstauskunft — date of birth, birth name, birth place, nationality, marital status, tax id, Güterstand — is synced from MyVesto but is never part of this API. The address on a reservation's `buyer` is the one captured for that purchase and can differ from this one.

### GET /customers/{customer_id}

Required scope: `customers:read`

Returns one customer of the token's tenant.

| Parameter     | Type       | Description                                              |
| ------------- | ---------- | -------------------------------------------------------- |
| `customer_id` | UUID       | Required customer id in the path.                        |
| `lang`        | `en \| de` | Display language for lifecycle labels. Defaults to `en`. |

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

Returns `404` when the id is unknown, belongs to another tenant, or names a person who is still a lead.

### GET /projects

Required scope: `projects:read`

Returns the tenant's own projects (buildings), plus projects with units shared into the tenant through the object network, with their units and the metadata of their project-level documents. Demo projects created with a new tenant never appear. See Shared objects below for how shared projects and units appear.

| Parameter          | Type            | Description                                                                                                                     |
| ------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `zip_code`         | list            | Exact zip codes, comma-separated or repeated.                                                                                   |
| `city`             | string          | City, case-insensitive exact match.                                                                                             |
| `q`                | string          | Substring of the project name or street.                                                                                        |
| `updated_since`    | ISO datetime    | Incremental sync filter against `updated_at`.                                                                                   |
| `document_type`    | list            | Narrows the embedded `documents`, e.g. `hoa_minutes,resolution_collection`. Never removes projects.                             |
| `file_type`        | list            | Narrows the embedded `documents` by extension: `pdf`, `jpg` (includes `.jpeg`), `png`, …                                        |
| `is_internal`      | boolean         | Narrows the embedded `documents` to internal (`true`) or shareable (`false`) files. Internal documents are included by default. |
| `lang`             | `en \| de`      | Display language for labels. Defaults to `en`.                                                                                  |
| `limit` / `cursor` | number / string | Cursor pagination. Limit `1` to `500`, default `100`. Ordered by `updated_at` descending.                                       |

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

```json
{
  "items": [
    {
      "id": "77777777-7777-7777-7777-777777777777",
      "name": "Musterstraße 12",
      "street": "Musterstraße",
      "house_number": "12",
      "zip_code": "10115",
      "city": "Berlin",
      "district": "Mitte",
      "state": "Berlin",
      "country": "Deutschland",
      "construction_year": 1908,
      "renovation_year": 2024,
      "total_floors": 5,
      "project_type": { "code": "existing_renovation", "label": "Existing-stock renovation" },
      "building_type": { "code": "residential", "label": "Residential" },
      "completion_date": "2025-03-31",
      "move_in_ready_date": "2025-04-01",
      "status": { "code": "sold", "label": "Sold" },
      "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": "Apartment" },
          "size_sqm": 64.5,
          "rooms": 2.5,
          "status": { "code": "completed", "label": "Completed" },
          "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": "HOA Minutes" },
          "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` holds only project-level documents; each unit's documents are in `GET /properties/{property_id}`. Document uploads do **not** move any `updated_at`; use `documents_updated_at` on the project and `document_count` / `documents_updated_at` on each unit to see which units to re-fetch. The markers honour the same filters as the embedded lists, so a filtered sync converges too. A new document does not move the project's `updated_at`, so an `updated_since` poll never surfaces it. Read the full list (without `updated_since`) when you want to see document changes, and use `updated_since` only for changes to the project and unit rows themselves. `unit_count` counts the listed units. `is_shared` marks a project containing units shared into the tenant through the object network, see Shared objects.

Besides the fields in the example, every project carries all fields that can also be written (see Field reference), under the same names, plus:

- `price_min` / `price_max`: lowest and highest purchase price (`purchase_price`) of the listed units.
- `size_min` / `size_max` and `price_per_sqm_min` / `price_per_sqm_max`: computed, read-only. `micro_location`: the micro location, read-only.
- `visibility`: `offline`, `pre_sale` or `online`.
- `images`: project images with `id`, `url`, `category`, `title`, `display_order`.
- `parking_spaces`: the project's parking pool with `id`, `identifier`, `parking_type`, `assigned_unit_id` (fixed to a unit) or `null` (optional), `sale_price`, `monthly_rent`, `monthly_cost_deductible`, `monthly_cost_non_deductible`, `display_order`.
- `commission_rate` and, per parking space, `developer_purchase_price` only with `purchase_prices:read`; otherwise the keys are absent.

Units in the list additionally carry `purchase_price` and `visibility`.

### GET /projects/{project_id}

Required scope: `projects:read`

Returns one project in the same shape as a list item and accepts the same `document_type`, `file_type`, `is_internal` and `lang` parameters. Returns `404` for an unknown id, a project belonging to another tenant that is not shared with this tenant, or a demo project.

### GET /properties/{property_id}

Required scope: `projects:read`

Returns one unit with its data and the unit's own documents. The id is the same `property_id` used by `/reservations`, including units shared into the tenant through the object network, see Shared objects below. Accepts `document_type`, `file_type`, `is_internal` and `lang`. `GET /units/{unit_id}` returns the same response under the path the unit write endpoints use.

```json
{
  "id": "99999999-9999-9999-9999-999999999999",
  "project_id": "77777777-7777-7777-7777-777777777777",
  "unit_number": "WE 3",
  "floor": "1. OG",
  "property_type": { "code": "apartment", "label": "Apartment" },
  "property_usage": { "code": "condominium", "label": "Condominium" },
  "size_sqm": 64.5,
  "rooms": 2.5,
  "bathrooms": 1,
  "balcony": { "code": "balcony", "label": "Balcony" },
  "has_cellar": true,
  "object_share_owner": 0.0645,
  "rental_type": { "code": "normal_rental", "label": "Normal rental" },
  "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": "Underground parking" }, "monthly_rent": "80.00" }
  ],
  "status": { "code": "completed", "label": "Completed" },
  "is_shared": false,
  "documents": [],
  "created_at": "2025-01-10T08:00:00Z",
  "updated_at": "2026-08-20T09:00:00Z"
}
```

`object_share_owner` is the co-ownership share as a fraction between 0 and 1 at full precision. `monthly_rent` is the planned cold rent from the sales calculation, not a lease; `total_monthly_rent` adds the rents of the unit's parking spaces and is the figure the gross rental yield is based on. `hoa_costs` is the landlord's non-apportionable share of the Hausgeld, not the whole Hausgeld: the full monthly Hausgeld is `hoa_costs` + `operation_cost_tenant` (the tenant's apportionable share) + `maintenance_reserve` (the monthly allocation to the reserve). `maintenance_reserve_balance` is the accumulated reserve. Parking rent is per space, in `parking_spaces[].monthly_rent`. Money values are decimal strings. Tenant-wide central documents (a sample purchase contract and the like, which Vesturium Cloud shows on every unit) are not part of this API: `documents` holds only the files attached to the project or the unit itself, and the counts and markers follow that. `is_shared` marks a unit shared into the tenant through the object network, see Shared objects. Returns `404` for an unknown id, a unit belonging to another tenant that is not shared with this tenant, a demo unit or a unit of a demo project. `rental_type` is `existing_tenant` (let at purchase), `rental_pool` (Mietpool), `operator` (let to an operator under a fixed contract), `normal_rental` (let by the owner) or `null`; `has_sev` says whether a Sondereigentumsverwaltung is agreed for the unit.

Besides the fields in the example, every unit carries all fields that can also be written (see Field reference), under the same names — values read can be written back unchanged. These include the sale prices: `purchase_price` (total investment), `apartment_price` (apartment price: total investment minus furniture and fixed parking spaces) and `furniture_price`; per parking space `sale_price`. Further `visibility` (`deactivated`, `draft`, `active`), `images` as on the project, and the computed, read-only `price_per_sqm` and `gross_rental_yield`. `developer_purchase_price` (purchase price) and `commission_rate` (effective commission rate) appear only with `purchase_prices:read`.

### GET /documents/{document_id}

Required scope: `documents:read` (document ids come from `projects:read` responses, so pulling files needs both scopes)

Returns one project or unit document with a download link, the uploader and existing text.

| Parameter     | Type       | Description                                             |
| ------------- | ---------- | ------------------------------------------------------- |
| `document_id` | UUID       | Required document id in the path.                       |
| `lang`        | `en \| de` | Display language for the document-type label. Defaults to `en`. |

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

- Documents flagged internal in Vesturium Cloud (`is_internal: true`) are returned here just like any other; `documents:read` therefore also grants their download link and OCR text.
- `download_url` is valid for one hour; request the document again for a fresh link. It is `null` when a link cannot be signed. In rare cases a link can return `404` shortly after an AI import, before the file has been moved into place.
- `text` is Markdown from OCR and is present only where Vesturium Cloud has already read the file (AI import or the assistant), today a small minority of documents. The API never starts OCR. A parser upgrade can hide existing text until the file is read again. On a developer's shared document `text` is `null`: the API never exposes the developer's OCR; only text your own account has already had read (the assistant) can appear.
- `uploaded_by` is the user who uploaded the file; for synced files (Investagon, TeamProQ) it is the user who ran the sync. It is `null` for developer-portal uploads and deleted users.
- Also resolves documents on a shared unit or project — the developer's shared documents and the tenant's own uploads on it; see Shared objects for which of the developer's documents are visible.
- Returns `404` for an unknown id, a document belonging to another tenant that is not visible through a share, or a document of a demo project or unit.

## Writing objects

With the write scopes, a partner system creates projects with units, parking spaces, images and documents, maintains them and publishes them. The same rules apply as in Vesturium Cloud itself: required fields, plan limits, the publication checklist and permissions.

### Basics

- **Acting person:** a write runs on behalf of the person who created the token (for rotated tokens: the person who rotated it last). The token can never do more than that person: their Vesturium Cloud permissions apply, limited to the token's scopes. Changes appear in Vesturium Cloud as changes by that person; the audit log also records the token.
- **That person's view:** what the person cannot see in Vesturium Cloud, the token cannot change, for example units outside their property groups unless they have "Can see all properties". Read endpoints are unaffected and keep returning the whole tenant.
- **Own stock:** writes only work on the tenant's own projects and units; ones shared through the object network return `403 not_owned`.
- **Field names:** reads and writes use the same names and codes (see Field reference). Enum values are sent as codes, e.g. `"rental_type": "normal_rental"`; amounts as numbers or decimal strings. Unknown fields return `400 invalid_body`.
- **Responses:** create and update respond with the project or unit in its read shape (`lang` as for reads).
- **Errors:** as under Pagination and errors, plus the codes for writes.

### Projects

#### POST /projects

Required scope: `projects:write`

Creates a project and responds with `201` and the project. `name`, `street`, `house_number`, `zip_code`, `city` and `state` are required. Without `latitude` / `longitude`, Vesturium Cloud determines the coordinates from the address; if that fails, you get `400 invalid_request` asking you to send the coordinates. The name must be unique within the tenant (`409 conflict`). If one more project exceeds the plan's quota, you get `402 confirm_overage_required`; repeat with the query parameter `confirm_overage=true` to accept the extra cost. A publication checklist is created automatically, as in Vesturium Cloud.

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

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

#### PATCH /projects/{project_id}

Required scope: `projects:write`

Changes only the fields sent.

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

Required scope: `projects:write`

Body `{ "visibility": "offline" | "pre_sale" | "online" }`. If open checklist items block publishing, you get `409 publication_blocked` with the items in `details.blockers` — see Checklist.

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

Required scope: `projects:write`

Refreshes the micro location from the address and responds with the project.

### Units

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

Required scope: `properties:write`

Creates up to 50 units at once: `{ "units": [ … ] }`. `unit_number`, `property_type`, `size_sqm`, `rooms`, `purchase_price` and `monthly_rent` are required per unit. Each unit is saved on its own; one failure does not stop the others. The response is always `200`:

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

- Send `unit_number` without the "WE" prefix; Vesturium Cloud adds it when displaying.
- New units start with status `available`.
- `apartment_price` and `key_box_description` can only be set after creation, with `PATCH /units/{unit_id}`. An `apartment_price` then recomputes `purchase_price` (apartment + furniture + fixed parking spaces); sending both at once is an error.

#### PATCH /units/{unit_id}

Required scope: `properties:write`

Changes only the fields sent and responds with the unit.

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

Required scope: `properties:write`

Body `{ "status": "<code>" }` with a status code as in `GET /units/{unit_id}`, e.g. `available` or `reserved`. Vesturium Cloud sets `available` only when no publication items are open.

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

Required scope: `properties:write`

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

#### DELETE /units/{unit_id}

Required scope: `properties:write`

Deletes a unit (`204`). A unit with a reservation cannot be deleted (`409 conflict`).

### Parking spaces

Parking spaces belong to a project's pool and appear in `parking_spaces` of `GET /projects/{project_id}`. With `assigned_unit_id`, a parking space is fixed to a unit and counts towards its purchase price; without it, it stays optional in the pool.

| Endpoint | Scope | Effect |
| --- | --- | --- |
| `POST /projects/{project_id}/parking-spaces` | `properties:write` | Create a parking space (`201`). Required: `identifier`, `parking_type` (`parking_space`, `garage`, `duplex_garage`, `underground_parking`, `carport`, `parking_garage`). Also: `assigned_unit_id`, `sale_price`, `monthly_rent`, `monthly_cost_deductible`, `monthly_cost_non_deductible`, `display_order`, with `purchase_prices:write` `developer_purchase_price`. |
| `PATCH /projects/{project_id}/parking-spaces/{space_id}` | `properties:write` | Change the fields sent. |
| `DELETE /projects/{project_id}/parking-spaces/{space_id}` | `properties:write` | Delete a parking space (`204`). |

### Uploads

Required scope: `media:write`

Files go straight to storage in three steps, never through the API:

1. `POST /uploads` with `entity_type` (`project` or `unit`), `entity_id`, `kind` (`images` or `documents`) and up to 50 `files` (`filename`, `content_type`, `size` in bytes). The response (`201`) contains `session_id`, `expires_at` and, per file, `staged_id`, `upload_url` and `required_headers`.
2. `PUT` each file to its `upload_url`, with exactly the `required_headers`.
3. `POST /uploads/{session_id}/finalize` with `items`: per file `staged_id`, `category` (for images the image category, e.g. `exterior`, `interior`, `floor_plan`; for documents the document type code, e.g. `expose` or `declaration_of_division`), optionally `title`, `description`, `display_order`, `is_internal`. The response lists the new `id`, `kind` (`image` or `document`) and `category` per file.

`DELETE /uploads/{session_id}` aborts an upload (`204`). Allowed file types and sizes are the same as in Vesturium Cloud.

### Images and documents

Required scope: `media:write`

| Endpoint | Effect |
| --- | --- |
| `PATCH /projects/{project_id}/images/{image_id}` | Change `category`, `title`, `description`, `display_order` of a project image. |
| `PATCH /units/{unit_id}/images/{image_id}` | Change `title`, `description`, `display_order` of a unit image. |
| `PUT /projects/{project_id}/images/order`, `PUT /units/{unit_id}/images/order` | Set the order: `{ "image_ids": [ … ] }` with all images, each exactly once. |
| `DELETE /projects/{project_id}/images/{image_id}`, `DELETE /units/{unit_id}/images/{image_id}` | Delete an image (`204`). |
| `PATCH /documents/{document_id}` | Change `document_type` (code) and `is_internal` of a project or unit document. |

### Checklist

Required scope: `checklist:write`. Needs the Smart checklists module in the tenant; without it you get `403 forbidden` with `details.submodule`.

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

Returns what still blocks publishing: `stats` (`total_required`, `completed_required`, `completion_percentage`, `pending_count`, `overdue_count`, `missing_count`, `not_needed_count`), `template_name`, `checklist_deadline` and `items` with `id`, `type`, `reference` (field or document type), `unit_id`, `unit_number`, `required`, `blocks_publication`, `status` and `rejection_reason`. Items marked "not needed" appear only with `include_not_needed=true`.

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

Sets `status` (`missing`, `requested`, `pending_approval`, `rejected`, `completed`, `not_needed`, `overdue`), `is_required`, `blocks_publication` or `rejection_reason`. `not_needed` requires `not_needed_reason`.

### Field reference

Project — the same names in `GET /projects`, `POST /projects` and `PATCH /projects/{project_id}`:

| Field | Type | Create | Notes |
|---|---|---|---|
| `name` | string | required |  |
| `street` | string | required |  |
| `house_number` | string | required |  |
| `city` | string | required |  |
| `district` | string | optional |  |
| `state` | string | required |  |
| `country` | string | optional |  |
| `zip_code` | string | required |  |
| `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 | — | PATCH only |
| `commission_rate` | number | optional | purchase_prices:* |
| `price_adjustment_enabled` | boolean | optional |  |

Unit — the same names in `GET /units/{unit_id}`, `POST /projects/{project_id}/units` and `PATCH /units/{unit_id}`:

| Field | Type | Create | Notes |
|---|---|---|---|
| `unit_number` | string | required |  |
| `property_type` | code: `apartment`, `studio`, `penthouse`, `maisonette`, `house`, `co_living`, `denkmal`, `commercial`, `parking`, `storage`, `other` | required |  |
| `property_usage` | code: `wg_apartment`, `condominium`, `micro_apartment`, `commercial_other`, `global_object` | optional |  |
| `size_sqm` | number | required |  |
| `rooms` | number | required |  |
| `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 | required |  |
| `apartment_price` | decimal string | — | PATCH only |
| `furniture_price` | decimal string | optional |  |
| `monthly_rent` | decimal string | required |  |
| `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 | create only |
| `key_box_description` | string | — | PATCH only |
| `developer_purchase_price` | decimal string | optional | purchase_prices:* |
| `commission_rate_override` | decimal string | optional | purchase_prices:* |

Fields marked `purchase_prices:*` are not returned without `purchase_prices:read` and are rejected with `403 forbidden` without `purchase_prices:write`. Both scopes only take effect if the person who created the token has the "View All Provisions" permission. A MaBV rate is `{ "percentage": 30, "payment_date": "2027-01-15", "description": "Construction start" }`.

## Shared objects

Units shared into the tenant through the object network appear alongside the tenant's own inventory: in `GET /projects`, `GET /projects/{project_id}` and `GET /properties/{property_id}`, and their documents in `GET /documents/{document_id}`. `is_shared` marks them `true`; the tenant's own objects always show `false`. A shared unit appears only once the tenant has approved its share; pending shares stay invisible.

- **Values:** unit and project fields are the values the developer shared and the tenant approved — not the developer's current data. The one exception is a unit's `status`: it is always current, exactly as for the tenant's own units. A project's `status` comes from the approved project review like its other building-level fields.
- **Buildings:** a shared project lists only the units shared with this tenant; `unit_count` counts those, never the developer's full building. Building-level fields (`name`, `city`, `project_type`, …) appear once the developer has shared, and the tenant has approved, a project review; until then they are `null` — the shared units themselves are still returned.
- **Developer contact:** `bautraeger` is the tenant's own contact for the developer, not the developer's own contact record.
- **Documents:** the developer's public documents, internal ones only where the developer has explicitly shared them, plus the tenant's own uploads on the shared unit. `uploaded_by` is `null` on the developer's documents, and so is `text` unless your own account has already had the file read — the developer's OCR is never exposed. `parking_spaces[].id` is `null` on shared units — snapshots carry no parking-space id.
- **Filters:** `zip_code`, `city` and `q` on `GET /projects` match a shared project on the approved values shown in the response, never on the developer's current data. A shared project without an approved project review matches none of them.
- **Lifecycle:** a stopped share stays visible; only an ended share removes the unit, and it disappears with no separate signal — reconcile with a full listing, as with any other deletion.
- **Sync:** a new share, a newly approved value or a change to the tenant's own overrides on a shared unit moves the unit's and project's `updated_at`, so `updated_since` also surfaces newly shared inventory and newly approved changes, not only edits to the developer's own data.
- **Prices:** shared units show the tenant's prices, never the developer's: `purchase_price` is the sale price from the approved share — with the agreed markup for a fixed-commission share, otherwise the tenant's own overridden price. `developer_purchase_price` is the share's handover price (or `null`), `commission_rate` the tenant's own commission rate. Shared projects carry neither `price_min` / `price_max` nor `parking_spaces`.
- **Writes:** shared projects and units cannot be changed through the API (`403 not_owned`).

## Webhooks

Outbound webhooks deliver changes to active HTTPS endpoints. The business mutation is not rolled back when webhook delivery fails.

Configuration endpoints in Admin API Access:

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

Delivery headers:

| Header                     | Description                         |
| -------------------------- | ----------------------------------- |
| `Content-Type`             | JSON content type.                  |
| `User-Agent`               | `Vesturium-Webhooks/1.0`.          |
| `X-Vesturium-Event`        | Event name.                         |
| `X-Vesturium-Delivery-Id`  | Unique delivery id for idempotency. |
| `X-Vesturium-Timestamp`    | Unix timestamp used for signing.    |
| `X-Vesturium-Signature`    | HMAC signature.                     |

The same four values are also sent under the former names `X-BlackVesto-Event`, `X-BlackVesto-Delivery-Id`, `X-BlackVesto-Timestamp` and `X-BlackVesto-Signature`. Existing checks against the old names keep working; new integrations should use `X-Vesturium-*`.

### Signature verification

Signatures use HMAC-SHA256 over the timestamp and raw JSON body:

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

Signature format:

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

Reject timestamps older than 5 minutes.

Test vector:

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

Receiver checklist:

1. Buffer the raw request body before parsing JSON.
2. Verify `X-Vesturium-Signature` with HMAC-SHA256.
3. Reject timestamps outside a 5 minute window.
4. Store `X-Vesturium-Delivery-Id` for idempotency.
5. Return any `2xx` status after successful acceptance.

### Events

| Event                        | Description                                                                                 |
| ---------------------------- | ------------------------------------------------------------------------------------------- |
| `reservation.status_changed` | Emitted when a reservation status changes between two different values.                     |
| `notary_appointment.created` | Emitted when a reservation receives a notary appointment for the first time.                |
| `notary_appointment.changed` | Emitted when an existing notary appointment datetime, timezone, or notary contact changes.  |
| `lead.created`               | Emitted when a lead is created, whether through `POST /leads` or in the Vesturium Cloud UI.      |
| `lead.status_changed`        | Emitted when a lead status changes between two different values, from the API or the CRM.   |
| `lead.converted`             | Emitted when a lead actually becomes a customer, never for a `status` write alone.          |

Example event payload:

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

Lead events share the same envelope and carry lowercase status and source codes:

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

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

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

`changed_by_user_id` and `converted_by_user_id` are `null` when the change came from the External API or from a MyVesto registration, because neither has an acting Vesturium Cloud user. `lead.converted` keeps the person id: conversion changes the record's type, not its id, so the same id addresses the customer afterwards.

## Limits

- **Delivery:** webhooks are delivered at least once. The envelope `id` is stable across redeliveries, so subscribers must deduplicate on it. There is no producer-side retry or backoff — a delivery that fails is recorded, not retried.
- **Deletion:** deleting a lead in the CRM produces no webhook and leaves no trace in `updated_since`. Reconcile deletions with a periodic full read.
- **Customer sync:** an update that touches only customer-specific fields, such as `lifecycle_status` or the MyVesto sync timestamp, may not move `updated_at` and can therefore be missed by an `updated_since` poll. Reads and conversions are unaffected.
- **Rate limits:** 600 requests per minute and 20 000 per day per token, shared between reads and writes.
- **Writes:** up to 50 units per `POST /projects/{project_id}/units`, up to 50 files per upload. Files go straight to storage (`upload_url`), never through the API. The micro location can be refreshed at most ten times per hour per person.
- **Projects and documents:** projects, units and documents are deleted without a trace in `updated_since`; reconcile with a periodic full read. Document uploads move no `updated_at`; use the `documents_updated_at` and `document_count` markers. The markers honour the same `document_type` / `file_type` / `is_internal` filters, so a filtered sync converges too. A document upload does not move the project's own `updated_at` either, so `updated_since` alone never surfaces it. Read the full list (without `updated_since`) for document changes, and use `updated_since` only for the project and unit rows themselves. `updated_at` values are transaction-start times: poll with a 15-minute overlap and de-duplicate by `id`.
- **File downloads:** one request per document link. A first full sync of a large tenant (≈300 projects, ≈1,300 units, ≈10,000 documents) needs about 12,000 requests — about 20 minutes at 600/min and more than half the daily budget; later syncs only fetch changed units and new documents.
