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.
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:readalready discloses the metadata (titles and file names) of documents flagged internal in Vesturium Cloud, anddocuments:readadds 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:
{
"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.
| 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. |
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
| 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. |
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. |
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.
| Parameter | Type | Description |
|---|---|---|
user_id | UUID | Required 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.
| 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. |
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.
| 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. |
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.
| 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. |
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. |
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" }
emailcannot be changed after creation — it is the deduplication anchor. The update body has noemailkey at all, so sending one is rejected with400 invalid_bodyanddetails.fieldsreportsbody.email.statuscannot be set toconverted. 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 with400 invalid_body.- Any other pipeline status is accepted; there is no transition graph.
contacted,qualifying,qualified,invited,nurturingandunresponsiveare 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 nextupdated_sincepoll. - An actual status change emits
lead.status_changed. A PATCH that leaves the status unchanged emits nothing. - Returns
404when 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. |
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.
| Parameter | Type | Description |
|---|---|---|
customer_id | UUID | Required customer id in the path. |
lang | en | de | Display 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.
| 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. |
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_maxandprice_per_sqm_min/price_per_sqm_max: computed, read-only.micro_location: the micro location, read-only.visibility:offline,pre_saleoronline.images: project images withid,url,category,title,display_order.parking_spaces: the project's parking pool withid,identifier,parking_type,assigned_unit_id(fixed to a unit) ornull(optional),sale_price,monthly_rent,monthly_cost_deductible,monthly_cost_non_deductible,display_order.commission_rateand, per parking space,developer_purchase_priceonly withpurchase_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.
| 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. |
{
"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:readtherefore also grants their download link and OCR text. download_urlis valid for one hour; request the document again for a fresh link. It isnullwhen a link cannot be signed. In rare cases a link can return404shortly after an AI import, before the file has been moved into place.textis 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 documenttextisnull: the API never exposes the developer's OCR; only text your own account has already had read (the assistant) can appear.uploaded_byis the user who uploaded the file; for synced files (Investagon, TeamProQ) it is the user who ran the sync. It isnullfor 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
404for 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 return400 invalid_body. - Responses: create and update respond with the project or unit in its read shape (
langas 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_numberwithout the "WE" prefix; Vesturium Cloud adds it when displaying. - New units start with status
available. apartment_priceandkey_box_descriptioncan only be set after creation, withPATCH /units/{unit_id}. Anapartment_pricethen recomputespurchase_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:
POST /uploadswithentity_type(projectorunit),entity_id,kind(imagesordocuments) and up to 50files(filename,content_type,sizein bytes). The response (201) containssession_id,expires_atand, per file,staged_id,upload_urlandrequired_headers.PUTeach file to itsupload_url, with exactly therequired_headers.POST /uploads/{session_id}/finalizewithitems: per filestaged_id,category(for images the image category, e.g.exterior,interior,floor_plan; for documents the document type code, e.g.exposeordeclaration_of_division), optionallytitle,description,display_order,is_internal. The response lists the newid,kind(imageordocument) andcategoryper 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'sstatuscomes from the approved project review like its other building-level fields. - Buildings: a shared project lists only the units shared with this tenant;
unit_countcounts 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 arenull— the shared units themselves are still returned. - Developer contact:
bautraegeris 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_byisnullon the developer's documents, and so istextunless your own account has already had the file read — the developer's OCR is never exposed.parking_spaces[].idisnullon shared units — snapshots carry no parking-space id. - Filters:
zip_code,cityandqonGET /projectsmatch 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, soupdated_sincealso 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_priceis 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_priceis the share's handover price (ornull),commission_ratethe tenant's own commission rate. Shared projects carry neitherprice_min/price_maxnorparking_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:
| 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:
<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:
- Buffer the raw request body before parsing JSON.
- Verify
X-Vesturium-Signaturewith HMAC-SHA256. - Reject timestamps outside a 5 minute window.
- Store
X-Vesturium-Delivery-Idfor idempotency. - Return any
2xxstatus 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:
{
"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
idis 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_statusor the MyVesto sync timestamp, may not moveupdated_atand can therefore be missed by anupdated_sincepoll. 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 noupdated_at; use thedocuments_updated_atanddocument_countmarkers. The markers honour the samedocument_type/file_type/is_internalfilters, so a filtered sync converges too. A document upload does not move the project's ownupdated_ateither, soupdated_sincealone never surfaces it. Read the full list (withoutupdated_since) for document changes, and useupdated_sinceonly for the project and unit rows themselves.updated_atvalues are transaction-start times: poll with a 15-minute overlap and de-duplicate byid. - 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.