Requests & responses
Base URL and versioning
Base URL: https://api.manage2hero.com. All endpoints start with /v1.
Versioning, changes and deprecation
The version is part of the path (/v1). Within v1 we only make additive changes. Breaking changes would only come with a new version (/v2); the old one would remain available in parallel for at least 12 months. The period starts on the date of the entry in the changelog that announces the new version.
| Change | Within v1 | Notice |
|---|---|---|
| New endpoints, new optional query parameters | possible at any time | entry in the changelog |
| New fields in responses and in webhook messages | possible at any time | entry in the changelog |
| New values in existing enumerations (error codes, status values, event types, disable reasons, scopes, garage types) | possible at any time | entry in the changelog |
| Texts (error.message, descriptions) | may change | none |
| Removing or renaming endpoints, fields, parameters, error codes or webhook fields; changed meaning or type of a field; new required parameters | not in v1 | only with a new version (/v2), 12 months in parallel |
What your program should do: ignore unknown fields, not crash on unknown values in enumerations (status, error code, event type) but treat them as “unknown”, and rely on error.codeonly, never on error.message. In webhook messages id, type, created_at and data keep their meaning; the signature is computed as in the webhooks guide — evaluate only t and v1 in the X-M2H-Signature header and ignore further entries.
Code generators: Enumerations (enum) in openapi.json are open sets of values: new status, error, event and scope values may be added within v1 (see the table above). Plan a fallback branch for unknown values in generated clients and validators (no hard failure, no exhaustive switch without a default case) and treat them as “unknown”.
Webhooks and new event types: A webhook only receives the event types it subscribed to in events. A new event type therefore does not reach existing webhooks by itself; it has to be added through PATCH /v1/webhooks/{id}. The list of subscribable types only grows.
Deprecation: Within v1 we do not remove anything. Should a function nevertheless have to change at short notice for security reasons, this is stated in the changelog immediately. The API does not currently send the headers Deprecation and Sunset; if we introduce them, the changelog will say so — do not rely on them, read the changelog.
Specification revisions: besides info.version (the API version, 1.0.0), openapi.json carries the field info.x-revision — the date of the latest changelog entry that affects the specification (currently 2026-10-20). Generated clients and validation tools can tell by it whether the description changed; the corresponding change is listed under that date in the changelog. For reproducible builds, store the openapi.json together with its revision.
Envelope
{ "success": true, "data": { … }, "request_id": "req_…" }{ "success": false, "error": { "code": "…", "message": "…" }, "request_id": "req_…" }error.code is stable and meant for programs; error.message is an explanatory text that may change and is currently returned in German. The request_id is also sent in the X-Request-Id header — please quote it when contacting us.
Paginated lists
Lists return data.items and data.next_cursor (the only exception: GET /v1/webhooks has no next_cursor, at most 10 entries — see the note below). Use ?limit= (1–200, default 50) to control the page size. If next_cursor is not null, fetch the next page with ?cursor=<next_cursor>. Please pass the cursor back unchanged.
GET /v1/webhooks returns all webhooks of the organization (at most 10) in one response; there data contains only items, no next_cursor.async function fetchAll(path) {
const items = [];
let cursor = null;
do {
const url = new URL(path, 'https://api.manage2hero.com');
url.searchParams.set('limit', '200');
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.M2H_API_TOKEN}` } });
const body = await res.json();
if (!body.success) throw new Error(body.error.code);
items.push(...body.data.items);
cursor = body.data.next_cursor;
} while (cursor);
return items;
}List fetch or change sync?
| Aspect | List fetch (cursor) | Change sync (changes) |
|---|---|---|
| Suited for | fetching and evaluating a period, exports, one-time initial load | ongoing synchronization: new, changed and removed items since the last sync |
| Available for | all lists | bookings, invoices, payments, dunning_notices, documents, emails, contracts, damages |
| The cursor means | position in the sort order (and, for lists with a time filter, the time bounds resolved on the first call) | state of the sync; valid for 90 days (410 cursor_expired) |
Cursor and filters: pass the cursor back unchanged and send the same filters (status, location, time filter etc.) on every following page as on the first. With other filters the result is not defined. A cursor only applies to the endpoint it came from.
No snapshot: pages are read one after the other. If a record changes or is created while you page through, it may appear on a later page or be missing; we do not guarantee a snapshot across pages. Match records by their id (duplicates overwrite) and use the change sync instead of repeated full fetches for ongoing synchronization.
Limits per call (resource-specific):
| Call | Page size | Maximum span / size |
|---|---|---|
| Lists in general | 1–200 (default 50); file (CSV/Excel): one page, up to 200 rows | — |
/v1/bookings | as in general | at most 366 calendar days per call (from and to both count; to − from at most 365) (otherwise 400 range_too_large) |
/v1/payments | as in general | at most 366 calendar days per call (from and to both count; to − from at most 365) |
/v1/invoices | as in general | at most 1,831 calendar days per call (from and to both count; to − from at most 1,830) |
/v1/dunning/notices | as in general | at most 1,831 calendar days per call (from and to both count; to − from at most 1,830) |
…/changes | 1–500 (default 100) | changes kept for 90 days |
…/by-ids | up to 100 identifiers in ids | — |
/v1/tenant-imports/{id} | records per page 1–500 (default 100) | 1 MB per request (takeover 64 KB) |
Syncing changes
For bookings, invoices, incoming payments, dunning notices, documents, emails, contracts and damages you do not have to fetch everything every time: (1) get a starting cursor with GET /v1/…/changes/cursor, (2) fetch everything once via the list, (3) then regularly call GET /v1/…/changes?cursor=…. The response lists the IDs that are new, changed or removed. Reload each via GET /v1/…/by-ids?ids=…: what it returns is the current state; if an ID is missing there, remove the record on your side. The order of the entries is not the order of the changes. Page on with next_cursor; when it is null, keep resume_cursor for the next sync. Only committed changes appear — even one that was still running during your fetch reliably comes next time. Changes are kept for 90 days; an older cursor returns 410 cursor_expired (then start again at step 1). Changes of linked master data (e.g. contract number) do not trigger an event — an occasional full fetch, e.g. weekly, reconciles them.
Idempotency: retrying writes safely
Writing endpoints that create or trigger something require the header Idempotency-Key (1–255 visible ASCII characters, best one UUID per operation; if it is missing: 400 idempotency_key_required, invalid characters: 400 validation_error). This makes a call safe to repeat after a timeout or lost connection: the same request only takes effect once. The same key with the same content returns the result of the first call (usually 200 instead of 201); the same key with different content results in 409 idempotency_conflict — this is the default rule. There is exactly one exception: POST /v1/webhooks/{id}/deliveries/retry-failed binds the key to the filters of the request; another filter with the same key is a new operation there and does not result in a 409 (table “Per endpoint”).
The flow in your client
- Generate and store the key before you send — together with the operation (database, queue). Only then do you still know it after a crash.
- Fix the body once and use it unchanged for every retry (same fields, same values). Changed content with the old key is an error (
409), not a new operation. - Send with a timeout (e.g. 30 seconds). If it expires or the connection drops, the outcome is unknown: the server may have carried out the operation.
- Retry with the same key and the same body on timeout, lost connection,
429(wait forRetry-Afterfirst),500and503— with a growing wait and an upper limit of attempts — also on409 readings_locked(meter readings are being edited right now; after a few seconds). A retry does not fix any other error (400,401,403,404,409,413,415,422): correct the input or the access; for a changed request use a new key. - Mark it done only after the response and do not reuse the key afterwards: a key belongs to exactly one operation.
import { randomUUID } from 'node:crypto';
const BASE = 'https://api.manage2hero.com';
// 1. Persist the operation together with its key BEFORE sending
// (store/markDone: your database, file or queue).
const operation = {
key: randomUUID(),
body: JSON.stringify({ url: 'https://erp.example.com/hooks/manage2hero', events: ['payment.received'] }),
};
await store(operation);
// 2. Send. On timeout, connection loss, 429, 500 and 503 retry with the SAME key
// and an unchanged body, with a growing wait.
async function send(op) {
for (let attempt = 1; attempt <= 5; attempt++) {
let res = null;
try {
res = await fetch(BASE + '/v1/webhooks', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.M2H_API_TOKEN,
'Content-Type': 'application/json',
'Idempotency-Key': op.key,
},
body: op.body,
signal: AbortSignal.timeout(30_000),
});
} catch {
// Timeout or abort: the outcome is unknown. That is exactly what the key is for.
}
// 200/201 = success; a retry does not fix other 4xx (correct the input or the access).
if (res && res.status !== 429 && res.status < 500) return res;
const wait = Number(res?.headers.get('Retry-After')) || 2 ** attempt;
await new Promise((r) => setTimeout(r, wait * 1000));
}
throw new Error('No result after 5 attempts. Keep operation and key, send again later.');
}
// 3. Evaluate; only now mark the operation as done.
const answer = await (await send(operation)).json();
if (!answer.success) throw new Error(answer.error.code + ' (' + answer.request_id + ')');
await markDone(operation.key, answer.data);secret is only in the first successful response (201); a retry answers 200 without the secret. If the first response was lost, create a new one with rotate-secret.Per endpoint
The binding of the key is not the same everywhere. For retries always use the same token and a fresh key per operation; then you are independent of the differences. If a key is bound to the organization, another token of the same organization hits it too; if it is bound to the token, the same key with another token is a new operation.
| Endpoint | Key is bound to | Retention | Same key, other token of the organization |
|---|---|---|---|
POST /v1/webhooks | token and creation | As long as the webhook exists; deleting it makes the API forget the key | New operation |
POST /v1/webhooks/{id}/rotate-secret | token and the operation “rotation” | 30 days | New operation |
POST /v1/webhooks/{id}/test | token and the operation “test” | As long as the test delivery is in the delivery history (30 days); after that the same key triggers a new test event | New operation |
POST /v1/sandbox/events | token and the operation “sandbox event”: the same key selects the same sample object and queues nothing again | 7 days (sandbox deliveries) | New operation |
POST /v1/webhooks/{id}/deliveries/{delivery_id}/retry | token and exactly this delivery | 30 days | New operation |
POST /v1/webhooks/{id}/deliveries/retry-failed | token and the filters of the request; the same key with another filter is a new operation (no 409) | 30 days | New operation |
POST /v1/tenant-imports | organization | As long as the import exists (completed imports are deleted after 30 days, open ones after 90 days, a cancelled one immediately) | Match: the same operation |
POST /v1/tenant-imports/{id}/commit | no Idempotency-Key needed | Repeating is harmless: records already taken over report already_transferred | as with any token |
POST /v1/damages, PATCH /v1/damages/{id}/status | token; both endpoints share the keys | 90 days. If the key belongs to a deleted damage report, the API answers 404 and creates nothing new | New operation |
POST /v1/appointments | organization | As long as the appointment exists; a deleted appointment is created anew on a retry | Match: the same operation |
POST /v1/meters/{id}/readings | token | As long as the reading exists (a cancelled one still matches); a retry is not checked for plausibility again | New operation |
POST /v1/prospects | token | As long as the inquiry exists | New operation |
POST /v1/datev/exports | organization | As long as the export entry exists (the file itself can only be fetched for 24 hours) | Match: the same operation |
Time filter
The newer lists share one time filter: documents, emails, access log, contracts, statements and meter readings, DATEV exports, contractor orders, damage reports, appointments, prospects, waiting list and the delivery history of webhooks. Which point in time the filter refers to (creation, last change, appointment) is stated in the respective guide and in the reference. Use exactly one of the three forms; several at once give 400 period_conflict.
| Parameter | Meaning |
|---|---|
period=today | Today. Also: yesterday, this_month, last_month, last_7_days, last_30_days, last_3_months, last_12_months |
period=all | No time filter — page through with the cursor |
date=2026-10-04 | Exactly one calendar day |
from=…&to=… | Free range. A date in to names the last day (inclusive), a timestamp (2026-10-04T08:00:00Z) is the exclusive upper bound. from after to gives 400 range_invalid |
Without any of them the last 30 days apply, not “all” — so there is no accidentally huge request. Exceptions, stated in the respective guide: units, meters and contractors (master data) have no time filter, appointments can be fetched from now on with upcoming=true, and for statements with year, lease_id or updated_since and no time form, period=all applies. Days and months are calculated in the Europe/Berlin time zone (including daylight saving time). The older lists (bookings, invoices, payments) keep their existing from and to as plain calendar days.
Retrieve as a file (CSV, Excel)
Bookings, invoices, payments, dunning notices, open items, emails, contracts, statements, meters and meter readings, contractors and their orders, damage reports, appointments, prospects and the waiting list return a file instead of JSON on request: ?format=csv or ?format=xlsx. For CSV, delimiter selects the separator (; for German Excel, the default, or ,; also as the words semicolon and comma). The file is UTF-8 with a marker for umlauts. Errors always come as JSON.
curl -H "Authorization: Bearer $TOKEN" -o bookings.csv "https://api.manage2hero.com/bookings?from=2026-09-01&to=2026-09-30&format=csv"A file holds one page at most (up to 200 rows). The cursor of the next page comes in the response header X-Next-Cursor; without the header it was the last page. Text that starts with =, +, - or @ gets a leading ' so that Excel does not run it as a formula; numbers and amounts stay real numbers. Several values of one row (for example the allocations of a lump-sum payment) are written as JSON text into one cell. Excel rejects cells over 32,767 characters; the API then answers 400 unsupported_format pointing to CSV or JSON.
Formats
Timestamps in ISO 8601 in UTC (2026-09-24T08:15:00.000Z), calendar dates as YYYY-MM-DD, rates as a decimal fraction between 0 and 1 (0.8667 = 86.67 %). Character set UTF-8.