Skip to content

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.

ChangeWithin v1Notice
New endpoints, new optional query parameterspossible at any timeentry in the changelog
New fields in responses and in webhook messagespossible at any timeentry in the changelog
New values in existing enumerations (error codes, status values, event types, disable reasons, scopes, garage types)possible at any timeentry in the changelog
Texts (error.message, descriptions)may changenone
Removing or renaming endpoints, fields, parameters, error codes or webhook fields; changed meaning or type of a field; new required parametersnot in v1only 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
{ "success": true, "data": { … }, "request_id": "req_…" }
Error
{ "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.

Exception without paging: GET /v1/webhooks returns all webhooks of the organization (at most 10) in one response; there data contains only items, no next_cursor.
JavaScript: fetch all pages
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?

AspectList fetch (cursor)Change sync (changes)
Suited forfetching and evaluating a period, exports, one-time initial loadongoing synchronization: new, changed and removed items since the last sync
Available forall listsbookings, invoices, payments, dunning_notices, documents, emails, contracts, damages
The cursor meansposition 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):

CallPage sizeMaximum span / size
Lists in general1–200 (default 50); file (CSV/Excel): one page, up to 200 rows—
/v1/bookingsas in generalat most 366 calendar days per call (from and to both count; to − from at most 365) (otherwise 400 range_too_large)
/v1/paymentsas in generalat most 366 calendar days per call (from and to both count; to − from at most 365)
/v1/invoicesas in generalat most 1,831 calendar days per call (from and to both count; to − from at most 1,830)
/v1/dunning/noticesas in generalat most 1,831 calendar days per call (from and to both count; to − from at most 1,830)
…/changes1–500 (default 100)changes kept for 90 days
…/by-idsup 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

  1. Generate and store the key before you send — together with the operation (database, queue). Only then do you still know it after a crash.
  2. 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.
  3. 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.
  4. Retry with the same key and the same body on timeout, lost connection, 429 (wait for Retry-After first), 500 and 503 — with a growing wait and an upper limit of attempts — also on 409 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.
  5. Mark it done only after the response and do not reuse the key afterwards: a key belongs to exactly one operation.
JavaScript (Node.js 18+): flow
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);
Retry within minutes to hours, not days later: after the retention period (table) the same key counts as a new operation again. When creating a webhook, the 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.

EndpointKey is bound toRetentionSame key, other token of the organization
POST /v1/webhookstoken and creationAs long as the webhook exists; deleting it makes the API forget the keyNew operation
POST /v1/webhooks/{id}/rotate-secrettoken and the operation “rotation”30 daysNew operation
POST /v1/webhooks/{id}/testtoken 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 eventNew operation
POST /v1/sandbox/eventstoken and the operation “sandbox event”: the same key selects the same sample object and queues nothing again7 days (sandbox deliveries)New operation
POST /v1/webhooks/{id}/deliveries/{delivery_id}/retrytoken and exactly this delivery30 daysNew operation
POST /v1/webhooks/{id}/deliveries/retry-failedtoken and the filters of the request; the same key with another filter is a new operation (no 409)30 daysNew operation
POST /v1/tenant-importsorganizationAs 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}/commitno Idempotency-Key neededRepeating is harmless: records already taken over report already_transferredas with any token
POST /v1/damages, PATCH /v1/damages/{id}/statustoken; both endpoints share the keys90 days. If the key belongs to a deleted damage report, the API answers 404 and creates nothing newNew operation
POST /v1/appointmentsorganizationAs long as the appointment exists; a deleted appointment is created anew on a retryMatch: the same operation
POST /v1/meters/{id}/readingstokenAs long as the reading exists (a cancelled one still matches); a retry is not checked for plausibility againNew operation
POST /v1/prospectstokenAs long as the inquiry existsNew operation
POST /v1/datev/exportsorganizationAs 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.

ParameterMeaning
period=todayToday. Also: yesterday, this_month, last_month, last_7_days, last_30_days, last_3_months, last_12_months
period=allNo time filter — page through with the cursor
date=2026-10-04Exactly 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
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.