API Documentation

Vesturium Cloud External API and Webhooks

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

Download API docs

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.

ItemValue
Base URLhttps://api.vesturium.cloud/external/v1
Base path/external/v1
AuthenticationAuthorization: Bearer <external_api_token>
PaginationCursor
Webhook signingHMAC-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.

Authorization: Bearer <external_api_token>

Supported scopes:

ScopeAccess
reservations:readReservation list and detail endpoints
users:readUser list and detail endpoints
kpis:readKPI endpoint
leads:readList and read CRM leads
leads:writeCreate and update CRM leads
customers:readList and read CRM customers
projects:readProjects with units and document metadata, unit detail
documents:readDocument detail with download link and text — needs projects:read to find document ids
projects:writeCreate and update projects, publish them, refresh the micro location
properties:writeCreate units (up to 50 per request), update them, set status and visibility, delete; maintain parking spaces
media:writeUpload, label, order and delete images and documents
checklist:writeRead the publication checklist and set items
purchase_prices:readRead purchase price and commission rate — only if the token's creator has "View All Provisions"
purchase_prices:writeWrite 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.

StatusCodeMeaning
400invalid_filterA query parameter is unsupported or malformed.
400invalid_bodyThe request body failed validation: unknown key, missing required field, or unsupported enum value.
400invalid_assigneeassigned_agent_id is not an active user of the token's tenant.
401unauthorizedBearer token is missing, malformed, invalid, expired, or revoked.
403forbiddenThe token is valid but does not include the required scope, or the tenant's API access is inactive.
404not_foundThe record does not exist in the token's tenant, or no longer has the addressed type.
409duplicate_emailA person with this email already exists in the tenant. The existing record is returned in details for idempotency.
429rate_limitedThe token exceeded the external API rate limit.

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

StatusCodeMeaning
400invalid_requestVesturium Cloud rejects the operation on business grounds; the reason is in message.
402confirm_overage_requiredThe operation exceeds the quota included in the plan; repeat with confirm_overage=true to accept the extra cost.
403not_ownedThe project or unit is shared through the object network and not owned by the tenant; writes only work on own stock.
403token_owner_inactiveThe person who created the token is inactive or no longer belongs to the tenant. Rotate the token as an active user.
409publication_blockedOpen checklist items block publishing; they are listed in details.blockers.
409conflictThe operation conflicts with the current state, e.g. deleting a unit with a reservation.
502upstream_errorThe operation could not be completed; retry later.
503service_unavailableVesturium 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:

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

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

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

ParameterTypeDescription
viewnotary | commissionOptional reservation projection. Omit view to return the full resource.
langen | deDisplay language for status labels. Defaults to en.
statusstringComma-separated status codes such as reserved, cancelled, notary_appointment, or legacy integer/German aliases.
date_from / date_toISO date or datetimeInclusive date range for the selected date_field.
date_fieldcreated_at | updated_at | reservation_fee_paid_at | notary_appointment_at | cancelled_atDate column used by date_from and date_to. Defaults to updated_at.
advisor_id / property_idUUIDOptional filters for the reservation owner or property.
updated_sinceISO datetimeIncremental sync filter against updated_at.
limit / cursornumber / stringCursor pagination controls. Limit accepts 1 to 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": "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": "[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 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

CodeGerman labelMeaning
awaiting_signatureWartet auf UnterschriftBuyer expressed interest; reservation agreement not yet signed.
requestedAngefragtReservation requested.
reservedReserviertUnit reserved for the buyer.
notary_preparationNotarvorbereitungPurchase contract being drafted.
notary_appointmentNotarterminNotary appointment scheduled; not yet notarised.
aftersaleAftersalePurchase contract notarised. The buyer is the (future) owner.
completedAbgeschlossenSale fully completed.
legacy_completedLegacy abgeschlossenCompleted sale from an earlier data model; treat like completed.
cancelledAbgebrochenReservation cancelled.
availableFreiNo 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.

ParameterTypeDescription
reservation_idUUIDRequired reservation id in the path.
viewnotary | commissionOptional reservation projection. Omit view to return the full resource.
langen | deDisplay language for status labels. Defaults to en.
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.

ParameterTypeDescription
roleadvisorOptional advisor-only filter.
has_reservationsbooleanWhen true, returns only users owning reservations.
is_activeboolean or nullDefaults to true.
updated_sinceISO datetimeIncremental sync filter against updated_at.
limit / cursornumber / stringCursor pagination controls. Limit accepts 1 to 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}

Required scope: users:read

Returns one tenant user by id.

