Webhooks
Statt dauernd nachzufragen, meldet Manage²Hero ein Ereignis selbst: Sie legen eine https-Adresse Ihres Systems an und erhalten bei jedem abonnierten Ereignis einen signierten POST. Die Nachricht ist dünn: Sie nennt nur, was passiert ist und für welches Objekt. Die Einzelheiten holen Sie mit Ihrem Token per GET (z. B. GET /v1/payments/by-ids?ids=…). Die Objektdetails sind so durch die Lese-Rechte Ihres Tokens geschützt. Die Nachricht selbst nennt aber Ereignistyp, Zeitpunkt und Objekt-Kennung, auch wenn das Token den passenden Lese-Scope nicht hat (ein Token nur mit webhooks:manage reicht). Statten Sie Webhook-Tokens deshalb mit den passenden Lese-Scopes aus und tragen Sie nur vertrauenswürdige Ziele ein. Die wichtigsten Regeln für den Betrieb fasst die Integrations-Checkliste zusammen.
enqueue_failures am Webhook. Solange ein Webhook abgeschaltet ist, werden für ihn keine Nachrichten eingestellt. Gleichen Sie deshalb regelmäßig über den Änderungsabgleich ab (updated_since, /changes); Einzelheiten unter Bekannte Grenzen und Nach einer Abschaltung.Endpunkte
Alle Endpunkte brauchen den Scope webhooks:manage. Der Ersteller des Tokens braucht dafür das Recht „API-Zugang“ mit Vollzugriff. Fremde oder unbekannte Kennungen ergeben immer 404 not_found.
| Aufruf | Zweck |
|---|---|
GET /v1/webhooks | Alle Webhooks der Organisation (höchstens 10), nie ein Geheimnis; ohne Seitenwechsel: data.items ohne next_cursor |
POST /v1/webhooks | Anlegen; Idempotency-Key Pflicht; die Antwort enthält das Geheimnis einmalig |
GET /v1/webhooks/{id} | Ein Webhook |
PATCH /v1/webhooks/{id} | url, events, description (null löscht) und active ändern |
DELETE /v1/webhooks/{id} | Löschen, samt Zustellverlauf |
POST /v1/webhooks/{id}/rotate-secret | Geheimnis erneuern; Idempotency-Key Pflicht; neues Geheimnis einmalig |
POST /v1/webhooks/{id}/test | Testereignis webhook.test einstellen; Idempotency-Key Pflicht |
GET /v1/webhooks/{id}/deliveries | Zustellverlauf, neueste zuerst, mit Cursor und Zeitfilter |
POST /v1/webhooks/{id}/deliveries/{delivery_id}/retry | Eine fehlgeschlagene oder verworfene Zustellung erneut senden; Idempotency-Key Pflicht |
POST /v1/webhooks/{id}/deliveries/retry-failed | Bis zu 100 fehlgeschlagene oder verworfene Zustellungen erneut senden; Idempotency-Key Pflicht |
POST /v1/sandbox/events | Nur mit Sandbox-Token: Ereignis für ein Musterobjekt der Sandbox simulieren; Idempotency-Key Pflicht |
livemode (Körper) und X-M2H-Livemode (Kopf): false für Ereignisse einer Sandbox, true für alle anderen. Prüfen Sie es, bevor Sie buchen oder weiterleiten. Zustellungen, die vor Einführung des Feldes eingestellt wurden, tragen es nicht (ein fehlendes Feld gilt als true): Eine Zustellung behält bei jeder Wiederholung und jedem Replay genau denselben Körper. Mehr dazu auf der Seite Sandbox.Webhook anlegen
curl -s -X POST "https://api.manage2hero.com/v1/webhooks" \
-H "Authorization: Bearer $M2H_API_TOKEN" \
-H "Idempotency-Key: 5d9d6c3e-1b7a-4c2f-9d0e-7a3b1c2d4e5f" \
-H "Content-Type: application/json" \
--data '{"url":"https://erp.example.com/hooks/manage2hero","events":["payment.received","invoice.created"],"description":"ERP"}'{
"success": true,
"data": {
"id": "3f6c1b52-8a47-4d09-9e1c-5b2a7d4e6f80",
"url": "https://erp.example.com/hooks/manage2hero",
"description": "ERP",
"events": ["invoice.created", "payment.received"],
"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_…"
},
"request_id": "req_4n2Xc9…"
}secret) steht nur in dieser Antwort und in der Antwort auf rotate-secret. Manage²Hero speichert es verschlüsselt und nur zum Signieren; lesbar ist es danach nicht mehr. Haben Sie es verloren, erzeugen Sie mit rotate-secret ein neues.| Regel | Einzelheiten |
|---|---|
| Adresse | Nur https auf Port 443 (kein Port oder ausdrücklich :443; jeder andere Port wird abgelehnt), nur öffentlich erreichbare Adressen (keine privaten, Loopback- oder Link-Local-Adressen, keine internen Namen), keine Zugangsdaten (user:pass@), kein #-Fragment, höchstens 2048 Zeichen. Der Name wird beim Anlegen per DNS geprüft und beim Senden erneut (Schutz vor Umleitung auf interne Ziele, SSRF). Verstoß: 400 webhook_url_invalid. |
| Ereignisse | events mit mindestens einem Eintrag, ohne Doppelte. Die möglichen Ereignisse stehen unten. |
| Anzahl | Höchstens 10 Webhooks je Organisation, abgeschaltete zählen mit: 409 webhook_limit_reached (error.details: limit und used, z. B. { "limit": 10, "used": 10 }; in einer Sandbox nur limit). |
| Wiederholung | Gleicher Idempotency-Key (mit demselben Token) und gleicher Inhalt: War die erste Anlage abgeschlossen, antwortet die Wiederholung mit 200 und demselben Webhook, aber ohne secret. Brach die erste Anfrage vor dem Abschluss ab, legt die Wiederholung den Webhook jetzt an: 201 mit einem neuen secret. Anderer Inhalt: 409 idempotency_conflict. |
| Server-Zustellung | Gesendet wird nur aus der Produktion. In Entwicklung und Staging funktionieren Verwaltung und Zustellverlauf, die Zustellungen bleiben aber offen (pending). |
Ereignisse
| type | Wann | data | Details holen mit |
|---|---|---|---|
payment.received | Ein Zahlungseingang (Betrag größer 0) wird erfasst | payment_id | GET /v1/payments/by-ids?ids={id} |
dunning.sent | Eine Mahnung wurde versendet | dunning_notice_id | GET /v1/dunning/notices/{id} |
contract.signed | Ein Vertrag erreicht den Status „unterschrieben“ | lease_id | GET /v1/contracts/{id} |
invoice.created | Eine Rechnung wird angelegt (kein Entwurf) oder aus dem Entwurf freigegeben | invoice_id | GET /v1/invoices/{id} |
document.created | Ein Dokument (Mieterakte-Hauptordner, Standortdatei) wird abgelegt | document_id | GET /v1/documents/{id} |
email.sent | Eine System-E-Mail an einen Mieter wird versendet (dieselben Vorlagen wie bei GET /v1/emails) | email_id | GET /v1/emails/{id} |
damage.created | Eine Schaden-Fallakte wird angelegt, auch über POST /v1/damages | damage_id | GET /v1/damages/{id} |
contract.terminated | Eine Kündigung wird bestätigt (Verwaltungskündigung oder bestätigter Mieterantrag). Gemeldet wird die Bestätigung, nicht das Vertragsende: das Datum steht im Abruf als end_date der Kündigung | lease_id | GET /v1/contracts/{id} |
invoice.cancelled | Eine Rechnung (kein Entwurf) wird storniert. data ist die stornierte Original-Rechnung; die Storno-Rechnung meldet invoice.created | invoice_id | GET /v1/invoices/{id} |
damage.updated | Eine Schaden-Fallakte ändert ein in der API sichtbares Feld (Status, Art, Priorität, Beschreibung, Datum, Verursacher-Art, Melder-Art, Einheit/Standort, Fallnummer). Dokumente und Meldungen lösen es nicht aus | damage_id | GET /v1/damages/{id} |
webhook.test | Testereignis; nicht abonnierbar | webhook_id | — |
In der Spalte „Details holen mit“ steht {id} für die Kennung aus data. Zu den Details brauchen Sie jeweils den passenden Lese-Scope Ihres Tokens (payments:read, dunning:read, contracts:read, invoices:read, documents:read, emails:read, damages:read). Je Webhook und Objekt gibt es höchstens eine Nachricht je Ereignistyp: Ein Rechnungsentwurf, der hin und her wechselt, meldet nur einmal. Ausnahme: damage.updated kommt bei jeder Änderung erneut und contract.terminated erneut, wenn ein Vertrag nach Rücknahme noch einmal gekündigt wird; mehrere Nachrichten zum selben Objekt sind dort normal. Die Nachricht ist ein Hinweis auf den aktuellen Stand, kein Protokoll jedes Zwischenstands. Die Reihenfolge mehrerer Nachrichten ist nicht garantiert; prüfen Sie bei Bedarf den Stand per GET. Die drei Typen contract.terminated, invoice.cancelled und damage.updated erhalten nur Webhooks, die sie ausdrücklich in events aufführen; bestehende Webhooks ändern sich nicht. Ihr Empfänger sollte unbekannte Ereignistypen ignorieren.
Das Ereignis entsteht in derselben Datenbank-Transaktion wie das Geschäftsereignis: Wird dieses zurückgenommen, gibt es auch keine Nachricht. Gesendet wird danach im Minutentakt vom Server, nie während des Vorgangs in der Verwaltung.
Die Nachricht
POST /hooks/manage2hero HTTP/1.1
Content-Type: application/json; charset=utf-8
User-Agent: Manage2Hero-Webhooks/1.0
X-M2H-Event-Id: 0e7f5a31-9c2b-4d68-8a14-6f3e2d1c0b97
X-M2H-Event-Type: payment.received
X-M2H-Delivery-Attempt: 1
X-M2H-Livemode: true
X-M2H-Signature: t=1791622960,v1=5b0c…e7a1
{"id":"0e7f5a31-9c2b-4d68-8a14-6f3e2d1c0b97","type":"payment.received","created_at":"2026-10-10T09:02:11.208Z","livemode":true,"data":{"payment_id":"5c1b6f0a-2d3e-4f50-8a61-7b8c9d0e1f23"}}openapi.json im Abschnitt webhooks (OpenAPI 3.1), nicht unter paths: je Ereignistyp ein Eintrag (payment.received, dunning.sent, contract.signed, invoice.created, document.created, email.sent, damage.created, contract.terminated, invoice.cancelled, damage.updated und webhook.test), der Körper unter webhooks.<Ereignistyp>.post.requestBody. Die Schemas sind dort eingebettet und tragen keine eigenen Namen unter components.schemas. Hinweis für Codegeneratoren: Viele Generatoren werten nur paths aus und erzeugen für die Nachrichten keine Typen; nehmen Sie dafür einen Generator mit Unterstützung für den Abschnitt webhooks (OpenAPI 3.1) oder beschreiben Sie die Nachricht nach diesem Handbuch.Die Ereignis-Kennung id im Körper (zur Information auch im Kopf X-M2H-Event-Id) bleibt bei jeder Wiederholung gleich — speichern Sie verarbeitete Kennungen und ignorieren Sie Doppelte. Die Nachricht enthält keine Organisation, keine Namen und keine Beträge. Antworten Sie mit 2xx, erst nachdem Sie die Nachricht dauerhaft gespeichert haben, am besten in einer Eingangstabelle mit eindeutiger Ereignis-Kennung (ein Duplikat wird dann einfach ignoriert). Verarbeiten Sie sie danach in einem eigenen Arbeitsschritt, der die Kennung als Idempotenzschlüssel für alles verwendet, was er bucht oder versendet; bei einem Fehler beim Speichern antworten Sie mit 5xx, damit Manage²Hero es erneut versucht. Der Inhalt der Antwort ist egal und wird nie gespeichert. Alles andere zählt als Fehlschlag: ein anderer Status, ein Timeout (10 Sekunden), ein Zertifikatsfehler und auch eine Weiterleitung (3xx) — sie wird nicht verfolgt.
Signatur prüfen
Der Kopf lautet X-M2H-Signature: t=<Unix-Sekunden>,v1=<hex>. Dabei ist t der Zeitpunkt dieses Sendeversuchs, nicht der des Ereignisses (created_at); jede Wiederholung hat ein anderes t. Der Wert v1 ist ein HMAC-SHA256 über den Text "<t>.<Rohkörper>". Als Schlüssel dient das Geheimnis, die ganze Zeichenkette whsec_… als UTF-8; die Ausgabe ist hexadezimal.
- Verwenden Sie den Rohkörper (die Bytes, wie sie ankamen), nie einen neu serialisierten.
- Lesen Sie
tund allev1aus dem Kopf. Während einer Rotation stehen zweiv1darin. - Berechnen Sie den HMAC und vergleichen Sie ihn mit jedem
v1in konstanter Zeit. - Lehnen Sie ab, wenn
tum mehr als 5 Minuten von Ihrer Uhr abweicht (Schutz vor erneut eingespielten Nachrichten). Bei jeder Wiederholung setzt Manage²Herotneu und signiert neu. - Prüfen Sie erst nach erfolgreicher Signaturprüfung die
idaus dem Körper gegen bereits verarbeitete Kennungen. Der KopfX-M2H-Event-Idist nicht signiert; verwenden Sie ihn nie als Duplikat- oder Idempotenzschlüssel.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
/** header: value of X-M2H-Signature, rawBody: the bytes as received (string), secret: whsec_… */
export function verifyWebhook(header, rawBody, secret, nowSeconds = Date.now() / 1000) {
let t = null;
const signatures = [];
for (const part of header.split(',')) {
const [name, value = ''] = part.split('=');
if (name === 't' && /^\d{1,12}$/.test(value)) t = Number(value);
if (name === 'v1' && /^[0-9a-f]{64}$/.test(value)) signatures.push(value);
}
if (t === null || signatures.length === 0) return false;
if (Math.abs(nowSeconds - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
return signatures.some((s) => {
const given = Buffer.from(s, 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
});
}import express from 'express';
import { verifyWebhook } from './verify-webhook.js';
const app = express();
// Inbox pattern: store first, acknowledge after the commit, process later in a separate worker.
// webhook_inbox has a UNIQUE constraint on event_id, so a duplicate delivery is simply ignored.
app.post('/hooks/manage2hero', express.raw({ type: 'application/json' }), async (req, res) => {
try {
const rawBody = req.body.toString('utf8');
if (!verifyWebhook(req.get('X-M2H-Signature') ?? '', rawBody, process.env.M2H_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(rawBody);
await db.query(
'INSERT INTO webhook_inbox (event_id, type, payload) VALUES ($1, $2, $3) ON CONFLICT (event_id) DO NOTHING',
[event.id, event.type, rawBody],
);
res.sendStatus(204); // 2xx only after the commit
} catch {
res.sendStatus(500); // no 2xx: Manage2Hero retries later
}
});
// The worker reads webhook_inbox, fetches the details with GET (e.g. GET /v1/payments/by-ids?ids=<data.payment_id> for a payment)
// and uses event.id as the idempotency key for everything it books or sends, so a re-run does no harm.import hashlib
import hmac
import re
import time
TOLERANCE_SECONDS = 300
def verify_webhook(header: str, raw_body: bytes, secret: str, now: float | None = None) -> bool:
t = None
signatures = []
for part in header.split(","):
name, _, value = part.partition("=")
if name == "t" and re.fullmatch(r"[0-9]{1,12}", value):
t = int(value)
elif name == "v1" and re.fullmatch(r"[0-9a-f]{64}", value):
signatures.append(value)
if t is None or not signatures:
return False
if abs((time.time() if now is None else now) - t) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode("utf-8"), f"{t}.".encode("utf-8") + raw_body, hashlib.sha256
).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)
# Flask: request.get_data() returns the raw body
# if not verify_webhook(request.headers.get("X-M2H-Signature", ""), request.get_data(), SECRET):
# abort(400)<?php
declare(strict_types=1);
const TOLERANCE_SECONDS = 300;
/** $header: value of X-M2H-Signature, $rawBody: the bytes as received, $secret: whsec_… */
function verify_webhook(string $header, string $rawBody, string $secret, ?int $now = null): bool
{
$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', $part, 2);
$value = $pair[1] ?? '';
if ($pair[0] === 't' && preg_match('/^[0-9]{1,12}$/D', $value) === 1) {
$t = (int) $value;
} elseif ($pair[0] === 'v1' && preg_match('/^[0-9a-f]{64}$/D', $value) === 1) {
$signatures[] = $value;
}
}
if ($t === null || $signatures === []) {
return false;
}
if (abs(($now ?? time()) - $t) > TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
// Plain PHP / Laravel / Symfony: take the raw body, never a decoded or re-encoded one.
// $ok = verify_webhook(
// $_SERVER['HTTP_X_M2H_SIGNATURE'] ?? '',
// file_get_contents('php://input'),
// getenv('M2H_WEBHOOK_SECRET'),
// );
// if (!$ok) { http_response_code(400); exit; }Testvektor
Zum Prüfen Ihrer Umsetzung: Geheimnis whsec_testvektor_1, t = 1700000000, Körper {"id":"e1","type":"payment.received"} ergibt v1 = 0bb862164c5c29039c8121a86d1c9ea83b9ed274f6bb9686383047a29c4014e7. Für diesen Vektor muss Ihre Prüfung die aktuelle Zeit auf t setzen (die Beispiele nehmen sie als vierten Parameter), denn sonst lehnt die Zeitprüfung den alten Zeitstempel ab.
Weitere Fälle liefert die Datei test/signatur-testvektoren.json des Starterprojekts: Rohbytes (UTF-8, abschließender Zeilenumbruch, neu serialisiertes JSON), Rotation mit zwei v1, das Zeitfenster (genau 300 und 301 Sekunden), Manipulation und Duplikate. Jeder Fall nennt Geheimnis, Kopf, Körper als Hex-Bytes, die Uhr des Empfängers und das erwartete Ergebnis; prüfen Sie Ihre Umsetzung in jeder Sprache gegen dieselbe Datei.
Wiederholungen und Abschaltung
Eine Nachricht wird bis zu 6 Mal versucht: sofort, dann nach 1 Minute, 5 Minuten, 30 Minuten, 2 Stunden und 6 Stunden (zusammen gut 8,5 Stunden). Scheitert auch der sechste Versuch, wird der Webhook abgeschaltet (active = false, disabled_reason = delivery_failed). Es wird nichts verworfen: Offene Nachrichten dieses Webhooks werden pausiert (paused), und solange die API Ihrer Organisation freigeschaltet ist, werden neue Ereignisse weiter aufgezeichnet (ebenfalls paused): bis zu 10.000 je Webhook und höchstens 29 Tage lang. Nach dem Wiedereinschalten nimmt der Server sie von selbst wieder auf (siehe unten). Ausnahmen: Die Nachricht mit dem sechsten Fehlversuch bleibt failed (sie lässt sich bewusst erneut einstellen, damit eine Nachricht, die Ihr System immer ablehnt, nicht in einer Dauerschleife endet), und Testereignisse (webhook.test) werden verworfen. Alle Nutzer mit Vollzugriff auf die API-Zugänge erhalten eine Benachrichtigung in der Verwaltung, mit dem Host, nie mit der ganzen Adresse; nach 21 Tagen erinnert sie die Verwaltung, wenn pausierte Nachrichten ablaufen würden. Eine Nachricht, die im Moment der Abschaltung gerade gesendet wird, läuft zu Ende: Kommt sie an, gilt sie als zugestellt, sonst wird sie pausiert.
Kommen Nachrichten ungewöhnlich spät an, prüfen Sie zuerst die Statusseite: Sie zeigt, ob die Webhook-Zustellung der Plattform gerade verzögert oder unterbrochen ist. Pausierte Webhooks und abgeschaltete Endpunkte Ihrer Organisation erscheinen dort nicht als Störung – sie sehen Sie nur im Webhook selbst (active, disabled_reason, pending_deliveries).
| disabled_reason | Bedeutung |
|---|---|
manual | Per PATCH mit active false abgeschaltet |
delivery_failed | 6 Versuche erfolglos |
token_revoked | Das Token, mit dem der Webhook angelegt oder zuletzt aktiviert wurde, ist widerrufen oder gelöscht (sofort) |
token_expired | Dieses Token ist abgelaufen |
creator_lost_access | Der Ersteller des Tokens ist nicht mehr aktiv oder hat das Recht „API-Zugang“ mit Vollzugriff verloren |
api_disabled | Die API ist für die Organisation abgeschaltet |
PATCH /v1/webhooks/{id} mit { "active": true }. Das aufrufende Token wird dann Erzeuger-Token, auch bei einem noch aktiven Webhook, und offene Nachrichten gehen nicht verloren.Langlebige Anbindungen betreiben
- Legen Sie den Webhook mit einem Token eines eigenen Benutzerkontos an, das nur für diese Anbindung dient und nicht an eine Person gebunden ist. Wird der Ersteller des Tokens deaktiviert oder verliert er das Recht „API-Zugang“ mit Vollzugriff, schaltet sich der Webhook ab (
creator_lost_access). - Die Laufzeit eines Tokens wählen Sie beim Anlegen aus 30, 90, 180 oder 365 Tagen. Übernehmen Sie den Webhook deshalb vor dem Ablauf mit einem neuen Token (siehe oben). Eine Erinnerung per E-Mail geht 14 Tage vor dem Ablauf an die Verwalter der API-Zugänge und den Ersteller; tragen Sie zusätzlich eine eigene Erinnerung ein.
- Überwachen Sie die Webhooks: Rufen Sie
GET /v1/webhooksregelmäßig ab (z. B. täglich) und schlagen Sie Alarm, wennactiveauffalsesteht.disabled_reasonnennt die Ursache. - Beachten Sie dabei: Mit dem Token, dessen Ablauf den Webhook abschaltet, bekommen Sie nach dem Ablauf keine Antwort mit
active = false, sondern 401 (token_expired; bei einem widerrufenen Tokeninvalid_token). Werten Sie deshalb auch Fehlerantworten (alles außer 2xx, besonders 401) als Alarm, oder überwachen Sie mit einem unabhängig davon erneuerten Token.
Nach einer Abschaltung
Wieder einschalten: Beheben Sie zuerst die Ursache (Empfänger erreichbar? Token gültig?). Dann PATCH /v1/webhooks/{id} mit { "active": true }; das aufrufende Token wird dabei Erzeuger-Token.
Wiederaufnahme: Nach { "active": true } nimmt der Server die pausierten Nachrichten von selbst wieder auf, älteste zuerst, je Minute höchstens 60 je Webhook (10.000 Nachrichten brauchen also knapp drei Stunden), mit derselben Ereignis-Kennung id und einem neuen Versuchszähler. Bis dahin zeigt paused_deliveries am Webhook die Restzahl. Nachrichten, die älter als 29 Tage sind, werden nicht mehr zugestellt (abandoned, error = paused_expired). Wollen Sie die pausierten Nachrichten nicht, etwa weil Sie per Änderungsabgleich aufholen, schalten Sie mit { "active": true, "paused_deliveries": "discard" } ein: dann werden sie verworfen (abandoned).
Verpasstes nachholen: Fehlgeschlagene und verworfene Nachrichten stellen Sie nach dem Wiedereinschalten mit den Replay-Endpunkten erneut ein (höchstens 29 Tage alt, höchstens 1000 je Webhook und Stunde, nur bei aktivem Webhook, keine Testereignisse). Was während der Abschaltung nicht mehr aufgezeichnet werden konnte (mehr als 10.000 Ereignisse), zeigt recording_gap am Webhook (from, to; to ist null, solange die Lücke offen ist; ein späterer Überlauf erweitert sie, ihr Beginn wandert nie nach hinten); holen Sie es über den Änderungsabgleich nach (updated_since = from). Nach einem Ausfall Ihres Systems bleibt in der Regel eine Nachricht failed (die mit dem sechsten Fehlversuch): PATCH active: true, dann retry-failed. Jede Ressource hat dabei ihren eigenen Cursor: Ein Cursor gilt nur für die Ressource, von der er stammt, an einem anderen …/changes wird er mit 400 invalid_cursor abgelehnt. Gehen Sie deshalb für jede Ressource getrennt vor (Einstieg …/changes/cursor, Vollabruf, …/changes bis next_cursor null ist) und speichern Sie je Ressource einen eigenen resume_cursor:
- Haben Sie noch einen gemerkten
resume_cursor, rufen Sie…/changesab diesem Cursor ab. Eine Seite hat höchstenslimitEinträge (Vorgabe 100, höchstens 500): Blättern Sie mitnext_cursorweiter, bis ernullist. Erst dann ist der Abgleich vollständig und die Antwort liefert den neuenresume_cursor(auf den Seiten davor ist ernull). Laden Sie die genannten Kennungen mit…/by-idsnach (höchstens 100 je Aufruf) und verarbeiten Sie sie dauerhaft. Speichern Sie den neuenresume_cursorerst danach: Bei einem Fehler behalten Sie den bisherigen Cursor und wiederholen den Abgleich, denn bereits gelieferte Änderungen erscheinen mit dem neuen Cursor nicht noch einmal. Änderungen werden 90 Tage aufbewahrt; ein älterer Cursor ergibt 410cursor_expired. - Sonst holen Sie zuerst einen neuen Einstieg mit
…/changes/cursorund rufen danach die Liste vollständig ab. Je Abruf (frombis einschließlichto) sind höchstens 366 Tage bei Buchungen, 366 bei Zahlungseingängen, 1.831 bei Rechnungen und 1.831 bei Mahnungen erlaubt, sonst antwortet die API mit 400range_too_large. Rufen Sie deshalb in aufeinanderfolgenden Zeitfenstern ab, alle Seiten je Fenster; das nächste Fenster beginnt am Tag nach demtodes vorigen. Dokumente und E-Mails rufen Sie mitperiod=allab. Danach holen Sie mit…/changesab dem Einstiegs-Cursor nach, was sich währenddessen geändert hat (ebenfalls mitnext_cursorbisnullweiterblättern, die Kennungen nachladen und verarbeiten und erst danach den geliefertenresume_cursorspeichern). - Den Änderungsabgleich gibt es für Buchungen, Rechnungen, Zahlungseingänge, Mahnungen, Dokumente, E-Mails, Verträge und Schäden. Auch für Verträge und Schäden gilt derselbe Ablauf (Einstieg
/v1/contracts/changes/cursorbzw./v1/damages/changes/cursor, Vollabruf mitperiod=all,…/changes,…/by-ids). Der Strom meldet auch Löschungen und Sichtbarkeitswechsel: gelöschte oder zum Löschen vorgemerkte Verträge, zurückgesetzte Gegenzeichnungen, gelöschte Kündigungen und Nachträge, gelöschte Schäden, Schaden-Dokumente und Meldungen./v1/damages/by-idsliefert die Listenform ohnedocuments; die Dokument-Kennungen holen Sie mitGET /v1/damages/{id}. Die frühere Abfrage seit einem Zeitpunkt (frombzw.updated_since) bleibt möglich, wird aber nicht mehr empfohlen: Sie sieht Löschungen nicht. Bleibt ein Abruf mit 410cursor_expiredhängen, wurde der Strom nach einem internen Fehler entwertet: neu einsteigen (Vollabruf), nötigenfalls nach kurzer Wartezeit.
Geheimnis rotieren
POST /v1/webhooks/{id}/rotate-secret (mit Idempotency-Key) liefert ein neues secret, einmalig. Das bisherige bleibt 24 Stunden als zweite Signatur gültig (previous_secret_valid_until): In dieser Zeit trägt jede Nachricht zwei Einträge v1, das neue zuerst. Prüfen Sie gegen beide Geheimnisse, oder gegen das neue, sobald es hinterlegt ist. So gibt es keinen Ausfall. Danach gilt nur noch das neue. Rotieren Sie innerhalb der 24 Stunden ein zweites Mal, endet die Übergangszeit des ersten Geheimnisses sofort; es gibt immer nur ein „altes“.
Eine Wiederholung mit einem früheren Idempotency-Key rotiert innerhalb von 30 Tagen nicht noch einmal. Danach gilt derselbe Schlüssel als neuer Vorgang, weil der Nachweis nach 30 Tagen gelöscht wird.
Geheimnis oder Antwort verloren
Das Geheimnis wird nur einmal angezeigt. Ging die Antwort auf das Anlegen verloren, wissen Sie nicht, ob der Webhook entstanden ist, und kennen seine id nicht. Wiederholen Sie dann zuerst dieselbe Anlage mit dem gleichen Idempotency-Key (mit demselben Token) und gleichem Inhalt. War die erste Anlage abgeschlossen, erhalten Sie 200 mit dem Webhook samt id, aber ohne Geheimnis. Brach sie vorher ab, entsteht der Webhook jetzt: 201 mit einem Geheimnis (die Anlage kann dann auch an der Höchstzahl oder der Adressprüfung scheitern). Alternativ ermitteln Sie die id mit GET /v1/webhooks.
Fehlt Ihnen das Geheimnis eines bestehenden Webhooks, etwa weil die Antwort auf rotate-secret verloren ging, rufen Sie rotate-secret mit einem neuen Idempotency-Key auf. Dieselbe rotate-secret-Anfrage mit dem gleichen Schlüssel gibt das Geheimnis nicht noch einmal aus (innerhalb von 30 Tagen); sie liefert nur den Webhook ohne secret. Der neue Aufruf erzeugt ein weiteres Geheimnis. Das davor gültige Geheimnis bleibt noch 24 Stunden als zweite Signatur gültig und danach nicht mehr; ein bereits früher übergangsweise gültiges Geheimnis endet dabei sofort. Hinterlegen Sie das neue Geheimnis deshalb gleich bei Ihrem Empfänger.
Testen
POST /v1/webhooks/{id}/test (mit Idempotency-Key) stellt das Ereignis webhook.test ein (data: { "webhook_id": "…" }), mit derselben Signatur und denselben Kopfzeilen. Es gibt einen Versuch, und ein Fehlschlag schaltet den Webhook nicht ab. Der Server sendet höchstens eine Minute später. Das Ergebnis steht im Zustellverlauf (status, response_status, error). Je Webhook warten höchstens 3 Testereignisse gleichzeitig, sonst 429 rate_limited. Derselbe Schlüssel ergibt dieselbe Testzustellung, solange sie im Zustellverlauf steht (30 Tage).
Zustellverlauf
GET /v1/webhooks/{id}/deliveries zeigt, was an Ihre Adresse gesendet wurde und wie sie geantwortet hat. Parameter: der Zeitfilter (period, date, from/to; ohne Angabe die letzten 30 Tage), status (pending, succeeded, failed, abandoned, paused), event_type, limit (1–200, Vorgabe 50) und cursor. Der Verlauf wird 30 Tage aufbewahrt.
Felder: id, event_id, event_type, status, attempts, replay_count, created_at, last_attempt_at, next_attempt_at (nur bei pending), delivered_at, response_status (HTTP-Status Ihrer Antwort) und error. Der Fehler ist ein fester Code: timeout, connection_failed, tls_error, blocked_target (die Adresse zeigt inzwischen auf ein nicht öffentliches Ziel oder nutzt einen anderen Port als 443), redirect_not_followed oder http_status. Nie enthalten sind der Antwortkörper Ihres Systems und die Nutzlast.
Ereignisse erneut senden
Nachrichten, die endgültig fehlgeschlagen sind (failed) oder beim Abschalten des Webhooks verworfen wurden (abandoned), lassen Sie erneut einstellen, ohne den Änderungsabgleich zu bemühen. Die Nachricht trägt dieselbe Ereignis-Kennung id und denselben Körper wie vorher; Zeitstempel t und Signatur sind neu. Gesendet wird wie immer im Minutentakt vom Server.
Typischer Ablauf nach einem Ausfall Ihres Systems:
- Beheben Sie die Ursache und schalten Sie den Webhook wieder ein:
PATCH /v1/webhooks/{id}mit{ "active": true }. - Stellen Sie die verpassten Nachrichten mit
POST /v1/webhooks/{id}/deliveries/retry-failederneut ein. Liefert die Antwortremaininggrößer 0, wiederholen Sie den Aufruf mit einem neuenIdempotency-Key, bisremaining0 ist. - Im Zustellverlauf sehen Sie die Nachrichten wieder als
pending;replay_countzählt, wie oft eine Zustellung wiederhergestellt wurde.
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"}'{
"success": true,
"data": { "replayed": 42, "remaining": 0 },
"request_id": "req_4n2Xc9…"
}Eine einzelne Zustellung (Kennung aus dem Zustellverlauf) stellen Sie so erneut ein:
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)"| Regel | Einzelheiten |
|---|---|
| Welche Nachrichten | failed und abandoned, die höchstens 29 Tage alt sind (der Verlauf wird 30 Tage aufbewahrt; ein Tag Reserve lässt der Wiederholungsfolge Zeit). Keine Testereignisse: Ein neues Testereignis löst POST /v1/webhooks/{id}/test aus. Wartende (pending), zugestellte (succeeded) und zu alte Nachrichten: 409 delivery_not_replayable, error.details nennt status und reason (not_failed, test_event oder expired). |
| Sammelaufruf | Der Body ist optional (leer oder {} = alle) und höchstens 4 KB groß (sonst 413 payload_too_large). Filter: Zeitraum wie im Zustellverlauf (genau eine Form aus period, date, from/to), status (failed oder abandoned), event_type und limit (1–100). Die ältesten Nachrichten kommen zuerst. Antwort: replayed (Anzahl) und remaining (weitere passende). |
| Voraussetzung | Der Webhook ist aktiv und sendefähig (Token gültig). Sonst 400 validation_error mit dem Hinweis auf PATCH mit { "active": true }. |
| Versuche | Die Versuche zählen neu ab 0 (attempts; X-M2H-Delivery-Attempt beginnt wieder bei 1). Scheitern auch diese 6 Versuche, wird der Webhook wie sonst abgeschaltet. |
| Grenzen | Höchstens 100 Nachrichten je Sammelaufruf und 1000 je Webhook und Stunde: sonst 429 rate_limited mit Retry-After (error.details: limit, retry_after). Ist nichts mehr passend, antwortet der Aufruf mit replayed 0. |
| Wiederholung | Idempotency-Key ist Pflicht. Derselbe Schlüssel für dieselbe Zustellung bzw. dieselben Filter liefert das Ergebnis des ersten Aufrufs und stellt nichts erneut ein (replayed false bei einer einzelnen Zustellung). Der Nachweis gilt 30 Tage. |
id.paused) nimmt der Server selbst wieder auf; ein Replay ist für sie nicht möglich (409 delivery_not_replayable, Grund paused). Ereignisse über der Grenze von 10.000 je Webhook und Abschaltung werden nicht aufgezeichnet (recording_gap); diese Lücke schließen Sie über die Listen und den Änderungsabgleich (/changes). Nachrichten, die im Moment des Abschaltens gerade gesendet wurden, erscheinen erst nach bis zu 3 Minuten als paused.Starterprojekt, Postman und Testvektoren
Starterprojekt (Node.js)
Ein lauffähiges Empfänger-Projekt ohne Fremdabhängigkeiten (Node.js 22.13 oder neuer): Es prüft die Signatur, speichert die Nachricht dauerhaft, antwortet erst danach mit 2xx, verarbeitet getrennt davon und gleicht regelmäßig ab. Dazu gehören npm run simulate (ohne Manage²Hero ausprobieren), Tests, die gemeinsamen Testvektoren und eine Sammlung der Aufrufe im Format .http. Starterprojekt herunterladen (ZIP, ohne Anmeldung, manage2hero-webhook-starterprojekt.zip). Der Beispielcode steht unter der MIT-Lizenz; die API selbst unterliegt den Nutzungsbedingungen.
Der Abgleich des Starterprojekts deckt alle sieben abonnierbaren Ressourcen ab. Welche er abgleicht, legt M2H_RECONCILE fest (Standard payments, weil jede Ressource den Lese-Scope ihrer Objekte braucht):
| Ressource | Verfahren | Vollabruf |
|---|---|---|
payments | Änderungsstrom | from/to, ≤ 366 Tage |
invoices | Änderungsstrom | from/to, ≤ 1.831 Tage |
dunning_notices | Änderungsstrom | from/to, ≤ 1.831 Tage |
documents | Änderungsstrom | period=all |
emails | Änderungsstrom | period=all |
contracts | Änderungsstrom | period=all |
damages | Änderungsstrom | period=all |
Verträge und Schäden nutzen denselben Änderungsstrom wie die übrigen Ressourcen (siehe Nach einer Abschaltung); Löschungen und Sichtbarkeitswechsel meldet der Strom selbst. Im Spiegel steht die Listenform (Schäden ohne documents; die Dokument-Kennungen liefert GET /v1/damages/{id}). Ein gelegentlicher Vollabruf mit --full bleibt sinnvoll, etwa für geänderte Stammdaten.
Postman-Sammlung
Dieselben Aufrufe als importfertige Sammlung (Collection v2.1): /openapi.json?format=postman. Sie enthält die Variablen base_url und token, Webhook-Verwaltung mit Test und Replay, den Abgleich je Ressource, Seitenweise Abrufen, Fehlerfälle und feste Idempotency-Keys (ein Schlüssel je Anfrage, bis Sie idempotency_renew auf true setzen). In Postman: Import → Link mit der vollständigen Adresse, oder die Datei speichern und importieren. Tragen Sie Ihr Token nur in die Variable token ein, nie in die exportierte Datei.
Bekannte Grenzen
id. Halten Sie Ihre Verarbeitung deshalb fachlich idempotent: Gleichen Sie über die Ereignis-Kennung ab und prüfen Sie den Stand des Objekts per GET, bevor Sie handeln.Nicht lückenlos. Ein Ereignis, das technisch nicht eingestellt werden kann, wird vorgemerkt und automatisch nachgestellt; es behält Ereignis-Kennung und Zeitpunkt, kommt aber später an. Die Abfrage, ob überhaupt ein Webhook das Ereignis abonniert, liegt bewusst vor diesem Schutz (sie kostet jedes Geschäftsereignis sonst spürbar mehr): Scheitert schon sie, wird der Geschäftsvorgang abgebrochen, und es entsteht weder ein Geschäftsereignis noch eine Nachricht. Muss der Server die Empfänger beim Nachstellen ausnahmsweise neu ermitteln, ist das eine Näherung: Webhooks, die erst nach dem Ereignis angelegt wurden, bekommen es nicht, nachträglich hinzugefügte Abonnements schon. Nach acht erfolglosen Versuchen oder nach 29 Tagen gilt ein Ereignis als verloren; die Verwaltung wird benachrichtigt, und enqueue_failures zählt es weiter. Scheitern Einstellen und Vormerken zugleich, bleibt nur eine Warnung im Datenbank-Protokoll. Gleichen Sie deshalb regelmäßig über die Listen-Endpunkte ab (updated_since, Änderungsabgleich /changes). Weitere Grenzen: Die Reihenfolge der Nachrichten ist nicht garantiert, eine Nachricht kann mehrfach ankommen (Wiederholungen), und die Aufzeichnung während einer Abschaltung hat Grenzen: höchstens 10.000 Ereignisse je Webhook und 29 Tage (danach recording_gap; ältere pausierte Nachrichten werden nicht mehr zugestellt). Nach einem Ausfall bleibt eine Nachricht failed (erneut einstellen: siehe oben).
Integrations-Checkliste
Das Wichtigste auf einen Blick. Die Einzelheiten stehen in den genannten Abschnitten dieses Handbuchs und in den Regeln zur Idempotenz.
- Token-Rechte: Das Token braucht
webhooks:manageund für jedes abonnierte Ereignis den passenden Lese-Scope (z. B.payments:readfürpayment.received), sonst scheitert das Nachladen der Details mit403. Nur so viele Rechte vergeben, wie das Programm braucht. - Signatur am Rohkörper prüfen:
X-M2H-Signatureüber die Bytes prüfen, wie sie ankamen (nicht über erneut serialisiertes JSON), mit Vergleich in konstanter Zeit und Zeitstempel-Toleranz von 5 Minuten. Ungültige Nachrichten ablehnen (z. B. mit400) und nicht speichern. - Inbox-Muster: Nachricht samt Ereignis-Kennung
id(eindeutig) dauerhaft speichern, erst danach mit2xxantworten; scheitert das Speichern, mit5xxantworten. Verarbeiten in einem eigenen Arbeitsschritt und Duplikate über die Kennung ignorieren. Die Reihenfolge der Nachrichten ist nicht garantiert. - Dauerhafte Idempotenz: Bei schreibenden Aufrufen den
Idempotency-Keyvor dem Senden dauerhaft speichern und bei jeder Wiederholung mit demselben Body wiederverwenden — auch nach einem Neustart. Die Ereignis-Kennung dient als Schlüssel für alles, was die Verarbeitung bucht oder versendet. - Vollabgleich und Änderungs-Cursor: Webhooks sind ein Hinweis, keine lückenlose Quelle. Je Ressource in dieser Reihenfolge vorgehen: (1) den Einstieg
…/changes/cursorholen und vor dem Vollabruf sichern, (2) die Liste vollständig abrufen, (3)…/changesab dem Einstiegs-Cursor mitnext_cursorbisnullabarbeiten und (4) den geliefertenresume_cursorerst nach dem Verarbeiten speichern; danach regelmäßig ab dem gespeichertenresume_cursor. Jede Ressource hat ihren eigenen Cursor. Ein Cursor gilt 90 Tage; danach (410 cursor_expired) beginnen Sie wieder bei Schritt 1. Dazu ein gelegentlicher Vollabruf, etwa wöchentlich. - 429 und Backoff: Bei
429zuerstRetry-Afterabwarten; bei Timeout,500und503mit wachsender Wartezeit und einer Obergrenze an Versuchen wiederholen. Eine Ausnahme unter den 4xx:409 readings_locked(Zählerstände werden gerade bearbeitet) wiederholen Sie nach einigen Sekunden mit demselben Schlüssel. Andere Fehler behebt eine Wiederholung nicht; die Handlungsanweisung jeerror.codesteht im Fehlerkatalog. - Replay: Eine Nachricht wird bis zu 6 Mal versucht. Fehlgeschlagene (
failed) und verworfene (abandoned) Zustellungen lassen sich nach dem Beheben der Ursache mitPOST /v1/webhooks/{id}/deliveries/{delivery_id}/retryoder gesammelt mit…/deliveries/retry-failederneut einstellen — höchstens 29 Tage alt, höchstens 1000 je Webhook und Stunde, nur bei aktivem Webhook. - Überwachung:
GET /v1/webhooksregelmäßig prüfen und bei diesen Werten handeln:active: false(abgeschaltet, Grund indisabled_reason),enqueue_failuresgrößer 0 (Ereignisse, die nicht eingestellt werden konnten),recording_gapnichtnull(Lücke in der Aufzeichnung: per Änderungsabgleich abfromnachholen),pending_deliveriesdauerhaft hoch (Rückstau) undpaused_deliveriesgrößer 0 (pausierte Zustellungen warten auf die Wiederaufnahme). Den Betriebszustand der Plattform zeigt die Statusseite. - Fristen im Überblick: Replay-Fenster 29 Tage · Änderungs-Cursor 90 Tage · Zustellverlauf 30 Tage. Wer länger als das Replay-Fenster ausfällt, holt Verpasstes über den Änderungsabgleich nach.
- Codegeneratoren: Die Webhook-Nachrichten stehen in der
openapi.jsonim Abschnittwebhooks(je Ereignistyp ein Eintrag), nicht unterpaths. Generatoren, die nurpathsauswerten, erzeugen dafür keine Typen.
Sicherheit in Kürze
- Nur https auf Port 443, nur öffentliche Ziele, geprüft beim Anlegen und bei jedem Senden (auch gegen DNS-Wechsel); keine Weiterleitungen.
- Signierte Nachrichten mit Zeitstempel (Toleranz 5 Minuten) und gleichbleibender Ereignis-Kennung.
- Das Geheimnis wird nur verschlüsselt gespeichert und nur einmal angezeigt; wer es verliert, rotiert.
- Dünne Nutzlast: Objektdetails gibt es nur über die Lese-Endpunkte mit den Rechten Ihres Tokens; Typ, Zeitpunkt und Kennung stehen in der Nachricht.
Weiter: Berechtigungen, Referenz der Webhook-Endpunkte Grenzen und das Bedrohungsmodell.