Reference
Machine-readable as OpenAPI 3.1: /openapi.json?lang=en — e.g. to generate a client or to import it into Postman/Insomnia.
The webhook calls including the reconciliation as an import-ready Postman collection: /openapi.json?format=postman&lang=en.
GET/v1/locations
All garage parks of your organization, sorted alphabetically by name. With `active=true`, only active locations.
Scope: locations:read
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
active | query | true = only active, false = only inactive garage parks or contractors or meters |
curl -s "https://api.manage2hero.com/v1/locations" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/locations" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/locations', {
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",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"code": "HH01",
"name": "Garagenpark Hamburg Nord",
"street": "Beispielweg",
"house_number": "12",
"postal_code": "22297",
"city": "Hamburg",
"country_code": "DE",
"active": true
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}GET/v1/occupancy
Occupancy figures per garage park. `occupancy_rate` = rented units ÷ all units (0–1). Leases that have been terminated but are still running count as rented; reserved and blocked units are part of the denominator. The raw figures are always included. With `group_by=storage_type`, each garage park additionally contains `storage_types`: the same figures per garage type.
Scope: occupancy:read
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
group_by | query | Occupancy: storage_type = additionally per garage type (storage_types). Revenue: month (default), category or location. Open items (summary): location |
curl -s "https://api.manage2hero.com/v1/occupancy" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/occupancy" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/occupancy', {
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",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"location_code": "HH01",
"location_name": "Garagenpark Hamburg Nord",
"as_of": "2026-09-24T08:15:00.000Z",
"units_total": 120,
"units_rented": 104,
"units_reserved": 3,
"units_blocked": 2,
"units_available": 11,
"occupancy_rate": 0.8667,
"occupancy_rate_definition": "rented_of_total"
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}GET/v1/locations/{id}/occupancy
Like `/v1/occupancy`, for exactly one garage park (also with `group_by=storage_type`). Unknown or foreign id: 404.
Scope: occupancy:read
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
group_by | query | Occupancy: storage_type = additionally per garage type (storage_types). Revenue: month (default), category or location. Open items (summary): location |
curl -s "https://api.manage2hero.com/v1/locations/6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b/occupancy" \
-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/occupancy" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/locations/6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b/occupancy', {
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/occupancy",
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"])Example response
{
"success": true,
"data": {
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"location_code": "HH01",
"location_name": "Garagenpark Hamburg Nord",
"as_of": "2026-09-24T08:15:00.000Z",
"units_total": 120,
"units_rented": 104,
"units_reserved": 3,
"units_blocked": 2,
"units_available": 11,
"occupancy_rate": 0.8667,
"occupancy_rate_definition": "rented_of_total"
},
"request_id": "req_4n2Xc9…"
}GET/v1/locations/{id}/tenants
One row per running lease, sorted by unit number. "Running" includes the notice and move-out phase (`lease.status = terminated`). Contains contact details only — no postal address, no bank details, no date of birth.
Scope: tenants:read
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
curl -s "https://api.manage2hero.com/v1/locations/6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b/tenants" \
-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" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/locations/6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b/tenants', {
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",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"lease": {
"id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"contract_number": "HH01-0042",
"status": "active",
"lease_start": "2023-04-01",
"lease_end": null
},
"unit": {
"id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
"number": "42"
},
"tenant": {
"id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
"customer_number": "10042",
"salutation": "mr",
"first_name": "Max",
"last_name": "Mustermann",
"company": null,
"email": "max.mustermann@example.org",
"phone": "+49 40 1234567",
"mobile": null
}
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}POST/v1/tenant-imports
Creates an import for exactly one garage park (1–500 records, body at most 1 MB). The records are validated like in the file import; invalid ones are created with error codes and can be corrected in the management app. Assigning units and taking over the tenants happens either in the import wizard of the management app or with `POST /v1/tenant-imports/{id}/commit` (separate scope `tenants:import_commit`). Bank details (`iban`, `bic`, `account_holder`) are not allowed. The same `Idempotency-Key` with the same content returns the same import (200); with different content, 409. Separate limits per organization (default 50 open imports, 20 new ones per token and hour; can be raised via support): 422 `import_limit_reached` or 429 `import_rate_limited`, limit and current count in `error.details`. Fixed overall limit: 500 imports per organization (file and API, including completed ones) → 422 `import_total_limit_reached`.
Scope: tenants:import
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"])Example response
{
"success": true,
"data": {
"id": "01923f4e-7a2b-7c3d-8e4f-5a6b7c8d9e0f",
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"name": "Übernahme aus Altsystem",
"status": "pending",
"record_count": 1,
"error_count": 0,
"transferred_count": 0,
"created_at": "2026-09-24T08:15:00.000Z",
"completed_at": null,
"records": [
{
"row": 1,
"status": "pending",
"errors": [],
"warnings": [],
"storage_unit_id": null,
"tenant_id": null,
"lease_id": null,
"transferred_at": null,
"error_code": null
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}GET/v1/tenant-imports/{id}
Status of the import and, per record, the validation result (codes), the assignment and — after the takeover — `tenant_id`/`lease_id`. No field values. Only imports created via the API; otherwise 404.
Scope: tenants:import
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
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"])Example response
{
"success": true,
"data": {
"id": "01923f4e-7a2b-7c3d-8e4f-5a6b7c8d9e0f",
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"name": "Übernahme aus Altsystem",
"status": "pending",
"record_count": 1,
"error_count": 0,
"transferred_count": 0,
"created_at": "2026-09-24T08:15:00.000Z",
"completed_at": null,
"records": [
{
"row": 1,
"status": "pending",
"errors": [],
"warnings": [],
"storage_unit_id": null,
"tenant_id": null,
"lease_id": null,
"transferred_at": null,
"error_code": null
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}DELETE/v1/tenant-imports/{id}
Cancels an import created via the API and deletes it with all its records, so that failed or superfluous imports do not occupy the limit of open imports. Imports that were created in the management app, unknown or foreign ids and imports that were already deleted: 404. If at least one record of the import has already been taken over, nothing is deleted: 409 `session_has_transferred_records` (tenants and leases refer to the import). Deleting an import that is less than an hour old counts against the hourly limit of new imports per token: 429 `import_rate_limited`. No query parameters and no body. The scope `tenants:import` is enough; additionally the creator of the token needs the management-app permission Data import with full access, as for deleting an import there (otherwise 403 `insufficient_permissions`).
Scope: tenants:import
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
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"])Example response
{
"success": true,
"data": {
"session_id": "01923f4e-7a2b-7c3d-8e4f-5a6b7c8d9e0f",
"deleted": true
},
"request_id": "req_4n2Xc9…"
}POST/v1/tenant-imports/{id}/commit
Takes over the records of an API import like the import wizard: per record a tenant, a running lease with its charges, the initial meter reading and the recurring posting from the first of next month (nothing is booked retroactively). Records without a unit are assigned via the lease number. `on_duplicate` controls records whose email already belongs to a tenant: `reject` (default) does not take them over (`skipped`, `duplicate_tenant`; in the import the record stays `pending` with `error_code: tenant_decision_required` — the response says `skipped`, the import status is not changed), `reuse` attaches the lease to that tenant (only with exactly one match), `create_new` creates a new tenant. Without `rows`, all records are taken over. Retrying is harmless: records already taken over report `already_transferred` (with `tenant_id`, `lease_id` and `contract_number`). Request body at most 64 KB (413 `payload_too_large`). Invalid records report `failed` with a code and can be corrected in the management app; `record_changed` means the record was changed in the management app during the takeover — simply call again. Final — a takeover cannot be undone via the API.
Scope: tenants:import_commit
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
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" \
-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/01923f4e-7a2b-7c3d-8e4f-5a6b7c8d9e0f/commit" -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-01923f4e-7a2b-7c3d-8e4f-5a6b7c8d9e0f-commit-${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/01923f4e-7a2b-7c3d-8e4f-5a6b7c8d9e0f/commit', {
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({
"on_duplicate": "reject"
}),
});
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-01923f4e-7a2b-7c3d-8e4f-5a6b7c8d9e0f-commit-{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/01923f4e-7a2b-7c3d-8e4f-5a6b7c8d9e0f/commit",
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"])Example response
{
"success": true,
"data": {
"session_id": "01923f4e-7a2b-7c3d-8e4f-5a6b7c8d9e0f",
"on_duplicate": "reject",
"created": 1,
"already_transferred": 0,
"skipped": 1,
"failed": 1,
"records": [
{
"row": 1,
"outcome": "created",
"code": null,
"tenant_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"contract_number": "HH01 42-1",
"warnings": []
},
{
"row": 2,
"outcome": "skipped",
"code": "duplicate_tenant",
"tenant_id": null,
"lease_id": null,
"contract_number": null,
"warnings": []
},
{
"row": 3,
"outcome": "failed",
"code": "unit_occupied",
"tenant_id": null,
"lease_id": null,
"contract_number": null,
"warnings": []
}
]
},
"request_id": "req_4n2Xc9…"
}GET/v1/occupancy/history
Occupancy per day (`interval=day`, at most 366 calendar days, `from` and `to` both included) or per month (`interval=month`, default, at most 1,831 calendar days, `from` and `to` both included). Without `location_id`, the total of all garage parks of your organization — the rate is computed from the summed units. Measured once per hour; the daily value is the last measurement of the day (German time), the monthly value is the last available daily value of the month. `partial: true` marks the current day or month. Measuring started when this endpoint was introduced (`history_starts_on`); earlier periods are empty, nothing is back-calculated. Days without a measurement (outage) are not filled in but listed in `missing_days`. If a garage park is deleted, its history disappears as well. Periods that are too long: 400 `range_too_large`.
Scope: occupancy:read
| Parameter | In | Description |
|---|---|---|
from | query | First day of the period, YYYY-MM-DD. For lists with period also possible as a timestamp, e.g. 2026-10-01T08:00:00Z |
to | query | Last day of the period (inclusive), YYYY-MM-DD. For lists with period also possible as a timestamp; then it is the exclusive upper bound |
location_id | query | Only this garage park (id from GET /v1/locations); without = whole organization |
interval | query | day = per day (at most 366 calendar days, from and to both included), month = per month (default, at most 1,831 calendar days, from and to both included) |
curl -s "https://api.manage2hero.com/v1/occupancy/history?from=2026-07-01&to=2026-09-30" \
-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-07-01&to=2026-09-30" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/occupancy/history?from=2026-07-01&to=2026-09-30', {
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-07-01&to=2026-09-30",
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"])Example response
{
"success": true,
"data": {
"location_id": null,
"from": "2026-07-01",
"to": "2026-09-30",
"interval": "month",
"history_starts_on": "2026-07-14",
"occupancy_rate_definition": "rented_of_total",
"points": [
{
"period": "2026-07",
"date": "2026-07-31",
"units_total": 240,
"units_rented": 205,
"units_reserved": 6,
"units_blocked": 4,
"units_available": 25,
"occupancy_rate": 0.8542,
"partial": false
},
{
"period": "2026-08",
"date": "2026-08-31",
"units_total": 240,
"units_rented": 209,
"units_reserved": 5,
"units_blocked": 4,
"units_available": 22,
"occupancy_rate": 0.8708,
"partial": false
},
{
"period": "2026-09",
"date": "2026-09-28",
"units_total": 240,
"units_rented": 211,
"units_reserved": 3,
"units_blocked": 4,
"units_available": 22,
"occupancy_rate": 0.8792,
"partial": true
}
],
"missing_days": [
"2026-08-17"
]
},
"request_id": "req_4n2Xc9…"
}GET/v1/bookings
All bookings of your organization with a booking date in the period `from`–`to` (at most 366 calendar days, `from` and `to` both included, otherwise 400 `range_too_large`), sorted by booking date. With account numbers (SKR03) for debit and credit, net/tax/gross amounts (`vat_rate` as a fraction, 0.19 = 19 %) and document number. `category`: rent, deposit, electricity, water, damage, dunning_fee, revenue_other, overpayment — unknown new document types appear as `other`. Counter-postings of payments (`is_counter_posting`) are included; hide them with `include_counter_postings=false`. Bookings without a garage have `location_id = null` and only appear without a location filter. Cancelled bookings are listed with `status = cancelled`.
Scope: bookings:read
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
from | query | First day of the period, YYYY-MM-DD. For lists with period also possible as a timestamp, e.g. 2026-10-01T08:00:00Z |
to | query | Last day of the period (inclusive), YYYY-MM-DD. For lists with period also possible as a timestamp; then it is the exclusive upper bound |
location_id | query | Only this garage park (id from GET /v1/locations); without = whole organization |
lease_id | query | Only this lease |
category | query | Bookings: type of entry (rent, deposit, electricity, water, damage, dunning_fee, revenue_other, overpayment). Documents: main folder of the document |
status | query | Bookings: open, partially_paid, paid, cancelled. Emails: sent, delivered, opened. Contractor orders: not_sent, sent, failed. Access log: HTTP status (e.g. 404) or class (4xx, 5xx). Units: available, reserved, rented, blocked. Damages: WEITERE_KLAERUNG, PLANEN_BEAUFTRAGEN, BEAUFTRAGT, ABGESCHLOSSEN, KEINE_REPARATUR. Statements: finalized, sent, cancelled. Webhook deliveries: pending, succeeded, failed, abandoned. Appointments (handover appointments only): planned, confirmed, rescheduled, completed, cancelled. Prospects: new, open_bank_details, bank_details_received, contract_ready, contract_signing, contract_submitted, waitlisted, converted, rejected, expired, withdrawn. Waiting list: waiting, notified, assigned, cancelled |
include_counter_postings | query | false = hide counter-postings of payments (default true) |
curl -s "https://api.manage2hero.com/v1/bookings?from=2026-09-01&to=2026-09-30" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/bookings?from=2026-09-01&to=2026-09-30" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/bookings?from=2026-09-01&to=2026-09-30', {
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/bookings?from=2026-09-01&to=2026-09-30",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "5e0b7a3c-2f1d-4c6b-9a8e-7d6c5b4a3f21",
"booking_date": "2026-09-01",
"due_date": "2026-09-03",
"is_payment_planning_target": false,
"document_number": "RE-2026-00412",
"category": "rent",
"booking_text": "Miete 09/2026 Garage 42",
"debit_account": "10042",
"credit_account": "8400",
"amount_net": 84.03,
"vat_rate": 0.19,
"vat_amount": 15.97,
"amount_gross": 100,
"status": "open",
"is_counter_posting": false,
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"unit_id": "3a4b5c6d-7e8f-4a0b-8c1d-2e3f4a5b6c7d",
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b"
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}GET/v1/revenue
Net revenue in the period `from`–`to` (at most 1,831 calendar days, `from` and `to` both included), grouped with `group_by=month` (default, every month even with 0), `category` or `location`. The same figure as in the dashboard of the management app: credit minus debit on the revenue accounts, without counter-postings and without cancelled bookings; credit notes reduce revenue. The booking date is decisive. **Cancellations apply retroactively:** a cancelled booking drops out of its original month — values of earlier months may therefore change later. Bookings without a garage appear under `key = null` with `group_by=location`.
Scope: bookings:read
| Parameter | In | Description |
|---|---|---|
from | query | First day of the period, YYYY-MM-DD. For lists with period also possible as a timestamp, e.g. 2026-10-01T08:00:00Z |
to | query | Last day of the period (inclusive), YYYY-MM-DD. For lists with period also possible as a timestamp; then it is the exclusive upper bound |
location_id | query | Only this garage park (id from GET /v1/locations); without = whole organization |
group_by | query | Occupancy: storage_type = additionally per garage type (storage_types). Revenue: month (default), category or location. Open items (summary): location |
curl -s "https://api.manage2hero.com/v1/revenue?from=2026-01-01&to=2026-03-31" \
-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-03-31" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/revenue?from=2026-01-01&to=2026-03-31', {
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-03-31",
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"])Example response
{
"success": true,
"data": {
"from": "2026-01-01",
"to": "2026-03-31",
"location_id": null,
"group_by": "month",
"currency": "EUR",
"revenue_net_total": 30512.4,
"groups": [
{
"key": "2026-01",
"name": null,
"revenue_net": 10150.2
},
{
"key": "2026-02",
"name": null,
"revenue_net": 10080
},
{
"key": "2026-03",
"name": null,
"revenue_net": 10282.2
}
],
"definition": "revenue_accounts_credit_minus_debit_excl_cancelled"
},
"request_id": "req_4n2Xc9…"
}GET/v1/tenants/{id}
Contact details of a tenant of your organization — also without a running lease (`has_current_lease = false`), e.g. for open receivables of former tenants. No postal address, no bank details, no date of birth. Unknown or foreign id: 404.
Scope: tenants:read
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
curl -s "https://api.manage2hero.com/v1/tenants/9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d" \
-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" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/tenants/9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d', {
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",
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"])Example response
{
"success": true,
"data": {
"id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"customer_number": "10042",
"salutation": "mr",
"first_name": "Max",
"last_name": "Muster",
"company": null,
"email": "max@example.org",
"phone": "040 1234567",
"mobile": null,
"has_current_lease": false
},
"request_id": "req_4n2Xc9…"
}GET/v1/invoices
Invoices of your organization with an invoice date in the period `from`–`to` (at most 1,831 calendar days, `from` and `to` both included), sorted by date. Drafts never appear. `status`: open, partially_paid, paid, cancelled, dunning. Cancellations: `is_cancellation` marks a cancellation invoice, `cancels_invoice_id` and `cancelled_by_invoice_id` link original and cancellation. `amount_paid` only contains payments recorded on the invoice — bank allocations are in `/v1/payments`, the actual remaining amount of a rent receivable in `/v1/open-items`. No customer address and no customer email.
Scope: invoices:read
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
from | query | First day of the period, YYYY-MM-DD. For lists with period also possible as a timestamp, e.g. 2026-10-01T08:00:00Z |
to | query | Last day of the period (inclusive), YYYY-MM-DD. For lists with period also possible as a timestamp; then it is the exclusive upper bound |
status | query | Bookings: open, partially_paid, paid, cancelled. Emails: sent, delivered, opened. Contractor orders: not_sent, sent, failed. Access log: HTTP status (e.g. 404) or class (4xx, 5xx). Units: available, reserved, rented, blocked. Damages: WEITERE_KLAERUNG, PLANEN_BEAUFTRAGEN, BEAUFTRAGT, ABGESCHLOSSEN, KEINE_REPARATUR. Statements: finalized, sent, cancelled. Webhook deliveries: pending, succeeded, failed, abandoned. Appointments (handover appointments only): planned, confirmed, rescheduled, completed, cancelled. Prospects: new, open_bank_details, bank_details_received, contract_ready, contract_signing, contract_submitted, waitlisted, converted, rejected, expired, withdrawn. Waiting list: waiting, notified, assigned, cancelled |
lease_id | query | Only this lease |
tenant_id | query | Only this tenant |
curl -s "https://api.manage2hero.com/v1/invoices?from=2026-09-01&to=2026-09-30" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/invoices?from=2026-09-01&to=2026-09-30" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/invoices?from=2026-09-01&to=2026-09-30', {
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/invoices?from=2026-09-01&to=2026-09-30",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091",
"invoice_number": "RE-2026-00412",
"kind": "recurring_rent",
"status": "partially_paid",
"invoice_date": "2026-09-01",
"service_date": "2026-09-01",
"due_date": "2026-09-03",
"is_payment_planning_target": false,
"subject": "Miete September 2026",
"total_net": 84.03,
"total_vat": 15.97,
"total_gross": 100,
"amount_paid": 40,
"paid_at": null,
"is_cancellation": false,
"cancels_invoice_id": null,
"cancelled_by_invoice_id": null,
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"tenant_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"customer_number": "10042",
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"unit_id": "3a4b5c6d-7e8f-4a0b-8c1d-2e3f4a5b6c7d"
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}GET/v1/invoices/{id}
Like an entry of `/v1/invoices`, plus the line items. Unknown or foreign id and drafts: 404.
Scope: invoices:read
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
curl -s "https://api.manage2hero.com/v1/invoices/2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/invoices/2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/invoices/2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091', {
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/invoices/2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091",
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"])Example response
{
"success": true,
"data": {
"id": "2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091",
"invoice_number": "RE-2026-00412",
"kind": "recurring_rent",
"status": "partially_paid",
"invoice_date": "2026-09-01",
"service_date": "2026-09-01",
"due_date": "2026-09-03",
"is_payment_planning_target": false,
"subject": "Miete September 2026",
"total_net": 84.03,
"total_vat": 15.97,
"total_gross": 100,
"amount_paid": 40,
"paid_at": null,
"is_cancellation": false,
"cancels_invoice_id": null,
"cancelled_by_invoice_id": null,
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"tenant_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"customer_number": "10042",
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"unit_id": "3a4b5c6d-7e8f-4a0b-8c1d-2e3f4a5b6c7d",
"items": [
{
"position": 1,
"description": "Miete Garage 42, September 2026",
"quantity": 1,
"unit": "Monat",
"unit_price_net": 84.03,
"discount_percent": 0,
"vat_rate": 0.19,
"line_net": 84.03,
"line_vat": 15.97,
"line_gross": 100
}
]
},
"request_id": "req_4n2Xc9…"
}GET/v1/payments
Incoming bank transactions (amount > 0) with a booking date in the period `from`–`to` (at most 366 calendar days, `from` and `to` both included), sorted by date, with their allocation: `allocations` names the lease and amount per booking — one payment can be split across several leases. `overpayment_amount` = remainder booked to the lease as overpayment during allocation, `unallocated_amount` = neither allocated nor booked as overpayment. Bank transactions only: payments recorded manually on an invoice are in `/v1/invoices` (`amount_paid`). **Bank details only with the add-on scope:** counterparty (`counterparty`: name, IBAN, BIC, bank), payment reference, mandate reference and creditor id only appear if the token additionally has `payments:read_counterparty` — otherwise these fields are missing entirely. For transactions from the bank connection, `bic`, `bank_name` and `creditor_id` are often `null` because the bank does not provide them.
Scope: payments:read
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
from | query | First day of the period, YYYY-MM-DD. For lists with period also possible as a timestamp, e.g. 2026-10-01T08:00:00Z |
to | query | Last day of the period (inclusive), YYYY-MM-DD. For lists with period also possible as a timestamp; then it is the exclusive upper bound |
location_id | query | Only this garage park (id from GET /v1/locations); without = whole organization |
match_status | query | Allocation: unmatched, partial, matched, ignored |
curl -s "https://api.manage2hero.com/v1/payments?from=2026-09-01&to=2026-09-30" \
-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" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/payments?from=2026-09-01&to=2026-09-30', {
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",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "8f7e6d5c-4b3a-4291-8a7b-6c5d4e3f2a19",
"booking_date": "2026-09-02",
"value_date": "2026-09-02",
"amount": 100,
"currency": "EUR",
"match_status": "matched",
"allocated_amount": 100,
"overpayment_amount": 0,
"unallocated_amount": 0,
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"is_potential_duplicate": false,
"allocations": [
{
"booking_id": "5e0b7a3c-2f1d-4c6b-9a8e-7d6c5b4a3f21",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"amount": 100
}
]
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}GET/v1/open-items
Open receivables with remaining amount (`amount_open`), sorted by due date. Included are lease-related receivables (debit on a debtor account) — the same set as the receivables overview of the management app. Credit notes, counter-postings and overpayments do not count; partial payments (bank allocation and payment recorded on the invoice) are deducted; what a final settlement after move-out transferred to the final-settlement account or covered with credit no longer counts (the remainder is a separate item on that account). **Individual invoices without a lease are missing** — they are listed with status and paid amount in `/v1/invoices`. Filters: `location_id`, `lease_id`, `tenant_id` (contact details via `GET /v1/tenants/{id}`), `overdue_only=true`. `days_overdue` counts by German date.
Scope: open_items:read
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
location_id | query | Only this garage park (id from GET /v1/locations); without = whole organization |
lease_id | query | Only this lease |
tenant_id | query | Only this tenant |
overdue_only | query | true = only overdue receivables |
curl -s "https://api.manage2hero.com/v1/open-items" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/open-items" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/open-items', {
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/open-items",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "5e0b7a3c-2f1d-4c6b-9a8e-7d6c5b4a3f21",
"document_number": "RE-2026-00388",
"category": "rent",
"booking_text": "Miete 08/2026 Garage 42",
"due_date": "2026-08-03",
"days_overdue": 56,
"amount_gross": 100,
"amount_paid": 40,
"amount_open": 60,
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"contract_number": "HH01-0042",
"tenant_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"customer_number": "10042",
"unit_id": "3a4b5c6d-7e8f-4a0b-8c1d-2e3f4a5b6c7d",
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"dunning_level": 1,
"excluded_from_dunning": false
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}GET/v1/open-items/summary
Number and total of open receivables, of which overdue, and the aging buckets `not_due`, `1_30`, `31_60`, `61_90`, `over_90` (days after due date). With `group_by=location` additionally per garage park. Included are lease-related receivables (debit on a debtor account) — the same set as the receivables overview of the management app. Credit notes, counter-postings and overpayments do not count; partial payments (bank allocation and payment recorded on the invoice) are deducted; what a final settlement after move-out transferred to the final-settlement account or covered with credit no longer counts (the remainder is a separate item on that account). **Individual invoices without a lease are missing** — they are listed with status and paid amount in `/v1/invoices`.
Scope: open_items:read
| Parameter | In | Description |
|---|---|---|
location_id | query | Only this garage park (id from GET /v1/locations); without = whole organization |
group_by | query | Occupancy: storage_type = additionally per garage type (storage_types). Revenue: month (default), category or location. Open items (summary): location |
curl -s "https://api.manage2hero.com/v1/open-items/summary" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/open-items/summary" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/open-items/summary', {
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/open-items/summary",
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"])Example response
{
"success": true,
"data": {
"as_of": "2026-09-28",
"location_id": null,
"count": 37,
"amount_open": 4210.5,
"overdue_count": 29,
"overdue_amount": 3580.5,
"aging": [
{
"bucket": "not_due",
"count": 8,
"amount": 630
},
{
"bucket": "1_30",
"count": 14,
"amount": 1420
},
{
"bucket": "31_60",
"count": 9,
"amount": 1210.5
},
{
"bucket": "61_90",
"count": 4,
"amount": 650
},
{
"bucket": "over_90",
"count": 2,
"amount": 300
}
]
},
"request_id": "req_4n2Xc9…"
}GET/v1/dunning/leases
All leases with at least one overdue receivable: open total, open dunning fees, highest level already dunned (`dunning_level`), the level a dunning notice would set today (`next_level`, null = no notice possible at the moment, e.g. because the payment deadline of the last notice is still running) and whether termination is recommended — computed like the receivables overview of the management app with the dunning levels configured there. Sorted by `lease_id`. With `min_level`, only leases from this dunned level; a page may then contain fewer entries than `limit`. Dunning notices cannot be triggered via the API.
Scope: dunning:read
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
location_id | query | Only this garage park (id from GET /v1/locations); without = whole organization |
min_level | query | Only leases from this level already dunned (0–5) |
curl -s "https://api.manage2hero.com/v1/dunning/leases" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/dunning/leases" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/dunning/leases', {
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",
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"])Example response
{
"success": true,
"data": {
"as_of": "2026-09-28",
"items": [
{
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"contract_number": "HH01-0042",
"tenant_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"total_open": 160,
"total_open_fees": 5,
"oldest_due_date": "2026-07-03",
"max_days_overdue": 87,
"dunning_level": 2,
"next_level": 3,
"termination_recommended": false,
"items": [
{
"open_item_id": "5e0b7a3c-2f1d-4c6b-9a8e-7d6c5b4a3f21",
"document_number": "RE-2026-00301",
"due_date": "2026-07-03",
"days_overdue": 87,
"amount_open": 100,
"dunning_level": 2,
"next_level": 3,
"excluded_from_dunning": false
}
]
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}GET/v1/dunning/notices
Binding dunning notices with an issue date in the period `from`–`to` (at most 1,831 calendar days, `from` and `to` both included), sorted by date: level, payment deadline, totals, origin (`manual`/`automatic`) and time of sending (`sent_at`, null = not sent yet). Without recipient address and without PDF.
Scope: dunning:read
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
from | query | First day of the period, YYYY-MM-DD. For lists with period also possible as a timestamp, e.g. 2026-10-01T08:00:00Z |
to | query | Last day of the period (inclusive), YYYY-MM-DD. For lists with period also possible as a timestamp; then it is the exclusive upper bound |
lease_id | query | Only this lease |
level | query | Only dunning notices of this level (1–5) |
curl -s "https://api.manage2hero.com/v1/dunning/notices?from=2026-09-01&to=2026-09-30" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/dunning/notices?from=2026-09-01&to=2026-09-30" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/dunning/notices?from=2026-09-01&to=2026-09-30', {
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/notices?from=2026-09-01&to=2026-09-30",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "7d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"contract_number": "HH01-0042",
"tenant_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"level": 2,
"issued_on": "2026-09-10",
"payment_deadline": "2026-09-17",
"total_open": 160,
"total_fees": 5,
"total_due": 165,
"source": "automatic",
"sent_at": "2026-09-10T06:02:11.000Z"
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}GET/v1/dunning/notices/{id}
Like an entry of `/v1/dunning/notices`, plus the dunned receivables with level, open amount at the time of the notice and fee. Unknown or foreign id: 404.
Scope: dunning:read
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
curl -s "https://api.manage2hero.com/v1/dunning/notices/7d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/dunning/notices/7d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/dunning/notices/7d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6', {
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/notices/7d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6",
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"])Example response
{
"success": true,
"data": {
"id": "7d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"contract_number": "HH01-0042",
"tenant_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"level": 2,
"issued_on": "2026-09-10",
"payment_deadline": "2026-09-17",
"total_open": 160,
"total_fees": 5,
"total_due": 165,
"source": "automatic",
"sent_at": "2026-09-10T06:02:11.000Z",
"items": [
{
"open_item_id": "5e0b7a3c-2f1d-4c6b-9a8e-7d6c5b4a3f21",
"document_number": "RE-2026-00301",
"due_date": "2026-07-03",
"level": 2,
"amount_open": 100,
"fee": 5
}
]
},
"request_id": "req_4n2Xc9…"
}GET/v1/bookings/changes/cursor
Returns the starting cursor for the change sync of bookings. Get it **before** a full fetch: everything that changes afterwards is returned by `/v1/bookings/changes` from this cursor — including changes that were not yet committed during the full fetch. The cursor only works for this resource: a cursor of another resource is rejected with 400 `invalid_cursor`.
Scope: bookings:read
| Parameter | In | Description |
|---|
curl -s "https://api.manage2hero.com/v1/bookings/changes/cursor" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/bookings/changes/cursor" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/bookings/changes/cursor', {
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/bookings/changes/cursor",
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"])Example response
{
"success": true,
"data": {
"cursor": "eyJsbyI6IjEyMzQ1NiJ9"
},
"request_id": "req_4n2Xc9…"
}GET/v1/bookings/changes
IDs of bookings that changed since the cursor — new, changed, removed or no longer visible. Reload every ID via `/v1/bookings/by-ids`: what it returns is the current state; remove an ID locally if it is missing there. The order of the entries is not the order of the changes. Only committed changes appear, nothing is lost. Page on with `next_cursor`; when it is `null`, keep `resume_cursor` for the next sync. The same ID can appear more than once. Changes of linked master data (e.g. contract number, location of a garage) do not trigger an event; an occasional full fetch (e.g. weekly) reconciles them. Changes are kept for 90 days; an older cursor returns 410 `cursor_expired` — then start again.
Scope: bookings:read
| Parameter | In | Description |
|---|---|---|
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
curl -s "https://api.manage2hero.com/v1/bookings/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/bookings/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/bookings/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9', {
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/bookings/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "5e0b7a3c-2f1d-4c6b-9a8e-7d6c5b4a3f21",
"changed_at": "2026-09-29T21:14:03.512Z"
}
],
"next_cursor": null,
"resume_cursor": "eyJsbyI6IjEyMzQ1NiJ9"
},
"request_id": "req_4n2Xc9…"
}GET/v1/bookings/by-ids
Current state of 1 to 100 bookings (`ids`, comma-separated), with the same fields as the list — without period and without filters. Unknown, foreign or no longer visible IDs are missing from the response. More than 100 IDs return 400 `validation_error` (`details.ids = ["too_many_ids"]`), and so do duplicate IDs (`["duplicate_ids"]`).
Scope: bookings:read
| Parameter | In | Description |
|---|---|---|
ids | query | IDs, comma-separated (1–100), e.g. from …/changes |
curl -s "https://api.manage2hero.com/v1/bookings/by-ids?ids=5e0b7a3c-2f1d-4c6b-9a8e-7d6c5b4a3f21" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/bookings/by-ids?ids=5e0b7a3c-2f1d-4c6b-9a8e-7d6c5b4a3f21" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/bookings/by-ids?ids=5e0b7a3c-2f1d-4c6b-9a8e-7d6c5b4a3f21', {
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/bookings/by-ids?ids=5e0b7a3c-2f1d-4c6b-9a8e-7d6c5b4a3f21",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "5e0b7a3c-2f1d-4c6b-9a8e-7d6c5b4a3f21",
"booking_date": "2026-09-01",
"due_date": "2026-09-03",
"is_payment_planning_target": false,
"document_number": "RE-2026-00412",
"category": "rent",
"booking_text": "Miete 09/2026 Garage 42",
"debit_account": "10042",
"credit_account": "8400",
"amount_net": 84.03,
"vat_rate": 0.19,
"vat_amount": 15.97,
"amount_gross": 100,
"status": "open",
"is_counter_posting": false,
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"unit_id": "3a4b5c6d-7e8f-4a0b-8c1d-2e3f4a5b6c7d",
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b"
}
]
},
"request_id": "req_4n2Xc9…"
}GET/v1/invoices/changes/cursor
Returns the starting cursor for the change sync of invoices. Get it **before** a full fetch: everything that changes afterwards is returned by `/v1/invoices/changes` from this cursor — including changes that were not yet committed during the full fetch. The cursor only works for this resource: a cursor of another resource is rejected with 400 `invalid_cursor`.
Scope: invoices:read
| Parameter | In | Description |
|---|
curl -s "https://api.manage2hero.com/v1/invoices/changes/cursor" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/invoices/changes/cursor" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/invoices/changes/cursor', {
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/invoices/changes/cursor",
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"])Example response
{
"success": true,
"data": {
"cursor": "eyJsbyI6IjEyMzQ1NiJ9"
},
"request_id": "req_4n2Xc9…"
}GET/v1/invoices/changes
IDs of invoices that changed since the cursor — new, changed, removed or no longer visible. Reload every ID via `/v1/invoices/by-ids`: what it returns is the current state; remove an ID locally if it is missing there. The order of the entries is not the order of the changes. Only committed changes appear, nothing is lost. Page on with `next_cursor`; when it is `null`, keep `resume_cursor` for the next sync. The same ID can appear more than once. Changes of linked master data (e.g. contract number, location of a garage) do not trigger an event; an occasional full fetch (e.g. weekly) reconciles them. Changes are kept for 90 days; an older cursor returns 410 `cursor_expired` — then start again.
Scope: invoices:read
| Parameter | In | Description |
|---|---|---|
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
curl -s "https://api.manage2hero.com/v1/invoices/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/invoices/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/invoices/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9', {
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/invoices/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091",
"changed_at": "2026-09-29T21:14:03.512Z"
}
],
"next_cursor": null,
"resume_cursor": "eyJsbyI6IjEyMzQ1NiJ9"
},
"request_id": "req_4n2Xc9…"
}GET/v1/invoices/by-ids
Current state of 1 to 100 invoices (`ids`, comma-separated), with the same fields as the list — without period and without filters. Unknown, foreign or no longer visible IDs are missing from the response. More than 100 IDs return 400 `validation_error` (`details.ids = ["too_many_ids"]`), and so do duplicate IDs (`["duplicate_ids"]`).
Scope: invoices:read
| Parameter | In | Description |
|---|---|---|
ids | query | IDs, comma-separated (1–100), e.g. from …/changes |
curl -s "https://api.manage2hero.com/v1/invoices/by-ids?ids=2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/invoices/by-ids?ids=2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/invoices/by-ids?ids=2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091', {
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/invoices/by-ids?ids=2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091",
"invoice_number": "RE-2026-00412",
"kind": "recurring_rent",
"status": "partially_paid",
"invoice_date": "2026-09-01",
"service_date": "2026-09-01",
"due_date": "2026-09-03",
"is_payment_planning_target": false,
"subject": "Miete September 2026",
"total_net": 84.03,
"total_vat": 15.97,
"total_gross": 100,
"amount_paid": 40,
"paid_at": null,
"is_cancellation": false,
"cancels_invoice_id": null,
"cancelled_by_invoice_id": null,
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"tenant_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"customer_number": "10042",
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"unit_id": "3a4b5c6d-7e8f-4a0b-8c1d-2e3f4a5b6c7d"
}
]
},
"request_id": "req_4n2Xc9…"
}GET/v1/payments/changes/cursor
Returns the starting cursor for the change sync of incoming payments. Get it **before** a full fetch: everything that changes afterwards is returned by `/v1/payments/changes` from this cursor — including changes that were not yet committed during the full fetch. The cursor only works for this resource: a cursor of another resource is rejected with 400 `invalid_cursor`.
Scope: payments:read
| Parameter | In | Description |
|---|
curl -s "https://api.manage2hero.com/v1/payments/changes/cursor" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/payments/changes/cursor" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/payments/changes/cursor', {
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/changes/cursor",
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"])Example response
{
"success": true,
"data": {
"cursor": "eyJsbyI6IjEyMzQ1NiJ9"
},
"request_id": "req_4n2Xc9…"
}GET/v1/payments/changes
IDs of incoming payments that changed since the cursor — new, changed, removed or no longer visible. Reload every ID via `/v1/payments/by-ids`: what it returns is the current state; remove an ID locally if it is missing there. The order of the entries is not the order of the changes. Only committed changes appear, nothing is lost. Page on with `next_cursor`; when it is `null`, keep `resume_cursor` for the next sync. The same ID can appear more than once. Changes of linked master data (e.g. contract number, location of a garage) do not trigger an event; an occasional full fetch (e.g. weekly) reconciles them. Changes are kept for 90 days; an older cursor returns 410 `cursor_expired` — then start again.
Scope: payments:read
| Parameter | In | Description |
|---|---|---|
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
curl -s "https://api.manage2hero.com/v1/payments/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/payments/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/payments/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9', {
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/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "8f7e6d5c-4b3a-4291-8a7b-6c5d4e3f2a19",
"changed_at": "2026-09-29T21:14:03.512Z"
}
],
"next_cursor": null,
"resume_cursor": "eyJsbyI6IjEyMzQ1NiJ9"
},
"request_id": "req_4n2Xc9…"
}GET/v1/payments/by-ids
Current state of 1 to 100 incoming payments (`ids`, comma-separated), with the same fields as the list — without period and without filters. Unknown, foreign or no longer visible IDs are missing from the response. More than 100 IDs return 400 `validation_error` (`details.ids = ["too_many_ids"]`), and so do duplicate IDs (`["duplicate_ids"]`).
Scope: payments:read
| Parameter | In | Description |
|---|---|---|
ids | query | IDs, comma-separated (1–100), e.g. from …/changes |
curl -s "https://api.manage2hero.com/v1/payments/by-ids?ids=8f7e6d5c-4b3a-4291-8a7b-6c5d4e3f2a19" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/payments/by-ids?ids=8f7e6d5c-4b3a-4291-8a7b-6c5d4e3f2a19" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/payments/by-ids?ids=8f7e6d5c-4b3a-4291-8a7b-6c5d4e3f2a19', {
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/by-ids?ids=8f7e6d5c-4b3a-4291-8a7b-6c5d4e3f2a19",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "8f7e6d5c-4b3a-4291-8a7b-6c5d4e3f2a19",
"booking_date": "2026-09-02",
"value_date": "2026-09-02",
"amount": 100,
"currency": "EUR",
"match_status": "matched",
"allocated_amount": 100,
"overpayment_amount": 0,
"unallocated_amount": 0,
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"is_potential_duplicate": false,
"allocations": [
{
"booking_id": "5e0b7a3c-2f1d-4c6b-9a8e-7d6c5b4a3f21",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"amount": 100
}
]
}
]
},
"request_id": "req_4n2Xc9…"
}GET/v1/dunning/notices/changes/cursor
Returns the starting cursor for the change sync of dunning notices. Get it **before** a full fetch: everything that changes afterwards is returned by `/v1/dunning/notices/changes` from this cursor — including changes that were not yet committed during the full fetch. The cursor only works for this resource: a cursor of another resource is rejected with 400 `invalid_cursor`.
Scope: dunning:read
| Parameter | In | Description |
|---|
curl -s "https://api.manage2hero.com/v1/dunning/notices/changes/cursor" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/dunning/notices/changes/cursor" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/dunning/notices/changes/cursor', {
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/notices/changes/cursor",
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"])Example response
{
"success": true,
"data": {
"cursor": "eyJsbyI6IjEyMzQ1NiJ9"
},
"request_id": "req_4n2Xc9…"
}GET/v1/dunning/notices/changes
IDs of dunning notices that changed since the cursor — new, changed, removed or no longer visible. Reload every ID via `/v1/dunning/notices/by-ids`: what it returns is the current state; remove an ID locally if it is missing there. The order of the entries is not the order of the changes. Only committed changes appear, nothing is lost. Page on with `next_cursor`; when it is `null`, keep `resume_cursor` for the next sync. The same ID can appear more than once. Changes of linked master data (e.g. contract number, location of a garage) do not trigger an event; an occasional full fetch (e.g. weekly) reconciles them. Changes are kept for 90 days; an older cursor returns 410 `cursor_expired` — then start again.
Scope: dunning:read
| Parameter | In | Description |
|---|---|---|
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
curl -s "https://api.manage2hero.com/v1/dunning/notices/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/dunning/notices/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/dunning/notices/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9', {
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/notices/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "7d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6",
"changed_at": "2026-09-29T21:14:03.512Z"
}
],
"next_cursor": null,
"resume_cursor": "eyJsbyI6IjEyMzQ1NiJ9"
},
"request_id": "req_4n2Xc9…"
}GET/v1/dunning/notices/by-ids
Current state of 1 to 100 dunning notices (`ids`, comma-separated), with the same fields as the list — without period and without filters. Unknown, foreign or no longer visible IDs are missing from the response. More than 100 IDs return 400 `validation_error` (`details.ids = ["too_many_ids"]`), and so do duplicate IDs (`["duplicate_ids"]`).
Scope: dunning:read
| Parameter | In | Description |
|---|---|---|
ids | query | IDs, comma-separated (1–100), e.g. from …/changes |
curl -s "https://api.manage2hero.com/v1/dunning/notices/by-ids?ids=7d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/dunning/notices/by-ids?ids=7d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/dunning/notices/by-ids?ids=7d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6', {
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/notices/by-ids?ids=7d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "7d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"contract_number": "HH01-0042",
"tenant_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"level": 2,
"issued_on": "2026-09-10",
"payment_deadline": "2026-09-17",
"total_open": 160,
"total_fees": 5,
"total_due": 165,
"source": "automatic",
"sent_at": "2026-09-10T06:02:11.000Z"
}
]
},
"request_id": "req_4n2Xc9…"
}GET/v1/documents/changes/cursor
Returns the starting cursor for the change sync of documents. Get it **before** a full fetch: everything that changes afterwards is returned by `/v1/documents/changes` from this cursor — including changes that were not yet committed during the full fetch. The cursor only works for this resource: a cursor of another resource is rejected with 400 `invalid_cursor`.
Scope: documents:read
| Parameter | In | Description |
|---|
curl -s "https://api.manage2hero.com/v1/documents/changes/cursor" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/documents/changes/cursor" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/documents/changes/cursor', {
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/documents/changes/cursor",
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"])Example response
{
"success": true,
"data": {
"cursor": "eyJsbyI6IjEyMzQ1NiJ9"
},
"request_id": "req_4n2Xc9…"
}GET/v1/documents/changes
IDs of documents that changed since the cursor — new, changed, removed or no longer visible. Reload every ID via `/v1/documents/by-ids`: what it returns is the current state; remove an ID locally if it is missing there. The order of the entries is not the order of the changes. Only committed changes appear, nothing is lost. Page on with `next_cursor`; when it is `null`, keep `resume_cursor` for the next sync. The same ID can appear more than once. Changes of linked master data (e.g. contract number, location of a garage) do not trigger an event; an occasional full fetch (e.g. weekly) reconciles them. Changes are kept for 90 days; an older cursor returns 410 `cursor_expired` — then start again.
Scope: documents:read
| Parameter | In | Description |
|---|---|---|
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
curl -s "https://api.manage2hero.com/v1/documents/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/documents/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/documents/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9', {
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/documents/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
"changed_at": "2026-09-29T21:14:03.512Z"
}
],
"next_cursor": null,
"resume_cursor": "eyJsbyI6IjEyMzQ1NiJ9"
},
"request_id": "req_4n2Xc9…"
}GET/v1/documents/by-ids
Current state of 1 to 100 documents (`ids`, comma-separated), with the same fields as the list — without period and without filters. Unknown, foreign or no longer visible IDs are missing from the response. More than 100 IDs return 400 `validation_error` (`details.ids = ["too_many_ids"]`), and so do duplicate IDs (`["duplicate_ids"]`).
Scope: documents:read
| Parameter | In | Description |
|---|---|---|
ids | query | IDs, comma-separated (1–100), e.g. from …/changes |
curl -s "https://api.manage2hero.com/v1/documents/by-ids?ids=7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/documents/by-ids?ids=7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/documents/by-ids?ids=7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f', {
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/documents/by-ids?ids=7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
"source": "tenant",
"category": "contract_data",
"folder": null,
"file_name": "Mietvertrag HH01-0042.pdf",
"file_size": 184320,
"mime_type": "application/pdf",
"created_at": "2026-09-18T09:41:12.000Z",
"tenant_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"file_available": true,
"settlement_cancelled": false
}
]
},
"request_id": "req_4n2Xc9…"
}GET/v1/emails/changes/cursor
Returns the starting cursor for the change sync of emails. Get it **before** a full fetch: everything that changes afterwards is returned by `/v1/emails/changes` from this cursor — including changes that were not yet committed during the full fetch. The cursor only works for this resource: a cursor of another resource is rejected with 400 `invalid_cursor`.
Scope: emails:read
| Parameter | In | Description |
|---|
curl -s "https://api.manage2hero.com/v1/emails/changes/cursor" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/emails/changes/cursor" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/emails/changes/cursor', {
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/emails/changes/cursor",
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"])Example response
{
"success": true,
"data": {
"cursor": "eyJsbyI6IjEyMzQ1NiJ9"
},
"request_id": "req_4n2Xc9…"
}GET/v1/emails/changes
IDs of emails that changed since the cursor — new, changed, removed or no longer visible. Reload every ID via `/v1/emails/by-ids`: what it returns is the current state; remove an ID locally if it is missing there. The order of the entries is not the order of the changes. Only committed changes appear, nothing is lost. Page on with `next_cursor`; when it is `null`, keep `resume_cursor` for the next sync. The same ID can appear more than once. Changes of linked master data (e.g. contract number, location of a garage) do not trigger an event; an occasional full fetch (e.g. weekly) reconciles them. Changes are kept for 90 days; an older cursor returns 410 `cursor_expired` — then start again.
Scope: emails:read
| Parameter | In | Description |
|---|---|---|
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
curl -s "https://api.manage2hero.com/v1/emails/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/emails/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/emails/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9', {
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/emails/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "7d3f9a52-1c84-4e6b-b2a0-5f8e9c1d3a47",
"changed_at": "2026-09-29T21:14:03.512Z"
}
],
"next_cursor": null,
"resume_cursor": "eyJsbyI6IjEyMzQ1NiJ9"
},
"request_id": "req_4n2Xc9…"
}GET/v1/emails/by-ids
Current state of 1 to 100 emails (`ids`, comma-separated), with the same fields as the list — without period and without filters. Unknown, foreign or no longer visible IDs are missing from the response. More than 100 IDs return 400 `validation_error` (`details.ids = ["too_many_ids"]`), and so do duplicate IDs (`["duplicate_ids"]`).
Scope: emails:read
| Parameter | In | Description |
|---|---|---|
ids | query | IDs, comma-separated (1–100), e.g. from …/changes |
curl -s "https://api.manage2hero.com/v1/emails/by-ids?ids=7d3f9a52-1c84-4e6b-b2a0-5f8e9c1d3a47" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/emails/by-ids?ids=7d3f9a52-1c84-4e6b-b2a0-5f8e9c1d3a47" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/emails/by-ids?ids=7d3f9a52-1c84-4e6b-b2a0-5f8e9c1d3a47', {
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/emails/by-ids?ids=7d3f9a52-1c84-4e6b-b2a0-5f8e9c1d3a47",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "7d3f9a52-1c84-4e6b-b2a0-5f8e9c1d3a47",
"template_key": "dunning_notice",
"subject": "Zahlungserinnerung zu Ihrem Garagenstellplatz",
"body_preview": "Guten Tag Herr Muster, Zahlungserinnerung zur Miete September 2026.",
"status": "delivered",
"created_at": "2026-10-02T07:15:42.318204+00:00",
"tenant_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b"
}
]
},
"request_id": "req_4n2Xc9…"
}GET/v1/contracts/changes/cursor
Returns the starting cursor for the change sync of contracts. Get it **before** a full fetch: everything that changes afterwards is returned by `/v1/contracts/changes` from this cursor — including changes that were not yet committed during the full fetch. The cursor only works for this resource: a cursor of another resource is rejected with 400 `invalid_cursor`.
Scope: contracts:read
| Parameter | In | Description |
|---|
curl -s "https://api.manage2hero.com/v1/contracts/changes/cursor" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/contracts/changes/cursor" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/contracts/changes/cursor', {
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/changes/cursor",
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"])Example response
{
"success": true,
"data": {
"cursor": "eyJsbyI6IjEyMzQ1NiJ9"
},
"request_id": "req_4n2Xc9…"
}GET/v1/contracts/changes
IDs of contracts that changed since the cursor — new, changed, removed or no longer visible. Reload every ID via `/v1/contracts/by-ids`: what it returns is the current state; remove an ID locally if it is missing there. The order of the entries is not the order of the changes. Only committed changes appear, nothing is lost. Page on with `next_cursor`; when it is `null`, keep `resume_cursor` for the next sync. The same ID can appear more than once. Changes of linked master data (e.g. contract number, location of a garage) do not trigger an event; an occasional full fetch (e.g. weekly) reconciles them. Changes are kept for 90 days; an older cursor returns 410 `cursor_expired` — then start again.
Scope: contracts:read
| Parameter | In | Description |
|---|---|---|
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
curl -s "https://api.manage2hero.com/v1/contracts/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/contracts/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/contracts/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9', {
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/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"changed_at": "2026-09-29T21:14:03.512Z"
}
],
"next_cursor": null,
"resume_cursor": "eyJsbyI6IjEyMzQ1NiJ9"
},
"request_id": "req_4n2Xc9…"
}GET/v1/contracts/by-ids
Current state of 1 to 100 contracts (`ids`, comma-separated), with the same fields as the list — without period and without filters. Unknown, foreign or no longer visible IDs are missing from the response. More than 100 IDs return 400 `validation_error` (`details.ids = ["too_many_ids"]`), and so do duplicate IDs (`["duplicate_ids"]`).
Scope: contracts:read
| Parameter | In | Description |
|---|---|---|
ids | query | IDs, comma-separated (1–100), e.g. from …/changes |
curl -s "https://api.manage2hero.com/v1/contracts/by-ids?ids=0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/contracts/by-ids?ids=0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/contracts/by-ids?ids=0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b', {
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/by-ids?ids=0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"contract_number": "V-2026-0042",
"status": "termination_received",
"lease_start": "2025-04-01",
"lease_end": "2026-12-31",
"tenant_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"unit_id": "3a7d1e55-2b9c-4f60-8d14-6c0e9b2a7f31",
"location_id": "5e2f8c10-7a3b-4d96-b1c8-0f4e6d2a9b73",
"deposit_amount": 450,
"notice_period_months": 3,
"minimum_lease_months": 12,
"signature": {
"tenant_signed": true,
"tenant_signed_at": "2025-03-12T09:41:07.512330+00:00",
"countersigned": true,
"countersigned_at": "2025-03-13T14:02:55.108421+00:00",
"signed_documents": 3
},
"terminations": [
{
"id": "c41e7f2a-98b3-4d05-a6e1-2b7c3d9f0e18",
"status": "confirmed",
"type": "ordinary",
"terminated_by": "tenant",
"termination_date": "2026-09-15",
"requested_end_date": "2026-12-31",
"end_date": "2026-12-31",
"decided_at": "2026-09-16T08:20:11.004512+00:00",
"created_at": "2026-09-15T18:03:27.771902+00:00"
}
],
"supplementary_agreements": [
{
"id": "e8a05b31-6c2d-4f79-9d83-1a4b7c0e5f26",
"resource_type": "electricity",
"agreement_type": "activation",
"status": "completed",
"requested_date": "2025-05-01",
"effective_date": "2025-05-06",
"created_at": "2025-04-28T10:15:44.201733+00:00"
}
],
"created_at": "2025-03-10T12:00:00.000000+00:00",
"updated_at": "2026-09-16T08:20:11.004512+00:00"
}
]
},
"request_id": "req_4n2Xc9…"
}GET/v1/damages/changes/cursor
Returns the starting cursor for the change sync of damages. Get it **before** a full fetch: everything that changes afterwards is returned by `/v1/damages/changes` from this cursor — including changes that were not yet committed during the full fetch. The cursor only works for this resource: a cursor of another resource is rejected with 400 `invalid_cursor`.
Scope: damages:read
| Parameter | In | Description |
|---|
curl -s "https://api.manage2hero.com/v1/damages/changes/cursor" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/damages/changes/cursor" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/damages/changes/cursor', {
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/changes/cursor",
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"])Example response
{
"success": true,
"data": {
"cursor": "eyJsbyI6IjEyMzQ1NiJ9"
},
"request_id": "req_4n2Xc9…"
}GET/v1/damages/changes
IDs of damages that changed since the cursor — new, changed, removed or no longer visible. Reload every ID via `/v1/damages/by-ids`: what it returns is the current state; remove an ID locally if it is missing there. The order of the entries is not the order of the changes. Only committed changes appear, nothing is lost. Page on with `next_cursor`; when it is `null`, keep `resume_cursor` for the next sync. The same ID can appear more than once. Changes of linked master data (e.g. contract number, location of a garage) do not trigger an event; an occasional full fetch (e.g. weekly) reconciles them. Changes are kept for 90 days; an older cursor returns 410 `cursor_expired` — then start again.
Scope: damages:read
| Parameter | In | Description |
|---|---|---|
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
curl -s "https://api.manage2hero.com/v1/damages/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/damages/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/damages/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9', {
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/changes?cursor=eyJsbyI6IjEyMzQ1NiJ9",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "2f6c1b84-3a9d-4e57-8c10-b7d5e0a9c312",
"changed_at": "2026-09-29T21:14:03.512Z"
}
],
"next_cursor": null,
"resume_cursor": "eyJsbyI6IjEyMzQ1NiJ9"
},
"request_id": "req_4n2Xc9…"
}GET/v1/damages/by-ids
Current state of 1 to 100 damages (`ids`, comma-separated), with the same fields as the list — without period and without filters. Unknown, foreign or no longer visible IDs are missing from the response. More than 100 IDs return 400 `validation_error` (`details.ids = ["too_many_ids"]`), and so do duplicate IDs (`["duplicate_ids"]`). The response has the list shape (without the `documents` field); the IDs of a damage's documents come from `GET /v1/damages/{id}`.
Scope: damages:read
| Parameter | In | Description |
|---|---|---|
ids | query | IDs, comma-separated (1–100), e.g. from …/changes |
curl -s "https://api.manage2hero.com/v1/damages/by-ids?ids=2f6c1b84-3a9d-4e57-8c10-b7d5e0a9c312" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/damages/by-ids?ids=2f6c1b84-3a9d-4e57-8c10-b7d5e0a9c312" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/damages/by-ids?ids=2f6c1b84-3a9d-4e57-8c10-b7d5e0a9c312', {
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/by-ids?ids=2f6c1b84-3a9d-4e57-8c10-b7d5e0a9c312",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "2f6c1b84-3a9d-4e57-8c10-b7d5e0a9c312",
"case_number": "VDF-12-003",
"kind": "unit",
"status": "WEITERE_KLAERUNG",
"process_type": null,
"damage_type": "dent",
"priority": "normal",
"description": "Delle im Rolltor, links unten.",
"damage_date": "2026-10-02",
"reported_at": "2026-10-03T07:41:12.482915+00:00",
"updated_at": null,
"reporter_type": "administration",
"causer_type": null,
"unit_id": "5b1e7a30-9c42-4d6f-a8e1-0c3f2d4b6a79",
"unit_number": "12",
"location_id": "8e2d4c61-7b05-4a93-9f1c-6d3e5a7b9c20",
"lease_id": null,
"tenant_id": null,
"document_count": 0
}
]
},
"request_id": "req_4n2Xc9…"
}GET/v1/emails
System emails your organization sent to tenants (contracts, statements, invoices, dunning, terminations, supplements, appointments, protocols, decisions, damage reports), newest first. **Time filter** on the sending time: exactly one of `period` (`today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days`, `last_3_months`, `last_12_months`, `all`), `date` (one day) or `from`/`to` (ISO date or timestamp; a date in `to` names the last day inclusive, a timestamp is the exclusive upper bound); several forms at once → 400 `period_conflict`, `from` after `to` → 400 `range_invalid`. **Without any of them the last 30 days apply**; everything is only available with `period=all` (page through `next_cursor`). Days are in the Europe/Berlin time zone. Filters: `tenant_id`, `lease_id`, `location_id` (all tenants with a lease on a garage of the location; emails without a lease reference only appear without a location filter), `template_key`, `status` (`sent`, `delivered`, `opened`). The token sees the whole organization. **What never appears:** emails without a tenant reference (prospects, waiting list, inquiries), emails with credentials or one-time links (password and credential emails, codes, invitations, offers), templates not on the list, failed or undeliverable emails, test sends (`[Test]` in the subject) — and never the recipient address, the provider id or error messages. `body_preview` is a plain-text excerpt of at most 200 characters. **Full text only with the add-on scope:** `body_text` (plain text; tags, scripts and styles removed, paragraphs kept, links without address) only appears if the token additionally has `emails:read_body` — otherwise the field is missing entirely. `body_text` is text, not HTML: escape it like any text when showing it in a web page.
Scope: emails:read
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
period | query | Fixed period: today, yesterday, this_month, last_month, last_7_days, last_30_days (default), last_3_months, last_12_months or all. Not together with date, from or to |
date | query | Exactly one day, YYYY-MM-DD. Not together with period, from or to |
from | query | First day of the period, YYYY-MM-DD. For lists with period also possible as a timestamp, e.g. 2026-10-01T08:00:00Z |
to | query | Last day of the period (inclusive), YYYY-MM-DD. For lists with period also possible as a timestamp; then it is the exclusive upper bound |
tenant_id | query | Only this tenant |
lease_id | query | Only this lease |
location_id | query | Only this garage park (id from GET /v1/locations); without = whole organization |
template_key | query | Only emails of this template, e.g. nk_abrechnung |
status | query | Bookings: open, partially_paid, paid, cancelled. Emails: sent, delivered, opened. Contractor orders: not_sent, sent, failed. Access log: HTTP status (e.g. 404) or class (4xx, 5xx). Units: available, reserved, rented, blocked. Damages: WEITERE_KLAERUNG, PLANEN_BEAUFTRAGEN, BEAUFTRAGT, ABGESCHLOSSEN, KEINE_REPARATUR. Statements: finalized, sent, cancelled. Webhook deliveries: pending, succeeded, failed, abandoned. Appointments (handover appointments only): planned, confirmed, rescheduled, completed, cancelled. Prospects: new, open_bank_details, bank_details_received, contract_ready, contract_signing, contract_submitted, waitlisted, converted, rejected, expired, withdrawn. Waiting list: waiting, notified, assigned, cancelled |
curl -s "https://api.manage2hero.com/v1/emails" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/emails" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/emails', {
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/emails",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "7d3f9a52-1c84-4e6b-b2a0-5f8e9c1d3a47",
"template_key": "dunning_notice",
"subject": "Zahlungserinnerung zu Ihrem Garagenstellplatz",
"body_preview": "Guten Tag Herr Muster, Zahlungserinnerung zur Miete September 2026.",
"status": "delivered",
"created_at": "2026-10-02T07:15:42.318204+00:00",
"tenant_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b"
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}GET/v1/emails/{id}
Like an entry of `/v1/emails`; with the add-on scope `emails:read_body` also `body_text`. Unknown or foreign id and any email that `/v1/emails` never returns (prospect, template not on the list, failed delivery, test): 404 `not_found`.
Scope: emails:read
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
curl -s "https://api.manage2hero.com/v1/emails/7d3f9a52-1c84-4e6b-b2a0-5f8e9c1d3a47" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/emails/7d3f9a52-1c84-4e6b-b2a0-5f8e9c1d3a47" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/emails/7d3f9a52-1c84-4e6b-b2a0-5f8e9c1d3a47', {
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/emails/7d3f9a52-1c84-4e6b-b2a0-5f8e9c1d3a47",
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"])Example response
{
"success": true,
"data": {
"id": "7d3f9a52-1c84-4e6b-b2a0-5f8e9c1d3a47",
"template_key": "dunning_notice",
"subject": "Zahlungserinnerung zu Ihrem Garagenstellplatz",
"body_preview": "Guten Tag Herr Muster, Zahlungserinnerung zur Miete September 2026.",
"status": "delivered",
"created_at": "2026-10-02T07:15:42.318204+00:00",
"tenant_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"body_text": "Guten Tag Herr Muster,\n\nZahlungserinnerung zur Miete September 2026, offener Betrag 100,00 EUR.\n\nFreundliche Grüße\nGaragenverwaltung"
},
"request_id": "req_4n2Xc9…"
}GET/v1/usage
The external calls of your organization (all tokens), newest first: token (`token_id`, `token_name`, `token_public_id` — never the secret), `method`, `path` (without query string), `status_code`, timestamp `created_at` and `request_id`. Calls from the management app do not appear. **Never included:** IP addresses, user agent and request contents. Time filter as for all lists: exactly one of the forms `period` (`today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days`, `last_3_months`, `last_12_months`, `all`), `date` (one day) or `from`/`to` (ISO date or datetime; a date in `to` names the last day inclusive, a datetime is the exclusive upper bound); without any the last 30 days; several forms → 400 `period_conflict`, `from` after `to` → 400 `range_invalid`. Further filters: `token_id` (a foreign or unknown token yields an empty list), `status` (exact code such as `404` or class `4xx`/`5xx`) and `path_prefix` (e.g. `/v1/invoices`). Page on with `next_cursor`. **Retention 90 days:** older calls can no longer be retrieved. The call of `/v1/usage` itself appears in the log. **Rejected calls** (401) that cannot be attributed to a token — missing header, other Authorization scheme, invalid format, unknown token identifier — are not logged; rejected sign-ins with a known identifier (wrong secret, revoked, expired) appear only throttled (at most 20 rows per token and minute).
Scope: usage:read
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
period | query | Fixed period: today, yesterday, this_month, last_month, last_7_days, last_30_days (default), last_3_months, last_12_months or all. Not together with date, from or to |
date | query | Exactly one day, YYYY-MM-DD. Not together with period, from or to |
from | query | First day of the period, YYYY-MM-DD. For lists with period also possible as a timestamp, e.g. 2026-10-01T08:00:00Z |
to | query | Last day of the period (inclusive), YYYY-MM-DD. For lists with period also possible as a timestamp; then it is the exclusive upper bound |
token_id | query | Only requests made by this API token |
status | query | Bookings: open, partially_paid, paid, cancelled. Emails: sent, delivered, opened. Contractor orders: not_sent, sent, failed. Access log: HTTP status (e.g. 404) or class (4xx, 5xx). Units: available, reserved, rented, blocked. Damages: WEITERE_KLAERUNG, PLANEN_BEAUFTRAGEN, BEAUFTRAGT, ABGESCHLOSSEN, KEINE_REPARATUR. Statements: finalized, sent, cancelled. Webhook deliveries: pending, succeeded, failed, abandoned. Appointments (handover appointments only): planned, confirmed, rescheduled, completed, cancelled. Prospects: new, open_bank_details, bank_details_received, contract_ready, contract_signing, contract_submitted, waitlisted, converted, rejected, expired, withdrawn. Waiting list: waiting, notified, assigned, cancelled |
path_prefix | query | Only requests whose path starts like this, e.g. /v1/invoices |
curl -s "https://api.manage2hero.com/v1/usage" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/usage" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/usage', {
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/usage",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a",
"created_at": "2026-10-05T08:14:22.481Z",
"token_id": "00000000-aaaa-aaaa-aaaa-000000000042",
"token_name": "Buchhaltung Export",
"token_public_id": "xxxxxxxxxxxx",
"method": "GET",
"path": "/v1/invoices",
"status_code": 200,
"request_id": "req_7f3a9c1e2b4d"
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}GET/v1/documents
Documents of your organization — tenant documents (tenant file) and location files — with a **creation date** in the period, newest first. Time filter: exactly one form of `period` (`today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days`, `last_3_months`, `last_12_months`, `all`), `date` (one day) or `from`/`to` (`to` excluded); without any, the last 30 days apply (day boundaries in Europe/Berlin), several forms return 400 `period_conflict`, a reversed range 400 `range_invalid`. Filters: `tenant_id`, `lease_id`, `location_id`, `category`. `tenant_id`, `lease_id` and `category` only concern tenant documents (no location files then); `location_id` returns the location files and the tenant documents of leases at that location. Page on with `next_cursor`. **Visible** are documents of the six main folders of the tenant file, not archived and without legacy categories (`category`: damages, communication, contract_data, handover, invoices, other); other kinds (such as identity documents or credit reports) never appear. The final PDF of a cancelled final settlement stays visible and carries `settlement_cancelled = true`. Handover protocols that exist only as a protocol and documents of other areas (e.g. prospects) are not included. Metadata only: **never** the storage path, bucket or file address. `file_available = false` means the row has no validly stored file. The file itself is only available via `POST /v1/documents/{id}/download-link`. The token sees the whole organization (no location limits).
Scope: documents:read
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
period | query | Fixed period: today, yesterday, this_month, last_month, last_7_days, last_30_days (default), last_3_months, last_12_months or all. Not together with date, from or to |
date | query | Exactly one day, YYYY-MM-DD. Not together with period, from or to |
from | query | First day of the period, YYYY-MM-DD. For lists with period also possible as a timestamp, e.g. 2026-10-01T08:00:00Z |
to | query | Last day of the period (inclusive), YYYY-MM-DD. For lists with period also possible as a timestamp; then it is the exclusive upper bound |
tenant_id | query | Only this tenant |
lease_id | query | Only this lease |
location_id | query | Only this garage park (id from GET /v1/locations); without = whole organization |
category | query | Bookings: type of entry (rent, deposit, electricity, water, damage, dunning_fee, revenue_other, overpayment). Documents: main folder of the document |
curl -s "https://api.manage2hero.com/v1/documents" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/documents" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/documents', {
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/documents",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
"source": "tenant",
"category": "contract_data",
"folder": null,
"file_name": "Mietvertrag HH01-0042.pdf",
"file_size": 184320,
"mime_type": "application/pdf",
"created_at": "2026-09-18T09:41:12.000Z",
"tenant_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"file_available": true,
"settlement_cancelled": false
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}GET/v1/documents/{id}
Like an entry of `/v1/documents`. Unknown, foreign or archived id and categories that are not released: 404 `not_found`.
Scope: documents:read
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
curl -s "https://api.manage2hero.com/v1/documents/7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/documents/7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/documents/7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f', {
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/documents/7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
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"])Example response
{
"success": true,
"data": {
"id": "7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
"source": "tenant",
"category": "contract_data",
"folder": null,
"file_name": "Mietvertrag HH01-0042.pdf",
"file_size": 184320,
"mime_type": "application/pdf",
"created_at": "2026-09-18T09:41:12.000Z",
"tenant_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"file_available": true,
"settlement_cancelled": false
},
"request_id": "req_4n2Xc9…"
}POST/v1/documents/{id}/download-link
Creates a **link that is valid for 5 minutes** (`expires_at`) and returns it in `url`. Do not store, share or log the link: anyone who has it can load the file. For a later request, create a new link — every call creates a new one and counts as a write call (tighter quota per token and minute). Needs the add-on scope `documents:download` **and** `documents:read` on the token; the scope is marked sensitive. No body needed. Unknown, foreign or unreleased documents: 404 `not_found`; a document without a validly stored file: 404 `file_not_available`.
Scope: documents:download
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
curl -s "https://api.manage2hero.com/v1/documents/7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f/download-link" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/documents/7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f/download-link" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/documents/7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f/download-link', {
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/documents/7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f/download-link",
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"])Example response
{
"success": true,
"data": {
"document_id": "7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
"url": "https://<storage-host>/storage/v1/object/sign/…?token=…",
"expires_at": "2026-10-05T12:05:00.000Z",
"expires_in_seconds": 300,
"file_name": "Mietvertrag HH01-0042.pdf",
"mime_type": "application/pdf"
},
"request_id": "req_4n2Xc9…"
}GET/v1/contractors
The contractors (executing companies) of your organization, newest first: `id`, `name`, `active`, `contact_name` (only the name of the contact person) and `categories` (categories of the articles the contractor carries out; empty if none is assigned). Filter: `active` (`true`/`false`). There is deliberately no change filter: `categories` is derived live from the articles of the contractor and `updated_at` only reports changes to the contractor record itself — fetch the (small) list in full (this also reveals deleted contractors). Page on with `next_cursor`, optionally as a file with `?format=csv` or `?format=xlsx`. The token sees the whole organization. **Never included:** address, phone, email, fax, website, tax number, VAT id, bank details and notes. Prerequisite for the token creator: the rights Articles, Storage units and Leasing process, each at least view (the orders show damage data).
Scope: contractors:read
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
active | query | true = only active, false = only inactive garage parks or contractors or meters |
curl -s "https://api.manage2hero.com/v1/contractors" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/contractors" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/contractors', {
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/contractors",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "3b1f6c2e-8a47-4d9b-9c15-e2a07f4d6b38",
"name": "Malerbetrieb Muster GmbH",
"active": true,
"contact_name": "Erika Beispiel",
"categories": [
"Malerarbeiten",
"Bodenbeläge"
],
"created_at": "2026-09-12T08:21:05.482113+00:00",
"updated_at": "2026-10-02T09:40:17.100245+00:00"
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}GET/v1/contractor-orders
The repair orders of your organization to contractors, newest first — one order per damage item. Per order: `status` (`not_sent` = not sent yet or no longer, `sent` = sent or marked as sent by hand, `failed` = sending failed), `order_kind` (`time_and_material` = by effort, `quote_request` = quote requested), timestamps `created_at` (creation), `sent_at` (sending, the order date) and `updated_at` (last change of the order or the item), the contractor (`contractor_id` from `/v1/contractors`, empty for a freely entered external company; `company_name`), the reference (`damage_id`, `unit_id`, `location_id` — identifiers only; for damages from handover protocols `damage_id` is the identifier of the protocol damage, for damages on common areas `unit_id` is empty) and the item (`item_name`, `quantity`, `unit`, `unit_price` net, `vat_rate`, `is_time_and_material`, `amount_net` = quantity × unit price, rounded to 2 decimals; empty when billed by effort or without a price). **Time filter** on the creation time: exactly one of `period` (`today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days`, `last_3_months`, `last_12_months`, `all`), `date` (one day) or `from`/`to` (ISO date or timestamp; a date in `to` names the last day inclusive, a timestamp is the exclusive upper bound); several forms at once → 400 `period_conflict`, `from` after `to` → 400 `range_invalid`. **Without any of them the last 30 days apply**; everything is only available with `period=all`. Days are in the Europe/Berlin time zone. Additionally `updated_since` for changes (order, item and — as long as the name does not come from the sending — the contractor; inclusive; `YYYY-MM-DD` = start of the day in Europe/Berlin or an ISO timestamp). **Given alone, no creation period applies** (not even the 30 days) — so an order created months ago and sent today shows up; together with `period`, `date` or `from`/`to` both apply (intersection). **Deleted orders** are not reported by `updated_since` (the management app can delete a damage item together with its order): reconcile regularly with a full retrieval (`period=all`, page by page) and remove identifiers that no longer appear. Further filters: `contractor_id`, `status`, `location_id`, `unit_id`; a foreign or unknown id → 404 `not_found`. Page on with `next_cursor`, optionally as a file with `?format=csv` or `?format=xlsx`. **Never included:** recipient address, error texts, sending logs, attachments, reasons for price changes and user identifiers.
Scope: contractors:read
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
period | query | Fixed period: today, yesterday, this_month, last_month, last_7_days, last_30_days (default), last_3_months, last_12_months or all. Not together with date, from or to |
date | query | Exactly one day, YYYY-MM-DD. Not together with period, from or to |
from | query | First day of the period, YYYY-MM-DD. For lists with period also possible as a timestamp, e.g. 2026-10-01T08:00:00Z |
to | query | Last day of the period (inclusive), YYYY-MM-DD. For lists with period also possible as a timestamp; then it is the exclusive upper bound |
updated_since | query | Only records changed since this point in time (ISO 8601 with zone, e.g. 2026-10-01T08:00:00Z). Meters and statements: also accepted as a date (created or changed since). Damages: only created or changed since this point in time (not a gap-free change stream: overlap the window). Contractor orders: inclusive, also accepted as YYYY-MM-DD (start of the day); given alone, no creation period applies there |
contractor_id | query | Only orders to this contractor (id from GET /v1/contractors) |
status | query | Bookings: open, partially_paid, paid, cancelled. Emails: sent, delivered, opened. Contractor orders: not_sent, sent, failed. Access log: HTTP status (e.g. 404) or class (4xx, 5xx). Units: available, reserved, rented, blocked. Damages: WEITERE_KLAERUNG, PLANEN_BEAUFTRAGEN, BEAUFTRAGT, ABGESCHLOSSEN, KEINE_REPARATUR. Statements: finalized, sent, cancelled. Webhook deliveries: pending, succeeded, failed, abandoned. Appointments (handover appointments only): planned, confirmed, rescheduled, completed, cancelled. Prospects: new, open_bank_details, bank_details_received, contract_ready, contract_signing, contract_submitted, waitlisted, converted, rejected, expired, withdrawn. Waiting list: waiting, notified, assigned, cancelled |
location_id | query | Only this garage park (id from GET /v1/locations); without = whole organization |
unit_id | query | Only this unit (garage; for meters: id from the units; for damages: id from unit.id of GET /v1/locations/{id}/tenants) |
curl -s "https://api.manage2hero.com/v1/contractor-orders" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/contractor-orders" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/contractor-orders', {
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",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "8e2d4a61-5b73-4c0f-a9d8-1f6e3b7c2a45",
"status": "sent",
"order_kind": "time_and_material",
"created_at": "2026-10-02T07:15:42.318204+00:00",
"updated_at": "2026-10-02T07:16:03.774512+00:00",
"sent_at": "2026-10-02T07:16:03.774512+00:00",
"sent_manually": false,
"contractor_id": "3b1f6c2e-8a47-4d9b-9c15-e2a07f4d6b38",
"company_name": "Malerbetrieb Muster GmbH",
"damage_id": "c4a9e8d2-1b36-4f75-8e0a-9d2c5b7f1a63",
"unit_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"location_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"item_name": "Wandfläche streichen",
"quantity": 12.5,
"unit": "m²",
"unit_price": 8.4,
"vat_rate": 19,
"is_time_and_material": false,
"amount_net": 105
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}GET/v1/units
All units (garages, parking spaces) of your organization with location, garage type, dimensions, equipment, **status**, current **tenant as a short reference** (id and display name), **base rent** and **meters**, most recently changed first. Filters: `location_id`, `storage_type_id`, `status` (`available`, `reserved`, `rented`, `blocked`). **Time filter** on the last change of the unit (`updated_at`): exactly one of `period`, `date` or `from`/`to` (like the other lists); **without any of them there is no time filter** — the list is complete, page through `next_cursor`. Several forms at once → 400 `period_conflict`, `from` after `to` → 400 `range_invalid`. Unknown or foreign location or garage type → 404. **Status:** for a reservation, `reserved` only holds while an active, non-expired reservation exists; an expired reservation that has not been released yet already shows as `available`. Units reserved manually or by a contract draft stay `reserved`. The **current lease** (`lease_id`, `tenant`, `current_rent`) is the running lease today (including notice and move-out phase, excluding future leases); `current_rent` is the base rent per the cost rate of the lease valid today (net, VAT rate as a fraction, gross rounded to cents; without service charges and deposit) and is `null` without a lease. `tenant` never contains address, email or phone. `meters` are the active meters of the unit. Dimensions and area come from the unit, otherwise from the garage type. `updated_at` only changes when the unit itself is changed — not for a new lease or tenant change; fetch the complete list regularly for the current occupancy. The token sees the whole organization.
Scope: units:read
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
period | query | Fixed period: today, yesterday, this_month, last_month, last_7_days, last_30_days (default), last_3_months, last_12_months or all. Not together with date, from or to |
date | query | Exactly one day, YYYY-MM-DD. Not together with period, from or to |
from | query | First day of the period, YYYY-MM-DD. For lists with period also possible as a timestamp, e.g. 2026-10-01T08:00:00Z |
to | query | Last day of the period (inclusive), YYYY-MM-DD. For lists with period also possible as a timestamp; then it is the exclusive upper bound |
location_id | query | Only this garage park (id from GET /v1/locations); without = whole organization |
storage_type_id | query | Prospect inquiries: only inquiries wishing for this garage type (id of a garage type). Units: only units of this garage type (id from the storage_type_id field of a unit) |
status | query | Bookings: open, partially_paid, paid, cancelled. Emails: sent, delivered, opened. Contractor orders: not_sent, sent, failed. Access log: HTTP status (e.g. 404) or class (4xx, 5xx). Units: available, reserved, rented, blocked. Damages: WEITERE_KLAERUNG, PLANEN_BEAUFTRAGEN, BEAUFTRAGT, ABGESCHLOSSEN, KEINE_REPARATUR. Statements: finalized, sent, cancelled. Webhook deliveries: pending, succeeded, failed, abandoned. Appointments (handover appointments only): planned, confirmed, rescheduled, completed, cancelled. Prospects: new, open_bank_details, bank_details_received, contract_ready, contract_signing, contract_submitted, waitlisted, converted, rejected, expired, withdrawn. Waiting list: waiting, notified, assigned, cancelled |
curl -s "https://api.manage2hero.com/v1/units" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/units" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/units', {
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",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "5b1e7c3a-2d49-4f60-9a8b-0c7d6e5f4a31",
"number": "A-14",
"location_id": "3f2a1b0c-9d8e-4f7a-8b6c-5d4e3f2a1b0c",
"location_name": "Garagenpark Nord",
"storage_type_id": "8e7d6c5b-4a39-4281-9706-f5e4d3c2b1a0",
"storage_type_name": "Einzelgarage",
"area": 18.5,
"width": 3,
"depth": 6,
"height": 2.4,
"has_water": false,
"is_insulated": true,
"has_electricity": true,
"has_high_voltage": false,
"status": "rented",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"tenant": {
"id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"display_name": "Erika Muster"
},
"current_rent": {
"net": 85,
"vat_rate": 0.19,
"gross": 101.15
},
"meters": [
{
"id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"type": "electricity",
"meter_number": "EZ-004711"
}
],
"created_at": "2025-03-01T08:00:00.123456+00:00",
"updated_at": "2026-09-28T14:21:07.654321+00:00"
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}GET/v1/units/{id}
Like an entry of `/v1/units`. Unknown or foreign id: 404 `not_found`.
Scope: units:read
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
curl -s "https://api.manage2hero.com/v1/units/5b1e7c3a-2d49-4f60-9a8b-0c7d6e5f4a31" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/units/5b1e7c3a-2d49-4f60-9a8b-0c7d6e5f4a31" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/units/5b1e7c3a-2d49-4f60-9a8b-0c7d6e5f4a31', {
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/5b1e7c3a-2d49-4f60-9a8b-0c7d6e5f4a31",
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"])Example response
{
"success": true,
"data": {
"id": "5b1e7c3a-2d49-4f60-9a8b-0c7d6e5f4a31",
"number": "A-14",
"location_id": "3f2a1b0c-9d8e-4f7a-8b6c-5d4e3f2a1b0c",
"location_name": "Garagenpark Nord",
"storage_type_id": "8e7d6c5b-4a39-4281-9706-f5e4d3c2b1a0",
"storage_type_name": "Einzelgarage",
"area": 18.5,
"width": 3,
"depth": 6,
"height": 2.4,
"has_water": false,
"is_insulated": true,
"has_electricity": true,
"has_high_voltage": false,
"status": "rented",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"tenant": {
"id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"display_name": "Erika Muster"
},
"current_rent": {
"net": 85,
"vat_rate": 0.19,
"gross": 101.15
},
"meters": [
{
"id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"type": "electricity",
"meter_number": "EZ-004711"
}
],
"created_at": "2025-03-01T08:00:00.123456+00:00",
"updated_at": "2026-09-28T14:21:07.654321+00:00"
},
"request_id": "req_4n2Xc9…"
}GET/v1/contracts
Lease contracts of your organization with term, status, deposit, terminations, supplements (electricity/water) and signature status, most recently changed first. **Time filter** on the last change (`updated_at`: the contract itself, a termination, a supplement or a signature/countersignature): exactly one of `period` (`today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days`, `last_3_months`, `last_12_months`, `all`), `date` (one day) or `from`/`to` (ISO date or timestamp; a date in `to` names the last day inclusive, a timestamp is the exclusive upper bound); several forms at once → 400 `period_conflict`, `from` after `to` → 400 `range_invalid`. **Without any of them the last 30 days apply** — use `period=all` for all contracts (page through `next_cursor`); to sync since a point in time use `from=<timestamp>`. **Limit of the sync:** `updated_at` captures new and changed contracts, terminations, supplements and signatures, but not the reset of a countersignature, the deletion of a termination or supplement, or deleted contracts — for a gap-free sync use the change stream (`GET /v1/contracts/changes`, see `changes/cursor` and `by-ids`). Days are in the Europe/Berlin time zone. Filters: `tenant_id`, `location_id` (contracts on garages of the location), `status` (contract status). Unknown tenant or location of your own organization → 404. The token sees the whole organization. **Signature:** `signature.tenant_signed`/`tenant_signed_at` (latest tenant signature), `countersigned`/`countersigned_at` (all signed documents countersigned by the landlord), `signed_documents` (main contract and annexes). The signed PDF is not part of this endpoint. **Supplements:** `effective_date` is the planned (`planned`) or executed day (`completed`, `confirmed`); requests, rejections and cancellations have none. **What never appears:** the contract text and snapshot, hashes, IP and device of the signature, file paths, free text and notes of the termination, bank and ID data. Contracts marked for deletion do not appear. Output also as `?format=csv` or `?format=xlsx` (terminations and supplements as JSON text in one cell).
Scope: contracts:read
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
period | query | Fixed period: today, yesterday, this_month, last_month, last_7_days, last_30_days (default), last_3_months, last_12_months or all. Not together with date, from or to |
date | query | Exactly one day, YYYY-MM-DD. Not together with period, from or to |
from | query | First day of the period, YYYY-MM-DD. For lists with period also possible as a timestamp, e.g. 2026-10-01T08:00:00Z |
to | query | Last day of the period (inclusive), YYYY-MM-DD. For lists with period also possible as a timestamp; then it is the exclusive upper bound |
tenant_id | query | Only this tenant |
location_id | query | Only this garage park (id from GET /v1/locations); without = whole organization |
status | query | Bookings: open, partially_paid, paid, cancelled. Emails: sent, delivered, opened. Contractor orders: not_sent, sent, failed. Access log: HTTP status (e.g. 404) or class (4xx, 5xx). Units: available, reserved, rented, blocked. Damages: WEITERE_KLAERUNG, PLANEN_BEAUFTRAGEN, BEAUFTRAGT, ABGESCHLOSSEN, KEINE_REPARATUR. Statements: finalized, sent, cancelled. Webhook deliveries: pending, succeeded, failed, abandoned. Appointments (handover appointments only): planned, confirmed, rescheduled, completed, cancelled. Prospects: new, open_bank_details, bank_details_received, contract_ready, contract_signing, contract_submitted, waitlisted, converted, rejected, expired, withdrawn. Waiting list: waiting, notified, assigned, cancelled |
curl -s "https://api.manage2hero.com/v1/contracts" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/contracts" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/contracts', {
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",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"contract_number": "V-2026-0042",
"status": "termination_received",
"lease_start": "2025-04-01",
"lease_end": "2026-12-31",
"tenant_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"unit_id": "3a7d1e55-2b9c-4f60-8d14-6c0e9b2a7f31",
"location_id": "5e2f8c10-7a3b-4d96-b1c8-0f4e6d2a9b73",
"deposit_amount": 450,
"notice_period_months": 3,
"minimum_lease_months": 12,
"signature": {
"tenant_signed": true,
"tenant_signed_at": "2025-03-12T09:41:07.512330+00:00",
"countersigned": true,
"countersigned_at": "2025-03-13T14:02:55.108421+00:00",
"signed_documents": 3
},
"terminations": [
{
"id": "c41e7f2a-98b3-4d05-a6e1-2b7c3d9f0e18",
"status": "confirmed",
"type": "ordinary",
"terminated_by": "tenant",
"termination_date": "2026-09-15",
"requested_end_date": "2026-12-31",
"end_date": "2026-12-31",
"decided_at": "2026-09-16T08:20:11.004512+00:00",
"created_at": "2026-09-15T18:03:27.771902+00:00"
}
],
"supplementary_agreements": [
{
"id": "e8a05b31-6c2d-4f79-9d83-1a4b7c0e5f26",
"resource_type": "electricity",
"agreement_type": "activation",
"status": "completed",
"requested_date": "2025-05-01",
"effective_date": "2025-05-06",
"created_at": "2025-04-28T10:15:44.201733+00:00"
}
],
"created_at": "2025-03-10T12:00:00.000000+00:00",
"updated_at": "2026-09-16T08:20:11.004512+00:00"
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}GET/v1/contracts/{id}
Like an entry of `/v1/contracts`. Unknown or foreign id and a contract marked for deletion: 404 `not_found`.
Scope: contracts:read
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
curl -s "https://api.manage2hero.com/v1/contracts/0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/contracts/0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/contracts/0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b', {
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/0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
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"])Example response
{
"success": true,
"data": {
"id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"contract_number": "V-2026-0042",
"status": "termination_received",
"lease_start": "2025-04-01",
"lease_end": "2026-12-31",
"tenant_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"unit_id": "3a7d1e55-2b9c-4f60-8d14-6c0e9b2a7f31",
"location_id": "5e2f8c10-7a3b-4d96-b1c8-0f4e6d2a9b73",
"deposit_amount": 450,
"notice_period_months": 3,
"minimum_lease_months": 12,
"signature": {
"tenant_signed": true,
"tenant_signed_at": "2025-03-12T09:41:07.512330+00:00",
"countersigned": true,
"countersigned_at": "2025-03-13T14:02:55.108421+00:00",
"signed_documents": 3
},
"terminations": [
{
"id": "c41e7f2a-98b3-4d05-a6e1-2b7c3d9f0e18",
"status": "confirmed",
"type": "ordinary",
"terminated_by": "tenant",
"termination_date": "2026-09-15",
"requested_end_date": "2026-12-31",
"end_date": "2026-12-31",
"decided_at": "2026-09-16T08:20:11.004512+00:00",
"created_at": "2026-09-15T18:03:27.771902+00:00"
}
],
"supplementary_agreements": [
{
"id": "e8a05b31-6c2d-4f79-9d83-1a4b7c0e5f26",
"resource_type": "electricity",
"agreement_type": "activation",
"status": "completed",
"requested_date": "2025-05-01",
"effective_date": "2025-05-06",
"created_at": "2025-04-28T10:15:44.201733+00:00"
}
],
"created_at": "2025-03-10T12:00:00.000000+00:00",
"updated_at": "2026-09-16T08:20:11.004512+00:00"
},
"request_id": "req_4n2Xc9…"
}GET/v1/tenants/{id}/timeline
Events of ONE tenant from the email history, dunning notices, payments, documents and contract in a single list, newest first. **Privacy:** a source only appears if the token ADDITIONALLY has its read scope — `emails:read` (emails), `dunning:read` (dunning notices), `payments:read` (payments), `documents:read` (documents), `contracts:read` (contract). `timeline:read` alone returns an empty list (200, `sources: []`), not an error. The additional scopes of the single endpoints (`emails:read_body`, `payments:read_counterparty`, `documents:download`) do not apply here: the timeline only carries short facts — never full text, counterparty, amount, file path or download link; details are in the respective single endpoint (via `object_id`). `sources` names the sources included for your token. An event only appears if the single endpoint also shows it for the same token. **Events and time (`occurred_at`):** `email_sent` (sending of the email), `dunning_notice_issued` (issue date of the dunning notice, start of the day in Europe/Berlin), `payment_received` (booking date of the incoming payment, start of the day in Europe/Berlin; only payments assigned to the tenant through a stored allocation to their contract — never guessed from name or IBAN), `document_added` (arrival of the document in the tenant file), `contract_created` (creation of the contract), `contract_signed` (latest tenant signature), `contract_countersigned` (latest countersignature, only once all signed documents are countersigned), `termination_filed` (receipt of a termination, `status` = termination status), `supplementary_agreement_filed` (receipt of a supplement, `status` = supplement status; per contract the latest 50 terminations and 100 supplements, as in `/v1/contracts`). Lease start and end are not events. **Fields:** `id` (unique, `<type>:<row id>` — for de-duplication), `source`, `type`, `object_id` (id of the single object; for a termination and a supplement the contract), `title` (email subject, file name, contract number; otherwise `null`), `status` (email status, dunning level `level_<n>`, allocation status of the payment, document category, termination/supplement status; otherwise `null`). **Time filter** on `occurred_at`: exactly one of `period` (`today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days`, `last_3_months`, `last_12_months`, `all`), `date` (one day) or `from`/`to` (ISO date or timestamp; a date in `to` names the last day inclusive, a timestamp is the exclusive upper bound); several forms at once → 400 `period_conflict`, `from` after `to` → 400 `range_invalid`. **Without any of them the last 30 days apply** — use `period=all` for the whole history (page through `next_cursor`; the cursor remembers the time limits of the first page). Days are in the Europe/Berlin time zone. **Paging:** the next page is not a snapshot; if a computed time changes between two calls (e.g. a new signature moves `contract_signed`), an event may be missing or appear twice — de-duplicate by `id` and fetch again if needed. Unknown or foreign tenant: 404. The token sees the whole organization. No CSV/Excel output.
Scope: timeline:read
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
period | query | Fixed period: today, yesterday, this_month, last_month, last_7_days, last_30_days (default), last_3_months, last_12_months or all. Not together with date, from or to |
date | query | Exactly one day, YYYY-MM-DD. Not together with period, from or to |
from | query | First day of the period, YYYY-MM-DD. For lists with period also possible as a timestamp, e.g. 2026-10-01T08:00:00Z |
to | query | Last day of the period (inclusive), YYYY-MM-DD. For lists with period also possible as a timestamp; then it is the exclusive upper bound |
curl -s "https://api.manage2hero.com/v1/tenants/9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d/timeline" \
-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" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/tenants/9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d/timeline', {
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",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "termination_filed:c41e7f2a-98b3-4d05-a6e1-2b7c3d9f0e18",
"occurred_at": "2026-09-15T18:03:27.771902+00:00",
"source": "contract",
"type": "termination_filed",
"object_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"title": "V-2026-0042",
"status": "requested"
},
{
"id": "payment_received:7d1f3c52-0a4b-4e68-9b21-5c8d7e6f4a30",
"occurred_at": "2026-09-02T22:00:00.000000+00:00",
"source": "payment",
"type": "payment_received",
"object_id": "7d1f3c52-0a4b-4e68-9b21-5c8d7e6f4a30",
"title": null,
"status": "matched"
},
{
"id": "email_sent:2f6a9d14-83c5-4b07-a1e2-9d3c8b7a6f51",
"occurred_at": "2026-09-02T07:15:09.482113+00:00",
"source": "email",
"type": "email_sent",
"object_id": "2f6a9d14-83c5-4b07-a1e2-9d3c8b7a6f51",
"title": "Ihre Zahlungserinnerung",
"status": "delivered"
}
],
"next_cursor": null,
"sources": [
"email",
"dunning",
"payment",
"document",
"contract"
]
},
"request_id": "req_4n2Xc9…"
}GET/v1/datev/exports
The DATEV exports of your organization requested via the API, with a **creation time** in the period, newest first: `status` (`requested` = running, `ready` = done, `failed` = failed with `failure_code`), location, year and month of the booking batch, format, number of booking records, size and whether the file can still be fetched. Time filter: exactly one form of `period` (`today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days`, `last_3_months`, `last_12_months`, `all`), `date` (one day) or `from`/`to` (a date in `to` names the last day inclusive, a datetime the exclusive upper bound); without any, the last 30 days apply (day boundaries in Europe/Berlin), several forms return 400 `period_conflict`, a reversed range 400 `range_invalid`. Filters: `status` and `location_id`. Page on with `next_cursor`. Only exports requested via the API appear — none from the management app. Never included: file name, storage location, booking data.
Scope: datev:export
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
period | query | Fixed period: today, yesterday, this_month, last_month, last_7_days, last_30_days (default), last_3_months, last_12_months or all. Not together with date, from or to |
date | query | Exactly one day, YYYY-MM-DD. Not together with period, from or to |
from | query | First day of the period, YYYY-MM-DD. For lists with period also possible as a timestamp, e.g. 2026-10-01T08:00:00Z |
to | query | Last day of the period (inclusive), YYYY-MM-DD. For lists with period also possible as a timestamp; then it is the exclusive upper bound |
status | query | Bookings: open, partially_paid, paid, cancelled. Emails: sent, delivered, opened. Contractor orders: not_sent, sent, failed. Access log: HTTP status (e.g. 404) or class (4xx, 5xx). Units: available, reserved, rented, blocked. Damages: WEITERE_KLAERUNG, PLANEN_BEAUFTRAGEN, BEAUFTRAGT, ABGESCHLOSSEN, KEINE_REPARATUR. Statements: finalized, sent, cancelled. Webhook deliveries: pending, succeeded, failed, abandoned. Appointments (handover appointments only): planned, confirmed, rescheduled, completed, cancelled. Prospects: new, open_bank_details, bank_details_received, contract_ready, contract_signing, contract_submitted, waitlisted, converted, rejected, expired, withdrawn. Waiting list: waiting, notified, assigned, cancelled |
location_id | query | Only this garage park (id from GET /v1/locations); without = whole organization |
curl -s "https://api.manage2hero.com/v1/datev/exports" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/datev/exports" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/datev/exports', {
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/datev/exports",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "2f6c1d3e-8a4b-4c5d-9e7f-0a1b2c3d4e5f",
"status": "ready",
"format": "datev",
"encoding": "windows-1252",
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"year": 2026,
"month": 9,
"row_count": 184,
"failure_code": null,
"file_available": true,
"file_size": 23871,
"file_expires_at": "2026-10-11T08:14:22.000Z",
"created_at": "2026-10-10T08:14:20.481516Z",
"completed_at": "2026-10-10T08:14:22.000Z"
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}POST/v1/datev/exports
Exports the **booking batch** of a location for one month (`location_id`, `year`, `month`) — with the same export run as the DATEV export in the management app (Finance), i.e. with the same tax keys, the same finalization flag and the same checks. `format`: `datev` (default, DATEV booking batch in EXTF format, character set Windows-1252, needs advisor and client number at the location) or `csv` (plain CSV, UTF-8 with BOM). The export runs **synchronously**: the response (201) names the finished export. `status = ready`: the file is available for **24 hours** (`POST /v1/datev/exports/{id}/download-link`). `status = failed`: `failure_code` names the reason (`datev_stammdaten_unvollstaendig`, `datev_bu_key_invalid`, `no_items_in_batch`, `export_changed_retry`, `storage_failed`, `internal_error`); details on rejected bookings are only shown in the management app. After correcting, request again with a new `Idempotency-Key`. The export **writes nothing to the accounting** (only the history is recorded) and returns the current state of the batch. The `Idempotency-Key` header is required: the same key with the same content returns the same export (200), with different content 409 `idempotency_conflict`. No booking batch for the location and month, or the location is unknown or foreign: 404 `not_found`. At most 30 new exports per organization and hour (429 `rate_limited`, `Retry-After`). The call counts as a write call (tighter quota per token). The token sees the whole organization (no location limits); the export contains the booking data of the batch — hand the token only to systems that may see it.
Scope: datev:export
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"])Example response
{
"success": true,
"data": {
"id": "2f6c1d3e-8a4b-4c5d-9e7f-0a1b2c3d4e5f",
"status": "ready",
"format": "datev",
"encoding": "windows-1252",
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"year": 2026,
"month": 9,
"row_count": 184,
"failure_code": null,
"file_available": true,
"file_size": 23871,
"file_expires_at": "2026-10-11T08:14:22.000Z",
"created_at": "2026-10-10T08:14:20.481516Z",
"completed_at": "2026-10-10T08:14:22.000Z"
},
"request_id": "req_4n2Xc9…"
}GET/v1/datev/exports/{id}
Like an entry of `/v1/datev/exports`: `status`, key figures and `file_available` (the file is ready, not expired and can be fetched). The export runs synchronously; you only see `requested` if you query it during the run — an order that stays open for more than 15 minutes counts as `failed` with `failure_code = timeout`. The file itself is only available via `POST /v1/datev/exports/{id}/download-link`. Unknown or foreign id: 404 `not_found`.
Scope: datev:export
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
curl -s "https://api.manage2hero.com/v1/datev/exports/2f6c1d3e-8a4b-4c5d-9e7f-0a1b2c3d4e5f" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/datev/exports/2f6c1d3e-8a4b-4c5d-9e7f-0a1b2c3d4e5f" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/datev/exports/2f6c1d3e-8a4b-4c5d-9e7f-0a1b2c3d4e5f', {
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/datev/exports/2f6c1d3e-8a4b-4c5d-9e7f-0a1b2c3d4e5f",
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"])Example response
{
"success": true,
"data": {
"id": "2f6c1d3e-8a4b-4c5d-9e7f-0a1b2c3d4e5f",
"status": "ready",
"format": "datev",
"encoding": "windows-1252",
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"year": 2026,
"month": 9,
"row_count": 184,
"failure_code": null,
"file_available": true,
"file_size": 23871,
"file_expires_at": "2026-10-11T08:14:22.000Z",
"created_at": "2026-10-10T08:14:20.481516Z",
"completed_at": "2026-10-10T08:14:22.000Z"
},
"request_id": "req_4n2Xc9…"
}POST/v1/datev/exports/{id}/download-link
Creates a **link that is valid for at most 5 minutes** (`expires_at`; never beyond the end of the retention of the file, `expires_in_seconds` gives the duration) and returns it in `url`. Do not store, share or log the link: anyone who has it can load the file — it contains the booking data of the batch. `encoding` names the character set of the file (`windows-1252` for `datev`, `utf-8` for `csv`). For a later request, create a new link — every call creates a new one and counts as a write call (tighter quota per token and minute). No body needed. The file is available for 24 hours after the export. The export is not finished yet: 409 `export_not_ready`. The export failed, the file expired or was removed: 404 `file_not_available`. Unknown or foreign id: 404 `not_found`.
Scope: datev:export
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
curl -s "https://api.manage2hero.com/v1/datev/exports/2f6c1d3e-8a4b-4c5d-9e7f-0a1b2c3d4e5f/download-link" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/datev/exports/2f6c1d3e-8a4b-4c5d-9e7f-0a1b2c3d4e5f/download-link" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/datev/exports/2f6c1d3e-8a4b-4c5d-9e7f-0a1b2c3d4e5f/download-link', {
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/datev/exports/2f6c1d3e-8a4b-4c5d-9e7f-0a1b2c3d4e5f/download-link",
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"])Example response
{
"success": true,
"data": {
"export_id": "2f6c1d3e-8a4b-4c5d-9e7f-0a1b2c3d4e5f",
"url": "https://<storage-host>/storage/v1/object/sign/…?token=…",
"expires_at": "2026-10-10T08:19:22.000Z",
"expires_in_seconds": 300,
"file_name": "datev_2026-09_2f6c1d3e.csv",
"mime_type": "text/csv",
"encoding": "windows-1252"
},
"request_id": "req_4n2Xc9…"
}GET/v1/prospects
Rental inquiries of prospects (from the public form, the tenant portal or via API), newest first. **Time filter** on the request time: exactly one of `period` (`today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days`, `last_3_months`, `last_12_months`, `all`), `date` (one day) or `from`/`to` (ISO date or timestamp; a date in `to` names the last day inclusive, a timestamp is the exclusive upper bound); several forms at once → 400 `period_conflict`, `from` after `to` → 400 `range_invalid`. **Without any of them the last 30 days apply**; everything is only available with `period=all` (page through `next_cursor`). Days are in the Europe/Berlin time zone. Filters: `location_id` (inquiries wishing for this location), `storage_type_id` (garage type), `status` (`new`, `open_bank_details`, `bank_details_received`, `contract_ready`, `contract_signing`, `contract_submitted`, `waitlisted`, `converted`, `rejected`, `expired`, `withdrawn`), `source` (`public_form`, `tenant_portal`, `api`) and `updated_since` (ISO timestamp; only inquiries changed since then — not a change feed, deleted inquiries do not appear). The token sees the whole organization. **Data protection:** only id, status, source, timestamps, name/company, usage type, multiple rental, `email_verified` and the wished locations and garage types (with start date and equipment) are returned — **never** email address, phone, address, date of birth, bank, credit or ID data, free text or notes. Only staff in the management app see these. Optionally as CSV/Excel (`format`); the wishes are then a JSON text in one cell.
Scope: prospects:read
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
period | query | Fixed period: today, yesterday, this_month, last_month, last_7_days, last_30_days (default), last_3_months, last_12_months or all. Not together with date, from or to |
date | query | Exactly one day, YYYY-MM-DD. Not together with period, from or to |
from | query | First day of the period, YYYY-MM-DD. For lists with period also possible as a timestamp, e.g. 2026-10-01T08:00:00Z |
to | query | Last day of the period (inclusive), YYYY-MM-DD. For lists with period also possible as a timestamp; then it is the exclusive upper bound |
location_id | query | Only this garage park (id from GET /v1/locations); without = whole organization |
storage_type_id | query | Prospect inquiries: only inquiries wishing for this garage type (id of a garage type). Units: only units of this garage type (id from the storage_type_id field of a unit) |
status | query | Bookings: open, partially_paid, paid, cancelled. Emails: sent, delivered, opened. Contractor orders: not_sent, sent, failed. Access log: HTTP status (e.g. 404) or class (4xx, 5xx). Units: available, reserved, rented, blocked. Damages: WEITERE_KLAERUNG, PLANEN_BEAUFTRAGEN, BEAUFTRAGT, ABGESCHLOSSEN, KEINE_REPARATUR. Statements: finalized, sent, cancelled. Webhook deliveries: pending, succeeded, failed, abandoned. Appointments (handover appointments only): planned, confirmed, rescheduled, completed, cancelled. Prospects: new, open_bank_details, bank_details_received, contract_ready, contract_signing, contract_submitted, waitlisted, converted, rejected, expired, withdrawn. Waiting list: waiting, notified, assigned, cancelled |
source | query | Origin of the inquiry: public_form (public form), tenant_portal (tenant portal) or api |
updated_since | query | Only records changed since this point in time (ISO 8601 with zone, e.g. 2026-10-01T08:00:00Z). Meters and statements: also accepted as a date (created or changed since). Damages: only created or changed since this point in time (not a gap-free change stream: overlap the window). Contractor orders: inclusive, also accepted as YYYY-MM-DD (start of the day); given alone, no creation period applies there |
curl -s "https://api.manage2hero.com/v1/prospects" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/prospects" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/prospects', {
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/prospects",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "4b7c2e19-8a3d-4f60-9c15-2d8e6a1b7f33",
"status": "new",
"source": "api",
"created_at": "2026-10-09T08:12:31.402117+00:00",
"updated_at": "2026-10-09T08:12:31.402117+00:00",
"first_name": "Max",
"last_name": "Muster",
"company": null,
"usage_type": "private",
"multiple_rental": false,
"unit_count": 1,
"email_verified": false,
"locations": [
{
"location_id": "5f1e9b0c-3a7d-4c82-b6e4-9d2a8c7f1e05",
"priority": 1,
"lease_start_date": "2026-11-01",
"unit_types": [
{
"storage_type_id": "a3c8d4e2-6b19-4f7a-8e05-1c9b2d3f4a60",
"priority": 1,
"power_option": "normalstrom",
"water_option": false,
"status": "new"
}
]
}
]
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}GET/v1/prospects/{id}
Like an entry of `/v1/prospects` (same fields, no contact data). Unknown or foreign id: 404 `not_found`.
Scope: prospects:read
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
curl -s "https://api.manage2hero.com/v1/prospects/4b7c2e19-8a3d-4f60-9c15-2d8e6a1b7f33" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/prospects/4b7c2e19-8a3d-4f60-9c15-2d8e6a1b7f33" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/prospects/4b7c2e19-8a3d-4f60-9c15-2d8e6a1b7f33', {
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/prospects/4b7c2e19-8a3d-4f60-9c15-2d8e6a1b7f33",
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"])Example response
{
"success": true,
"data": {
"id": "4b7c2e19-8a3d-4f60-9c15-2d8e6a1b7f33",
"status": "new",
"source": "api",
"created_at": "2026-10-09T08:12:31.402117+00:00",
"updated_at": "2026-10-09T08:12:31.402117+00:00",
"first_name": "Max",
"last_name": "Muster",
"company": null,
"usage_type": "private",
"multiple_rental": false,
"unit_count": 1,
"email_verified": false,
"locations": [
{
"location_id": "5f1e9b0c-3a7d-4c82-b6e4-9d2a8c7f1e05",
"priority": 1,
"lease_start_date": "2026-11-01",
"unit_types": [
{
"storage_type_id": "a3c8d4e2-6b19-4f7a-8e05-1c9b2d3f4a60",
"priority": 1,
"power_option": "normalstrom",
"water_option": false,
"status": "new"
}
]
}
]
},
"request_id": "req_4n2Xc9…"
}GET/v1/waitlist
Waiting-list entries (prospects and existing tenants waiting for a free garage). **Time filter** on the entry time: exactly one of `period` (`today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days`, `last_3_months`, `last_12_months`, `all`), `date` (one day) or `from`/`to` (ISO date or timestamp; a date in `to` names the last day inclusive, a timestamp is the exclusive upper bound); several forms at once → 400 `period_conflict`, `from` after `to` → 400 `range_invalid`. **Without any of them the last 30 days apply**; everything is only available with `period=all` (page through `next_cursor`). Days are in the Europe/Berlin time zone. Note: because of the default "last 30 days", older open entries only appear with `period=all`. Filters: `location_id`, `status` (`waiting`, `notified`, `assigned`, `cancelled`). `position` is the place in the waiting list of the location (1 = front, as in the management app) and only set for open entries (`waiting`, `notified`), otherwise `null`. `storage_type_ids` names the wished garage types (empty = any type). `prospect_id` refers to `GET /v1/prospects/{id}`, `tenant_id` to an existing tenant. **No names and no contact data.** Optionally as CSV/Excel (`format`).
Scope: prospects:read
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
period | query | Fixed period: today, yesterday, this_month, last_month, last_7_days, last_30_days (default), last_3_months, last_12_months or all. Not together with date, from or to |
date | query | Exactly one day, YYYY-MM-DD. Not together with period, from or to |
from | query | First day of the period, YYYY-MM-DD. For lists with period also possible as a timestamp, e.g. 2026-10-01T08:00:00Z |
to | query | Last day of the period (inclusive), YYYY-MM-DD. For lists with period also possible as a timestamp; then it is the exclusive upper bound |
location_id | query | Only this garage park (id from GET /v1/locations); without = whole organization |
status | query | Bookings: open, partially_paid, paid, cancelled. Emails: sent, delivered, opened. Contractor orders: not_sent, sent, failed. Access log: HTTP status (e.g. 404) or class (4xx, 5xx). Units: available, reserved, rented, blocked. Damages: WEITERE_KLAERUNG, PLANEN_BEAUFTRAGEN, BEAUFTRAGT, ABGESCHLOSSEN, KEINE_REPARATUR. Statements: finalized, sent, cancelled. Webhook deliveries: pending, succeeded, failed, abandoned. Appointments (handover appointments only): planned, confirmed, rescheduled, completed, cancelled. Prospects: new, open_bank_details, bank_details_received, contract_ready, contract_signing, contract_submitted, waitlisted, converted, rejected, expired, withdrawn. Waiting list: waiting, notified, assigned, cancelled |
curl -s "https://api.manage2hero.com/v1/waitlist" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/waitlist" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/waitlist', {
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",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "c91d3a7e-2b45-4e68-a0f3-7b1c5d9e2a48",
"location_id": "5f1e9b0c-3a7d-4c82-b6e4-9d2a8c7f1e05",
"status": "waiting",
"position": 3,
"created_at": "2026-09-21T14:03:55.118204+00:00",
"prospect_id": "4b7c2e19-8a3d-4f60-9c15-2d8e6a1b7f33",
"tenant_id": null,
"storage_type_ids": [
"a3c8d4e2-6b19-4f7a-8e05-1c9b2d3f4a60"
]
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}POST/v1/prospects
Creates a rental inquiry as if the person had filled in the public inquiry form: **the same field checks** (required fields, lengths, value lists, phone or mobile, date of birth and start date as real calendar days, start date not in the past, 1–5 locations with at most 20 garage types each, every location/garage type must belong to the organization and be offered there). **`privacy_consent` must be `true`** — the data-protection consent of the requesting person; without it 400. `Idempotency-Key` is required: the same key with the same content returns the same inquiry (200, even if the start date has passed in the meantime), with different content 409 `idempotency_conflict`. Bank details (`iban`, `bic`, `account_holder`) and `credit_check_consent` are not accepted (400 `field_not_allowed`) — the management app collects them later. **Differences from the form:** source `source: "api"`, the email address counts as **not verified** (`email_verified: false`), the API sends **no email** to the requesting person (confirmation and consent notices are the integrator's job), and there is no automatic link to a tenant-portal account. If the email address already belongs to another person (different name/date of birth), the inquiry is still created and marked with an important internal note in the management app; the response reveals nothing about it (no information leak). **Abuse limits:** at most 30 new inquiries per token and hour and 5 per email address and token in 24 hours (only your own API submissions are counted) → 429 `rate_limited` with `Retry-After`. Body at most 50 KB. No one-time links or credentials appear in the response; the response has the same shape as `GET /v1/prospects/{id}`. Additionally requires the management-app permission Rental process with edit access for the creator of the token.
Scope: prospects:write
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"])Example response
{
"success": true,
"data": {
"id": "4b7c2e19-8a3d-4f60-9c15-2d8e6a1b7f33",
"status": "new",
"source": "api",
"created_at": "2026-10-09T08:12:31.402117+00:00",
"updated_at": "2026-10-09T08:12:31.402117+00:00",
"first_name": "Max",
"last_name": "Muster",
"company": null,
"usage_type": "private",
"multiple_rental": false,
"unit_count": 1,
"email_verified": false,
"locations": [
{
"location_id": "5f1e9b0c-3a7d-4c82-b6e4-9d2a8c7f1e05",
"priority": 1,
"lease_start_date": "2026-11-01",
"unit_types": [
{
"storage_type_id": "a3c8d4e2-6b19-4f7a-8e05-1c9b2d3f4a60",
"priority": 1,
"power_option": "normalstrom",
"water_option": false,
"status": "new"
}
]
}
]
},
"request_id": "req_4n2Xc9…"
}GET/v1/appointments
Handover appointments (move-in `move_in`, move-out `move_out`) and general appointments (`general`, e.g. viewings) of your organization, **in ascending time order** (earliest first, ties by `id`). `date` and `time` are in Europe/Berlin; `starts_at` is the resulting point in time (UTC), the start of the day when there is no time. **Time filter** on the appointment time: exactly one of `period` (`today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days`, `last_3_months`, `last_12_months`, `all`), `date` (one day) or `from`/`to` (ISO date or timestamp; a date in `to` names the last day inclusive, a timestamp is the exclusive upper bound); several forms at once → 400 `period_conflict`, `from` after `to` → 400 `range_invalid`. **Without any of them the last 30 days apply** (past appointments!); for upcoming appointments use `upcoming=true` (everything from now on, not together with the time filters) or `from=<today>`. Filters: `type` (`move_in`, `move_out`, `general`), `status` (`planned`, `confirmed`, `rescheduled`, `completed`, `cancelled`; handover appointments only — general appointments have no status and drop out with this filter), `location_id`, `unit_id`, `tenant_id`, `lease_id` (unit, tenant and lease exist for handover appointments only). A foreign or unknown id in a filter → 404 `not_found`. The token sees the whole organization. **What never appears:** the caretaker, the people assigned to general appointments, internal notes and change reasons, handover windows, tasks and supplementary agreements (not appointments of this interface) and handover appointments without a date.
Scope: appointments:read
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
period | query | Fixed period: today, yesterday, this_month, last_month, last_7_days, last_30_days (default), last_3_months, last_12_months or all. Not together with date, from or to |
date | query | Exactly one day, YYYY-MM-DD. Not together with period, from or to |
from | query | First day of the period, YYYY-MM-DD. For lists with period also possible as a timestamp, e.g. 2026-10-01T08:00:00Z |
to | query | Last day of the period (inclusive), YYYY-MM-DD. For lists with period also possible as a timestamp; then it is the exclusive upper bound |
upcoming | query | Appointments: true = only appointments from now on (replaces the time filter; not together with period, date, from or to) |
type | query | Appointments: move_in, move_out or general (general appointment). Meters: meter kind electricity or water |
status | query | Bookings: open, partially_paid, paid, cancelled. Emails: sent, delivered, opened. Contractor orders: not_sent, sent, failed. Access log: HTTP status (e.g. 404) or class (4xx, 5xx). Units: available, reserved, rented, blocked. Damages: WEITERE_KLAERUNG, PLANEN_BEAUFTRAGEN, BEAUFTRAGT, ABGESCHLOSSEN, KEINE_REPARATUR. Statements: finalized, sent, cancelled. Webhook deliveries: pending, succeeded, failed, abandoned. Appointments (handover appointments only): planned, confirmed, rescheduled, completed, cancelled. Prospects: new, open_bank_details, bank_details_received, contract_ready, contract_signing, contract_submitted, waitlisted, converted, rejected, expired, withdrawn. Waiting list: waiting, notified, assigned, cancelled |
location_id | query | Only this garage park (id from GET /v1/locations); without = whole organization |
unit_id | query | Only this unit (garage; for meters: id from the units; for damages: id from unit.id of GET /v1/locations/{id}/tenants) |
tenant_id | query | Only this tenant |
lease_id | query | Only this lease |
curl -s "https://api.manage2hero.com/v1/appointments" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/appointments" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/appointments', {
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",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "3b9d1e6a-7c24-4f58-a1b3-8e2f6c4d9a07",
"type": "move_in",
"status": "planned",
"date": "2026-10-15",
"time": "10:30",
"starts_at": "2026-10-15T08:30:00+00:00",
"duration_minutes": null,
"title": null,
"description": "Schlüsselübergabe am Tor",
"location_id": "5e0b7a3c-2f1d-4c6b-9a8e-7d6c5b4a3f21",
"unit_id": "8a1c4e7b-3d52-4b96-8f0a-2c5e9d1b7a34",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"tenant_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"confirmed_by_tenant": false,
"created_at": "2026-10-10T09:12:44.318204+00:00",
"updated_at": null
},
{
"id": "6f2a9c3d-1b84-4e75-9d06-4a8b7c1e5f92",
"type": "general",
"status": null,
"date": "2026-10-20",
"time": "14:00",
"starts_at": "2026-10-20T12:00:00+00:00",
"duration_minutes": 30,
"title": "Besichtigung Garage 12",
"description": "Interessent trifft sich am Haupteingang",
"location_id": "5e0b7a3c-2f1d-4c6b-9a8e-7d6c5b4a3f21",
"unit_id": null,
"lease_id": null,
"tenant_id": null,
"confirmed_by_tenant": null,
"created_at": "2026-10-10T09:15:02.101933+00:00",
"updated_at": null
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}GET/v1/appointments/{id}
Like an entry of `/v1/appointments`. Unknown or foreign id and any appointment that `/v1/appointments` never returns (handover appointment without a date): 404 `not_found`.
Scope: appointments:read
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
curl -s "https://api.manage2hero.com/v1/appointments/3b9d1e6a-7c24-4f58-a1b3-8e2f6c4d9a07" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/appointments/3b9d1e6a-7c24-4f58-a1b3-8e2f6c4d9a07" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/appointments/3b9d1e6a-7c24-4f58-a1b3-8e2f6c4d9a07', {
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/3b9d1e6a-7c24-4f58-a1b3-8e2f6c4d9a07",
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"])Example response
{
"success": true,
"data": {
"id": "3b9d1e6a-7c24-4f58-a1b3-8e2f6c4d9a07",
"type": "move_in",
"status": "planned",
"date": "2026-10-15",
"time": "10:30",
"starts_at": "2026-10-15T08:30:00+00:00",
"duration_minutes": null,
"title": null,
"description": "Schlüsselübergabe am Tor",
"location_id": "5e0b7a3c-2f1d-4c6b-9a8e-7d6c5b4a3f21",
"unit_id": "8a1c4e7b-3d52-4b96-8f0a-2c5e9d1b7a34",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"tenant_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"confirmed_by_tenant": false,
"created_at": "2026-10-10T09:12:44.318204+00:00",
"updated_at": null
},
"request_id": "req_4n2Xc9…"
}POST/v1/appointments
Creates a **handover appointment** (`type` = `move_in` or `move_out`, with `lease_id`, `date`, `time`) or a **general appointment** (`type` = `general`, with `title`, `date`; optional `time`, `location_id`, `description`, `duration_minutes`) — following the rules of the appointment management (one transaction). Response: the created appointment (201). **Handover appointment:** lease of your own organization (otherwise 404 `not_found`); the caretaker is the location's default caretaker (none set → 400 with `lease_id: invalid_value`); `confirmed_by_tenant` (default `false`) sets the status `confirmed` instead of `planned`. There must be **no active appointment** for the lease and type (the management app would reschedule, the API never does): otherwise 409 `appointment_conflict`. As there, the lease status moves from "payment received" to "move-in protocol pending" or from "termination received" to "move-out protocol pending". **Date:** not in the past (today only with a time that has not passed yet; today without a time counts from the start of the day and is rejected), at most 3 years ahead (Europe/Berlin), otherwise 400 with `date: invalid_value`; a retry with the same key is not affected. **No emails and no notifications are sent** — neither to the caretaker nor to the tenant (the management app sends both when it sets a handover appointment). Required header `Idempotency-Key`: the same key with the same content returns the same appointment (200), with different content 409 `idempotency_conflict`. Retry detection is tied to the created appointment: if it is deleted in the management app before the retry arrives, the same request creates a new appointment (as with the tenant import). Fields that do not belong to the type are `unknown_field`. The scope `appointments:write` also needs `appointments:read`; handover appointments require the token creator to have the permission Leasing process (edit), general appointments the permission Calendar (edit). Body at most 16 KB.
Scope: appointments:write
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"])Example response
{
"success": true,
"data": {
"id": "3b9d1e6a-7c24-4f58-a1b3-8e2f6c4d9a07",
"type": "move_in",
"status": "planned",
"date": "2026-10-15",
"time": "10:30",
"starts_at": "2026-10-15T08:30:00+00:00",
"duration_minutes": null,
"title": null,
"description": "Schlüsselübergabe am Tor",
"location_id": "5e0b7a3c-2f1d-4c6b-9a8e-7d6c5b4a3f21",
"unit_id": "8a1c4e7b-3d52-4b96-8f0a-2c5e9d1b7a34",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"tenant_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"confirmed_by_tenant": false,
"created_at": "2026-10-10T09:12:44.318204+00:00",
"updated_at": null
},
"request_id": "req_4n2Xc9…"
}GET/v1/damages
The damage files of your organization, newest first. A damage is a **case file** of the management: at a unit (`kind = unit`) or on the grounds/park of a location (`kind = location`, with `process_type`). Reports from tenants or caretakers that still await the management’s decision only appear once accepted; handover-protocol damages without a case file are not included. **Time filter** on the report time (`reported_at`): exactly one of `period` (`today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days`, `last_3_months`, `last_12_months`, `all`), `date` (one day) or `from`/`to` (ISO date or datetime; a date in `to` names the last day inclusive, a datetime is the exclusive upper bound); **without any the last 30 days apply** (everything only with `period=all`, paged with `next_cursor`); several forms → 400 `period_conflict`, `from` after `to` → 400 `range_invalid`. Day boundaries in Europe/Berlin. Filters: `location_id` (grounds damages and damages at units of the location), `unit_id`, `lease_id` and `tenant_id` (lease or tenant of the tenant’s report; never the causer), `status` (`WEITERE_KLAERUNG`, `PLANEN_BEAUFTRAGEN`, `BEAUFTRAGT`, `ABGESCHLOSSEN`, `KEINE_REPARATUR`), `kind` (`unit`, `location`), `reporter_type` (`tenant`, `caretaker`, `administration`) and `updated_since` (ISO datetime with time zone, e.g. `2026-10-01T10:00:00Z`; file created or changed since then, also by a new or changed document — deleting a document does not count; **not a gap-free change stream** — for a gap-free sync use `GET /v1/damages/changes`). Optionally as CSV or Excel (`format=csv|xlsx`). **Privacy:** only the kind of reporter and causer, never names or contact data; no insurance, cost or item data. **`description` is free text and untrusted** (may contain arbitrary characters and third-party information) — never output it as HTML. The token sees the whole organization (no location limits).
Scope: damages:read
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
period | query | Fixed period: today, yesterday, this_month, last_month, last_7_days, last_30_days (default), last_3_months, last_12_months or all. Not together with date, from or to |
date | query | Exactly one day, YYYY-MM-DD. Not together with period, from or to |
from | query | First day of the period, YYYY-MM-DD. For lists with period also possible as a timestamp, e.g. 2026-10-01T08:00:00Z |
to | query | Last day of the period (inclusive), YYYY-MM-DD. For lists with period also possible as a timestamp; then it is the exclusive upper bound |
location_id | query | Only this garage park (id from GET /v1/locations); without = whole organization |
unit_id | query | Only this unit (garage; for meters: id from the units; for damages: id from unit.id of GET /v1/locations/{id}/tenants) |
lease_id | query | Only this lease |
tenant_id | query | Only this tenant |
status | query | Bookings: open, partially_paid, paid, cancelled. Emails: sent, delivered, opened. Contractor orders: not_sent, sent, failed. Access log: HTTP status (e.g. 404) or class (4xx, 5xx). Units: available, reserved, rented, blocked. Damages: WEITERE_KLAERUNG, PLANEN_BEAUFTRAGEN, BEAUFTRAGT, ABGESCHLOSSEN, KEINE_REPARATUR. Statements: finalized, sent, cancelled. Webhook deliveries: pending, succeeded, failed, abandoned. Appointments (handover appointments only): planned, confirmed, rescheduled, completed, cancelled. Prospects: new, open_bank_details, bank_details_received, contract_ready, contract_signing, contract_submitted, waitlisted, converted, rejected, expired, withdrawn. Waiting list: waiting, notified, assigned, cancelled |
kind | query | Damages: unit = at a unit, location = on the grounds (park) of a location |
reporter_type | query | Damages: kind of reporter tenant, caretaker or administration |
updated_since | query | Only records changed since this point in time (ISO 8601 with zone, e.g. 2026-10-01T08:00:00Z). Meters and statements: also accepted as a date (created or changed since). Damages: only created or changed since this point in time (not a gap-free change stream: overlap the window). Contractor orders: inclusive, also accepted as YYYY-MM-DD (start of the day); given alone, no creation period applies there |
curl -s "https://api.manage2hero.com/v1/damages" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/damages" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/damages', {
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",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "2f6c1b84-3a9d-4e57-8c10-b7d5e0a9c312",
"case_number": "VDF-12-003",
"kind": "unit",
"status": "WEITERE_KLAERUNG",
"process_type": null,
"damage_type": "dent",
"priority": "normal",
"description": "Delle im Rolltor, links unten.",
"damage_date": "2026-10-02",
"reported_at": "2026-10-03T07:41:12.482915+00:00",
"updated_at": null,
"reporter_type": "administration",
"causer_type": null,
"unit_id": "5b1e7a30-9c42-4d6f-a8e1-0c3f2d4b6a79",
"unit_number": "12",
"location_id": "8e2d4c61-7b05-4a93-9f1c-6d3e5a7b9c20",
"lease_id": null,
"tenant_id": null,
"document_count": 0
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}GET/v1/damages/{id}
Like an entry of `/v1/damages`, plus `documents`: references to the documents of the damage file (photos, receipts) — only `id`, `file_type`, `mime_type`, `file_size` and `created_at`, at most 200, **never file name, storage path or file address**; the files themselves cannot be retrieved via the API (`/v1/documents` covers tenant and location documents, not the documents of the damage file). Unknown or foreign id and handover-protocol damages without a case file: 404.
Scope: damages:read
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
curl -s "https://api.manage2hero.com/v1/damages/2f6c1b84-3a9d-4e57-8c10-b7d5e0a9c312" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/damages/2f6c1b84-3a9d-4e57-8c10-b7d5e0a9c312" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/damages/2f6c1b84-3a9d-4e57-8c10-b7d5e0a9c312', {
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/2f6c1b84-3a9d-4e57-8c10-b7d5e0a9c312",
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"])Example response
{
"success": true,
"data": {
"id": "2f6c1b84-3a9d-4e57-8c10-b7d5e0a9c312",
"case_number": "VDF-12-003",
"kind": "unit",
"status": "WEITERE_KLAERUNG",
"process_type": null,
"damage_type": "dent",
"priority": "normal",
"description": "Delle im Rolltor, links unten.",
"damage_date": "2026-10-02",
"reported_at": "2026-10-03T07:41:12.482915+00:00",
"updated_at": "2026-10-03T07:45:02.118+00:00",
"reporter_type": "administration",
"causer_type": null,
"unit_id": "5b1e7a30-9c42-4d6f-a8e1-0c3f2d4b6a79",
"unit_number": "12",
"location_id": "8e2d4c61-7b05-4a93-9f1c-6d3e5a7b9c20",
"lease_id": null,
"tenant_id": null,
"document_count": 1,
"documents": [
{
"id": "c4a8e1f2-6b30-4d79-9e15-2a7f0b3d8c64",
"file_type": "foto",
"mime_type": "image/jpeg",
"file_size": 284113,
"created_at": "2026-10-03T07:45:02.118+00:00"
}
]
},
"request_id": "req_4n2Xc9…"
}POST/v1/damages
Creates a case file at a unit (`unit_id`, `damage_type`: `paint`, `dent`, `oil_stain`, `hole`, `scratch`, `dirt`, `other`; `damage_date` not before 2000-01-01 and at most tomorrow; `description` 1–5000 characters; optional `priority`). The same file arises as with “Record new damage” in the management app: status **`WEITERE_KLAERUNG`** (open), reporter `administration`, the management assigns the case number. The API **never** creates reports from tenants or caretakers and bypasses no approval path; grounds/park damages, photos, amounts, items, invoices and causers cannot be recorded via the API. An unknown or foreign `unit_id` → 404. Writing call (own, tighter quota). **`Idempotency-Key` is required** (1–255 visible ASCII characters, e.g. one UUID per operation, valid 90 days per token): same key with the same content → the same success without a repeated effect (200), same key with different content → 409 `idempotency_conflict`. Response 201 with the created damage (repeat: 200).
Scope: damages:write
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"])Example response
{
"success": true,
"data": {
"id": "2f6c1b84-3a9d-4e57-8c10-b7d5e0a9c312",
"case_number": "VDF-12-003",
"kind": "unit",
"status": "WEITERE_KLAERUNG",
"process_type": null,
"damage_type": "dent",
"priority": "normal",
"description": "Delle im Rolltor, links unten.",
"damage_date": "2026-10-02",
"reported_at": "2026-10-03T07:41:12.482915+00:00",
"updated_at": null,
"reporter_type": "administration",
"causer_type": null,
"unit_id": "5b1e7a30-9c42-4d6f-a8e1-0c3f2d4b6a79",
"unit_number": "12",
"location_id": "8e2d4c61-7b05-4a93-9f1c-6d3e5a7b9c20",
"lease_id": null,
"tenant_id": null,
"document_count": 0,
"documents": []
},
"request_id": "req_4n2Xc9…"
}PATCH/v1/damages/{id}/status
Sets the repair status of a case file to one of the four values of stage 12 of the management app: `WEITERE_KLAERUNG`, `PLANEN_BEAUFTRAGEN`, `BEAUFTRAGT`, `ABGESCHLOSSEN` (also backwards, e.g. reopening). **`KEINE_REPARATUR` is neither target nor starting point** (400 `validation_error`): the value belongs to the billing decision of the management. If the requested status equals the current one, nothing changes (200). Approval and billing paths are not touched. **Note:** the API does not trigger the email “Damage repaired” to the reporting caretaker that the management app sends on completion. Unknown or foreign id and handover-protocol damages without a case file: 404. Writing call (own, tighter quota). **`Idempotency-Key` is required** (1–255 visible ASCII characters, e.g. one UUID per operation, valid 90 days per token): same key with the same content → the same success without a repeated effect (200), same key with different content → 409 `idempotency_conflict`.
Scope: damages:write
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
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"])Example response
{
"success": true,
"data": {
"id": "2f6c1b84-3a9d-4e57-8c10-b7d5e0a9c312",
"case_number": "VDF-12-003",
"kind": "unit",
"status": "PLANEN_BEAUFTRAGEN",
"process_type": null,
"damage_type": "dent",
"priority": "normal",
"description": "Delle im Rolltor, links unten.",
"damage_date": "2026-10-02",
"reported_at": "2026-10-03T07:41:12.482915+00:00",
"updated_at": "2026-10-03T09:12:44.51+00:00",
"reporter_type": "administration",
"causer_type": null,
"unit_id": "5b1e7a30-9c42-4d6f-a8e1-0c3f2d4b6a79",
"unit_number": "12",
"location_id": "8e2d4c61-7b05-4a93-9f1c-6d3e5a7b9c20",
"lease_id": null,
"tenant_id": null,
"document_count": 1,
"documents": [
{
"id": "c4a8e1f2-6b30-4d79-9e15-2a7f0b3d8c64",
"file_type": "foto",
"mime_type": "image/jpeg",
"file_size": 284113,
"created_at": "2026-10-03T07:45:02.118+00:00"
}
]
},
"request_id": "req_4n2Xc9…"
}GET/v1/utility/statements
The electricity and water statements of your organization per tenant and lease: billing period, meter readings, consumption and the **stored** amounts (work price, meter fee, wastewater, costs net/gross, VAT, advance payments, balance). The API recalculates nothing and adds no amounts; costs and balance appear as the management app has fixed them. Drafts never appear. `status` applies **per item**: `finalized` (fixed, not yet sent), `sent` (this item was sent by email) or `cancelled` (also a single cancelled item). **Order: by change time ascending** (`updated_at`, then `id`; most recently changed last) — so no row is lost while paging if it changes in the meantime; to find only new changes use `updated_since`. Time filter as for all lists: exactly one of the forms `period` (`today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days`, `last_3_months`, `last_12_months`, `all`), `date` (one day) or `from`/`to` (ISO date or datetime; a date in `to` names the last day inclusive, a datetime is the exclusive upper bound); several forms → 400 `period_conflict`, `from` after `to` → 400 `range_invalid`. The time filter refers to the creation time of the statement; without any the last 30 days — **with `year`, `lease_id` or `updated_since` and no time form, `all`** (so a change sync never misses a statement that was cancelled or sent only today). `year` is the year in which the billing period ends. `location_id` is the location of the run, for runs covering all locations the location of the unit of the lease (also in the filter). Further filters: `status`, `tenant_id`, `lease_id`, `location_id` (a foreign or unknown id → 404 `not_found`) and `updated_since` (ISO date or datetime: statements created or changed since then). Page on with `next_cursor`; CSV or Excel on request.
Scope: utility:read
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
period | query | Fixed period: today, yesterday, this_month, last_month, last_7_days, last_30_days (default), last_3_months, last_12_months or all. Not together with date, from or to |
date | query | Exactly one day, YYYY-MM-DD. Not together with period, from or to |
from | query | First day of the period, YYYY-MM-DD. For lists with period also possible as a timestamp, e.g. 2026-10-01T08:00:00Z |
to | query | Last day of the period (inclusive), YYYY-MM-DD. For lists with period also possible as a timestamp; then it is the exclusive upper bound |
year | query | Billing year = year in which the billing period ends; without any time form then all statements of that year |
status | query | Bookings: open, partially_paid, paid, cancelled. Emails: sent, delivered, opened. Contractor orders: not_sent, sent, failed. Access log: HTTP status (e.g. 404) or class (4xx, 5xx). Units: available, reserved, rented, blocked. Damages: WEITERE_KLAERUNG, PLANEN_BEAUFTRAGEN, BEAUFTRAGT, ABGESCHLOSSEN, KEINE_REPARATUR. Statements: finalized, sent, cancelled. Webhook deliveries: pending, succeeded, failed, abandoned. Appointments (handover appointments only): planned, confirmed, rescheduled, completed, cancelled. Prospects: new, open_bank_details, bank_details_received, contract_ready, contract_signing, contract_submitted, waitlisted, converted, rejected, expired, withdrawn. Waiting list: waiting, notified, assigned, cancelled |
tenant_id | query | Only this tenant |
lease_id | query | Only this lease |
location_id | query | Only this garage park (id from GET /v1/locations); without = whole organization |
updated_since | query | Only records changed since this point in time (ISO 8601 with zone, e.g. 2026-10-01T08:00:00Z). Meters and statements: also accepted as a date (created or changed since). Damages: only created or changed since this point in time (not a gap-free change stream: overlap the window). Contractor orders: inclusive, also accepted as YYYY-MM-DD (start of the day); given alone, no creation period applies there |
curl -s "https://api.manage2hero.com/v1/utility/statements" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/utility/statements" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/utility/statements', {
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",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "3f1e2d4c-5b6a-4789-9abc-0d1e2f3a4b5c",
"settlement_id": "8a7b6c5d-4e3f-4a2b-9c1d-0e9f8a7b6c5d",
"status": "sent",
"resource_type": "electricity",
"is_moveout_settlement": false,
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"tenant_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"meter_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
"period_from": "2025-01-01",
"period_to": "2025-12-31",
"reading_start": 1204.5,
"reading_end": 1873.2,
"consumption": 668.7,
"work_amount_net": 200.61,
"meter_fee_net": 12,
"wastewater_amount_net": null,
"costs_net": 212.61,
"vat_amount": 40.4,
"costs_gross": 253.01,
"advance_payments_total": 240,
"balance_net": 10.91,
"balance_gross": 13.01,
"invoice_number": "NK-2025-00042",
"sent_at": "2026-03-14T09:12:00.000Z",
"cancelled_at": null,
"created_at": "2026-03-10T14:03:21.000Z",
"updated_at": "2026-03-14T09:12:00.000Z"
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}GET/v1/meters
The electricity and water meters of your organization: meter number (`meter_number`), kind (`type`: `electricity`, `water`), voltage (electricity only: 230 or 400), status (`active`), unit (`unit_id`) and location (`location_id`). `is_main_meter: true` is the main meter (house connection) of a location, `false` a sub-meter of a unit. Order: by change time ascending (`updated_at`, then `id`). Filters: `location_id`, `unit_id` (a foreign or unknown id → 404 `not_found`), `type`, `active` and `updated_since` (ISO date or datetime). No time filter. Page on with `next_cursor`; CSV or Excel on request. The readings of a meter are returned by `GET /v1/meters/{id}/readings`.
Scope: utility:read
| Parameter | In | Description |
|---|---|---|
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
location_id | query | Only this garage park (id from GET /v1/locations); without = whole organization |
unit_id | query | Only this unit (garage; for meters: id from the units; for damages: id from unit.id of GET /v1/locations/{id}/tenants) |
type | query | Appointments: move_in, move_out or general (general appointment). Meters: meter kind electricity or water |
active | query | true = only active, false = only inactive garage parks or contractors or meters |
updated_since | query | Only records changed since this point in time (ISO 8601 with zone, e.g. 2026-10-01T08:00:00Z). Meters and statements: also accepted as a date (created or changed since). Damages: only created or changed since this point in time (not a gap-free change stream: overlap the window). Contractor orders: inclusive, also accepted as YYYY-MM-DD (start of the day); given alone, no creation period applies there |
curl -s "https://api.manage2hero.com/v1/meters" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/meters" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/meters', {
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/meters",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
"meter_number": "EHZ-4711-0815",
"type": "electricity",
"voltage": 230,
"active": true,
"is_main_meter": false,
"unit_id": "2e3f4a5b-6c7d-4e8f-9a0b-1c2d3e4f5a6b",
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"installed_at": "2023-05-02T00:00:00.000Z",
"created_at": "2023-05-02T08:00:00.000Z",
"updated_at": "2023-05-02T08:00:00.000Z"
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}POST/v1/meters
Creates a meter on a **unit** together with its **initial reading** (`protocol_type` `new_installation`, source `api`). Intended for the one-time transfer of meter numbers from a legacy system. The token additionally needs `utility:read`. Body: `unit_id` (id of the unit, from `GET /v1/units`), `type` (`electricity` or `water`), `voltage` (required for electricity: `230` or `400`; not allowed for water), `meter_number` (meter number, 1–100 characters), `reading_date` (day `YYYY-MM-DD`, at most one day in the future; also the installation date) and `meter_value` (number ≥ 0, value of the initial reading). The **initial reading is required**. The API does not create the main meter of a location or registers (OBIS). **Prerequisite:** the unit must be set up for the kind in the management app (electricity 230 V, electricity 400 V or water); otherwise 422 `unit_not_configured` — the API does not change master data of the unit. If the unit already has an active meter of this kind, 409 `meter_exists` is returned (existing meters: `GET /v1/meters?unit_id=…`). **`Idempotency-Key` is required** (1–255 characters): the same key with the same content returns the same meter (200), with different content 409 `idempotency_conflict`. Meter numbers need not be unique; the caller checks duplicates beforehand. Unknown or foreign unit: 404 `not_found`. Response: the meter as in `GET /v1/meters` plus `reading_id`. Writing calls have a tighter quota. In addition, the creator of the token needs the permissions to edit meter readings and electricity/water billing in the management app.
Scope: utility:write
curl -s -X POST "https://api.manage2hero.com/v1/meters" \
-H "Authorization: Bearer $M2H_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data '{"unit_id":"2e3f4a5b-6c7d-4e8f-9a0b-1c2d3e4f5a6b","type":"electricity","voltage":230,"meter_number":"EHZ-4711-0815","reading_date":"2026-10-01","meter_value":1873.2}'$headers = @{
Authorization = "Bearer $env:M2H_API_TOKEN"
"Idempotency-Key" = [guid]::NewGuid().ToString()
}
$body = @'
{
"unit_id": "2e3f4a5b-6c7d-4e8f-9a0b-1c2d3e4f5a6b",
"type": "electricity",
"voltage": 230,
"meter_number": "EHZ-4711-0815",
"reading_date": "2026-10-01",
"meter_value": 1873.2
}
'@
Invoke-RestMethod -Method Post -Uri "https://api.manage2hero.com/v1/meters" -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-${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', {
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": "2e3f4a5b-6c7d-4e8f-9a0b-1c2d3e4f5a6b",
"type": "electricity",
"voltage": 230,
"meter_number": "EHZ-4711-0815",
"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-{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",
headers={
"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}",
"Idempotency-Key": idempotency_key,
},
json={
"unit_id": "2e3f4a5b-6c7d-4e8f-9a0b-1c2d3e4f5a6b",
"type": "electricity",
"voltage": 230,
"meter_number": "EHZ-4711-0815",
"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"])Example response
{
"success": true,
"data": {
"id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
"meter_number": "EHZ-4711-0815",
"type": "electricity",
"voltage": 230,
"active": true,
"is_main_meter": false,
"unit_id": "2e3f4a5b-6c7d-4e8f-9a0b-1c2d3e4f5a6b",
"location_id": "6a1f0c2e-4d7b-4b8e-9f10-2c3d4e5f6a7b",
"installed_at": "2023-05-02T00:00:00.000Z",
"created_at": "2023-05-02T08:00:00.000Z",
"updated_at": "2023-05-02T08:00:00.000Z",
"reading_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
},
"request_id": "req_4n2Xc9…"
}GET/v1/meters/{id}/readings
The readings of a meter: reading date (`reading_date`), value (`meter_value`), register (`obis_code`, main electricity meter only), kind of reading (`protocol_type`, e.g. `interim_reading`, `per_move_in_protocol`), source (`source`: `api` = entered via the API, `app` = management app, caretaker or protocol), lease (`lease_id`) and `is_billed` (already billed). Newest first. Time filter as for all lists: exactly one of the forms `period` (`today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days`, `last_3_months`, `last_12_months`, `all`), `date` (one day) or `from`/`to` (ISO date or datetime; a date in `to` names the last day inclusive, a datetime is the exclusive upper bound); several forms → 400 `period_conflict`, `from` after `to` → 400 `range_invalid`. The time filter refers to the **reading date**; without any the last 30 days. Cancelled readings appear only with `include_cancelled=true`. Photos and readers are not returned. Unknown or foreign meter: 404 `not_found`. Page on with `next_cursor`; CSV or Excel on request.
Scope: utility:read
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
period | query | Fixed period: today, yesterday, this_month, last_month, last_7_days, last_30_days (default), last_3_months, last_12_months or all. Not together with date, from or to |
date | query | Exactly one day, YYYY-MM-DD. Not together with period, from or to |
from | query | First day of the period, YYYY-MM-DD. For lists with period also possible as a timestamp, e.g. 2026-10-01T08:00:00Z |
to | query | Last day of the period (inclusive), YYYY-MM-DD. For lists with period also possible as a timestamp; then it is the exclusive upper bound |
include_cancelled | query | true = also cancelled readings (default false) |
curl -s "https://api.manage2hero.com/v1/meters/5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a/readings" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/meters/5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a/readings" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/meters/5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a/readings', {
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/meters/5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a/readings",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"meter_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
"reading_date": "2026-10-01T00:00:00.000Z",
"meter_value": 1873.2,
"obis_code": null,
"protocol_type": "interim_reading",
"source": "api",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"is_cancelled": false,
"is_billed": false,
"created_at": "2026-10-02T07:45:10.000Z"
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}POST/v1/meters/{id}/readings
Enters a meter reading as an **interim reading** (`interim_reading`), source `api`. Move-in/move-out protocols and corrections remain reserved for the management app. The token additionally needs `utility:read`. Body: `reading_date` (day `YYYY-MM-DD`, at most one day in the future), `meter_value` (number ≥ 0), `obis_code` (required on the main electricity meter, e.g. `1.8.0` grid consumption; otherwise not allowed) and optionally `force_warnings`. The lease is determined from the reading date as in the management app. **Plausibility check of the management app:** errors (e.g. value lower than the previous one, `chronological_order`; date inside an already billed period, `in_billed_period`) return 400 `validation_error` with the codes in `error.details.plausibility`; warnings (`unrealistic_consumption`, …) return 400 with `error.details.plausibility_warnings` and are only accepted with `force_warnings: true`. **`Idempotency-Key` is required** (1–255 characters): the same key with the same content returns the same reading (200), with different content 409 `idempotency_conflict`. Unknown or foreign meter: 404 `not_found`; a deactivated meter accepts no new readings (400 `validation_error`). **Limit of idempotency:** it holds as long as the reading exists — if it is deleted in the management app, sending again with the same `Idempotency-Key` creates it anew. If the same meter is changed at the same time (e.g. in the management app), the API checks again; if that fails three times, 503 `service_unavailable` is returned — send again with the same `Idempotency-Key`. Writing calls have a tighter quota. In addition, the creator of the token needs the permissions to edit meter readings and electricity/water billing in the management app.
Scope: utility:write
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
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"])Example response
{
"success": true,
"data": {
"id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"meter_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
"reading_date": "2026-10-01T00:00:00.000Z",
"meter_value": 1873.2,
"obis_code": null,
"protocol_type": "interim_reading",
"source": "api",
"lease_id": "0b6c2c1a-8e5f-4c2d-9a1b-3c4d5e6f7a8b",
"is_cancelled": false,
"is_billed": false,
"created_at": "2026-10-02T07:45:10.000Z"
},
"request_id": "req_4n2Xc9…"
}GET/v1/webhooks
All webhook endpoints of your organization (at most 10), oldest first. **Not paginated:** `data` contains only `items`, no `next_cursor` (unlike the other lists). A webhook reports events as a signed POST to an https address of your system. **Never** the signing secret: it only appears once on creation and on rotation. `active = false` with `disabled_reason` shows a disabled webhook (`manual`, `delivery_failed`, `token_revoked`, `token_expired`, `creator_lost_access`, `api_disabled`). Counters: `pending_deliveries` (waiting deliveries, backlog), `paused_deliveries` (paused deliveries; for an active webhook a value above 0 means the resumption is still running), `enqueue_failures` (events that could not be queued and are still being re-queued or are lost) and `recording_gap` (`null` or `{ from, to }`: from when on events could no longer be recorded during the shutdown; `to` is `null` while the gap is open — the change reconciliation with `updated_since = from` closes it). Subscribable events:`payment.received`, `dunning.sent`, `contract.signed`, `invoice.created`, `document.created`, `email.sent`, `damage.created` (new case file, also when created through the API) as well as `contract.terminated` (termination confirmed), `invoice.cancelled` (invoice cancelled) and `damage.updated` (case file changed). New types apply only to webhooks that list them explicitly in `events`. The scope requires full access to API access from the token creator; if the token ends or the creator loses the permission, its webhooks are disabled.
Scope: webhooks:manage
| Parameter | In | Description |
|---|
curl -s "https://api.manage2hero.com/v1/webhooks" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/webhooks" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/webhooks', {
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/webhooks",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80",
"url": "https://erp.example.com/hooks/manage2hero",
"description": "ERP Buchhaltung",
"events": [
"payment.received",
"invoice.created"
],
"active": true,
"disabled_reason": null,
"disabled_at": null,
"created_at": "2026-10-10T08:15:30.412907+00:00",
"updated_at": "2026-10-10T08:15:30.412907+00:00",
"secret_rotated_at": null,
"previous_secret_valid_until": null,
"last_success_at": null,
"last_failure_at": null,
"enqueue_failures": 0,
"pending_deliveries": 0,
"paused_deliveries": 0,
"recording_gap": null
}
]
},
"request_id": "req_4n2Xc9…"
}POST/v1/webhooks
Creates a webhook: `url` (**https on port 443** only — any other port is rejected —, publicly reachable addresses only, no credentials in the address, no redirects), `events` (at least one) and optionally `description`. The address is checked (form and DNS) — otherwise 400 `webhook_url_invalid`; more than 10 webhooks per organization: 409 `webhook_limit_reached`. The 201 response (creation) contains the signing secret `secret` (`whsec_…`) **once**; store it securely right away, it cannot be read afterwards (only rotated). `Idempotency-Key` is required: the same key (with the same token) + same content returns, if the first creation had completed, the same webhook (200) **without** the secret; if it was interrupted before, the webhook is created now (201 with the secret). Different content: 409 `idempotency_conflict`. Delivery is done by the server every minute, never within the call; message format, signature and retries: webhooks guide.
Scope: webhooks:manage
curl -s -X POST "https://api.manage2hero.com/v1/webhooks" \
-H "Authorization: Bearer $M2H_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data '{"url":"https://erp.example.com/hooks/manage2hero","events":["payment.received","invoice.created"],"description":"ERP Buchhaltung"}'$headers = @{
Authorization = "Bearer $env:M2H_API_TOKEN"
"Idempotency-Key" = [guid]::NewGuid().ToString()
}
$body = '{"url":"https://erp.example.com/hooks/manage2hero","events":["payment.received","invoice.created"],"description":"ERP Buchhaltung"}'
Invoke-RestMethod -Method Post -Uri "https://api.manage2hero.com/v1/webhooks" -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-webhooks-${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/webhooks', {
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({
"url": "https://erp.example.com/hooks/manage2hero",
"events": [
"payment.received",
"invoice.created"
],
"description": "ERP Buchhaltung"
}),
});
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-webhooks-{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/webhooks",
headers={
"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}",
"Idempotency-Key": idempotency_key,
},
json={"url":"https://erp.example.com/hooks/manage2hero","events":["payment.received","invoice.created"],"description":"ERP Buchhaltung"},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])Example response
{
"success": true,
"data": {
"id": "3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80",
"url": "https://erp.example.com/hooks/manage2hero",
"description": "ERP Buchhaltung",
"events": [
"payment.received",
"invoice.created"
],
"active": true,
"disabled_reason": null,
"disabled_at": null,
"created_at": "2026-10-10T08:15:30.412907+00:00",
"updated_at": "2026-10-10T08:15:30.412907+00:00",
"secret_rotated_at": null,
"previous_secret_valid_until": null,
"last_success_at": null,
"last_failure_at": null,
"enqueue_failures": 0,
"pending_deliveries": 0,
"paused_deliveries": 0,
"recording_gap": null,
"secret": "whsec_Q2xhdWRlRXhhbXBsZVNlY3JldE5vdFJlYWwwMDAwMDA"
},
"request_id": "req_4n2Xc9…"
}GET/v1/webhooks/{id}
One webhook endpoint without the secret. Unknown or foreign id: 404 `not_found`.
Scope: webhooks:manage
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
curl -s "https://api.manage2hero.com/v1/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80', {
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/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80",
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"])Example response
{
"success": true,
"data": {
"id": "3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80",
"url": "https://erp.example.com/hooks/manage2hero",
"description": "ERP Buchhaltung",
"events": [
"payment.received",
"invoice.created"
],
"active": true,
"disabled_reason": null,
"disabled_at": null,
"created_at": "2026-10-10T08:15:30.412907+00:00",
"updated_at": "2026-10-10T08:15:30.412907+00:00",
"secret_rotated_at": null,
"previous_secret_valid_until": null,
"last_success_at": null,
"last_failure_at": null,
"enqueue_failures": 0,
"pending_deliveries": 0,
"paused_deliveries": 0,
"recording_gap": null
},
"request_id": "req_4n2Xc9…"
}PATCH/v1/webhooks/{id}
Changes `url`, `events`, `description` (`null` deletes it) or `active` — at least one field. The new address is checked as on creation (`webhook_url_invalid`). `active: false` disables (`disabled_reason = manual`) and **pauses** open deliveries (`paused`, nothing is discarded); as long as the API is enabled, new events keep being recorded (up to 10,000 per webhook, 29 days). `active: true` makes the calling token the creator token (the webhook then depends on its validity) — also for an active webhook; for a disabled webhook it also switches it back on, and the delivery service resumes the paused deliveries by itself (at most 60 per minute per webhook, oldest first, same event id). `paused_deliveries` (only together with `active: true`, otherwise 400 `validation_error`): `resume` (default) or `discard` — discards all paused deliveries (`abandoned`), e.g. when you catch up through change reconciliation. Unknown or foreign id: 404.
Scope: webhooks:manage
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
curl -s -X PATCH "https://api.manage2hero.com/v1/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80" \
-H "Authorization: Bearer $M2H_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data '{"events":["payment.received"],"active":true}'$headers = @{
Authorization = "Bearer $env:M2H_API_TOKEN"
"Idempotency-Key" = [guid]::NewGuid().ToString()
}
$body = '{"events":["payment.received"],"active":true}'
Invoke-RestMethod -Method Patch -Uri "https://api.manage2hero.com/v1/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80" -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-webhooks-3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80-${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/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80', {
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({
"events": [
"payment.received"
],
"active": 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-patch-v1-webhooks-3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80-{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/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80",
headers={
"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}",
"Idempotency-Key": idempotency_key,
},
json={"events":["payment.received"],"active":True},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])Example response
{
"success": true,
"data": {
"id": "3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80",
"url": "https://erp.example.com/hooks/manage2hero",
"description": "ERP Buchhaltung",
"events": [
"payment.received"
],
"active": true,
"disabled_reason": null,
"disabled_at": null,
"created_at": "2026-10-10T08:15:30.412907+00:00",
"updated_at": "2026-10-10T09:30:00.000000+00:00",
"secret_rotated_at": null,
"previous_secret_valid_until": null,
"last_success_at": null,
"last_failure_at": null,
"enqueue_failures": 0,
"pending_deliveries": 0,
"paused_deliveries": 0,
"recording_gap": null
},
"request_id": "req_4n2Xc9…"
}DELETE/v1/webhooks/{id}
Deletes the webhook including its delivery history; open deliveries are dropped. No query parameters, no body. Unknown, foreign or already deleted id: 404.
Scope: webhooks:manage
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
curl -s -X DELETE "https://api.manage2hero.com/v1/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Method Delete -Uri "https://api.manage2hero.com/v1/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80', {
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/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80",
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"])Example response
{
"success": true,
"data": {
"id": "3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80",
"deleted": true
},
"request_id": "req_4n2Xc9…"
}POST/v1/webhooks/{id}/rotate-secret
Creates a new signing secret and shows it **once** (`secret`). The previous one stays valid for **24 hours**: during that time the `X-M2H-Signature` header carries the old signature next to the new one (two `v1=` entries, `previous_secret_valid_until` names the end) so you can switch without downtime. A second rotation within these 24 hours ends the transition period of the first secret. `Idempotency-Key` is required; repeating an earlier key (even after further rotations) does not rotate again within **30 days** and does not show the secret again; after that the same key counts as a new operation. Unknown or foreign id: 404.
Scope: webhooks:manage
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
curl -s -X POST "https://api.manage2hero.com/v1/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80/rotate-secret" \
-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/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80/rotate-secret" -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-webhooks-3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80-rotate-secret-${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/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80/rotate-secret', {
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-webhooks-3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80-rotate-secret-{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/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80/rotate-secret",
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"])Example response
{
"success": true,
"data": {
"id": "3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80",
"url": "https://erp.example.com/hooks/manage2hero",
"description": "ERP Buchhaltung",
"events": [
"payment.received",
"invoice.created"
],
"active": true,
"disabled_reason": null,
"disabled_at": null,
"created_at": "2026-10-10T08:15:30.412907+00:00",
"updated_at": "2026-10-10T08:15:30.412907+00:00",
"secret_rotated_at": "2026-10-10T10:00:00.000000+00:00",
"previous_secret_valid_until": "2026-10-11T10:00:00.000000+00:00",
"last_success_at": null,
"last_failure_at": null,
"enqueue_failures": 0,
"pending_deliveries": 0,
"paused_deliveries": 0,
"recording_gap": null,
"secret": "whsec_Q2xhdWRlUm90YXRlZFNlY3JldE5vdFJlYWwwMDAwMDA"
},
"request_id": "req_4n2Xc9…"
}POST/v1/webhooks/{id}/test
Queues a test event `webhook.test` for exactly this webhook (same signature and headers as every other event, `data` contains `webhook_id`). The server sends it on its one-minute cycle — the result then appears in `GET /v1/webhooks/{id}/deliveries`. One attempt; a failure does **not** disable the webhook. `Idempotency-Key` is required (same key with the same token = the same test delivery, even after it finished, as long as it is in the delivery history — which keeps 30 days; after that the same key triggers a new test event). At most 3 test events wait at the same time per webhook: 429 `rate_limited`. A disabled webhook: 400 `validation_error`. Unknown or foreign id: 404.
Scope: webhooks:manage
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
curl -s -X POST "https://api.manage2hero.com/v1/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80/test" \
-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/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80/test" -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-webhooks-3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80-test-${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/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80/test', {
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-webhooks-3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80-test-{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/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80/test",
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"])Example response
{
"success": true,
"data": {
"delivery": {
"id": "b8d2e4f6-1a3c-4e5d-9f70-8c6b4a2e0d13",
"event_id": "0e7f5a31-9c2b-4d68-8a14-6f3e2d1c0b97",
"event_type": "webhook.test",
"status": "pending",
"attempts": 0,
"replay_count": 0,
"created_at": "2026-10-10T09:02:11.208154+00:00",
"last_attempt_at": null,
"next_attempt_at": "2026-10-10T09:02:11.208154+00:00",
"delivered_at": null,
"response_status": null,
"error": null
}
},
"request_id": "req_4n2Xc9…"
}GET/v1/webhooks/{id}/deliveries
Deliveries of this webhook, newest first, with the recipient status code and a fixed error code (`timeout`, `connection_failed`, `tls_error`, `blocked_target`, `redirect_not_followed`, `http_status`) — **never** the recipient response body and never the payload. `status`: `pending` (waiting for the next attempt), `succeeded`, `failed` (six unsuccessful attempts — the webhook was disabled), `paused` (held because the webhook is or was disabled; resumed by itself after re-enabling, not after 29 days), `abandoned` (discarded: `paused_deliveries: discard`, a test event on shutdown, or older than 29 days on resumption); `failed` and `abandoned` can be queued again by replay (`POST …/deliveries/{delivery_id}/retry`, `POST …/deliveries/retry-failed`), `replay_count` counts them. Time filter on the event time: exactly one form of `period` (`today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days`, `last_3_months`, `last_12_months`, `all`), `date` or `from`/`to`; without any, the last 30 days apply. Deliveries are kept for 30 days. Filters: `status`, `event_type`. Page on with `next_cursor`. Unknown or foreign id: 404.
Scope: webhooks:manage
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
limit | query | Entries per page, 1–200 (default 50; for imports: records per page, 1–500, default 100; for change sync 1–500, default 100) |
cursor | query | next_cursor of the previous page; required for change sync: starting cursor from …/changes/cursor, next_cursor or resume_cursor |
period | query | Fixed period: today, yesterday, this_month, last_month, last_7_days, last_30_days (default), last_3_months, last_12_months or all. Not together with date, from or to |
date | query | Exactly one day, YYYY-MM-DD. Not together with period, from or to |
from | query | First day of the period, YYYY-MM-DD. For lists with period also possible as a timestamp, e.g. 2026-10-01T08:00:00Z |
to | query | Last day of the period (inclusive), YYYY-MM-DD. For lists with period also possible as a timestamp; then it is the exclusive upper bound |
status | query | Bookings: open, partially_paid, paid, cancelled. Emails: sent, delivered, opened. Contractor orders: not_sent, sent, failed. Access log: HTTP status (e.g. 404) or class (4xx, 5xx). Units: available, reserved, rented, blocked. Damages: WEITERE_KLAERUNG, PLANEN_BEAUFTRAGEN, BEAUFTRAGT, ABGESCHLOSSEN, KEINE_REPARATUR. Statements: finalized, sent, cancelled. Webhook deliveries: pending, succeeded, failed, abandoned. Appointments (handover appointments only): planned, confirmed, rescheduled, completed, cancelled. Prospects: new, open_bank_details, bank_details_received, contract_ready, contract_signing, contract_submitted, waitlisted, converted, rejected, expired, withdrawn. Waiting list: waiting, notified, assigned, cancelled |
event_type | query | Only deliveries of this event, e.g. payment.received or webhook.test |
curl -s "https://api.manage2hero.com/v1/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80/deliveries" \
-H "Authorization: Bearer $M2H_API_TOKEN"$headers = @{ Authorization = "Bearer $env:M2H_API_TOKEN" }
Invoke-RestMethod -Uri "https://api.manage2hero.com/v1/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80/deliveries" -Headers $headersconst res = await fetch('https://api.manage2hero.com/v1/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80/deliveries', {
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/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80/deliveries",
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"])Example response
{
"success": true,
"data": {
"items": [
{
"id": "b8d2e4f6-1a3c-4e5d-9f70-8c6b4a2e0d13",
"event_id": "0e7f5a31-9c2b-4d68-8a14-6f3e2d1c0b97",
"event_type": "payment.received",
"status": "succeeded",
"attempts": 1,
"replay_count": 0,
"created_at": "2026-10-10T09:02:11.208154+00:00",
"last_attempt_at": "2026-10-10T09:02:40.000000+00:00",
"next_attempt_at": null,
"delivered_at": "2026-10-10T09:02:40.512300+00:00",
"response_status": 200,
"error": null
}
],
"next_cursor": null
},
"request_id": "req_4n2Xc9…"
}POST/v1/webhooks/{id}/deliveries/{delivery_id}/retry
Queues **one** failed (`failed`) or abandoned (`abandoned`) delivery again: it becomes `pending` again, its attempts start over (`attempts` from 0, `replay_count` counts the restorations) and it is sent on the next one-minute cycle — with the **same** event id (`X-M2H-Event-Id`) and the same body, but a new timestamp and a new signature. The webhook must be active and able to send (otherwise 400 `validation_error`: first `PATCH {"active": true}`). Not possible for waiting (`pending`) and delivered (`succeeded`) deliveries, for test events (`webhook.test`; use `POST /v1/webhooks/{id}/test` instead) and for deliveries older than 29 days: 409 `delivery_not_replayable` (`error.details.reason`: `not_failed`, `test_event`, `expired`, `paused` — paused deliveries are resumed by the delivery service by itself after re-enabling). At most 1000 restored deliveries per webhook and hour: 429 `rate_limited` with `Retry-After`. `Idempotency-Key` is required: the same key for the same delivery does not queue anything again (`replayed: false`, current state). If the delivery fails six more times, the webhook is disabled as usual. Unknown or foreign webhook or delivery id: 404.
Scope: webhooks:manage
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
delivery_id | path | id (UUID) |
curl -s -X POST "https://api.manage2hero.com/v1/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80/deliveries/b8d2e4f6-1a3c-4e5d-9f70-8c6b4a2e0d13/retry" \
-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/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80/deliveries/b8d2e4f6-1a3c-4e5d-9f70-8c6b4a2e0d13/retry" -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-webhooks-3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80-deliveries-b8d2e4f6-1a3c-4e5d-9f70-8c6b4a2e0d13-retry-${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/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80/deliveries/b8d2e4f6-1a3c-4e5d-9f70-8c6b4a2e0d13/retry', {
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-webhooks-3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80-deliveries-b8d2e4f6-1a3c-4e5d-9f70-8c6b4a2e0d13-retry-{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/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80/deliveries/b8d2e4f6-1a3c-4e5d-9f70-8c6b4a2e0d13/retry",
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"])Example response
{
"success": true,
"data": {
"delivery": {
"id": "b8d2e4f6-1a3c-4e5d-9f70-8c6b4a2e0d13",
"event_id": "0e7f5a31-9c2b-4d68-8a14-6f3e2d1c0b97",
"event_type": "payment.received",
"status": "pending",
"attempts": 0,
"replay_count": 1,
"created_at": "2026-10-10T09:02:11.208154+00:00",
"last_attempt_at": "2026-10-10T09:02:40.000000+00:00",
"next_attempt_at": "2026-10-12T09:30:00.000000+00:00",
"delivered_at": null,
"response_status": 503,
"error": "http_status"
},
"replayed": true
},
"request_id": "req_4n2Xc9…"
}POST/v1/webhooks/{id}/deliveries/retry-failed
Bulk call after re-enabling: queues failed (`failed`) and abandoned (`abandoned`) deliveries of this webhook again (effect per delivery as for `POST …/deliveries/{delivery_id}/retry`) — **oldest first, at most 100 per call**. The body is optional (empty or `{}` = everything of the last 29 days) and at most 4 KB (413 `payload_too_large`); filters: time range as in the delivery history (exactly one form of `period`, `date`, `from`/`to`), `status` (`failed` or `abandoned`), `event_type` (not `webhook.test`) and `limit` (1–100). Response: `replayed` (count) and `remaining` (further matching deliveries) — if `remaining > 0`, repeat the call with a **new** `Idempotency-Key`. The webhook must be active and able to send (otherwise 400 `validation_error`); at most 1000 restored deliveries per webhook and hour (429 `rate_limited` with `Retry-After`; if nothing matches, the call answers `replayed: 0`). `Idempotency-Key` is required: the same request with the same key does not queue anything again and reports `replayed` of the first call (`remaining` is counted anew). Unknown or foreign id: 404.
Scope: webhooks:manage
| Parameter | In | Description |
|---|---|---|
id | path | id (UUID) |
curl -s -X POST "https://api.manage2hero.com/v1/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80/deliveries/retry-failed" \
-H "Authorization: Bearer $M2H_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data '{"status":"abandoned","period":"last_7_days","limit":100}'$headers = @{
Authorization = "Bearer $env:M2H_API_TOKEN"
"Idempotency-Key" = [guid]::NewGuid().ToString()
}
$body = '{"status":"abandoned","period":"last_7_days","limit":100}'
Invoke-RestMethod -Method Post -Uri "https://api.manage2hero.com/v1/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80/deliveries/retry-failed" -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-webhooks-3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80-deliveries-retry-failed-${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/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80/deliveries/retry-failed', {
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({
"status": "abandoned",
"period": "last_7_days",
"limit": 100
}),
});
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-webhooks-3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80-deliveries-retry-failed-{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/webhooks/3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80/deliveries/retry-failed",
headers={
"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}",
"Idempotency-Key": idempotency_key,
},
json={"status":"abandoned","period":"last_7_days","limit":100},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])Example response
{
"success": true,
"data": {
"replayed": 42,
"remaining": 0
},
"request_id": "req_4n2Xc9…"
}POST/v1/sandbox/events
Only with a **sandbox token** (`m2h_test_…`; with a real token the endpoint does not exist: 404). Queues an event of type `type` (`payment.received`, `dunning.sent`, `contract.signed`, `invoice.created`, `document.created`, `email.sent`, `damage.created`, `contract.terminated`, `invoice.cancelled`, `damage.updated`) for a **sample object** of the sandbox - without any real booking, e-mail or payment. Recipients are the **active** webhooks of the sandbox that subscribe to the type (at most 3 webhooks per sandbox); response: `event_id`, `object_id` (the sample object, details by GET with your token), `recipients` (webhooks this request was queued for) and `queued` (queued deliveries) - both come from the deliveries of the request and do not change on a repeat with the same key, even if you have disabled or changed webhooks in the meantime - and `delivery`. The server sends on its one-minute cycle, **with signature, retries, self-disabling and replay like real webhooks**, and the message carries `livemode: false` (and the header `X-M2H-Livemode: false`); the history is in `GET /v1/webhooks/{id}/deliveries`. `delivery: not_in_this_environment` means: development and staging do not deliver anything (only production sends) - the deliveries stay pending there. Delivery only happens with active API access (otherwise 403 `sandbox_unavailable` on every call). `Idempotency-Key` is required: the same key selects the same sample object and queues nothing again; the `event_id` stays stable for about seven days (as long as the deliveries exist), after that a new request starts with a new `event_id`. Quota: at most 100 waiting and 500 simulated deliveries queued per 24 hours per sandbox (422 `sandbox_limit_reached`); deliveries of the sandbox are deleted after 7 days.
Scope: webhooks:manage
curl -s -X POST "https://api.manage2hero.com/v1/sandbox/events" \
-H "Authorization: Bearer $M2H_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data '{"type":"payment.received"}'$headers = @{
Authorization = "Bearer $env:M2H_API_TOKEN"
"Idempotency-Key" = [guid]::NewGuid().ToString()
}
$body = @'
{
"type": "payment.received"
}
'@
Invoke-RestMethod -Method Post -Uri "https://api.manage2hero.com/v1/sandbox/events" -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-sandbox-events-${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/sandbox/events', {
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": "payment.received"
}),
});
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-sandbox-events-{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/sandbox/events",
headers={
"Authorization": f"Bearer {os.environ['M2H_API_TOKEN']}",
"Idempotency-Key": idempotency_key,
},
json={
"type": "payment.received"
},
timeout=30,
)
body = res.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])Example response
{
"success": true,
"data": {
"event_id": "0e7f5a31-9c2b-4d68-8a14-6f3e2d1c0b97",
"event_type": "payment.received",
"object_id": "7c1d9e2a-3b4f-4a65-8d70-1e2f3a4b5c6d",
"recipients": 1,
"queued": 1,
"delivery": "enabled"
},
"request_id": "req_4n2Xc9…"
}