ParameterTypeDescription
user_idUUIDRequired user id in the path.
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.

ParameterTypeDescription
viewtrends | funnel | leaderboard | pipelineOptional KPI insight group. Omit view to return all supported groups with response view=all.
periodlast_7_days | last_30_days | last_90_days | current_month | current_quarter | current_year | all_time | customReporting period. Defaults to current_month.
date_from / date_toISO dateRequired when period=custom.
granularitydaily | weekly | monthlyOptional for trends. Defaults to daily.
advisor_idUUIDOptional user filter.
limitnumberLeaderboard limit. Accepts 1 to 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

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.

ParameterTypeDescription
statuslead status codeFilter by pipeline status, for example new, contacted, qualifying, qualified, invited.
sourcelead source codeFilter by origin: website, campaign, trade_fair, referral, manual_entry, api.
assigned_agent_idUUIDOptional filter for the assigned agent.
invite_statuspending | used | registered | expiredFilter by the state of the MyVesto invite linked to the lead.
updated_sinceISO datetimeIncremental sync filter against updated_at.
langen | deDisplay language for status and source labels. Defaults to en.
limit / cursornumber / stringCursor pagination controls. Limit accepts 1 to 500 and defaults to 100.
GET /leads?status=qualified&lang=en&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": "Qualified" },
      "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 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.

ParameterTypeDescription
lead_idUUIDRequired lead id in the path.
langen | deDisplay language for status and source labels. Defaults to en.
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.

