Guides
Fetch the occupancy rate of all garage parks daily
Create a token with the scope occupancy:read, then call GET /v1/occupancy daily (all pages).
curl -s "https://api.manage2hero.com/v1/occupancy?limit=200" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/occupancy?limit=200" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/occupancy?limit=200', {
headers: { Authorization: `Bearer ${process.env.M2H_API_TOKEN}` },
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport os, requests
res = requests.get(
"https://api.manage2hero.com/v1/occupancy?limit=200",
headers={"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}"},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])Occupancy rate in Excel (Power Query) — without programming
Excel → Data → Get Data → From Other Sources → Blank Query → Advanced Editor, paste the following text and replace YOUR_TOKEN. Then “Close & Load”; with “Refresh All” you can fetch the latest figures at any time.
let
Token = "YOUR_TOKEN",
Response = Json.Document(Web.Contents("https://api.manage2hero.com/v1/occupancy",
[Query = [limit = "200"], Headers = [Authorization = "Bearer " & Token]])),
Items = Response[data][items],
Table = Table.FromRecords(Items),
Types = Table.TransformColumnTypes(Table, {
{"units_total", Int64.Type}, {"units_rented", Int64.Type},
{"occupancy_rate", Percentage.Type}})
in
Typesoccupancy:read. On the first refresh Excel asks for the privacy level of the source — choose “Organizational”.Occupancy rate history (months or days)
With the same scope occupancy:read, GET /v1/occupancy/history returns the development over time — without location_id for the whole organization, with location_id for one garage park. Measured hourly; the history starts when the endpoint was introduced (history_starts_on).
curl -s "https://api.manage2hero.com/v1/occupancy/history?from=2026-01-01&to=2026-12-31&interval=month" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/occupancy/history?from=2026-01-01&to=2026-12-31&interval=month" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/occupancy/history?from=2026-01-01&to=2026-12-31&interval=month', {
headers: { Authorization: `Bearer ${process.env.M2H_API_TOKEN}` },
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport os, requests
res = requests.get(
"https://api.manage2hero.com/v1/occupancy/history?from=2026-01-01&to=2026-12-31&interval=month",
headers={"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}"},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])Breakdown by garage type for today: GET /v1/occupancy?group_by=storage_type.
Revenue per month in Excel or Power BI
Token with the scope bookings:read. GET /v1/revenue returns the same figure as the dashboard of the management app; group_by=month gives one row per month (also months without revenue).
curl -s "https://api.manage2hero.com/v1/revenue?from=2026-01-01&to=2026-12-31&group_by=month" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/revenue?from=2026-01-01&to=2026-12-31&group_by=month" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/revenue?from=2026-01-01&to=2026-12-31&group_by=month', {
headers: { Authorization: `Bearer ${process.env.M2H_API_TOKEN}` },
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport os, requests
res = requests.get(
"https://api.manage2hero.com/v1/revenue?from=2026-01-01&to=2026-12-31&group_by=month",
headers={"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}"},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])let
Token = "YOUR_TOKEN",
Response = Json.Document(Web.Contents("https://api.manage2hero.com/v1/revenue",
[Query = [from = "2026-01-01", to = "2026-12-31", group_by = "month"],
Headers = [Authorization = "Bearer " & Token]])),
Table = Table.FromRecords(Response[data][groups]),
Types = Table.TransformColumnTypes(Table, {{"revenue_net", Currency.Type}})
in
TypesGET /v1/bookings.Reconcile incoming payments with the financial accounting
Token with payments:read (and bookings:read for the counter-postings in the journal). GET /v1/payments names for each incoming payment which bookings and leases it is allocated to and what is still open (unallocated_amount). If your system needs name, IBAN and payment reference of the payers, additionally grant payments:read_counterparty — only then does the response contain these fields.
curl -s "https://api.manage2hero.com/v1/payments?from=2026-09-01&to=2026-09-30&match_status=unmatched" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/payments?from=2026-09-01&to=2026-09-30&match_status=unmatched" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/payments?from=2026-09-01&to=2026-09-30&match_status=unmatched', {
headers: { Authorization: `Bearer ${process.env.M2H_API_TOKEN}` },
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport os, requests
res = requests.get(
"https://api.manage2hero.com/v1/payments?from=2026-09-01&to=2026-09-30&match_status=unmatched",
headers={"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}"},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])Open items and dunning status, e.g. for a collection agency
Create a token with only open_items:read and dunning:read (add tenants:read for names and contact details). GET /v1/dunning/leases?min_level=3 returns all leases that have reached at least the third dunning level — with open total, fees and the individual receivables.
curl -s "https://api.manage2hero.com/v1/dunning/leases?min_level=3&limit=100" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/dunning/leases?min_level=3&limit=100" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/dunning/leases?min_level=3&limit=100', {
headers: { Authorization: `Bearer ${process.env.M2H_API_TOKEN}` },
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport os, requests
res = requests.get(
"https://api.manage2hero.com/v1/dunning/leases?min_level=3&limit=100",
headers={"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}"},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])Totals and aging for liquidity planning: GET /v1/open-items/summary?group_by=location. The individual items are returned by GET /v1/open-items.
Export the current tenant list of a garage park
Token with tenants:read and locations:read. First determine the id of the garage park via GET /v1/locations, then:
curl -s "https://api.manage2hero.com/v1/locations/6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b/tenants?limit=200" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/locations/6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b/tenants?limit=200" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/locations/6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b/tenants?limit=200', {
headers: { Authorization: `Bearer ${process.env.M2H_API_TOKEN}` },
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport os, requests
res = requests.get(
"https://api.manage2hero.com/v1/locations/6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b/tenants?limit=200",
headers={"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}"},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])import csv, os, requests
TOKEN = os.environ["M2H_API_TOKEN"]
LOCATION = "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b"
rows, cursor = [], None
while True:
params = {"limit": 200, **({"cursor": cursor} if cursor else {})}
body = requests.get(f"https://api.manage2hero.com/v1/locations/{LOCATION}/tenants",
headers={"Authorization": f"Bearer {TOKEN}"}, params=params, timeout=30).json()
if not body["success"]:
raise RuntimeError(body["error"]["code"])
rows += body["data"]["items"]
cursor = body["data"]["next_cursor"]
if not cursor:
break
with open("tenants.csv", "w", newline="", encoding="utf-8") as f:
w = csv.writer(f, delimiter=";")
w.writerow(["Unit", "Lease", "Status", "Customer number", "First name", "Last name", "Company", "Email", "Phone"])
for r in rows:
t = r["tenant"]
w.writerow([r["unit"]["number"], r["lease"]["contract_number"], r["lease"]["status"],
t["customer_number"], t["first_name"], t["last_name"], t["company"], t["email"], t["phone"]])Import existing tenants
The import via the API is the same import as under Administration → Data import — just with JSON instead of a file. Procedure:
1. Token with tenants:import (and locations:read to find the id of the garage park).
2. POST /v1/tenant-imports with exactly one garage park and 1–500 records. Each record is validated like a row of the import file; the response lists errors and warnings per record (e.g. “a tenant with this email already exists”).
3. In the management app the import appears in the import wizard. Invalid records can be corrected there. You take the tenants over there or via the API (POST /v1/tenant-imports/{id}/commit, see below).
4. GET /v1/tenant-imports/{id} shows the status; after the takeover it contains tenant_id and lease_id.
Fields of a record
The same field names as in the sample file of the file import. Text as a string, amounts and numbers as a JSON number (89.5), yes/no as true/false, dates as YYYY-MM-DD. Required are salutation, first name, last name, date of birth, email, street, house number, postal code and city (with the salutation “Firma” additionally the company name). Salutation values are the German terms Herr, Frau, Firma or Divers. The lease number has the form code unit-seq. no., e.g. HH01 42-1; the import wizard uses it to assign the unit automatically.
| Field | Type | Required |
|---|---|---|
salutation | text: Herr, Frau, Firma or Divers | yes |
first_name | text | yes |
last_name | text | yes |
company_name | text | |
date_of_birth | date YYYY-MM-DD | yes |
email | text (email) | yes |
street | text | yes |
house_number | text | yes |
postal_code | text | yes |
city | text | yes |
country | text | |
phone | text | |
mobile | text | |
contract_number | text (code unit-no.) | |
lease_start | date YYYY-MM-DD | |
rent_net | number | |
deposit | number | |
deposit_paid | true/false | |
move_in_date | date YYYY-MM-DD | |
has_electricity_230v | true/false | |
has_electricity_400v | true/false | |
electricity_meter_fee | number | |
electricity_advance_payment | number | |
electricity_price_kwh_net | number | |
notice_period_months | integer | |
minimum_lease_months | integer | |
meter_reading | number | |
meter_reading_date | date YYYY-MM-DD |
iban, bic, account_holder) cannot be submitted via the API — a request containing them is rejected (field_not_allowed). Enter bank details in the management app.Retrying without a duplicate import
Every request needs the Idempotency-Key header — a key of your own per operation, e.g. a UUID. If you send the same request with the same key again after an interruption (timeout, network error), you get the same import back (status 200 instead of 201) — no second import is created. The same key with different content is rejected (idempotency_conflict).
curl -s -X POST "https://api.manage2hero.com/v1/tenant-imports" \
-H "Authorization: Bearer $M2H_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data @import.json$headers = @{
Authorization = "Bearer $env:M2H_API_TOKEN"
"Idempotency-Key" = [guid]::NewGuid().ToString()
}
$body = Get-Content -Raw -Encoding UTF8 import.json
Invoke-RestMethod -Method Post -Uri "https://api.manage2hero.com/v1/tenant-imports" -Headers $headers -ContentType "application/json; charset=utf-8" -Body $bodyimport { createHash, randomUUID } from 'node:crypto';
import { linkSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
// Generate the key ONCE and store it durably BEFORE sending (here: a file, one per operation). After a
// restart the same key is read. For a new, different operation choose a new file name. Delete the file only after the final response.
const VORGANG = 'auftrag-2026/1001'; // set YOUR OWN id per operation (e.g. order id); never reuse it for two operations
const VORGANG_ID = createHash('sha256').update(VORGANG).digest('hex').slice(0, 16); // hashed for the file name: any characters (/, \ …) in the id are harmless
const KEY_FILE = `idempotency-key-post-v1-tenant-imports-${VORGANG_ID}.txt`;
const neu = randomUUID();
const tmp = `${KEY_FILE}.${neu}`; // unique name per attempt, never overwrite an existing file
writeFileSync(tmp, neu, { flag: 'wx' }); // write it completely to a temporary file first
try {
linkSync(tmp, KEY_FILE); // publishes it atomically without overwriting an existing one
} catch (e) {
if (e.code !== 'EEXIST') throw e; // an existing key stays unchanged
} finally {
unlinkSync(tmp);
}
const idempotencyKey = readFileSync(KEY_FILE, 'utf8').trim();
const res = await fetch('https://api.manage2hero.com/v1/tenant-imports', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.M2H_API_TOKEN}`,
'Content-Type': 'application/json',
// On every retry (e.g. after a timeout) send this stored key again.
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"name": "Übernahme aus Altsystem",
"records": [
{
"salutation": "Herr",
"first_name": "Max",
"last_name": "Mustermann",
"date_of_birth": "1980-05-17",
"email": "max.mustermann@example.org",
"street": "Musterstraße",
"house_number": "1",
"postal_code": "22297",
"city": "Hamburg",
"phone": "+49 40 1234567",
"contract_number": "HH01 42-1",
"lease_start": "2023-04-01",
"rent_net": 89.5,
"deposit": 150,
"has_electricity_230v": true,
"electricity_advance_payment": 10,
"electricity_meter_fee": 2.5
}
]
}),
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const { id, records } = body.data; // import id and result per recordimport hashlib, json, os, uuid, requests
from pathlib import Path
# Generate the key ONCE and store it durably BEFORE sending (here: a file, one per operation). After a
# restart the same key is read. For a new, different operation choose a new file name. Delete the file only after the final response.
VORGANG = "auftrag-2026/1001" # set YOUR OWN id per operation (e.g. order id); never reuse it for two operations
VORGANG_ID = hashlib.sha256(VORGANG.encode()).hexdigest()[:16] # hashed for the file name: any characters (/, \ …) in the id are harmless
KEY_FILE = Path(f"idempotency-key-post-v1-tenant-imports-{VORGANG_ID}.txt")
neu = str(uuid.uuid4())
tmp = Path(f"{KEY_FILE}.{neu}") # unique name per attempt, never overwrite an existing file
with tmp.open("x") as f:
f.write(neu) # write it completely to a temporary file first
try:
os.link(tmp, KEY_FILE) # publishes it atomically without overwriting an existing one
except FileExistsError:
pass # an existing key stays unchanged
finally:
tmp.unlink()
idempotency_key = KEY_FILE.read_text().strip()
res = requests.post(
"https://api.manage2hero.com/v1/tenant-imports",
headers={
"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}",
"Idempotency-Key": idempotency_key,
},
json=json.load(open("import.json", encoding="utf-8")),
timeout=60,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"]["id"], body["data"]["records"])Check the status
curl -s "https://api.manage2hero.com/v1/tenant-imports/01923f4e-7a2b-7c3d-8e4f-5a6b7c8d9e0f" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/tenant-imports/01923f4e-7a2b-7c3d-8e4f-5a6b7c8d9e0f" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/tenant-imports/01923f4e-7a2b-7c3d-8e4f-5a6b7c8d9e0f', {
headers: { Authorization: `Bearer ${process.env.M2H_API_TOKEN}` },
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport os, requests
res = requests.get(
"https://api.manage2hero.com/v1/tenant-imports/01923f4e-7a2b-7c3d-8e4f-5a6b7c8d9e0f",
headers={"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}"},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])Cancel an import
Release an import you no longer need (wrong assignment, test run) with DELETE /v1/tenant-imports/{id}. This deletes the import with all its records and gives its place in the limit of open imports back immediately — otherwise it would stay occupied until the retention period (30 to 90 days) ends. The scope tenants:import is enough; in addition, the creator of the token needs Data import with full access in the management app — as for deleting an import there (otherwise 403 insufficient_permissions). The response is { "session_id": "…", "deleted": true }.
Only an import that was created via the API can be cancelled. Unknown or foreign imports, imports uploaded in the management app and a second call return 404 not_found. If at least one record has already been taken over, nothing is deleted (409 session_has_transferred_records): tenants and leases refer to the import. Someone who is editing the import in the import wizard at that moment loses it — coordinate for imports you share. Cancelling an import that is less than an hour old counts against the hourly limit of new imports per token (429 import_rate_limited). In total, a token can therefore create at most twice as many new imports per hour as the limit: once up to the limit, and once more after cancelling and creating again.
curl -s -X DELETE "https://api.manage2hero.com/v1/tenant-imports/01923f4e-7a2b-7c3d-8e4f-5a6b7c8d9e0f" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Method Delete -Uri "https://api.manage2hero.com/v1/tenant-imports/01923f4e-7a2b-7c3d-8e4f-5a6b7c8d9e0f" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/tenant-imports/01923f4e-7a2b-7c3d-8e4f-5a6b7c8d9e0f', {
method: 'DELETE',
headers: { Authorization: `Bearer ${process.env.M2H_API_TOKEN}` },
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport os, requests
res = requests.delete(
"https://api.manage2hero.com/v1/tenant-imports/01923f4e-7a2b-7c3d-8e4f-5a6b7c8d9e0f",
headers={"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}"},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])Retrieve and download documents
With documents:read you list the tenant and location documents of your organization: GET /v1/documents (filters tenant_id, lease_id, location_id, category and the time filter by creation date). The response gives file details only — name, size, type, date and assignment — never the storage location. Confidential kinds such as ID documents and credit documents are excluded, and so are archived documents. Change sync (/v1/documents/changes) also reports when a final settlement is cancelled (settlement_cancelled).
You fetch the file itself with POST /v1/documents/{id}/download-link (additionally documents:download). The response contains a link that is valid for 5 minutes — fetch the file right away and do not store the link. Every creation counts as a write call (20 instead of 120 per minute) and appears in the access log. If the entry has no file any more, the API answers 404 file_not_available.
Retrieve emails sent to tenants
GET /v1/emails (scope emails:read) shows the system emails your organization sent to tenants — contracts, statements, invoices, dunning notices, terminations, amendments, appointments, protocols, decisions and damage reports (38 approved templates, the same as in the email history of the tenant portal). Filters: tenant_id, lease_id, location_id (all tenants of the location), template_key, status (sent, delivered, opened) and the time filter by sending time. An email has a subject, an excerpt of at most 200 characters, status and timestamp. The full text as plain text (body_text) is only returned with the add-on scope emails:read_body; treat it as text, not HTML, when you display it.
Never included: the recipient address, identifiers of the mail provider, error messages, emails with credentials or one-time links, messages to prospects, and the emails about API access itself (which the platform operator manages). The tenant reference is set when the email is sent; older emails without it do not appear.
Read the access log
GET /v1/usage (scope usage:read) shows the calls of all tokens of your organization, newest first: token, method, path (without query string), status, timestamp and request_id. Filters: token_id, status (a code such as 404 or a class such as 4xx), path_prefix and the time filter. IP addresses, user agents and request contents never appear. Calls are kept for 90 days. For rejected sign-ins the caller chooses the path itself — never display it as HTML without checking.
Take over via the API
With a token with tenants:import_commit you take over an API import without the import wizard: per record a tenant, a running lease with its charges, the initial meter reading and the monthly recurring posting from the first of next month are created — nothing is booked retroactively. The creator of the token needs Data import (edit) and Leasing process (edit) in the management app; they are recorded as the author of the created data.
Records without a unit are assigned via the lease number like in the wizard (HH01 42-1 → unit 42). With rows you take over selected rows only, without rows all of them. on_duplicate controls records whose email already belongs to a tenant: reject (default) does not take them over, reuse attaches the lease to exactly that tenant, create_new creates a new tenant.
curl -s -X POST "https://api.manage2hero.com/v1/tenant-imports/01923f4e-7a2b-7c3d-8e4f-5a6b7c8d9e0f/commit" \
-H "Authorization: Bearer $M2H_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{"on_duplicate": "reject"}'The response lists per row created (with tenant_id, lease_id), skipped, failed (with a code, e.g. not_assigned, unit_occupied) or already_transferred. A repeated call — e.g. after a timeout — is harmless: rows already taken over are not created again and report already_transferred with tenant_id, lease_id and contract_number. If a row reports record_changed, it was changed in the management app in the meantime; simply call the takeover again. A takeover is final. The request body is at most 64 KB (413 payload_too_large).
on_duplicate: reject the response reports skipped with duplicate_tenant — in the import (GET /v1/tenant-imports/{id}) the row nevertheless stays with status pending and error_code: tenant_decision_required. It is not counted as taken over; you can decide it in the import wizard or by calling again with reuse or create_new.Read contracts
GET /v1/contracts and GET /v1/contracts/{id} (scope contracts:read) return contracts with status, term, deposit, signature state, terminations and amendments (electricity, water). The creator of the token needs Leasing process and Tenants, each at least view. Filters: tenant_id, location_id (contracts on garages of the location) and status. With ?format=csv or xlsx you get the list as a file.
The time filter refers to the last change (updated_at); without any, the last 30 days apply. For all contracts use period=all, for a sync since a point in time use from=<timestamp>. updated_at is the newest of the contract, its terminations, amendments and signatures.
curl -s "https://api.manage2hero.com/v1/contracts?period=last_7_days&status=termination_received" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/contracts?period=last_7_days&status=termination_received" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/contracts?period=last_7_days&status=termination_received', {
headers: { Authorization: `Bearer ${process.env.M2H_API_TOKEN}` },
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport os, requests
res = requests.get(
"https://api.manage2hero.com/v1/contracts?period=last_7_days&status=termination_received",
headers={"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}"},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])GET /v1/contracts/changes/cursor (starting cursor, before the full fetch), GET /v1/contracts/changes (IDs changed since the cursor) and GET /v1/contracts/by-ids (current state, at most 100 IDs). It also reports what a time filter never shows: resetting a countersignature, deleting a termination or an amendment, deleted contracts and contracts marked for deletion (the ID is then missing from by-ids). Procedure and errors (410 cursor_expired after 90 days) as for the other resources, see "Change sync". The signed PDF cannot be retrieved through the API; the contract text, hashes, bank details and free-text notes of terminations never appear. Contracts marked for deletion are missing from the list.Read units and garages
GET /v1/units and GET /v1/units/{id} (scope units:read; the creator needs Garages/units and Locations, each view) show the individual spaces with status, dimensions, equipment, current contract, tenant and base rent. Filters: location_id, storage_type_id and status (available, reserved, rented, blocked).
curl -s "https://api.manage2hero.com/v1/units?status=available&limit=200" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/units?status=available&limit=200" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/units?status=available&limit=200', {
headers: { Authorization: `Bearer ${process.env.M2H_API_TOKEN}` },
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport os, requests
res = requests.get(
"https://api.manage2hero.com/v1/units?status=available&limit=200",
headers={"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}"},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])Difference for this list: without a time filter the list is complete (master data); a time filter refers to the last change of the unit itself. An expired reservation that has not been released yet already appears as available. The reason for a block is never returned.
The current contract is the contract that runs today (Europe/Berlin): status from “moved in” to “settlement”, start date reached. Future, ended and deletion-marked contracts never appear. current_rent is the base rent without utilities, electricity, water and deposit (net per month, vat_rate as a fraction, gross rounded to the cent). It is null when no contract is running or the contract has no valid rate today. Of the tenant you only see the identifier and display name; details are available through GET /v1/tenants/{id}.
updated_at of the unit.Utility statements and meters
Read with utility:read: GET /v1/utility/statements (electricity and water statements per tenant and contract), GET /v1/meters (meters) and GET /v1/meters/{id}/readings (meter readings). The creator needs Meter readings, Electricity and Water, each at least view. All three lists are also available as CSV or Excel.
Statements
Filters: year (year in which the billing period ends), status (finalized, sent, cancelled, per line), tenant_id, lease_id, location_id and updated_since. The time filter refers to the creation time; with year, lease_id or updated_since and without any time form, period=all applies, otherwise the last 30 days. Amounts are the stored values of the management app; the API recalculates nothing. Drafts never appear.
curl -s "https://api.manage2hero.com/v1/utility/statements?year=2025" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/utility/statements?year=2025" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/utility/statements?year=2025', {
headers: { Authorization: `Bearer ${process.env.M2H_API_TOKEN}` },
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport os, requests
res = requests.get(
"https://api.manage2hero.com/v1/utility/statements?year=2025",
headers={"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}"},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])updated_at, then id) in ascending order; the cursor carries exactly this pair. A row that changes while you page moves to the end and is still delivered. Meter readings stay “newest reading first”. Meters have no time filter (master data).Enter a meter reading
POST /v1/meters/{id}/readings (scope utility:write in addition to utility:read; the creator needs edit) enters an interim reading. Required are the header Idempotency-Key, reading_date (a day, at most one day in the future) and meter_value (a number from 0). On the main electricity meter obis_code is required as well (1.8.0, 1.8.1, 1.8.2 import; 2.8.0, 2.8.1, 2.8.2 feed-in), on all other meters it is not allowed. The API determines the contract from the reading date like the management app, and the reader is the creator of the token. Move-in and move-out protocols, corrections and photos do not go through the API; deactivated meters accept no new readings.
curl -s -X POST "https://api.manage2hero.com/v1/meters/5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a/readings" \
-H "Authorization: Bearer $M2H_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data '{"reading_date":"2026-10-01","meter_value":1873.2}'$headers = @{
Authorization = "Bearer $env:M2H_API_TOKEN"
"Idempotency-Key" = [guid]::NewGuid().ToString()
}
$body = @'
{
"reading_date": "2026-10-01",
"meter_value": 1873.2
}
'@
Invoke-RestMethod -Method Post -Uri "https://api.manage2hero.com/v1/meters/5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a/readings" -Headers $headers -ContentType "application/json; charset=utf-8" -Body $bodyimport { createHash, randomUUID } from 'node:crypto';
import { linkSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
// Generate the key ONCE and store it durably BEFORE sending (here: a file, one per operation). After a
// restart the same key is read. For a new, different operation choose a new file name. Delete the file only after the final response.
const VORGANG = 'auftrag-2026/1001'; // set YOUR OWN id per operation (e.g. order id); never reuse it for two operations
const VORGANG_ID = createHash('sha256').update(VORGANG).digest('hex').slice(0, 16); // hashed for the file name: any characters (/, \ …) in the id are harmless
const KEY_FILE = `idempotency-key-post-v1-meters-5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a-readings-${VORGANG_ID}.txt`;
const neu = randomUUID();
const tmp = `${KEY_FILE}.${neu}`; // unique name per attempt, never overwrite an existing file
writeFileSync(tmp, neu, { flag: 'wx' }); // write it completely to a temporary file first
try {
linkSync(tmp, KEY_FILE); // publishes it atomically without overwriting an existing one
} catch (e) {
if (e.code !== 'EEXIST') throw e; // an existing key stays unchanged
} finally {
unlinkSync(tmp);
}
const idempotencyKey = readFileSync(KEY_FILE, 'utf8').trim();
const res = await fetch('https://api.manage2hero.com/v1/meters/5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a/readings', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.M2H_API_TOKEN}`,
'Content-Type': 'application/json',
// On every retry (e.g. after a timeout) send this stored key again.
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
"reading_date": "2026-10-01",
"meter_value": 1873.2
}),
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport hashlib, os, uuid, requests
from pathlib import Path
# Generate the key ONCE and store it durably BEFORE sending (here: a file, one per operation). After a
# restart the same key is read. For a new, different operation choose a new file name. Delete the file only after the final response.
VORGANG = "auftrag-2026/1001" # set YOUR OWN id per operation (e.g. order id); never reuse it for two operations
VORGANG_ID = hashlib.sha256(VORGANG.encode()).hexdigest()[:16] # hashed for the file name: any characters (/, \ …) in the id are harmless
KEY_FILE = Path(f"idempotency-key-post-v1-meters-5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a-readings-{VORGANG_ID}.txt")
neu = str(uuid.uuid4())
tmp = Path(f"{KEY_FILE}.{neu}") # unique name per attempt, never overwrite an existing file
with tmp.open("x") as f:
f.write(neu) # write it completely to a temporary file first
try:
os.link(tmp, KEY_FILE) # publishes it atomically without overwriting an existing one
except FileExistsError:
pass # an existing key stays unchanged
finally:
tmp.unlink()
idempotency_key = KEY_FILE.read_text().strip()
res = requests.post(
"https://api.manage2hero.com/v1/meters/5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a/readings",
headers={
"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}",
"Idempotency-Key": idempotency_key,
},
json={
"reading_date": "2026-10-01",
"meter_value": 1873.2
},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])Response 201 with the created reading (source: "api"). A retry with the same key and the same content returns 200 with the same reading (nothing is created twice), different content gives 409 idempotency_conflict. The key applies per token.
Plausibility check as in the management app: a value below the previous one or above a later one (chronological_order) and a date in an already billed period (in_billed_period) give 400 validation_error; the codes are in error.details.plausibility. A warning (unrealistic_consumption) also gives 400, with error.details.plausibility_warnings; confirm it with force_warnings: true (with the same or a new key). If the reading is deleted in the management app later, a retry with the same key creates it again. If the meter changes concurrently, the API re-checks up to three times on its own; after that 503 service_unavailable follows and you send again with the same key.
Request and download a DATEV export
With datev:export you start the booking batch of a location and month. For the scope itself the creator needs Financial accounting (view); the DATEV export in the management app checks the same finance permission. Only someone with API access at full level in the management app may create a token (this applies to every token). There is no separate add-on scope for the download; because the file contains booking data, keep the token safe. The export writes nothing into the accounting; only the history is recorded.
POST /v1/datev/exportswithIdempotency-Keyandlocation_id,year(2020–2099),month(1–12), optionallyformat(datevas default in Windows-1252, orcsvin UTF-8 with BOM). The export runs immediately; the 201 response showsstatus = ready,row_countandfile_available.- With
status = failed,failure_codenames the reason (e.g. missing consultant or client number at the location, a booking without a safe tax key, an empty batch). After the correction, request again with a new key. POST /v1/datev/exports/{id}/download-linkreturns a link that is valid for 5 minutes (never longer than the file is still kept);expires_in_secondsstates the actual duration. Download the file right away and mind the character set inencoding.
curl -s -X POST "https://api.manage2hero.com/v1/datev/exports" \
-H "Authorization: Bearer $M2H_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data '{"location_id":"6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b","year":2026,"month":9,"format":"datev"}'$headers = @{
Authorization = "Bearer $env:M2H_API_TOKEN"
"Idempotency-Key" = [guid]::NewGuid().ToString()
}
$body = @'
{
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"year": 2026,
"month": 9,
"format": "datev"
}
'@
Invoke-RestMethod -Method Post -Uri "https://api.manage2hero.com/v1/datev/exports" -Headers $headers -ContentType "application/json; charset=utf-8" -Body $bodyimport { createHash, randomUUID } from 'node:crypto';
import { linkSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
// Generate the key ONCE and store it durably BEFORE sending (here: a file, one per operation). After a
// restart the same key is read. For a new, different operation choose a new file name. Delete the file only after the final response.
const VORGANG = 'auftrag-2026/1001'; // set YOUR OWN id per operation (e.g. order id); never reuse it for two operations
const VORGANG_ID = createHash('sha256').update(VORGANG).digest('hex').slice(0, 16); // hashed for the file name: any characters (/, \ …) in the id are harmless
const KEY_FILE = `idempotency-key-post-v1-datev-exports-${VORGANG_ID}.txt`;
const neu = randomUUID();
const tmp = `${KEY_FILE}.${neu}`; // unique name per attempt, never overwrite an existing file
writeFileSync(tmp, neu, { flag: 'wx' }); // write it completely to a temporary file first
try {
linkSync(tmp, KEY_FILE); // publishes it atomically without overwriting an existing one
} catch (e) {
if (e.code !== 'EEXIST') throw e; // an existing key stays unchanged
} finally {
unlinkSync(tmp);
}
const idempotencyKey = readFileSync(KEY_FILE, 'utf8').trim();
const res = await fetch('https://api.manage2hero.com/v1/datev/exports', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.M2H_API_TOKEN}`,
'Content-Type': 'application/json',
// On every retry (e.g. after a timeout) send this stored key again.
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"year": 2026,
"month": 9,
"format": "datev"
}),
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport hashlib, os, uuid, requests
from pathlib import Path
# Generate the key ONCE and store it durably BEFORE sending (here: a file, one per operation). After a
# restart the same key is read. For a new, different operation choose a new file name. Delete the file only after the final response.
VORGANG = "auftrag-2026/1001" # set YOUR OWN id per operation (e.g. order id); never reuse it for two operations
VORGANG_ID = hashlib.sha256(VORGANG.encode()).hexdigest()[:16] # hashed for the file name: any characters (/, \ …) in the id are harmless
KEY_FILE = Path(f"idempotency-key-post-v1-datev-exports-{VORGANG_ID}.txt")
neu = str(uuid.uuid4())
tmp = Path(f"{KEY_FILE}.{neu}") # unique name per attempt, never overwrite an existing file
with tmp.open("x") as f:
f.write(neu) # write it completely to a temporary file first
try:
os.link(tmp, KEY_FILE) # publishes it atomically without overwriting an existing one
except FileExistsError:
pass # an existing key stays unchanged
finally:
tmp.unlink()
idempotency_key = KEY_FILE.read_text().strip()
res = requests.post(
"https://api.manage2hero.com/v1/datev/exports",
headers={
"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}",
"Idempotency-Key": idempotency_key,
},
json={
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"year": 2026,
"month": 9,
"format": "datev"
},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])curl -s -X POST "https://api.manage2hero.com/v1/datev/exports/2f6c1d3e-8a4b-4c5d-9e7f-0a1b2c3d4e5f/download-link" \
-H "Authorization: Bearer $M2H_API_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"$headers = @{
Authorization = "Bearer $env:M2H_API_TOKEN"
"Idempotency-Key" = [guid]::NewGuid().ToString()
}
Invoke-RestMethod -Method Post -Uri "https://api.manage2hero.com/v1/datev/exports/2f6c1d3e-8a4b-4c5d-9e7f-0a1b2c3d4e5f/download-link" -Headers $headersimport { createHash, randomUUID } from 'node:crypto';
import { linkSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
// Generate the key ONCE and store it durably BEFORE sending (here: a file, one per operation). After a
// restart the same key is read. For a new, different operation choose a new file name. Delete the file only after the final response.
const VORGANG = 'auftrag-2026/1001'; // set YOUR OWN id per operation (e.g. order id); never reuse it for two operations
const VORGANG_ID = createHash('sha256').update(VORGANG).digest('hex').slice(0, 16); // hashed for the file name: any characters (/, \ …) in the id are harmless
const KEY_FILE = `idempotency-key-post-v1-datev-exports-2f6c1d3e-8a4b-4c5d-9e7f-0a1b2c3d4e5f-download-link-${VORGANG_ID}.txt`;
const neu = randomUUID();
const tmp = `${KEY_FILE}.${neu}`; // unique name per attempt, never overwrite an existing file
writeFileSync(tmp, neu, { flag: 'wx' }); // write it completely to a temporary file first
try {
linkSync(tmp, KEY_FILE); // publishes it atomically without overwriting an existing one
} catch (e) {
if (e.code !== 'EEXIST') throw e; // an existing key stays unchanged
} finally {
unlinkSync(tmp);
}
const idempotencyKey = readFileSync(KEY_FILE, 'utf8').trim();
const res = await fetch('https://api.manage2hero.com/v1/datev/exports/2f6c1d3e-8a4b-4c5d-9e7f-0a1b2c3d4e5f/download-link', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.M2H_API_TOKEN}`,
// On every retry (e.g. after a timeout) send this stored key again.
'Idempotency-Key': idempotencyKey,
},
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport hashlib, os, uuid, requests
from pathlib import Path
# Generate the key ONCE and store it durably BEFORE sending (here: a file, one per operation). After a
# restart the same key is read. For a new, different operation choose a new file name. Delete the file only after the final response.
VORGANG = "auftrag-2026/1001" # set YOUR OWN id per operation (e.g. order id); never reuse it for two operations
VORGANG_ID = hashlib.sha256(VORGANG.encode()).hexdigest()[:16] # hashed for the file name: any characters (/, \ …) in the id are harmless
KEY_FILE = Path(f"idempotency-key-post-v1-datev-exports-2f6c1d3e-8a4b-4c5d-9e7f-0a1b2c3d4e5f-download-link-{VORGANG_ID}.txt")
neu = str(uuid.uuid4())
tmp = Path(f"{KEY_FILE}.{neu}") # unique name per attempt, never overwrite an existing file
with tmp.open("x") as f:
f.write(neu) # write it completely to a temporary file first
try:
os.link(tmp, KEY_FILE) # publishes it atomically without overwriting an existing one
except FileExistsError:
pass # an existing key stays unchanged
finally:
tmp.unlink()
idempotency_key = KEY_FILE.read_text().strip()
res = requests.post(
"https://api.manage2hero.com/v1/datev/exports/2f6c1d3e-8a4b-4c5d-9e7f-0a1b2c3d4e5f/download-link",
headers={
"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}",
"Idempotency-Key": idempotency_key,
},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])Read the state with GET /v1/datev/exports/{id}; GET /v1/datev/exports shows the history of the exports requested through the API (time filter by creation, status, location_id). Exports from the management app do not appear there.
file_expires_at); after that file_available is false and the link gives 404 file_not_available; the history entry stays. If the export is not finished yet, the link answers 409 export_not_ready. Every link request creates a new link, counts as a write call (20 instead of 120 per minute) and should never be stored or passed on. Hourly limit: at most 30 new exports per organization and hour (429 rate_limited, Retry-After: 3600); retries with the same key do not count. A job that stays open for more than 15 minutes counts as failed with timeout. The export shows the current state of the batch; a cancellation after the export creates no correcting booking in the batch already exported.Read contractors and orders
With contractors:read you read GET /v1/contractors (contractors) and GET /v1/contractor-orders (repair orders per damage item). The creator needs Articles and contractors, Garages/units and Leasing process, each at least view; if one is missing, token creation is already rejected. Both lists are also available as CSV or Excel.
curl -s "https://api.manage2hero.com/v1/contractor-orders?updated_since=2026-10-01T00:00:00Z&status=sent" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/contractor-orders?updated_since=2026-10-01T00:00:00Z&status=sent" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/contractor-orders?updated_since=2026-10-01T00:00:00Z&status=sent', {
headers: { Authorization: `Bearer ${process.env.M2H_API_TOKEN}` },
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport os, requests
res = requests.get(
"https://api.manage2hero.com/v1/contractor-orders?updated_since=2026-10-01T00:00:00Z&status=sent",
headers={"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}"},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])Contractors have no time filter (a small master-data list, fetch it completely), only active. categories are the categories of the articles the contractor performs. For orders the time filter refers to the creation time (30 days without any); filters: contractor_id, status (not_sent, sent, failed), location_id, unit_id. For a daily sync use updated_since alone: then no creation period applies, and an order created months ago but sent today appears.
updated_since does not report deleted orders. Therefore also fetch completely with period=all regularly, e.g. weekly, and remove identifiers that no longer appear. Never included are addresses, phone, email, tax and bank details of contractors and the recipient address, error texts and attachments of orders. amount_net is empty (null) for time-and-material billing or without a price; otherwise it is quantity times unit price, rounded to 2 places, so 0 for a unit price of 0.Read, create and set the status of damage reports
GET /v1/damages and GET /v1/damages/{id} (scope damages:read) show the damage case files: kind is unit (at a unit) or location (on the grounds). Reports by tenants and caretakers appear only after the management has accepted them. The time filter refers to the report time (30 days without any). Filters: location_id, unit_id, lease_id, tenant_id, status (WEITERE_KLAERUNG, PLANEN_BEAUFTRAGEN, BEAUFTRAGT, ABGESCHLOSSEN, KEINE_REPARATUR), kind, reporter_type and updated_since (with time zone). An unknown identifier as a filter gives an empty list.
curl -s "https://api.manage2hero.com/v1/damages?status=WEITERE_KLAERUNG" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/damages?status=WEITERE_KLAERUNG" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/damages?status=WEITERE_KLAERUNG', {
headers: { Authorization: `Bearer ${process.env.M2H_API_TOKEN}` },
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport os, requests
res = requests.get(
"https://api.manage2hero.com/v1/damages?status=WEITERE_KLAERUNG",
headers={"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}"},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])Change sync: GET /v1/damages/changes/cursor (starting cursor, before the full fetch), GET /v1/damages/changes (IDs changed since the cursor) and GET /v1/damages/by-ids (current state, at most 100 IDs, list shape without documents) report new and changed case files, changed or deleted documents and reports, and deleted case files (the ID is then missing from by-ids). The IDs of the documents of a case file come from GET /v1/damages/{id}. Use period=all for the full fetch.
With damages:write (in addition to damages:read; the creator needs Garages/units and Leasing process with edit) you create damage reports and set the status. Both need the header Idempotency-Key (per token, valid for 90 days; body at most 64 KB).
curl -s -X POST "https://api.manage2hero.com/v1/damages" \
-H "Authorization: Bearer $M2H_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data '{"unit_id":"5b1e7a30-9c42-4d6f-a8e1-0c3f2d4b6a79","damage_type":"dent","damage_date":"2026-10-02","description":"Delle im Rolltor, links unten.","priority":"normal"}'$headers = @{
Authorization = "Bearer $env:M2H_API_TOKEN"
"Idempotency-Key" = [guid]::NewGuid().ToString()
}
$body = @'
{
"unit_id": "5b1e7a30-9c42-4d6f-a8e1-0c3f2d4b6a79",
"damage_type": "dent",
"damage_date": "2026-10-02",
"description": "Delle im Rolltor, links unten.",
"priority": "normal"
}
'@
Invoke-RestMethod -Method Post -Uri "https://api.manage2hero.com/v1/damages" -Headers $headers -ContentType "application/json; charset=utf-8" -Body $bodyimport { createHash, randomUUID } from 'node:crypto';
import { linkSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
// Generate the key ONCE and store it durably BEFORE sending (here: a file, one per operation). After a
// restart the same key is read. For a new, different operation choose a new file name. Delete the file only after the final response.
const VORGANG = 'auftrag-2026/1001'; // set YOUR OWN id per operation (e.g. order id); never reuse it for two operations
const VORGANG_ID = createHash('sha256').update(VORGANG).digest('hex').slice(0, 16); // hashed for the file name: any characters (/, \ …) in the id are harmless
const KEY_FILE = `idempotency-key-post-v1-damages-${VORGANG_ID}.txt`;
const neu = randomUUID();
const tmp = `${KEY_FILE}.${neu}`; // unique name per attempt, never overwrite an existing file
writeFileSync(tmp, neu, { flag: 'wx' }); // write it completely to a temporary file first
try {
linkSync(tmp, KEY_FILE); // publishes it atomically without overwriting an existing one
} catch (e) {
if (e.code !== 'EEXIST') throw e; // an existing key stays unchanged
} finally {
unlinkSync(tmp);
}
const idempotencyKey = readFileSync(KEY_FILE, 'utf8').trim();
const res = await fetch('https://api.manage2hero.com/v1/damages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.M2H_API_TOKEN}`,
'Content-Type': 'application/json',
// On every retry (e.g. after a timeout) send this stored key again.
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
"unit_id": "5b1e7a30-9c42-4d6f-a8e1-0c3f2d4b6a79",
"damage_type": "dent",
"damage_date": "2026-10-02",
"description": "Delle im Rolltor, links unten.",
"priority": "normal"
}),
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport hashlib, os, uuid, requests
from pathlib import Path
# Generate the key ONCE and store it durably BEFORE sending (here: a file, one per operation). After a
# restart the same key is read. For a new, different operation choose a new file name. Delete the file only after the final response.
VORGANG = "auftrag-2026/1001" # set YOUR OWN id per operation (e.g. order id); never reuse it for two operations
VORGANG_ID = hashlib.sha256(VORGANG.encode()).hexdigest()[:16] # hashed for the file name: any characters (/, \ …) in the id are harmless
KEY_FILE = Path(f"idempotency-key-post-v1-damages-{VORGANG_ID}.txt")
neu = str(uuid.uuid4())
tmp = Path(f"{KEY_FILE}.{neu}") # unique name per attempt, never overwrite an existing file
with tmp.open("x") as f:
f.write(neu) # write it completely to a temporary file first
try:
os.link(tmp, KEY_FILE) # publishes it atomically without overwriting an existing one
except FileExistsError:
pass # an existing key stays unchanged
finally:
tmp.unlink()
idempotency_key = KEY_FILE.read_text().strip()
res = requests.post(
"https://api.manage2hero.com/v1/damages",
headers={
"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}",
"Idempotency-Key": idempotency_key,
},
json={
"unit_id": "5b1e7a30-9c42-4d6f-a8e1-0c3f2d4b6a79",
"damage_type": "dent",
"damage_date": "2026-10-02",
"description": "Delle im Rolltor, links unten.",
"priority": "normal"
},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])POST /v1/damages creates a damage report at a unit: unit_id, damage_type (paint, dent, oil_stain, hole, scratch, dirt, other), damage_date (not before 2000-01-01, at most tomorrow), description (1–5000 characters) and optionally priority. The same case file as in the management app is created, in the status WEITERE_KLAERUNG; the response is 201, a retry 200. The event damage.created can be subscribed to (see Webhooks).
curl -s -X PATCH "https://api.manage2hero.com/v1/damages/2f6c1b84-3a9d-4e57-8c10-b7d5e0a9c312/status" \
-H "Authorization: Bearer $M2H_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data '{"status":"PLANEN_BEAUFTRAGEN"}'$headers = @{
Authorization = "Bearer $env:M2H_API_TOKEN"
"Idempotency-Key" = [guid]::NewGuid().ToString()
}
$body = @'
{
"status": "PLANEN_BEAUFTRAGEN"
}
'@
Invoke-RestMethod -Method Patch -Uri "https://api.manage2hero.com/v1/damages/2f6c1b84-3a9d-4e57-8c10-b7d5e0a9c312/status" -Headers $headers -ContentType "application/json; charset=utf-8" -Body $bodyimport { createHash, randomUUID } from 'node:crypto';
import { linkSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
// Generate the key ONCE and store it durably BEFORE sending (here: a file, one per operation). After a
// restart the same key is read. For a new, different operation choose a new file name. Delete the file only after the final response.
const VORGANG = 'auftrag-2026/1001'; // set YOUR OWN id per operation (e.g. order id); never reuse it for two operations
const VORGANG_ID = createHash('sha256').update(VORGANG).digest('hex').slice(0, 16); // hashed for the file name: any characters (/, \ …) in the id are harmless
const KEY_FILE = `idempotency-key-patch-v1-damages-2f6c1b84-3a9d-4e57-8c10-b7d5e0a9c312-status-${VORGANG_ID}.txt`;
const neu = randomUUID();
const tmp = `${KEY_FILE}.${neu}`; // unique name per attempt, never overwrite an existing file
writeFileSync(tmp, neu, { flag: 'wx' }); // write it completely to a temporary file first
try {
linkSync(tmp, KEY_FILE); // publishes it atomically without overwriting an existing one
} catch (e) {
if (e.code !== 'EEXIST') throw e; // an existing key stays unchanged
} finally {
unlinkSync(tmp);
}
const idempotencyKey = readFileSync(KEY_FILE, 'utf8').trim();
const res = await fetch('https://api.manage2hero.com/v1/damages/2f6c1b84-3a9d-4e57-8c10-b7d5e0a9c312/status', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.M2H_API_TOKEN}`,
'Content-Type': 'application/json',
// On every retry (e.g. after a timeout) send this stored key again.
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
"status": "PLANEN_BEAUFTRAGEN"
}),
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport hashlib, os, uuid, requests
from pathlib import Path
# Generate the key ONCE and store it durably BEFORE sending (here: a file, one per operation). After a
# restart the same key is read. For a new, different operation choose a new file name. Delete the file only after the final response.
VORGANG = "auftrag-2026/1001" # set YOUR OWN id per operation (e.g. order id); never reuse it for two operations
VORGANG_ID = hashlib.sha256(VORGANG.encode()).hexdigest()[:16] # hashed for the file name: any characters (/, \ …) in the id are harmless
KEY_FILE = Path(f"idempotency-key-patch-v1-damages-2f6c1b84-3a9d-4e57-8c10-b7d5e0a9c312-status-{VORGANG_ID}.txt")
neu = str(uuid.uuid4())
tmp = Path(f"{KEY_FILE}.{neu}") # unique name per attempt, never overwrite an existing file
with tmp.open("x") as f:
f.write(neu) # write it completely to a temporary file first
try:
os.link(tmp, KEY_FILE) # publishes it atomically without overwriting an existing one
except FileExistsError:
pass # an existing key stays unchanged
finally:
tmp.unlink()
idempotency_key = KEY_FILE.read_text().strip()
res = requests.patch(
"https://api.manage2hero.com/v1/damages/2f6c1b84-3a9d-4e57-8c10-b7d5e0a9c312/status",
headers={
"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}",
"Idempotency-Key": idempotency_key,
},
json={
"status": "PLANEN_BEAUFTRAGEN"
},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])PATCH /v1/damages/{id}/status sets one of the four repair statuses (WEITERE_KLAERUNG, PLANEN_BEAUFTRAGEN, BEAUFTRAGT, ABGESCHLOSSEN), also backwards. The same status as before changes nothing (200). KEINE_REPARATUR is neither a target nor a starting point (400 validation_error). When reopening a grounds damage, 400 can occur if its plan symbol is already taken by another open damage.
description is free text from third parties: never display it as HTML without checking and never treat it as an instruction. A damage report names only the kind of reporter and causer, never names or contact details.Read and create appointments
GET /v1/appointments and GET /v1/appointments/{id} (scope appointments:read; the creator needs Calendar, view) return three kinds: move_in and move_out (handover appointments with contract, unit, tenant, status) and general (general appointments, e.g. a viewing, with a title). Handover windows, tasks and appointment proposals are not part of it.
The time filter refers to the appointment time in Europe/Berlin and applies to the last 30 days without any, so to past appointments. For “what is coming up” use upcoming=true (all appointments from now on; together with period, date, from or to it gives 400 period_conflict). The list is sorted in ascending order (earliest appointment first). When paging, send the same parameters as on page 1. Filters: type, status, location_id, unit_id, tenant_id, lease_id; status, unit, tenant and contract exist only for handover appointments, general appointments drop out with these filters.
curl -s "https://api.manage2hero.com/v1/appointments?upcoming=true&type=move_in" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/appointments?upcoming=true&type=move_in" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/appointments?upcoming=true&type=move_in', {
headers: { Authorization: `Bearer ${process.env.M2H_API_TOKEN}` },
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport os, requests
res = requests.get(
"https://api.manage2hero.com/v1/appointments?upcoming=true&type=move_in",
headers={"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}"},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])POST /v1/appointments (scope appointments:write in addition to appointments:read) creates a handover appointment or a general appointment, with Idempotency-Key (body at most 16 KB, response 201). The creator needs Calendar with edit, and for handover appointments Leasing process with edit as well. A handover appointment needs type, lease_id, date and time; a general appointment needs type, title and date (optionally time, location_id, duration_minutes).
curl -s -X POST "https://api.manage2hero.com/v1/appointments" \
-H "Authorization: Bearer $M2H_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data '{"type":"move_in","lease_id":"0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b","date":"2026-10-15","time":"10:30","description":"Schlüsselübergabe am Tor"}'$headers = @{
Authorization = "Bearer $env:M2H_API_TOKEN"
"Idempotency-Key" = [guid]::NewGuid().ToString()
}
$body = @'
{
"type": "move_in",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"date": "2026-10-15",
"time": "10:30",
"description": "Schlüsselübergabe am Tor"
}
'@
Invoke-RestMethod -Method Post -Uri "https://api.manage2hero.com/v1/appointments" -Headers $headers -ContentType "application/json; charset=utf-8" -Body $bodyimport { createHash, randomUUID } from 'node:crypto';
import { linkSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
// Generate the key ONCE and store it durably BEFORE sending (here: a file, one per operation). After a
// restart the same key is read. For a new, different operation choose a new file name. Delete the file only after the final response.
const VORGANG = 'auftrag-2026/1001'; // set YOUR OWN id per operation (e.g. order id); never reuse it for two operations
const VORGANG_ID = createHash('sha256').update(VORGANG).digest('hex').slice(0, 16); // hashed for the file name: any characters (/, \ …) in the id are harmless
const KEY_FILE = `idempotency-key-post-v1-appointments-${VORGANG_ID}.txt`;
const neu = randomUUID();
const tmp = `${KEY_FILE}.${neu}`; // unique name per attempt, never overwrite an existing file
writeFileSync(tmp, neu, { flag: 'wx' }); // write it completely to a temporary file first
try {
linkSync(tmp, KEY_FILE); // publishes it atomically without overwriting an existing one
} catch (e) {
if (e.code !== 'EEXIST') throw e; // an existing key stays unchanged
} finally {
unlinkSync(tmp);
}
const idempotencyKey = readFileSync(KEY_FILE, 'utf8').trim();
const res = await fetch('https://api.manage2hero.com/v1/appointments', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.M2H_API_TOKEN}`,
'Content-Type': 'application/json',
// On every retry (e.g. after a timeout) send this stored key again.
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
"type": "move_in",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"date": "2026-10-15",
"time": "10:30",
"description": "Schlüsselübergabe am Tor"
}),
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport hashlib, os, uuid, requests
from pathlib import Path
# Generate the key ONCE and store it durably BEFORE sending (here: a file, one per operation). After a
# restart the same key is read. For a new, different operation choose a new file name. Delete the file only after the final response.
VORGANG = "auftrag-2026/1001" # set YOUR OWN id per operation (e.g. order id); never reuse it for two operations
VORGANG_ID = hashlib.sha256(VORGANG.encode()).hexdigest()[:16] # hashed for the file name: any characters (/, \ …) in the id are harmless
KEY_FILE = Path(f"idempotency-key-post-v1-appointments-{VORGANG_ID}.txt")
neu = str(uuid.uuid4())
tmp = Path(f"{KEY_FILE}.{neu}") # unique name per attempt, never overwrite an existing file
with tmp.open("x") as f:
f.write(neu) # write it completely to a temporary file first
try:
os.link(tmp, KEY_FILE) # publishes it atomically without overwriting an existing one
except FileExistsError:
pass # an existing key stays unchanged
finally:
tmp.unlink()
idempotency_key = KEY_FILE.read_text().strip()
res = requests.post(
"https://api.manage2hero.com/v1/appointments",
headers={
"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}",
"Idempotency-Key": idempotency_key,
},
json={
"type": "move_in",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"date": "2026-10-15",
"time": "10:30",
"description": "Schlüsselübergabe am Tor"
},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])- Time is local time Europe/Berlin. A time that does not exist at the daylight-saving change (the gap in spring) is rejected (400
validation_error,time: invalid_value). - Date: not in the past. For today only a time that has not passed yet counts; a general appointment today without a time counts from the start of the day and is rejected. At most 3 years ahead. Violation: 400 with
date: invalid_value. - No rescheduling: if an active handover appointment already exists for the contract and kind, the result is 409
appointment_conflict, also for two concurrent requests. The API only creates; it does not reschedule, cancel or delete. - Caretaker: the default caretaker of the location is entered. If none is set, 400 follows (
lease_id: invalid_value). The caretaker never appears in the response. - As in the management app, the contract advances to “move-in protocol pending” on move-in and to “move-out protocol pending” on move-out.
- No emails and no notifications: unlike the management app, the API notifies neither the caretaker nor the tenant. The appointment appears in the calendar of the management and of the caretaker.
Prospects and waiting list
With prospects:read (Leasing process, view) you read GET /v1/prospects, GET /v1/prospects/{id} and GET /v1/waitlist. The responses contain no contact details: only identifier, status, origin, timestamps, name or company, type of use and the wishes (location, garage type, start date, electricity, water); never email, phone, address, date of birth, bank or ID data. The waiting list names identifiers only. There is deliberately no add-on scope for contact details.
Enquiries: the time filter refers to the enquiry time (30 days without any); filters location_id, storage_type_id, status, source (public_form, tenant_portal, api) and updated_since. There is no filter unit_id, because enquiries name garage types. The waiting list shows a position per entry (place at the location, 1 = front, only for waiting and notified); here too the last 30 days apply without any time form, so older open entries appear only with period=all.
curl -s "https://api.manage2hero.com/v1/waitlist?period=all&status=waiting" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/waitlist?period=all&status=waiting" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/waitlist?period=all&status=waiting', {
headers: { Authorization: `Bearer ${process.env.M2H_API_TOKEN}` },
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport os, requests
res = requests.get(
"https://api.manage2hero.com/v1/waitlist?period=all&status=waiting",
headers={"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}"},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])POST /v1/prospects (scope prospects:write in addition to prospects:read; the creator needs Leasing process with edit) submits a rental enquiry, for example from your own website. Required are the header Idempotency-Key and privacy_consent: true; the required fields and checks are the same as in the public form (salutation, name, date of birth, address, phone or mobile, email, use, origin, start date and 1–5 locations with wishes; electricity and water options are optional).
curl -s -X POST "https://api.manage2hero.com/v1/prospects" \
-H "Authorization: Bearer $M2H_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data '{"salutation":"Herr","first_name":"Max","last_name":"Muster","date_of_birth":"1985-06-15","street":"Musterstraße","house_number":"12","postal_code":"20095","city":"Hamburg","country":"Deutschland","mobile":"+49 170 1234567","email":"max.muster@example.com","usage_type":"private","usage_description":"Oldtimer-Einstellplatz","referral_source":"internet","lease_start_date":"2026-11-01","locations":[{"location_id":"5f1e9b0c-3a7d-4c82-b6e4-9d2a8c7f1e05","priority":1,"unit_types":[{"storage_type_id":"a3c8d4e2-6b19-4f7a-8e05-1c9b2d3f4a60","priority":1,"power_option":"normalstrom"}]}],"privacy_consent":true}'$headers = @{
Authorization = "Bearer $env:M2H_API_TOKEN"
"Idempotency-Key" = [guid]::NewGuid().ToString()
}
$body = @'
{
"salutation": "Herr",
"first_name": "Max",
"last_name": "Muster",
"date_of_birth": "1985-06-15",
"street": "Musterstraße",
"house_number": "12",
"postal_code": "20095",
"city": "Hamburg",
"country": "Deutschland",
"mobile": "+49 170 1234567",
"email": "max.muster@example.com",
"usage_type": "private",
"usage_description": "Oldtimer-Einstellplatz",
"referral_source": "internet",
"lease_start_date": "2026-11-01",
"locations": [
{
"location_id": "5f1e9b0c-3a7d-4c82-b6e4-9d2a8c7f1e05",
"priority": 1,
"unit_types": [
{
"storage_type_id": "a3c8d4e2-6b19-4f7a-8e05-1c9b2d3f4a60",
"priority": 1,
"power_option": "normalstrom"
}
]
}
],
"privacy_consent": true
}
'@
Invoke-RestMethod -Method Post -Uri "https://api.manage2hero.com/v1/prospects" -Headers $headers -ContentType "application/json; charset=utf-8" -Body $bodyimport { createHash, randomUUID } from 'node:crypto';
import { linkSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
// Generate the key ONCE and store it durably BEFORE sending (here: a file, one per operation). After a
// restart the same key is read. For a new, different operation choose a new file name. Delete the file only after the final response.
const VORGANG = 'auftrag-2026/1001'; // set YOUR OWN id per operation (e.g. order id); never reuse it for two operations
const VORGANG_ID = createHash('sha256').update(VORGANG).digest('hex').slice(0, 16); // hashed for the file name: any characters (/, \ …) in the id are harmless
const KEY_FILE = `idempotency-key-post-v1-prospects-${VORGANG_ID}.txt`;
const neu = randomUUID();
const tmp = `${KEY_FILE}.${neu}`; // unique name per attempt, never overwrite an existing file
writeFileSync(tmp, neu, { flag: 'wx' }); // write it completely to a temporary file first
try {
linkSync(tmp, KEY_FILE); // publishes it atomically without overwriting an existing one
} catch (e) {
if (e.code !== 'EEXIST') throw e; // an existing key stays unchanged
} finally {
unlinkSync(tmp);
}
const idempotencyKey = readFileSync(KEY_FILE, 'utf8').trim();
const res = await fetch('https://api.manage2hero.com/v1/prospects', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.M2H_API_TOKEN}`,
'Content-Type': 'application/json',
// On every retry (e.g. after a timeout) send this stored key again.
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
"salutation": "Herr",
"first_name": "Max",
"last_name": "Muster",
"date_of_birth": "1985-06-15",
"street": "Musterstraße",
"house_number": "12",
"postal_code": "20095",
"city": "Hamburg",
"country": "Deutschland",
"mobile": "+49 170 1234567",
"email": "max.muster@example.com",
"usage_type": "private",
"usage_description": "Oldtimer-Einstellplatz",
"referral_source": "internet",
"lease_start_date": "2026-11-01",
"locations": [
{
"location_id": "5f1e9b0c-3a7d-4c82-b6e4-9d2a8c7f1e05",
"priority": 1,
"unit_types": [
{
"storage_type_id": "a3c8d4e2-6b19-4f7a-8e05-1c9b2d3f4a60",
"priority": 1,
"power_option": "normalstrom"
}
]
}
],
"privacy_consent": true
}),
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport hashlib, os, uuid, requests
from pathlib import Path
# Generate the key ONCE and store it durably BEFORE sending (here: a file, one per operation). After a
# restart the same key is read. For a new, different operation choose a new file name. Delete the file only after the final response.
VORGANG = "auftrag-2026/1001" # set YOUR OWN id per operation (e.g. order id); never reuse it for two operations
VORGANG_ID = hashlib.sha256(VORGANG.encode()).hexdigest()[:16] # hashed for the file name: any characters (/, \ …) in the id are harmless
KEY_FILE = Path(f"idempotency-key-post-v1-prospects-{VORGANG_ID}.txt")
neu = str(uuid.uuid4())
tmp = Path(f"{KEY_FILE}.{neu}") # unique name per attempt, never overwrite an existing file
with tmp.open("x") as f:
f.write(neu) # write it completely to a temporary file first
try:
os.link(tmp, KEY_FILE) # publishes it atomically without overwriting an existing one
except FileExistsError:
pass # an existing key stays unchanged
finally:
tmp.unlink()
idempotency_key = KEY_FILE.read_text().strip()
res = requests.post(
"https://api.manage2hero.com/v1/prospects",
headers={
"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}",
"Idempotency-Key": idempotency_key,
},
json={
"salutation": "Herr",
"first_name": "Max",
"last_name": "Muster",
"date_of_birth": "1985-06-15",
"street": "Musterstraße",
"house_number": "12",
"postal_code": "20095",
"city": "Hamburg",
"country": "Deutschland",
"mobile": "+49 170 1234567",
"email": "max.muster@example.com",
"usage_type": "private",
"usage_description": "Oldtimer-Einstellplatz",
"referral_source": "internet",
"lease_start_date": "2026-11-01",
"locations": [
{
"location_id": "5f1e9b0c-3a7d-4c82-b6e4-9d2a8c7f1e05",
"priority": 1,
"unit_types": [
{
"storage_type_id": "a3c8d4e2-6b19-4f7a-8e05-1c9b2d3f4a60",
"priority": 1,
"power_option": "normalstrom"
}
]
}
],
"privacy_consent": True
},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])- Limits: at most 30 new enquiries per token and hour and 5 per token and email address in 24 hours; otherwise 429
rate_limitedwithRetry-After(3600 or 86400). Only enquiries submitted through this token are counted. The write quota (20 per minute) applies as well. Body at most 50 KB. - No confirmation email: the API sends no email to the enquiring person. The address counts as unconfirmed (
email_verified: false),sourceisapi. Confirmation and the consent notice are your responsibility. - Not accepted:
iban,bic,account_holder,credit_check_consent(400field_not_allowed) and status, origin and confirmation. - If the email address already belongs to another person, the enquiry is still created and marked internally in the management app; the response reveals nothing about it. A retry with the same key returns 200 with the same enquiry.
Timeline of a tenant
GET /v1/tenants/{id}/timeline (scope timeline:read) shows the events of one tenant from the email history, dunning notices, payments, documents and contract in a single list, newest first. For this scope the token creator needs the Tenants right (view).
emails:read (emails), dunning:read (dunning notices), payments:read (payments), documents:read (documents), contracts:read (contract). The creator also needs the rights of the respective scope. timeline:read alone returns 200 with an empty list and sources: [], not an error. The field sources names the sources included for your token. The add-on scopes emails:read_body, payments:read_counterparty and documents:download do not apply here: the timeline carries short facts only (time, type, object identifier, title, status), no full texts, amounts, counterparties, file paths or download links. Fetch details through object_id from the single endpoint; an event only appears if the single endpoint also shows it for the same token.curl -s "https://api.manage2hero.com/v1/tenants/9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d/timeline?period=last_3_months" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/tenants/9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d/timeline?period=last_3_months" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/tenants/9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d/timeline?period=last_3_months', {
headers: { Authorization: `Bearer ${process.env.M2H_API_TOKEN}` },
});
const body = await res.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
const data = body.data; // continue processing hereimport os, requests
res = requests.get(
"https://api.manage2hero.com/v1/tenants/9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d/timeline?period=last_3_months",
headers={"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}"},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])- Events and time (
occurred_at):email_sent(sending),dunning_notice_issued(issue date, start of the day in Europe/Berlin),payment_received(booking date, start of the day in Europe/Berlin),document_added(arrival in the tenant file),contract_created,contract_signed(latest tenant signature),contract_countersigned(latest countersignature, once all signed documents are countersigned),termination_filedandsupplementary_agreement_filed(receipt of a termination or supplement; per contract the latest 50 terminations and 100 supplements, as in/v1/contracts). Lease start and end are not events. - Payments appear only if they are assigned to the tenant through a stored allocation to their contract, not through name or IBAN. A payment split across several tenants appears in every timeline concerned.
- Fields:
id(<type>:<row id>, unique, for de-duplication),source,type,object_id(the contract for a termination and a supplement),title(email subject, file name, contract number; otherwisenull) andstatus(email status, dunning levellevel_<n>, allocation status of the payment, document category, termination or supplement status; otherwisenull). - Time filter on
occurred_atas in the other lists; without any, the last 30 days apply, useperiod=allfor the whole history. There are no other parameters and no CSV/Excel output. - Paging: the next pages are not a snapshot. If a computed time changes between two calls (for example through a new signature), an event may be missing or appear twice; de-duplicate by
idand fetch again if needed. - Unknown or foreign tenant: 404
not_found.