API v1: Floor plans
Attach floor plans to apartments, floors and buildings — one file at a time or 25 URLs per call — and rename, retag or delete them.
Requires the properties:read / properties:write scopes. Uploads also need the acting user's properties.create permission (the same permission the app's Media tab needs); editing needs properties.create or properties.manage, deleting properties.create or properties.delete.
How floor plans are stored
A building's plans all live on the building:
plan_type | What it is | Stored with |
|---|---|---|
unit | One apartment's plan (разпределение) | unit_property_id = the apartment |
floor | A whole-floor sheet (етажен план) | floor_number |
building | Building-level sheet (ситуация, разрез, фасада) | — |
A standalone property (no building) keeps its plans under its own id.
You never have to look the building up yourself: address the apartment id and the plan is stored on its building and tagged to it. Addressing a building id works too — then pass unit_property_id to tag one of its units.
Accepted files: PDF, JPEG, PNG, WebP, up to 50MB each. The content is checked by its bytes, not the declared type; Office documents (including macro-enabled .docm / .xlsm / .pptm), archives, HTML and SVG are refused.
Upload one plan
POST /api/v1/properties/{id}/floor-plans
{id} is an apartment, a building or a standalone property. Send either:
multipart/form-data — a local file:
curl -X POST "https://api.avensa.bg/api/v1/properties/$UNIT_ID/floor-plans" \ -H "Authorization: Bearer $AVENSA_API_KEY" \ -F "file=@plans/A-12.pdf" \ -F "display_name=Ап. 12 – разпределение" \ -F "floor_number=3" \ -F "skip_if_exists=true"
application/json — a file the server downloads:
{
"source_url": "https://files.example.com/plans/A-12.pdf",
"plan_type": "unit",
"display_name": "Ап. 12 – разпределение",
"floor_number": 3,
"skip_if_exists": true
}
| Field | Notes |
|---|---|
plan_type | unit (default when {id} is an apartment), floor, building (default for a building) |
unit_property_id | Tag a unit of the building {id}. Must be a unit of that building, in your account |
floor_number | Required for floor; defaults to the apartment's floor for unit / floor |
display_name, caption | Up to 200 characters |
sort_order | Position; appended at the end when omitted |
skip_if_exists | Return the existing plan instead of adding a duplicate when the same target (+ unit, + floor for floor sheets) already has a plan with this display_name or original file name |
source_url rules: https only, no credentials in the URL, the host must resolve to a public address (private, loopback, link-local and cloud-metadata addresses are refused), redirects are not followed (use a direct file link — a presigned S3/R2 URL works, a Dropbox/Google Drive share page does not), 30 second timeout.
Responses:
{
"status": "created",
"data": {
"id": "…",
"property_id": "<building id>",
"plan_type": "unit",
"unit_property_id": "<apartment id>",
"floor_number": 3,
"display_name": "Ап. 12 – разпределение",
"url": "https://…/floor-plans/….pdf",
"file_name": "A-12.pdf",
"mime_type": "application/pdf",
"sort_order": 4
},
"target": { "property_id": "<building id>", "plan_type": "unit", "unit_property_id": "<apartment id>", "floor_number": 3 }
}
201 created · 200 with "status": "skipped" when skip_if_exists matched · 400 bad field or refused URL · 403 missing permission or storage plan limit · 404 property not in your account · 413 over 50MB · 415 not a PDF/JPEG/PNG/WebP · 422 invalid target (unit not in this building, unit without a unit, floor without a floor) · 429 rate limit · 502 download or storage failure. Error bodies carry a machine-readable code.
Upload up to 25 plans from URLs
POST /api/v1/floor-plans/bulk
{
"skip_if_exists": true,
"items": [
{
"unit_property_id": "3f1c2b9e-5d7a-4c1e-9b3a-2a6f0e8d4c11",
"plan_type": "unit",
"source_url": "https://files.example.com/plans/A-12.pdf",
"display_name": "Ап. 12 – разпределение",
"floor_number": 3
},
{
"property_id": "9b0e…(building id)",
"plan_type": "floor",
"floor_number": 3,
"source_url": "https://files.example.com/plans/floor-3.png",
"display_name": "Етаж 3"
}
]
}
Each item needs source_url and unit_property_id and/or property_id. An item with only unit_property_id must point at an apartment (a unit of a building). Items are independent — partial success is normal. The response is 200 whenever the body is valid:
{
"data": {
"summary": { "total": 2, "created": 1, "skipped": 0, "failed": 1 },
"results": [
{ "index": 0, "status": "created", "floor_plan": { "id": "…" }, "target": { "…": "…" } },
{ "index": 1, "status": "failed", "error": { "code": "fetch_failed", "message": "Could not download source_url: HTTP 404: Not Found" } }
]
}
}
results[i] always describes items[i]. Retry only the failed items; with skip_if_exists: true a full re-run is also safe.
List plans
GET /api/v1/properties/{id}/floor-plans
For a building or standalone property: the plans stored on it. For an apartment: its own plans plus the building's plans tagged to it ("source": "unit") and the building's sheet for its floor ("source": "floor"). Add ?include_building=true for the building-level sheets ("source": "building"), ?plan_type= to filter.
Rename, retag or delete
PATCH /api/v1/floor-plans/{planId}
DELETE /api/v1/floor-plans/{planId}
PATCH accepts any of display_name, caption, floor_number, sort_order, plan_type, unit_property_id. A retag is validated exactly like an upload. DELETE removes the plan and its stored file.
Rate limits
Single uploads share an account budget of 60 per minute; bulk calls 10 per minute (250 plans/min). The general per-key limit (see Rate limits and errors) applies on top. On 429, wait for Retry-After seconds.
Worked example: a floor-plan manifest
A manifest with one row per file:
unit_property_id,plan_type,file,display_name,floor_number 3f1c2b9e-5d7a-4c1e-9b3a-2a6f0e8d4c11,unit,A-12.pdf,Ап. 12 – разпределение,3 7a2d44c0-1b9e-4f5a-8c3d-6e0f1a2b3c4d,unit,A-13.jpg,Ап. 13 – разпределение,3
Files on disk — one multipart call per row (1,800 files ≈ 30 minutes at 60/min):
import { readFile } from 'node:fs/promises';
import { parse } from 'csv-parse/sync';
const API = 'https://api.avensa.bg/api/v1';
const headers = { Authorization: `Bearer ${process.env.AVENSA_API_KEY}` };
const rows = parse(await readFile('manifest.csv'), { columns: true });
for (const row of rows) {
const form = new FormData();
form.set('file', new Blob([await readFile(`plans/${row.file}`)]), row.file);
form.set('plan_type', row.plan_type);
form.set('display_name', row.display_name);
if (row.floor_number) form.set('floor_number', row.floor_number);
form.set('skip_if_exists', 'true');
const res = await fetch(`${API}/properties/${row.unit_property_id}/floor-plans`, {
method: 'POST',
headers,
body: form,
});
if (res.status === 429) {
await new Promise((r) => setTimeout(r, Number(res.headers.get('Retry-After') ?? 5) * 1000));
rows.push(row); // try again at the end
continue;
}
const body = await res.json();
console.log(row.file, res.status, body.status ?? body.code);
}
Files already hosted (for example uploaded to a bucket at https://files.example.com/plans/) — 25 rows per bulk call:
const BASE = 'https://files.example.com/plans/';
for (let i = 0; i < rows.length; i += 25) {
const page = rows.slice(i, i + 25);
const res = await fetch(`${API}/floor-plans/bulk`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
skip_if_exists: true,
items: page.map((row) => ({
unit_property_id: row.unit_property_id,
plan_type: row.plan_type,
source_url: BASE + encodeURIComponent(row.file),
display_name: row.display_name,
floor_number: row.floor_number ? Number(row.floor_number) : null,
})),
}),
});
const { data } = await res.json();
data.results
.filter((r) => r.status === 'failed')
.forEach((r) => console.warn(page[r.index].file, r.error.code, r.error.message));
}
Because every call uses skip_if_exists, the whole manifest can be re-run after fixing the failed rows — plans already attached are reported as skipped and are not downloaded again.
The same operation is available to the AI assistant and MCP clients as the upload_floor_plans tool (URLs only, up to 25 per call, confirmed before it runs).