FieldTypeDescription
first_namestringRequired.
last_namestringRequired.
emailstringRequired. Must not already exist in the tenant, on a lead or on a customer.
phonestring or nullOptional.
notesstring or nullOptional.
sourcelead sourceOptional. Defaults to api.
statuslead statusOptional. Defaults to new. converted is not accepted.
assigned_agent_idUUID or nullOptional. Must be an active user of the token's tenant. Unassigned by default.
langquery parameterDisplay language of the response labels. Defaults to en.
POST /leads
Content-Type: application/json
{
  "first_name": "Erika",
  "last_name": "Musterfrau",
  "email": "[email protected]",
  "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.

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

ParameterTypeDescription
lifecycle_statuslifecycle status codeFilter by registered, active, purchaser, completed, inactive, churned.
assigned_agent_idUUIDOptional filter for the assigned agent.
myvesto_registeredbooleantrue returns only customers with a MyVesto account.
updated_sinceISO datetimeIncremental sync filter against updated_at.
langen | deDisplay language for lifecycle labels. Defaults to en.
limit / cursornumber / stringCursor pagination controls. Limit accepts 1 to 500 and defaults to 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": "Registered" },
      "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 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.

ParameterTypeDescription
customer_idUUIDRequired customer id in the path.
langen | deDisplay language for lifecycle labels. Defaults to en.
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.

ParameterTypeDescription
zip_codelistExact zip codes, comma-separated or repeated.
citystringCity, case-insensitive exact match.
qstringSubstring of the project name or street.
updated_sinceISO datetimeIncremental sync filter against updated_at.
document_typelistNarrows the embedded documents, e.g. hoa_minutes,resolution_collection. Never removes projects.
file_typelistNarrows the embedded documents by extension: pdf, jpg (includes .jpeg), png, …
is_internalbooleanNarrows the embedded documents to internal (true) or shareable (false) files. Internal documents are included by default.
langen | deDisplay language for labels. Defaults to en.
limit / cursornumber / stringCursor pagination. Limit 1 to 500, default 100. Ordered by updated_at descending.
GET /projects?zip_code=10115&document_type=hoa_minutes&lang=en
{
  "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.

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

ParameterTypeDescription
document_idUUIDRequired document id in the path.
langen | deDisplay language for the document-type label. Defaults to 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": "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": "[email protected]" },
  "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.

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:

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

EndpointScopeEffect
POST /projects/{project_id}/parking-spacesproperties:writeCreate 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:writeChange the fields sent.
DELETE /projects/{project_id}/parking-spaces/{space_id}properties:writeDelete 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

EndpointEffect
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/orderSet 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}:

FieldTypeCreateNotes
namestringrequired
streetstringrequired
house_numberstringrequired
citystringrequired
districtstringoptional
statestringrequired
countrystringoptional
zip_codestringrequired
latitudenumberoptional
longitudenumberoptional
construction_yearintegeroptional
completion_datedateoptional
renovation_yearintegeroptional
renovation_measure_amountdecimal stringoptional
renovation_measure_completion_datedateoptional
total_floorsintegeroptional
total_unitsintegeroptional
building_typecode: residential, commercial, mixedoptional
project_typecode: new_build, kfw_renovation, existing_renovation, existing_propertyoptional
has_elevatorbooleanoptional
has_parkingbooleanoptional
has_basementbooleanoptional
has_gardenbooleanoptional
energy_certificate_typecode: consumption, demandoptional
energy_consumptionnumberoptional
energy_classcode: a_plus, a, b, c, d, e, f, g, hoptional
heating_typecode: gas_central, oil_central, district_central, heat_pump_central, pellet_central, electric_central, gas_floor, electric_floor, solar_thermal, central, floor, other, unknownoptional
primary_energy_consumptionnumberoptional
heating_building_yearintegeroptional
backyard_developmentbooleanoptional
sev_takeover_one_yearbooleanoptional
renovationsstringoptional
modernizationslist (string)optional
move_in_ready_datedateoptional
mabv_rateslist (MaBV rate)optional
descriptionstringoptional
amenitieslist (string)optional
bautraeger_idUUIDoptional
video_youtube_urlstringoptional
expose_template_idUUIDoptional
default_image_set_idUUID—PATCH only
commission_ratenumberoptionalpurchase_prices:*
price_adjustment_enabledbooleanoptional

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

FieldTypeCreateNotes
unit_numberstringrequired
property_typecode: apartment, studio, penthouse, maisonette, house, co_living, denkmal, commercial, parking, storage, otherrequired
property_usagecode: wg_apartment, condominium, micro_apartment, commercial_other, global_objectoptional
size_sqmnumberrequired
roomsnumberrequired
bathroomsintegeroptional
floorstringoptional
balconycode: balcony, loggia, terrace, roof_terrace, conservatoryoptional
has_cellarbooleanoptional
object_share_ownernumberoptional
rental_typecode: existing_tenant, rental_pool, operator, normal_rentaloptional
has_sevbooleanoptional
has_initial_letting_guaranteebooleanoptional
purchase_pricedecimal stringrequired
apartment_pricedecimal string—PATCH only
furniture_pricedecimal stringoptional
monthly_rentdecimal stringrequired
management_feedecimal stringoptional
hoa_costsdecimal stringoptional
operation_cost_tenantdecimal stringoptional
maintenance_reservedecimal stringoptional
maintenance_reserve_balancedecimal stringoptional
work_contract_amountdecimal stringoptional
land_valuedecimal stringoptional
afa_typecode: linear, degressiveoptional
depreciation_rate_building_manualdecimal stringoptional
depreciation_special_7bbooleanoptional
renovation_costsdecimal stringoptional
renovation_tax_treatmentcode: maintenance_expense, capitalized, redevelopment_area_7h, heritage_7ioptional
renovation_depreciation_rate_manualdecimal stringoptional
completion_date_overridedateoptional
move_in_ready_date_overridedateoptional
mabv_rates_overridelist (MaBV rate)optional
takeover_special_charges_monthsintegeroptional
takeover_special_charges_amountdecimal stringoptional
modernizationslist (string)optional
notary_override_percentagedecimal stringoptional
kfw_enabledbooleanoptional
kfw_programstringoptional
kfw_program_idUUIDoptional
kfw_repayment_bonusdecimal stringoptional
kfw_wpbbooleanoptional
kfw_baubegleitung_enabledbooleanoptional
external_idstringoptionalcreate only
key_box_descriptionstring—PATCH only
developer_purchase_pricedecimal stringoptionalpurchase_prices:*
commission_rate_overridedecimal stringoptionalpurchase_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:

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:

HeaderDescription
Content-TypeJSON content type.
User-AgentVesturium-Webhooks/1.0.
X-Vesturium-EventEvent name.
X-Vesturium-Delivery-IdUnique delivery id for idempotency.
X-Vesturium-TimestampUnix timestamp used for signing.
X-Vesturium-SignatureHMAC 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:

<timestamp>.<raw_json_body>

Signature format:

v1=<hex_digest>

Reject timestamps older than 5 minutes.

Test vector:

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

EventDescription
reservation.status_changedEmitted when a reservation status changes between two different values.
notary_appointment.createdEmitted when a reservation receives a notary appointment for the first time.
notary_appointment.changedEmitted when an existing notary appointment datetime, timezone, or notary contact changes.
lead.createdEmitted when a lead is created, whether through POST /leads or in the Vesturium Cloud UI.
lead.status_changedEmitted when a lead status changes between two different values, from the API or the CRM.
lead.convertedEmitted when a lead actually becomes a customer, never for a status write alone.

Example event 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 share the same envelope and carry lowercase status and source codes:

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